From 38670efd4663157bd2bb820a213fa1f8727bb8b5 Mon Sep 17 00:00:00 2001 From: CyberRex <26585194+CyberRex0@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:39:33 +0900 Subject: [PATCH] First commit --- .gitignore | 7 + AGENTS.md | 32 + Cargo.lock | 1688 ++++++++++ Cargo.toml | 61 + README.md | 36 + api.d.ts | 442 +++ config/channels.yml | 1 + config/server.yml | 10 + config/tuners.yml | 1 + crates/mirakurun-client/Cargo.toml | 22 + crates/mirakurun-client/src/lib.rs | 180 + crates/mirakurun-core/Cargo.toml | 28 + crates/mirakurun-core/src/config.rs | 229 ++ crates/mirakurun-core/src/epg.rs | 509 +++ crates/mirakurun-core/src/error.rs | 48 + crates/mirakurun-core/src/filter.rs | 319 ++ crates/mirakurun-core/src/lib.rs | 15 + crates/mirakurun-core/src/persistence.rs | 241 ++ crates/mirakurun-core/src/service.rs | 242 ++ crates/mirakurun-core/src/ts.rs | 220 ++ crates/mirakurun-core/src/tuner.rs | 846 +++++ crates/mirakurun-rs/Cargo.toml | 43 + crates/mirakurun-rs/build.rs | 19 + crates/mirakurun-rs/src/access.rs | 177 + crates/mirakurun-rs/src/api.rs | 1700 ++++++++++ crates/mirakurun-rs/src/assets.rs | 94 + crates/mirakurun-rs/src/cli.rs | 102 + crates/mirakurun-rs/src/commands.rs | 147 + crates/mirakurun-rs/src/jobs.rs | 786 +++++ crates/mirakurun-rs/src/main.rs | 37 + crates/mirakurun-rs/src/rpc.rs | 219 ++ crates/mirakurun-rs/src/scan.rs | 462 +++ crates/mirakurun-rs/src/server.rs | 172 + crates/mirakurun-rs/src/state.rs | 284 ++ crates/mirakurun-types/Cargo.toml | 13 + crates/mirakurun-types/src/lib.rs | 808 +++++ dist/mirakurun-rs.service | 31 + doc/installation.md | 54 + doc/migration.md | 19 + rust-toolchain.toml | 4 + web/package-lock.json | 3335 +++++++++++++++++++ web/package.json | 44 + web/src/components/DateTimeRange.sass | 33 + web/src/components/DateTimeRange.tsx | 105 + web/src/components/EPGTable.sass | 333 ++ web/src/components/EPGTable.tsx | 654 ++++ web/src/components/Nav.sass | 82 + web/src/components/Nav.tsx | 169 + web/src/components/ProgramAVInfo.sass | 23 + web/src/components/ProgramAVInfo.tsx | 63 + web/src/components/ProgramCardBase.sass | 44 + web/src/components/ProgramCardBase.tsx | 120 + web/src/components/ProgramGenres.sass | 20 + web/src/components/ProgramGenres.tsx | 60 + web/src/components/ProgramPopover.sass | 4 + web/src/components/ProgramPopover.tsx | 70 + web/src/components/ProgramRelatedLinks.sass | 16 + web/src/components/ProgramRelatedLinks.tsx | 104 + web/src/components/ProgramTitle.sass | 38 + web/src/components/ProgramTitle.tsx | 101 + web/src/components/Restart.tsx | 50 + web/src/components/ServiceLink.sass | 8 + web/src/components/ServiceLink.tsx | 73 + web/src/components/VersionStatus.tsx | 127 + web/src/components/WatchButton.tsx | 126 + web/src/custom.d.ts | 4 + web/src/hooks/useWebStorageState.ts | 97 + web/src/icon-active.svg | 50 + web/src/icon-gray.svg | 45 + web/src/icon.svg | 45 + web/src/index.html | 15 + web/src/index.sass | 217 ++ web/src/index.tsx | 101 + web/src/modules/at.ts | 63 + web/src/modules/common.ts | 119 + web/src/modules/constants.ts | 292 ++ web/src/modules/regexp.ts | 35 + web/src/modules/state.ts | 427 +++ web/src/modules/ui.ts | 80 + web/src/redoc-ui.html | 37 + web/src/routes/AboutView.sass | 108 + web/src/routes/AboutView.tsx | 188 ++ web/src/routes/ChannelsConfigView.sass | 78 + web/src/routes/ChannelsConfigView.tsx | 915 +++++ web/src/routes/EPGView.tsx | 198 ++ web/src/routes/HomeView.sass | 238 ++ web/src/routes/HomeView.tsx | 583 ++++ web/src/routes/JobsView.sass | 48 + web/src/routes/JobsView.tsx | 510 +++ web/src/routes/LogsView.sass | 44 + web/src/routes/LogsView.tsx | 104 + web/src/routes/ProgramView.sass | 42 + web/src/routes/ProgramView.tsx | 209 ++ web/src/routes/SearchView.sass | 21 + web/src/routes/SearchView.tsx | 160 + web/src/routes/ServerConfigView.sass | 86 + web/src/routes/ServerConfigView.tsx | 584 ++++ web/src/routes/TunersConfigView.sass | 61 + web/src/routes/TunersConfigView.tsx | 431 +++ web/src/tsconfig.json | 29 + web/src/vars.sass | 19 + web/types/rpc.d.ts | 7 + web/webpack.config.js | 74 + 103 files changed, 22514 insertions(+) create mode 100644 .gitignore create mode 100644 AGENTS.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 api.d.ts create mode 100644 config/channels.yml create mode 100644 config/server.yml create mode 100644 config/tuners.yml create mode 100644 crates/mirakurun-client/Cargo.toml create mode 100644 crates/mirakurun-client/src/lib.rs create mode 100644 crates/mirakurun-core/Cargo.toml create mode 100644 crates/mirakurun-core/src/config.rs create mode 100644 crates/mirakurun-core/src/epg.rs create mode 100644 crates/mirakurun-core/src/error.rs create mode 100644 crates/mirakurun-core/src/filter.rs create mode 100644 crates/mirakurun-core/src/lib.rs create mode 100644 crates/mirakurun-core/src/persistence.rs create mode 100644 crates/mirakurun-core/src/service.rs create mode 100644 crates/mirakurun-core/src/ts.rs create mode 100644 crates/mirakurun-core/src/tuner.rs create mode 100644 crates/mirakurun-rs/Cargo.toml create mode 100644 crates/mirakurun-rs/build.rs create mode 100644 crates/mirakurun-rs/src/access.rs create mode 100644 crates/mirakurun-rs/src/api.rs create mode 100644 crates/mirakurun-rs/src/assets.rs create mode 100644 crates/mirakurun-rs/src/cli.rs create mode 100644 crates/mirakurun-rs/src/commands.rs create mode 100644 crates/mirakurun-rs/src/jobs.rs create mode 100644 crates/mirakurun-rs/src/main.rs create mode 100644 crates/mirakurun-rs/src/rpc.rs create mode 100644 crates/mirakurun-rs/src/scan.rs create mode 100644 crates/mirakurun-rs/src/server.rs create mode 100644 crates/mirakurun-rs/src/state.rs create mode 100644 crates/mirakurun-types/Cargo.toml create mode 100644 crates/mirakurun-types/src/lib.rs create mode 100644 dist/mirakurun-rs.service create mode 100644 doc/installation.md create mode 100644 doc/migration.md create mode 100644 rust-toolchain.toml create mode 100644 web/package-lock.json create mode 100644 web/package.json create mode 100644 web/src/components/DateTimeRange.sass create mode 100644 web/src/components/DateTimeRange.tsx create mode 100644 web/src/components/EPGTable.sass create mode 100644 web/src/components/EPGTable.tsx create mode 100644 web/src/components/Nav.sass create mode 100644 web/src/components/Nav.tsx create mode 100644 web/src/components/ProgramAVInfo.sass create mode 100644 web/src/components/ProgramAVInfo.tsx create mode 100644 web/src/components/ProgramCardBase.sass create mode 100644 web/src/components/ProgramCardBase.tsx create mode 100644 web/src/components/ProgramGenres.sass create mode 100644 web/src/components/ProgramGenres.tsx create mode 100644 web/src/components/ProgramPopover.sass create mode 100644 web/src/components/ProgramPopover.tsx create mode 100644 web/src/components/ProgramRelatedLinks.sass create mode 100644 web/src/components/ProgramRelatedLinks.tsx create mode 100644 web/src/components/ProgramTitle.sass create mode 100644 web/src/components/ProgramTitle.tsx create mode 100644 web/src/components/Restart.tsx create mode 100644 web/src/components/ServiceLink.sass create mode 100644 web/src/components/ServiceLink.tsx create mode 100644 web/src/components/VersionStatus.tsx create mode 100644 web/src/components/WatchButton.tsx create mode 100644 web/src/custom.d.ts create mode 100644 web/src/hooks/useWebStorageState.ts create mode 100644 web/src/icon-active.svg create mode 100644 web/src/icon-gray.svg create mode 100644 web/src/icon.svg create mode 100644 web/src/index.html create mode 100644 web/src/index.sass create mode 100644 web/src/index.tsx create mode 100644 web/src/modules/at.ts create mode 100644 web/src/modules/common.ts create mode 100644 web/src/modules/constants.ts create mode 100644 web/src/modules/regexp.ts create mode 100644 web/src/modules/state.ts create mode 100644 web/src/modules/ui.ts create mode 100644 web/src/redoc-ui.html create mode 100644 web/src/routes/AboutView.sass create mode 100644 web/src/routes/AboutView.tsx create mode 100644 web/src/routes/ChannelsConfigView.sass create mode 100644 web/src/routes/ChannelsConfigView.tsx create mode 100644 web/src/routes/EPGView.tsx create mode 100644 web/src/routes/HomeView.sass create mode 100644 web/src/routes/HomeView.tsx create mode 100644 web/src/routes/JobsView.sass create mode 100644 web/src/routes/JobsView.tsx create mode 100644 web/src/routes/LogsView.sass create mode 100644 web/src/routes/LogsView.tsx create mode 100644 web/src/routes/ProgramView.sass create mode 100644 web/src/routes/ProgramView.tsx create mode 100644 web/src/routes/SearchView.sass create mode 100644 web/src/routes/SearchView.tsx create mode 100644 web/src/routes/ServerConfigView.sass create mode 100644 web/src/routes/ServerConfigView.tsx create mode 100644 web/src/routes/TunersConfigView.sass create mode 100644 web/src/routes/TunersConfigView.tsx create mode 100644 web/src/tsconfig.json create mode 100644 web/src/vars.sass create mode 100644 web/types/rpc.d.ts create mode 100644 web/webpack.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8d8da3d --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +/target +/.idea +/.vscode +*.swp +*.tmp +node_modules/ +web/dist/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8dd4cd7 --- /dev/null +++ b/AGENTS.md @@ -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、連続性カウンター、切断時の後処理を確認する。 diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..2b9bf93 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1688 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "base64", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sha1", + "sync_wrapper", + "tokio", + "tokio-tungstenite", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "const-oid" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + +[[package]] +name = "data-encoding" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "const-oid", + "crypto-common 0.2.2", +] + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +dependencies = [ + "serde", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "mirakurun-client" +version = "4.1.3-rs.0" +dependencies = [ + "bytes", + "futures-util", + "http", + "http-body-util", + "hyper", + "hyper-util", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "mirakurun-core" +version = "4.1.3-rs.0" +dependencies = [ + "base64", + "bytes", + "chrono", + "encoding_rs", + "mirakurun-types", + "nix", + "serde", + "serde_json", + "serde_yaml_ng", + "sha2 0.10.9", + "tempfile", + "thiserror", + "tokio", + "tracing", +] + +[[package]] +name = "mirakurun-rs" +version = "4.1.3-rs.0" +dependencies = [ + "anyhow", + "async-stream", + "axum", + "bytes", + "chrono", + "clap", + "futures-util", + "http", + "http-body-util", + "ipnet", + "mime_guess", + "mirakurun-client", + "mirakurun-core", + "mirakurun-types", + "rust-embed", + "serde", + "serde_json", + "tempfile", + "tokio", + "tokio-stream", + "tokio-util", + "tower", + "tower-http", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "mirakurun-types" +version = "4.1.3-rs.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "nix" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libc", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rust-embed" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" +dependencies = [ + "rust-embed-impl", + "rust-embed-utils", + "walkdir", +] + +[[package]] +name = "rust-embed-impl" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" +dependencies = [ + "mime_guess", + "proc-macro2", + "quote", + "rust-embed-utils", + "syn 2.0.119", + "walkdir", +] + +[[package]] +name = "rust-embed-utils" +version = "8.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" +dependencies = [ + "sha2 0.11.0", + "walkdir", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_yaml_ng" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "sha1" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest 0.10.7", +] + +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "futures-util", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "http", + "http-body", + "pin-project-lite", + "tower-layer", + "tower-service", + "tracing", + "uuid", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tungstenite" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", +] + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..478c6e9 --- /dev/null +++ b/Cargo.toml @@ -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" diff --git a/README.md b/README.md new file mode 100644 index 0000000..c37f0f9 --- /dev/null +++ b/README.md @@ -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は +オプション扱いで、ネイティブ版の互換性完成後に追加します。 diff --git a/api.d.ts b/api.d.ts new file mode 100644 index 0000000..632853d --- /dev/null +++ b/api.d.ts @@ -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; +} + +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 { + 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; + 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; + }; + }; +} diff --git a/config/channels.yml b/config/channels.yml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/config/channels.yml @@ -0,0 +1 @@ +[] diff --git a/config/server.yml b/config/server.yml new file mode 100644 index 0000000..8d61ec5 --- /dev/null +++ b/config/server.yml @@ -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 diff --git a/config/tuners.yml b/config/tuners.yml new file mode 100644 index 0000000..fe51488 --- /dev/null +++ b/config/tuners.yml @@ -0,0 +1 @@ +[] diff --git a/crates/mirakurun-client/Cargo.toml b/crates/mirakurun-client/Cargo.toml new file mode 100644 index 0000000..9690e12 --- /dev/null +++ b/crates/mirakurun-client/Cargo.toml @@ -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 diff --git a/crates/mirakurun-client/src/lib.rs b/crates/mirakurun-client/src/lib.rs new file mode 100644 index 0000000..10baf79 --- /dev/null +++ b/crates/mirakurun-client/src/lib.rs @@ -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> + 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 = std::result::Result; + +#[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) -> 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(&self, path: &str) -> Result + 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 { + 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> { + 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> { + 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> { + 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( + stream: T, + path: &str, + host: &str, +) -> Result> +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::::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"); + } +} diff --git a/crates/mirakurun-core/Cargo.toml b/crates/mirakurun-core/Cargo.toml new file mode 100644 index 0000000..3ab6ea3 --- /dev/null +++ b/crates/mirakurun-core/Cargo.toml @@ -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 diff --git a/crates/mirakurun-core/src/config.rs b/crates/mirakurun-core/src/config.rs new file mode 100644 index 0000000..d088f37 --- /dev/null +++ b/crates/mirakurun-core/src/config.rs @@ -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, + pub channels: Vec, +} + +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 { + 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( + path: &Path, + default_contents: &str, + kind: &'static str, +) -> Result +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(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 { + 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()); + } +} diff --git a/crates/mirakurun-core/src/epg.rs b/crates/mirakurun-core/src/epg.rs new file mode 100644 index 0000000..da1a660 --- /dev/null +++ b/crates/mirakurun-core/src/epg.rs @@ -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, + 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 { + let mut programs = self.programs.into_values().collect::>(); + programs.sort_by_key(|program| (program.start_at, program.id)); + programs + } +} + +fn parse_eit_section(section: &[u8], programs: &mut HashMap) -> 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::>(); + if !genres.is_empty() { + program.genres = Some(genres); + } +} + +fn decode_mjd_time(value: &[u8]) -> Option { + if value.len() != 5 || value.iter().all(|byte| *byte == 0xff) { + return None; + } + let mjd = i64::from(u16::from_be_bytes([value[0], value[1]])); + let hour = i64::from(decode_bcd(value[2])?); + let minute = i64::from(decode_bcd(value[3])?); + let second = i64::from(decode_bcd(value[4])?); + if hour > 23 || minute > 59 || second > 59 { + return None; + } + let unix_seconds = (mjd - 40_587) + .checked_mul(86_400)? + .checked_add((hour - 9).checked_mul(3600)?)? + .checked_add(minute.checked_mul(60)?)? + .checked_add(second)?; + u64::try_from(unix_seconds.checked_mul(1000)?).ok() +} + +fn decode_bcd_duration(value: &[u8]) -> Option { + if value.len() != 3 || value.iter().all(|byte| *byte == 0xff) { + return None; + } + let hours = u64::from(decode_bcd(value[0])?); + let minutes = u64::from(decode_bcd(value[1])?); + let seconds = u64::from(decode_bcd(value[2])?); + if minutes > 59 || seconds > 59 { + return None; + } + hours + .checked_mul(3600)? + .checked_add(minutes.checked_mul(60)?)? + .checked_add(seconds)? + .checked_mul(1000) +} + +fn decode_bcd(value: u8) -> Option { + let high = value >> 4; + let low = value & 0x0f; + (high <= 9 && low <= 9).then_some(high * 10 + low) +} + +#[derive(Debug, Clone, Copy)] +enum GraphicSet { + Kanji, + Alphanumeric, + Hiragana, + Katakana, +} + +pub(crate) fn decode_arib(input: &[u8]) -> String { + let mut output = String::new(); + let mut sets = [ + GraphicSet::Kanji, + GraphicSet::Alphanumeric, + GraphicSet::Hiragana, + GraphicSet::Katakana, + ]; + let mut left = 0usize; + let mut right = 2usize; + let mut single_shift = None; + let mut offset = 0; + while offset < input.len() { + let byte = input[offset]; + match byte { + 0x0e => left = 1, + 0x0f => left = 0, + 0x19 => single_shift = Some(2), + 0x1d => single_shift = Some(3), + 0x1b => { + offset += consume_escape(&input[offset..], &mut sets, &mut left, &mut right) + .saturating_sub(1); + } + 0x0d => output.push('\n'), + 0x20 | 0xa0 => output.push(' '), + 0x21..=0x7e => { + let set = single_shift.take().unwrap_or(left); + let consumed = decode_graphic(sets[set], &input[offset..], false, &mut output); + offset += consumed.saturating_sub(1); + } + 0xa1..=0xfe => { + let consumed = decode_graphic(sets[right], &input[offset..], true, &mut output); + offset += consumed.saturating_sub(1); + } + _ => {} + } + offset += 1; + } + output +} + +fn decode_graphic(set: GraphicSet, input: &[u8], high_bit: bool, output: &mut String) -> usize { + let first = if high_bit { input[0] & 0x7f } else { input[0] }; + match set { + GraphicSet::Kanji => { + let Some(second) = input.get(1).copied() else { + return 1; + }; + let second = if high_bit { second & 0x7f } else { second }; + append_euc_jp(&[first | 0x80, second | 0x80], output); + 2 + } + GraphicSet::Alphanumeric => { + output.push(char::from(first)); + 1 + } + GraphicSet::Hiragana => { + append_euc_jp(&[0xa4, first | 0x80], output); + 1 + } + GraphicSet::Katakana => { + append_euc_jp(&[0xa5, first | 0x80], output); + 1 + } + } +} + +fn append_euc_jp(bytes: &[u8], output: &mut String) { + let (decoded, had_errors) = EUC_JP.decode_without_bom_handling(bytes); + if had_errors { + output.push('\u{fffd}'); + } else { + output.push_str(&decoded); + } +} + +fn consume_escape( + input: &[u8], + sets: &mut [GraphicSet; 4], + left: &mut usize, + right: &mut usize, +) -> usize { + let Some(second) = input.get(1).copied() else { + return 1; + }; + match second { + 0x6e => *left = 2, + 0x6f => *left = 3, + 0x7c => *right = 3, + 0x7d => *right = 2, + 0x7e => *right = 1, + 0x28..=0x2b => { + let Some(code) = input.get(2).copied() else { + return 2; + }; + if let Some(set) = graphic_set(code) { + sets[usize::from(second - 0x28)] = set; + } + return 3; + } + 0x24 => { + let Some(third) = input.get(2).copied() else { + return 2; + }; + if matches!(third, 0x28..=0x2b) { + let Some(code) = input.get(3).copied() else { + return 3; + }; + if let Some(set) = graphic_set(code) { + sets[usize::from(third - 0x28)] = set; + } + return 4; + } + if let Some(set) = graphic_set(third) { + sets[0] = set; + } + return 3; + } + _ => {} + } + 2 +} + +const fn graphic_set(code: u8) -> Option { + match code { + 0x42 | 0x39 => Some(GraphicSet::Kanji), + 0x4a | 0x36 => Some(GraphicSet::Alphanumeric), + 0x30 | 0x37 => Some(GraphicSet::Hiragana), + 0x31 | 0x38 | 0x49 => Some(GraphicSet::Katakana), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use crate::ts::{PACKET_SIZE, crc32_mpeg2}; + + use 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); + } +} diff --git a/crates/mirakurun-core/src/error.rs b/crates/mirakurun-core/src/error.rs new file mode 100644 index 0000000..d98b622 --- /dev/null +++ b/crates/mirakurun-core/src/error.rs @@ -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 = std::result::Result; diff --git a/crates/mirakurun-core/src/filter.rs b/crates/mirakurun-core/src/filter.rs new file mode 100644 index 0000000..af63812 --- /dev/null +++ b/crates/mirakurun-core/src/filter.rs @@ -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, + pmt_pid: Option, + allowed_pids: HashSet, + 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, + expected: Option, +} + +impl SectionAssembler { + pub(crate) fn push(&mut self, packet: Packet<'_>) -> Vec> { + 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>) { + 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 { + 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> { + 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) { + 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) -> Vec { + 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 + } +} diff --git a/crates/mirakurun-core/src/lib.rs b/crates/mirakurun-core/src/lib.rs new file mode 100644 index 0000000..cbc0a35 --- /dev/null +++ b/crates/mirakurun-core/src/lib.rs @@ -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}; diff --git a/crates/mirakurun-core/src/persistence.rs b/crates/mirakurun-core/src/persistence.rs new file mode 100644 index 0000000..9cc4807 --- /dev/null +++ b/crates/mirakurun-core/src/persistence.rs @@ -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(path: &Path, expected_integrity: &str) -> Result> +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 = 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(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::, _>>()?, + ); + 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 { + 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 = load_json_db(&path, "expected") + .await + .expect("load database"); + assert_eq!(loaded, services); + + let rejected: Vec = 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="); + } +} diff --git a/crates/mirakurun-core/src/service.rs b/crates/mirakurun-core/src/service.rs new file mode 100644 index 0000000..34e8221 --- /dev/null +++ b/crates/mirakurun-core/src/service.rs @@ -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, + sections_seen: HashSet<(u8, u8)>, + last_section: Option, + 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 { + let mut services = self.services.into_values().collect::>(); + services.sort_by_key(|service| service.service_id); + services + } +} + +struct ParsedSdt { + version: u8, + section_number: u8, + last_section: u8, + services: Vec, +} + +fn parse_sdt_section(section: &[u8], channel: &ConfigChannel) -> Option { + 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); + } +} diff --git a/crates/mirakurun-core/src/ts.rs b/crates/mirakurun-core/src/ts.rs new file mode 100644 index 0000000..d00e3c5 --- /dev/null +++ b/crates/mirakurun-core/src/ts.rs @@ -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 { + 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, +} + +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 { + self.entries + .iter() + .map(|(&pid, &(_, stats))| (pid, stats)) + .collect() + } +} + +#[derive(Debug, Default)] +pub struct PacketFramer { + buffer: Vec, +} + +impl PacketFramer { + pub fn push(&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 { + 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); + } +} diff --git a/crates/mirakurun-core/src/tuner.rs b/crates/mirakurun-core/src/tuner.rs new file mode 100644 index 0000000..61053d3 --- /dev/null +++ b/crates/mirakurun-core/src/tuner.rs @@ -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, + pub event_id: Option, + pub priority: i32, + pub agent: Option, + pub url: Option, + pub disable_decoder: bool, +} + +#[derive(Debug)] +pub struct TunerSubscription { + pub device_index: usize, + pub receiver: broadcast::Receiver, + pub decoder: Option, + pub user_id: String, + command: mpsc::Sender, + 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>, +} + +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 { + 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 { + 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 { + 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, +} + +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 { + 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 { + let (response, result) = oneshot::channel(); + self.command + .send(DeviceCommand::Status { response }) + .await + .ok()?; + result.await.ok() + } + + async fn snapshot(&self) -> Option { + 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>, + }, + Unsubscribe { + subscription_id: u64, + }, + StopIfIdle { + generation: u64, + }, + OutputEnded { + generation: u64, + }, + Status { + response: oneshot::Sender, + }, + Snapshot { + response: oneshot::Sender, + }, + Kill { + response: oneshot::Sender>, + }, +} + +#[derive(Debug)] +struct DeviceActor { + index: usize, + config: ConfigTuner, + command_sender: mpsc::Sender, + current_channel: Option, + command_display: Option, + process: Option, + broadcaster: Option>, + users: HashMap, + available: bool, + fault: bool, + fatal_count: u8, + generation: u64, +} + +impl DeviceActor { + fn new(index: usize, config: ConfigTuner, command_sender: mpsc::Sender) -> 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) { + 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 { + 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, + channel: Option, + users: usize, + priority: i32, + available: bool, + fault: bool, +} + +#[derive(Debug)] +struct ProcessGroup { + children: Vec, +} + +impl ProcessGroup { + fn pid(&self) -> Option { + 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: "".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) { + match output { + TunerOutput::Process(output) => broadcast_output(output, broadcaster).await, + TunerOutput::Dvb(output) => broadcast_output(output, broadcaster).await, + } +} + +async fn broadcast_output(mut output: R, broadcaster: broadcast::Sender) +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)> { + 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 ", &channel), + "rec BS BS01_0 JCSAT" + ); + } +} diff --git a/crates/mirakurun-rs/Cargo.toml b/crates/mirakurun-rs/Cargo.toml new file mode 100644 index 0000000..ff04265 --- /dev/null +++ b/crates/mirakurun-rs/Cargo.toml @@ -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 diff --git a/crates/mirakurun-rs/build.rs b/crates/mirakurun-rs/build.rs new file mode 100644 index 0000000..b19c67f --- /dev/null +++ b/crates/mirakurun-rs/build.rs @@ -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}"); +} diff --git a/crates/mirakurun-rs/src/access.rs b/crates/mirakurun-rs/src/access.rs new file mode 100644 index 0000000..862adea --- /dev/null +++ b/crates/mirakurun-rs/src/access.rs @@ -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>, + request: Request, + next: Next, +) -> Response { + let config = state.config.read().await.server.clone(); + let remote_address = request + .extensions() + .get::>() + .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::() 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::() + .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)); + } +} diff --git a/crates/mirakurun-rs/src/api.rs b/crates/mirakurun-rs/src/api.rs new file mode 100644 index 0000000..c7f89d9 --- /dev/null +++ b/crates/mirakurun-rs/src/api.rs @@ -0,0 +1,1700 @@ +// 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}, + convert::Infallible, + io, + process::Stdio, + sync::{Arc, atomic::Ordering}, +}; + +use axum::{ + Json, Router, + body::Body, + extract::{Path, Query, State}, + http::{ + HeaderMap, HeaderValue, StatusCode, + header::{CONTENT_TYPE, HOST}, + }, + response::{IntoResponse, Response}, + routing::{get, put}, +}; +use bytes::Bytes; +use chrono::{DateTime, Utc}; +use mirakurun_core::{ + config::{save_channels, save_server, save_tuners}, + tuner::StreamRequest, +}; +use mirakurun_types::{ + ApiError, Channel, ChannelType, ConfigChannel, ConfigServer, ConfigTuner, EpgStatus, + ErrorCount, MemoryUsage, ProcessStatus, Program, Service, Status, StreamCount, TimerAccuracy, + TunerProcess, Version, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use tokio::sync::broadcast; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + process::{Child, ChildStdin, ChildStdout, Command}, +}; + +use crate::{ + jobs, scan, + state::{AppState, DecoderGuard, StreamGuard}, +}; + +pub fn router() -> Router> { + Router::new() + .route("/api/docs", get(get_docs)) + .route("/api/channels", get(get_channels)) + .route("/api/channels/{type}", get(get_channels_by_type)) + .route("/api/channels/{type}/{channel}", get(get_channel)) + .route( + "/api/channels/{type}/{channel}/services", + get(get_services_by_channel), + ) + .route( + "/api/channels/{type}/{channel}/services/{id}", + get(get_service_by_channel), + ) + .route( + "/api/channels/{type}/{channel}/stream", + get(get_channel_stream).head(head_channel_stream), + ) + .route( + "/api/channels/{type}/{channel}/services/{id}/stream", + get(get_service_stream_by_channel).head(head_service_stream_by_channel), + ) + .route("/api/services", get(get_services)) + .route("/api/services/{id}", get(get_service)) + .route("/api/services/{id}/logo", get(get_service_logo)) + .route( + "/api/services/{id}/stream", + get(get_service_stream).head(head_service_stream), + ) + .route("/api/programs", get(get_programs)) + .route("/api/programs/{id}", get(get_program)) + .route( + "/api/programs/{id}/stream", + get(get_program_stream).head(head_program_stream), + ) + .route("/api/tuners", get(get_tuners)) + .route("/api/tuners/{index}", get(get_tuner)) + .route( + "/api/tuners/{index}/process", + get(get_tuner_process).delete(delete_tuner_process), + ) + .route( + "/api/config/server", + get(get_server_config).put(put_server_config), + ) + .route( + "/api/config/tuners", + get(get_tuners_config).put(put_tuners_config), + ) + .route( + "/api/config/channels", + get(get_channels_config).put(put_channels_config), + ) + .route( + "/api/config/channels/scan", + get(get_channel_scan_status) + .put(start_channel_scan) + .delete(stop_channel_scan), + ) + .route("/api/events", get(get_events)) + .route("/api/events/stream", get(get_events_stream)) + .route("/api/log", get(get_log)) + .route("/api/log/stream", get(get_log_stream)) + .route("/api/jobs", get(get_jobs)) + .route("/api/job-schedules", get(get_job_schedules)) + .route("/api/job-schedules/{key}/run", put(run_job_schedule)) + .route("/api/jobs/{id}/abort", put(abort_job)) + .route("/api/jobs/{id}/rerun", put(rerun_job)) + .route("/api/status", get(get_status)) + .route("/api/version", get(get_version)) + .route("/api/restart", put(restart)) + .route("/api/iptv/discover.json", get(get_iptv_discover)) + .route("/api/iptv/lineup.json", get(get_iptv_lineup)) + .route("/api/iptv/lineup_status.json", get(get_iptv_lineup_status)) + .route("/api/iptv/playlist", get(get_iptv_playlist)) + .route("/api/iptv/xmltv", get(get_iptv_xmltv)) +} + +#[derive(Debug)] +pub struct ApiFailure { + status: StatusCode, + reason: Option, +} + +impl ApiFailure { + fn not_found() -> Self { + Self { + status: StatusCode::NOT_FOUND, + reason: None, + } + } + + fn bad_request(reason: impl Into) -> Self { + Self { + status: StatusCode::BAD_REQUEST, + reason: Some(reason.into()), + } + } + + fn unavailable(reason: impl Into) -> Self { + Self { + status: StatusCode::SERVICE_UNAVAILABLE, + reason: Some(reason.into()), + } + } + + fn conflict(reason: impl Into) -> Self { + Self { + status: StatusCode::CONFLICT, + reason: Some(reason.into()), + } + } + + fn internal(reason: impl Into) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + reason: Some(reason.into()), + } + } +} + +impl IntoResponse for ApiFailure { + fn into_response(self) -> Response { + let body = ApiError { + code: self.status.as_u16(), + reason: self.reason, + errors: Vec::new(), + }; + (self.status, Json(body)).into_response() + } +} + +async fn get_docs() -> Json { + Json(json!({ + "swagger": "2.0", + "info": { + "title": "Mirakurun", + "version": env!("CARGO_PKG_VERSION"), + "description": "DVR Tuner Server for Japanese TV." + }, + "basePath": "/api", + "consumes": ["application/json"], + "produces": ["application/json"], + "paths": documented_paths(), + "definitions": {} + })) +} + +fn documented_paths() -> Value { + let paths: &[(&str, &[&str])] = &[ + ("/channels", &["get"]), + ("/channels/{type}", &["get"]), + ("/channels/{type}/{channel}", &["get"]), + ("/channels/{type}/{channel}/services", &["get"]), + ("/channels/{type}/{channel}/services/{id}", &["get"]), + ("/channels/{type}/{channel}/stream", &["get", "head"]), + ( + "/channels/{type}/{channel}/services/{id}/stream", + &["get", "head"], + ), + ("/services", &["get"]), + ("/services/{id}", &["get"]), + ("/services/{id}/logo", &["get"]), + ("/services/{id}/stream", &["get", "head"]), + ("/programs", &["get"]), + ("/programs/{id}", &["get"]), + ("/programs/{id}/stream", &["get", "head"]), + ("/tuners", &["get"]), + ("/tuners/{index}", &["get"]), + ("/tuners/{index}/process", &["get", "delete"]), + ("/config/server", &["get", "put"]), + ("/config/tuners", &["get", "put"]), + ("/config/channels", &["get", "put"]), + ("/config/channels/scan", &["get", "put", "delete"]), + ("/events", &["get"]), + ("/events/stream", &["get"]), + ("/log", &["get"]), + ("/log/stream", &["get"]), + ("/jobs", &["get"]), + ("/jobs/{id}/abort", &["put"]), + ("/jobs/{id}/rerun", &["put"]), + ("/job-schedules", &["get"]), + ("/job-schedules/{key}/run", &["put"]), + ("/status", &["get"]), + ("/version", &["get"]), + ("/restart", &["put"]), + ("/iptv/discover.json", &["get"]), + ("/iptv/lineup.json", &["get"]), + ("/iptv/lineup_status.json", &["get"]), + ("/iptv/playlist", &["get"]), + ("/iptv/xmltv", &["get"]), + ]; + Value::Object( + paths + .iter() + .map(|(path, methods)| { + ( + (*path).into(), + Value::Object( + methods + .iter() + .map(|method| { + ( + (*method).into(), + json!({"responses": {"200": {"description": "OK"}}}), + ) + }) + .collect(), + ), + ) + }) + .collect(), + ) +} + +async fn get_channels( + State(state): State>, + Query(query): Query>, +) -> Json> { + Json( + build_channels(&state) + .await + .into_iter() + .filter(|channel| matches_channel_query(channel, &query)) + .collect(), + ) +} + +async fn get_channels_by_type( + State(state): State>, + Path(channel_type): Path, + Query(query): Query>, +) -> Json> { + Json( + build_channels(&state) + .await + .into_iter() + .filter(|channel| { + channel.channel_type == channel_type && matches_channel_query(channel, &query) + }) + .collect(), + ) +} + +async fn get_channel( + State(state): State>, + Path((channel_type, channel_name)): Path<(ChannelType, String)>, +) -> Result, ApiFailure> { + find_channel(&state, channel_type, &channel_name) + .await + .map(Json) + .ok_or_else(ApiFailure::not_found) +} + +async fn get_services_by_channel( + State(state): State>, + Path((channel_type, channel_name)): Path<(ChannelType, String)>, +) -> Result>, ApiFailure> { + let channel = find_channel(&state, channel_type, &channel_name) + .await + .ok_or_else(ApiFailure::not_found)?; + Ok(Json(channel.services.unwrap_or_default())) +} + +async fn get_service_by_channel( + State(state): State>, + Path((channel_type, channel_name, service_id)): Path<(ChannelType, String, u16)>, +) -> Result, ApiFailure> { + let channel = find_channel(&state, channel_type, &channel_name) + .await + .ok_or_else(ApiFailure::not_found)?; + channel + .services + .unwrap_or_default() + .into_iter() + .find(|service| service.service_id == service_id) + .map(Json) + .ok_or_else(ApiFailure::not_found) +} + +async fn get_services( + State(state): State>, + Query(query): Query>, +) -> Json> { + let mut services = build_services(&state).await; + services.retain(|service| matches_service_query(service, &query)); + Json(services) +} + +pub async fn build_services(state: &AppState) -> Vec { + let mut services = state.services.read().await.clone(); + services.sort_by_key(service_order); + for service in &mut services { + service.has_logo_data = Some(logo_exists(state, service).await); + } + services +} + +async fn get_service( + State(state): State>, + Path(id): Path, +) -> Result, ApiFailure> { + let mut service = state + .services + .read() + .await + .iter() + .find(|service| service.id == id) + .cloned() + .ok_or_else(ApiFailure::not_found)?; + service.has_logo_data = Some(logo_exists(&state, &service).await); + Ok(Json(service)) +} + +async fn get_service_logo( + State(state): State>, + Path(id): Path, +) -> Result { + let service = state + .services + .read() + .await + .iter() + .find(|service| service.id == id) + .cloned() + .ok_or_else(ApiFailure::not_found)?; + let logo_id = service.logo_id.ok_or_else(ApiFailure::not_found)?; + let path = state + .paths + .logo_data_dir + .join(format!("{}_{}.png", service.network_id, logo_id)); + let data = tokio::fs::read(path) + .await + .map_err(|_| ApiFailure::not_found())?; + Ok(( + StatusCode::OK, + [(CONTENT_TYPE, HeaderValue::from_static("image/png"))], + data, + ) + .into_response()) +} + +async fn get_programs( + State(state): State>, + Query(query): Query>, +) -> Json> { + Json( + state + .programs + .read() + .await + .iter() + .filter(|program| matches_program_query(program, &query)) + .cloned() + .collect(), + ) +} + +async fn get_program( + State(state): State>, + Path(id): Path, +) -> Result, ApiFailure> { + state + .programs + .read() + .await + .iter() + .find(|program| program.id == id) + .cloned() + .map(Json) + .ok_or_else(ApiFailure::not_found) +} + +async fn get_tuners(State(state): State>) -> Json> { + Json(state.tuners.statuses().await) +} + +async fn get_tuner( + State(state): State>, + Path(index): Path, +) -> Result, ApiFailure> { + state + .tuners + .statuses() + .await + .into_iter() + .find(|tuner| tuner.index == index) + .map(Json) + .ok_or_else(ApiFailure::not_found) +} + +async fn get_tuner_process( + State(state): State>, + Path(index): Path, +) -> Result, ApiFailure> { + state + .tuners + .statuses() + .await + .into_iter() + .find(|tuner| tuner.index == index) + .and_then(|tuner| tuner.pid) + .map(|pid| Json(TunerProcess { pid })) + .ok_or_else(ApiFailure::not_found) +} + +async fn delete_tuner_process( + State(state): State>, + Path(index): Path, +) -> Result { + let is_running = state + .tuners + .statuses() + .await + .into_iter() + .any(|tuner| tuner.index == index && tuner.pid.is_some()); + if !is_running { + return Err(ApiFailure::not_found()); + } + state + .tuners + .kill(index) + .await + .map_err(|error| ApiFailure::internal(error.to_string()))?; + Ok(StatusCode::NO_CONTENT) +} + +async fn get_server_config(State(state): State>) -> Json { + Json(state.config.read().await.server.clone()) +} + +async fn put_server_config( + State(state): State>, + Json(config): Json, +) -> Result, ApiFailure> { + save_server(&state.paths.server, &config) + .await + .map_err(|error| ApiFailure::internal(error.to_string()))?; + state.config.write().await.server = config.clone(); + Ok(Json(config)) +} + +async fn get_tuners_config(State(state): State>) -> Json> { + Json(state.config.read().await.tuners.clone()) +} + +async fn put_tuners_config( + State(state): State>, + Json(config): Json>, +) -> Result>, ApiFailure> { + save_tuners(&state.paths.tuners, &config) + .await + .map_err(|error| ApiFailure::internal(error.to_string()))?; + state.config.write().await.tuners.clone_from(&config); + Ok(Json(config)) +} + +async fn get_channels_config(State(state): State>) -> Json> { + Json(state.config.read().await.channels.clone()) +} + +async fn put_channels_config( + State(state): State>, + Json(config): Json>, +) -> Result>, ApiFailure> { + save_channels(&state.paths.channels, &config) + .await + .map_err(|error| ApiFailure::internal(error.to_string()))?; + state.config.write().await.channels.clone_from(&config); + Ok(Json(config)) +} + +async fn get_channel_scan_status( + State(state): State>, +) -> Json { + Json(state.scan_status.read().await.clone()) +} + +async fn start_channel_scan( + State(state): State>, + Query(query): Query>, +) -> Result { + let options = scan::ScanOptions::parse(&query).map_err(ApiFailure::bad_request)?; + let cancel = scan::initialize(&state, &options) + .await + .map_err(ApiFailure::conflict)?; + if options.is_asynchronous() { + tokio::spawn(scan::run(state, options, cancel)); + return Ok(( + StatusCode::ACCEPTED, + Json(json!({ + "status": "accepted", + "message": "Channel scan started in async mode" + })), + ) + .into_response()); + } + scan::run(state.clone(), options, cancel).await; + let text = state + .scan_status + .read() + .await + .scan_log + .clone() + .unwrap_or_default() + .concat(); + Ok(response_with_content_type( + Body::from(text), + "text/plain; charset=utf-8", + )) +} + +async fn stop_channel_scan(State(state): State>) -> Result { + if !state.scan_status.read().await.is_scanning { + return Err(ApiFailure { + status: StatusCode::NOT_FOUND, + reason: Some("No scan in progress".into()), + }); + } + let token = state.scan_cancel.lock().await.clone(); + let Some(token) = token else { + return Err(ApiFailure::conflict("Already Stopping")); + }; + if token.is_cancelled() { + return Err(ApiFailure::conflict("Already Stopping")); + } + token.cancel(); + { + let mut status = state.scan_status.write().await; + status.status = mirakurun_types::ChannelScanPhase::Cancelled; + status + .scan_log + .get_or_insert_with(Vec::new) + .push("Scan cancellation requested by user.\n".into()); + status.update_time = Some(AppState::now_ms()); + } + Ok(( + StatusCode::PARTIAL_CONTENT, + Json(json!({ + "status": "stopping", + "message": "Channel scan stop has been requested" + })), + ) + .into_response()) +} + +async fn get_events(State(state): State>) -> Json> { + Json(state.event_history.read().await.iter().cloned().collect()) +} + +async fn get_events_stream( + State(state): State>, + Query(query): Query>, +) -> Response { + let mut receiver = state.event_sender.subscribe(); + let output = async_stream::stream! { + yield Ok::(Bytes::from_static(b"[\n")); + loop { + match receiver.recv().await { + Ok(event) if matches_event_query(&event, &query) => { + if let Ok(mut encoded) = serde_json::to_vec(&event) { + encoded.extend_from_slice(b"\n,\n"); + yield Ok(Bytes::from(encoded)); + } + } + Ok(_) | Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => break, + } + } + }; + response_with_content_type(Body::from_stream(output), "application/json; charset=utf-8") +} + +async fn get_log(State(state): State>) -> Response { + let mut text = state + .log_history + .read() + .await + .iter() + .cloned() + .collect::>() + .join("\n"); + if !text.is_empty() { + text.push('\n'); + } + response_with_content_type(Body::from(text), "text/plain; charset=utf-8") +} + +async fn get_log_stream(State(state): State>) -> Response { + let mut receiver = state.log_sender.subscribe(); + let output = async_stream::stream! { + loop { + match receiver.recv().await { + Ok(line) => yield Ok::(Bytes::from(format!("{line}\n"))), + Err(broadcast::error::RecvError::Lagged(_)) => {} + Err(broadcast::error::RecvError::Closed) => break, + } + } + }; + response_with_content_type(Body::from_stream(output), "text/plain; charset=utf-8") +} + +async fn get_jobs(State(state): State>) -> Json> { + Json(state.jobs.read().await.clone()) +} + +async fn get_job_schedules( + State(state): State>, +) -> Json> { + Json(state.job_schedules.read().await.clone()) +} + +async fn run_job_schedule( + State(state): State>, + Path(key): Path, +) -> Result { + if jobs::run_schedule(state, &key).await { + Ok(StatusCode::ACCEPTED) + } else { + Err(ApiFailure::not_found()) + } +} + +async fn abort_job( + State(state): State>, + Path(id): Path, +) -> Result { + if jobs::abort(&state, &id).await { + Ok(StatusCode::ACCEPTED) + } else { + Err(ApiFailure::conflict( + "job is missing or already been abort requested or other unacceptable state", + )) + } +} + +async fn rerun_job( + State(state): State>, + Path(id): Path, +) -> Result { + if jobs::rerun(state, &id).await { + Ok(StatusCode::ACCEPTED) + } else { + Err(ApiFailure::conflict( + "job is missing or already been abort requested or other unacceptable state", + )) + } +} + +async fn get_status(State(state): State>) -> Json { + Json(build_status(&state).await) +} + +pub async fn build_status(state: &AppState) -> Status { + let tuner_statuses = state.tuners.statuses().await; + let mut versions = BTreeMap::new(); + versions.insert( + "rust".into(), + option_env!("RUSTC_VERSION").unwrap_or("unknown").into(), + ); + versions.insert("mirakurun".into(), env!("CARGO_PKG_VERSION").into()); + let environment_names = [ + "PATH", + "DOCKER", + "DOCKER_NETWORK", + "SERVER_CONFIG_PATH", + "TUNERS_CONFIG_PATH", + "CHANNELS_CONFIG_PATH", + "SERVICES_DB_PATH", + "PROGRAMS_DB_PATH", + "LOGO_DATA_DIR_PATH", + ]; + let environment = environment_names + .into_iter() + .filter_map(|name| std::env::var(name).ok().map(|value| (name.into(), value))) + .collect(); + Status { + time: AppState::now_ms(), + version: env!("CARGO_PKG_VERSION").into(), + process: ProcessStatus { + arch: std::env::consts::ARCH.into(), + platform: std::env::consts::OS.into(), + versions, + env: environment, + pid: std::process::id(), + memory_usage: process_memory_usage(), + }, + epg: EpgStatus { + gathering_networks: state + .gathering_networks + .read() + .await + .iter() + .copied() + .collect(), + stored_events: state.programs.read().await.len(), + }, + rpc_count: state.rpc_count(), + stream_count: StreamCount { + tuner_device: tuner_statuses.iter().filter(|tuner| tuner.is_using).count() as u64, + ts_filter: state.stream_count.load(Ordering::Relaxed), + decoder: state.decoder_count.load(Ordering::Relaxed), + }, + error_count: ErrorCount { + buffer_overflow: state.buffer_overflow_count.load(Ordering::Relaxed), + ..ErrorCount::default() + }, + timer_accuracy: TimerAccuracy::default(), + } +} + +async fn get_version() -> Json { + let version = env!("CARGO_PKG_VERSION").to_owned(); + Json(Version { + current: version.clone(), + latest: version, + }) +} + +async fn restart(State(state): State>) -> (StatusCode, Json) { + tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + state.restart_requested.store(true, Ordering::Release); + state.shutdown.cancel(); + }); + ( + StatusCode::ACCEPTED, + Json(json!({ + "type": "restart", + "data": {} + })), + ) +} + +async fn get_iptv_discover(State(state): State>, headers: HeaderMap) -> Json { + let host = header_text(&headers, HOST).unwrap_or("localhost"); + let api_root = format!("http://{host}/api"); + let device_id: String = host + .chars() + .filter(|character| !matches!(character, '[' | ']' | '.' | ':')) + .collect(); + Json(json!({ + "FriendlyName": "Mirakurun", + "ModelNumber": "MIRAKURUN", + "FirmwareName": format!("mirakurun_{}_{}", std::env::consts::ARCH, std::env::consts::OS), + "FirmwareVersion": env!("CARGO_PKG_VERSION"), + "Manufacturer": "Chinachu Project", + "DeviceID": device_id, + "DeviceAuth": "MIRAKURUN", + "TunerCount": state.tuners.statuses().await.len(), + "BaseURL": format!("{api_root}/iptv"), + "LineupURL": format!("{api_root}/iptv/lineup.json") + })) +} + +async fn get_iptv_lineup( + State(state): State>, + headers: HeaderMap, +) -> Json> { + let host = header_text(&headers, HOST).unwrap_or("localhost"); + let api_root = format!("http://{host}/api"); + let mut services = state.services.read().await.clone(); + services.sort_by_key(service_order); + let mut counts = HashMap::::new(); + let mut lineup = Vec::new(); + for service in services + .iter() + .filter(|service| matches!(service.service_type, 1 | 173)) + { + let main = u16::from(service.remote_control_key_id.unwrap_or(0)); + let main = if main == 0 { service.service_id } else { main }; + let sub = counts + .entry(main) + .and_modify(|count| *count += 1) + .or_insert(1); + lineup.push(json!({ + "GuideNumber": format!("{main}.{sub}"), + "GuideName": service.name, + "HD": 1, + "URL": format!("{api_root}/services/{}/stream", service.id) + })); + } + Json(lineup) +} + +async fn get_iptv_lineup_status() -> Json { + Json(json!({ + "ScanInProgress": 0, + "ScanPossible": 0, + "Source": "Antenna", + "SourceList": ["Antenna"] + })) +} + +async fn get_iptv_playlist(State(state): State>, headers: HeaderMap) -> Response { + let host = header_text(&headers, HOST).unwrap_or("localhost"); + let api_root = format!("http://{host}/api"); + let mut services = state.services.read().await.clone(); + services.sort_by_key(service_order); + let mut playlist = format!("#EXTM3U url-tvg=\"{api_root}/iptv/xmltv\"\n"); + for service in services + .iter() + .filter(|service| matches!(service.service_type, 1 | 173)) + { + playlist.push_str("#KODIPROP:mimetype=video/mp2t\n"); + playlist.push_str(&format!("#EXTINF:-1 tvg-id=\"{}\"", service.id)); + if logo_exists(&state, service).await { + playlist.push_str(&format!( + " tvg-logo=\"{api_root}/services/{}/logo\"", + service.id + )); + } + let group = service + .channel + .as_deref() + .map_or("", |channel| channel.channel_type.as_str()); + playlist.push_str(&format!(" group-title=\"{group}\",{}\n", service.name)); + playlist.push_str(&format!("{api_root}/services/{}/stream\n", service.id)); + } + response_with_content_type(Body::from(playlist), "application/x-mpegURL; charset=utf-8") +} + +async fn get_iptv_xmltv(State(state): State>, headers: HeaderMap) -> Response { + let host = header_text(&headers, HOST).unwrap_or("localhost"); + let api_root = format!("http://{host}/api"); + let mut services = state.services.read().await.clone(); + services.sort_by_key(service_order); + let programs = state.programs.read().await.clone(); + let mut xml = String::from( + "\n\ + \n\ + \n", + ); + + let mut channel_numbers = HashMap::::new(); + for service in services + .iter() + .filter(|service| matches!(service.service_type, 1 | 173)) + { + let main = u16::from(service.remote_control_key_id.unwrap_or(0)); + let main = if main == 0 { service.service_id } else { main }; + let sub = channel_numbers + .entry(main) + .and_modify(|count| *count += 1) + .or_insert(1); + xml.push_str(&format!( + "\n{}\n\ + {main}.{sub}\n", + service.id, + escape_xml(&service.name) + )); + if logo_exists(&state, service).await { + xml.push_str(&format!( + "\n", + service.id + )); + } + xml.push_str("\n"); + } + + let service_ids = services + .iter() + .map(|service| ((service.network_id, service.service_id), service.id)) + .collect::>(); + for program in programs { + let Some(service_id) = service_ids.get(&(program.network_id, program.service_id)) else { + continue; + }; + let stop = program.start_at.saturating_add(program.duration); + xml.push_str(&format!( + "\n", + xmltv_datetime(program.start_at), + xmltv_datetime(stop) + )); + xml.push_str(&format!( + "{}\n{}\n", + escape_xml(program.name.as_deref().unwrap_or("")), + escape_xml(program.description.as_deref().unwrap_or("")) + )); + if let Some(genres) = program.genres { + for genre in genres { + xml.push_str(&format!( + "{}\n", + escape_xml(&genre_name(genre.lv1, genre.lv2, genre.un1, genre.un2)) + )); + } + } + xml.push_str("\n"); + } + xml.push_str(""); + response_with_content_type(Body::from(xml), "text/xml; charset=utf-8") +} + +fn escape_xml(value: &str) -> String { + value + .replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +fn xmltv_datetime(time_ms: u64) -> String { + let seconds = i64::try_from(time_ms / 1000).unwrap_or(i64::MAX); + DateTime::::from_timestamp(seconds, 0).map_or_else(String::new, |date| { + date.format("%Y%m%d%H%M%S +0000").to_string() + }) +} + +fn genre_name(level1: u8, level2: u8, user1: u8, user2: u8) -> String { + if level1 == 0xE { + return format!("拡張 - {level2:X}{user1:X}{user2:X}"); + } + format!( + "{} - {}", + genre_main_name(level1), + genre_sub_name(level1, level2) + ) +} + +fn genre_main_name(level1: u8) -> &'static str { + match level1 { + 0x0 => "ニュース/報道", + 0x1 => "スポーツ", + 0x2 => "情報/ワイドショー", + 0x3 => "ドラマ", + 0x4 => "音楽", + 0x5 => "バラエティ", + 0x6 => "映画", + 0x7 => "アニメ/特撮", + 0x8 => "ドキュメンタリー/教養", + 0x9 => "劇場/公演", + 0xA => "趣味/教育", + 0xB => "福祉", + 0xC | 0xD => "予備", + _ => "その他", + } +} + +fn genre_sub_name(level1: u8, level2: u8) -> &'static str { + match (level1, level2) { + (0x0, 0x0) => "定時・総合", + (0x0, 0x1) => "天気", + (0x0, 0x2) => "特集・ドキュメント", + (0x0, 0x3) => "政治・国会", + (0x0, 0x4) => "経済・市況", + (0x0, 0x5) => "海外・国際", + (0x0, 0x6) => "解説", + (0x0, 0x7) => "討論・会談", + (0x0, 0x8) => "報道特番", + (0x0, 0x9) => "ローカル・地域", + (0x0, 0xA) => "交通", + (0x1, 0x0) => "スポーツニュース", + (0x1, 0x1) => "野球", + (0x1, 0x2) => "サッカー", + (0x1, 0x3) => "ゴルフ", + (0x1, 0x4) => "その他の球技", + (0x1, 0x5) => "相撲・格闘技", + (0x1, 0x6) => "オリンピック・国際大会", + (0x1, 0x7) => "マラソン・陸上・水泳", + (0x1, 0x8) => "モータースポーツ", + (0x1, 0x9) => "マリン・ウィンタースポーツ", + (0x1, 0xA) => "競馬・公営競技", + (0x2, 0x0) => "芸能・ワイドショー", + (0x2, 0x1) => "ファッション", + (0x2, 0x2) => "暮らし・住まい", + (0x2, 0x3) => "健康・医療", + (0x2, 0x4) => "ショッピング・通販", + (0x2, 0x5) => "グルメ・料理", + (0x2, 0x6) => "イベント", + (0x2, 0x7) => "番組紹介・お知らせ", + (0x3, 0x0) => "国内ドラマ", + (0x3, 0x1) => "海外ドラマ", + (0x3, 0x2) => "時代劇", + (0x4, 0x0) => "国内ロック・ポップス", + (0x4, 0x1) => "海外ロック・ポップス", + (0x4, 0x2) => "クラシック・オペラ", + (0x4, 0x3) => "ジャズ・フュージョン", + (0x4, 0x4) => "歌謡曲・演歌", + (0x4, 0x5) => "ライブ・コンサート", + (0x4, 0x6) => "ランキング・リクエスト", + (0x4, 0x7) => "カラオケ・のど自慢", + (0x4, 0x8) => "民謡・邦楽", + (0x4, 0x9) => "童謡・キッズ", + (0x4, 0xA) => "民族音楽・ワールドミュージック", + (0x5, 0x0) => "クイズ", + (0x5, 0x1) => "ゲーム", + (0x5, 0x2) => "トークバラエティ", + (0x5, 0x3) => "お笑い・コメディ", + (0x5, 0x4) => "音楽バラエティ", + (0x5, 0x5) => "旅バラエティ", + (0x5, 0x6) => "料理バラエティ", + (0x6, 0x0) => "洋画", + (0x6, 0x1) => "邦画", + (0x6, 0x2) => "アニメ", + (0x7, 0x0) => "国内アニメ", + (0x7, 0x1) => "海外アニメ", + (0x7, 0x2) => "特撮", + (0x8, 0x0) => "社会・時事", + (0x8, 0x1) => "歴史・紀行", + (0x8, 0x2) => "自然・動物・環境", + (0x8, 0x3) => "宇宙・科学・医学", + (0x8, 0x4) => "カルチャー・伝統文化", + (0x8, 0x5) => "文学・文芸", + (0x8, 0x6) => "スポーツ", + (0x8, 0x7) => "ドキュメンタリー全般", + (0x8, 0x8) => "インタビュー・討論", + (0x9, 0x0) => "現代劇・新劇", + (0x9, 0x1) => "ミュージカル", + (0x9, 0x2) => "ダンス・バレエ", + (0x9, 0x3) => "落語・演芸", + (0x9, 0x4) => "歌舞伎・古典", + (0xA, 0x0) => "旅・釣り・アウトドア", + (0xA, 0x1) => "園芸・ペット・手芸", + (0xA, 0x2) => "音楽・美術・工芸", + (0xA, 0x3) => "囲碁・将棋", + (0xA, 0x4) => "麻雀・パチンコ", + (0xA, 0x5) => "車・オートバイ", + (0xA, 0x6) => "コンピュータ・TVゲーム", + (0xA, 0x7) => "会話・語学", + (0xA, 0x8) => "幼児・小学生", + (0xA, 0x9) => "中学生・高校生", + (0xA, 0xA) => "大学生・受験", + (0xA, 0xB) => "生涯教育・資格", + (0xA, 0xC) => "教育問題", + (0xB, 0x0) => "高齢者", + (0xB, 0x1) => "障害者", + (0xB, 0x2) => "社会福祉", + (0xB, 0x3) => "ボランティア", + (0xB, 0x4) => "手話", + (0xB, 0x5) => "文字(字幕)", + (0xB, 0x6) => "音声解説", + _ => "その他", + } +} + +async fn get_channel_stream( + State(state): State>, + Path((channel_type, channel_name)): Path<(ChannelType, String)>, + headers: HeaderMap, + Query(query): Query, +) -> Result { + let channel = find_config_channel(&state, channel_type, &channel_name) + .await + .ok_or_else(ApiFailure::not_found)?; + stream_channel( + state, + channel, + None, + None, + None, + query.decode == Some(0), + headers, + ) + .await +} + +async fn head_channel_stream( + State(state): State>, + Path((channel_type, channel_name)): Path<(ChannelType, String)>, +) -> Result { + find_config_channel(&state, channel_type, &channel_name) + .await + .ok_or_else(ApiFailure::not_found)?; + Ok(empty_stream_response()) +} + +async fn get_service_stream( + State(state): State>, + Path(id): Path, + headers: HeaderMap, + Query(query): Query, +) -> Result { + let service = state + .services + .read() + .await + .iter() + .find(|service| service.id == id) + .cloned() + .ok_or_else(ApiFailure::not_found)?; + let service_channel = service + .channel + .as_deref() + .ok_or_else(ApiFailure::not_found)?; + let channel = find_config_channel( + &state, + service_channel.channel_type, + &service_channel.channel, + ) + .await + .ok_or_else(ApiFailure::not_found)?; + stream_channel( + state, + channel, + Some(service.service_id), + None, + None, + query.decode == Some(0), + headers, + ) + .await +} + +async fn head_service_stream( + State(state): State>, + Path(id): Path, +) -> Result { + if state + .services + .read() + .await + .iter() + .any(|service| service.id == id) + { + Ok(empty_stream_response()) + } else { + Err(ApiFailure::not_found()) + } +} + +async fn get_service_stream_by_channel( + State(state): State>, + Path((channel_type, channel_name, service_id)): Path<(ChannelType, String, u16)>, + headers: HeaderMap, + Query(query): Query, +) -> Result { + let exists = service_exists_on_channel(&state, channel_type, &channel_name, service_id).await; + if !exists { + return Err(ApiFailure::not_found()); + } + let channel = find_config_channel(&state, channel_type, &channel_name) + .await + .ok_or_else(ApiFailure::not_found)?; + stream_channel( + state, + channel, + Some(service_id), + None, + None, + query.decode == Some(0), + headers, + ) + .await +} + +async fn head_service_stream_by_channel( + State(state): State>, + Path((channel_type, channel_name, service_id)): Path<(ChannelType, String, u16)>, +) -> Result { + if service_exists_on_channel(&state, channel_type, &channel_name, service_id).await { + Ok(empty_stream_response()) + } else { + Err(ApiFailure::not_found()) + } +} + +async fn get_program_stream( + State(state): State>, + Path(id): Path, + headers: HeaderMap, + Query(query): Query, +) -> Result { + let program = state + .programs + .read() + .await + .iter() + .find(|program| program.id == id) + .cloned() + .ok_or_else(ApiFailure::not_found)?; + let service = state + .services + .read() + .await + .iter() + .find(|service| { + service.network_id == program.network_id && service.service_id == program.service_id + }) + .cloned() + .ok_or_else(ApiFailure::not_found)?; + let service_channel = service + .channel + .as_deref() + .ok_or_else(ApiFailure::not_found)?; + let channel = find_config_channel( + &state, + service_channel.channel_type, + &service_channel.channel, + ) + .await + .ok_or_else(ApiFailure::not_found)?; + let event_end_timeout = state + .config + .read() + .await + .server + .event_end_timeout + .unwrap_or(1000); + let end_at = program + .start_at + .saturating_add(program.duration) + .saturating_add(event_end_timeout); + stream_channel( + state, + channel, + Some(program.service_id), + Some(program.event_id), + Some(end_at), + query.decode == Some(0), + headers, + ) + .await +} + +async fn head_program_stream( + State(state): State>, + Path(id): Path, +) -> Result { + if state + .programs + .read() + .await + .iter() + .any(|program| program.id == id) + { + Ok(empty_stream_response()) + } else { + Err(ApiFailure::not_found()) + } +} + +async fn stream_channel( + state: Arc, + channel: ConfigChannel, + service_id: Option, + event_id: Option, + end_at: Option, + disable_decoder: bool, + headers: HeaderMap, +) -> Result { + let priority = header_text(&headers, "x-mirakurun-priority") + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + let request = StreamRequest { + channel, + service_id, + event_id, + priority, + agent: header_text(&headers, "user-agent").map(str::to_owned), + url: None, + disable_decoder, + }; + let mut subscription = state + .tuners + .subscribe(request) + .await + .map_err(|error| ApiFailure::unavailable(error.to_string()))?; + let user_id = subscription.user_id.clone(); + let mut decoder = subscription + .decoder + .as_deref() + .map(spawn_decoder) + .transpose()?; + let stream_state = state.clone(); + let output = async_stream::stream! { + let _guard = StreamGuard::new(stream_state.clone()); + let _decoder_guard = decoder + .as_ref() + .map(|_| DecoderGuard::new(stream_state.clone())); + let mut service_filter = service_id.map(mirakurun_core::filter::ServiceFilter::new); + let mut decoder_buffer = vec![0_u8; 32 * 1024]; + let mut source_open = true; + loop { + if stream_expired(end_at) { + break; + } + if let Some(process) = decoder.as_mut() { + if source_open { + tokio::select! { + source = subscription.receiver.recv() => { + match source { + Ok(chunk) => { + let filtered = filter_stream_chunk(&mut service_filter, &chunk); + if !filtered.is_empty() + && process.stdin.write_all(&filtered).await.is_err() + { + break; + } + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + stream_state.buffer_overflow_count.fetch_add(skipped, Ordering::Relaxed); + break; + } + Err(broadcast::error::RecvError::Closed) => { + source_open = false; + let _ = process.stdin.shutdown().await; + } + } + } + decoder_read = process.stdout.read(&mut decoder_buffer) => { + match decoder_read { + Ok(0) | Err(_) => break, + Ok(length) => { + yield Ok::(Bytes::copy_from_slice(&decoder_buffer[..length])); + } + } + } + } + } else { + match process.stdout.read(&mut decoder_buffer).await { + Ok(0) | Err(_) => break, + Ok(length) => { + yield Ok::(Bytes::copy_from_slice(&decoder_buffer[..length])); + } + } + } + } else { + match subscription.receiver.recv().await { + Ok(chunk) => { + let filtered = filter_stream_chunk(&mut service_filter, &chunk); + if !filtered.is_empty() { + yield Ok::(filtered); + } + } + Err(broadcast::error::RecvError::Lagged(skipped)) => { + stream_state.buffer_overflow_count.fetch_add(skipped, Ordering::Relaxed); + break; + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + } + }; + Ok(stream_response(Body::from_stream(output), &user_id)) +} + +fn stream_expired(end_at: Option) -> bool { + end_at.is_some_and(|end_at| AppState::now_ms() >= end_at) +} + +fn stream_response(body: Body, user_id: &str) -> Response { + let mut response = response_with_content_type(body, "video/MP2T"); + if let Ok(value) = HeaderValue::from_str(user_id) { + response + .headers_mut() + .insert("x-mirakurun-tuner-user-id", value); + } + response +} + +#[derive(Debug, Default, Deserialize)] +struct StreamQuery { + decode: Option, +} + +struct DecoderProcess { + _child: Child, + stdin: ChildStdin, + stdout: ChildStdout, +} + +fn spawn_decoder(command_line: &str) -> Result { + let (program, arguments) = parse_process_command(command_line)?; + let mut command = Command::new(&program); + command + .args(arguments) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true); + let mut child = command + .spawn() + .map_err(|error| ApiFailure::internal(format!("failed to start decoder: {error}")))?; + let stdin = child + .stdin + .take() + .ok_or_else(|| ApiFailure::internal("decoder stdin is unavailable"))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| ApiFailure::internal("decoder stdout is unavailable"))?; + Ok(DecoderProcess { + _child: child, + stdin, + stdout, + }) +} + +fn parse_process_command(command: &str) -> Result<(String, Vec), ApiFailure> { + 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(ApiFailure::internal( + "decoder command has an unterminated quote", + )); + } + if !current.is_empty() { + parts.push(current); + } + let mut parts = parts.into_iter(); + let program = parts + .next() + .ok_or_else(|| ApiFailure::internal("decoder command is empty"))?; + Ok((program, parts.collect())) +} + +fn filter_stream_chunk( + filter: &mut Option, + chunk: &Bytes, +) -> Bytes { + match filter { + Some(filter) => filter.push(chunk), + None => chunk.clone(), + } +} + +async fn build_channels(state: &AppState) -> Vec { + let configs = state.config.read().await.channels.clone(); + let services = state.services.read().await.clone(); + configs + .into_iter() + .filter(|config| config.is_disabled != Some(true)) + .map(|config| { + let channel_services = services + .iter() + .filter(|service| { + service.channel.as_deref().is_some_and(|channel| { + channel.channel_type == config.channel_type + && channel.channel == config.channel + }) + }) + .map(reduced_service) + .collect(); + Channel { + channel_type: config.channel_type, + channel: config.channel, + name: Some(config.name), + services: Some(channel_services), + } + }) + .collect() +} + +async fn find_channel( + state: &AppState, + channel_type: ChannelType, + channel_name: &str, +) -> Option { + build_channels(state) + .await + .into_iter() + .find(|channel| channel.channel_type == channel_type && channel.channel == channel_name) +} + +async fn find_config_channel( + state: &AppState, + channel_type: ChannelType, + channel_name: &str, +) -> Option { + state + .config + .read() + .await + .channels + .iter() + .find(|channel| { + channel.is_disabled != Some(true) + && channel.channel_type == channel_type + && channel.channel == channel_name + }) + .cloned() +} + +async fn service_exists_on_channel( + state: &AppState, + channel_type: ChannelType, + channel_name: &str, + service_id: u16, +) -> bool { + state.services.read().await.iter().any(|service| { + service.service_id == service_id + && service.channel.as_deref().is_some_and(|channel| { + channel.channel_type == channel_type && channel.channel == channel_name + }) + }) +} + +fn reduced_service(service: &Service) -> Service { + Service { + id: service.id, + service_id: service.service_id, + network_id: service.network_id, + name: service.name.clone(), + service_type: service.service_type, + logo_id: None, + has_logo_data: None, + remote_control_key_id: None, + epg_ready: None, + epg_updated_at: None, + channel: None, + } +} + +fn service_order(service: &Service) -> (u8, u16, u16) { + let channel_type = service + .channel + .as_deref() + .map_or(ChannelType::Sky, |channel| channel.channel_type); + let type_order = match channel_type { + ChannelType::Gr => 1, + ChannelType::Bs => 2, + ChannelType::Cs => 3, + ChannelType::Sky => 4, + }; + let remote_order = service + .remote_control_key_id + .filter(|key| *key != 0) + .map_or(200, |key| u16::from(key) + 100); + (type_order, remote_order, service.service_id) +} + +async fn logo_exists(state: &AppState, service: &Service) -> bool { + let Some(logo_id) = service.logo_id else { + return false; + }; + tokio::fs::metadata( + state + .paths + .logo_data_dir + .join(format!("{}_{}.png", service.network_id, logo_id)), + ) + .await + .is_ok() +} + +fn matches_channel_query(channel: &Channel, query: &HashMap) -> bool { + query.iter().all(|(key, value)| match key.as_str() { + "type" => channel.channel_type.as_str() == value, + "channel" => channel.channel == *value, + "name" => channel.name.as_deref() == Some(value), + _ => true, + }) +} + +fn matches_service_query(service: &Service, query: &HashMap) -> bool { + query.iter().all(|(key, value)| match key.as_str() { + "serviceId" => value.parse() == Ok(service.service_id), + "networkId" => value.parse() == Ok(service.network_id), + "name" => service.name == *value, + "type" => value.parse() == Ok(service.service_type), + "channel.type" => service + .channel + .as_deref() + .is_some_and(|channel| channel.channel_type.as_str() == value), + "channel.channel" => service + .channel + .as_deref() + .is_some_and(|channel| channel.channel == *value), + _ => true, + }) +} + +fn matches_program_query(program: &Program, query: &HashMap) -> bool { + query.iter().all(|(key, value)| match key.as_str() { + "networkId" => value.parse() == Ok(program.network_id), + "serviceId" => value.parse() == Ok(program.service_id), + "eventId" => value.parse() == Ok(program.event_id), + _ => true, + }) +} + +fn matches_event_query(event: &mirakurun_types::Event, query: &HashMap) -> bool { + query.iter().all(|(key, value)| match key.as_str() { + "resource" => { + serde_json::to_value(event.resource) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .as_deref() + == Some(value) + } + "type" => { + serde_json::to_value(event.event_type) + .ok() + .and_then(|value| value.as_str().map(str::to_owned)) + .as_deref() + == Some(value) + } + _ => true, + }) +} + +fn process_memory_usage() -> MemoryUsage { + let rss = std::fs::read_to_string("/proc/self/statm") + .ok() + .and_then(|contents| { + contents + .split_whitespace() + .nth(1) + .and_then(|pages| pages.parse::().ok()) + }) + .map_or(0, |pages| pages * 4096); + MemoryUsage { + rss, + heap_total: 0, + heap_used: 0, + external: 0, + array_buffers: 0, + } +} + +fn header_text(headers: &HeaderMap, key: K) -> Option<&str> +where + K: axum::http::header::AsHeaderName, +{ + headers.get(key).and_then(|value| value.to_str().ok()) +} + +fn response_with_content_type(body: Body, content_type: &'static str) -> Response { + ( + StatusCode::OK, + [(CONTENT_TYPE, HeaderValue::from_static(content_type))], + body, + ) + .into_response() +} + +fn empty_stream_response() -> Response { + response_with_content_type(Body::empty(), "video/MP2T") +} + +#[allow(dead_code)] +fn json_response(value: &T) -> Response +where + T: Serialize, +{ + Json(value).into_response() +} diff --git a/crates/mirakurun-rs/src/assets.rs b/crates/mirakurun-rs/src/assets.rs new file mode 100644 index 0000000..d3f7105 --- /dev/null +++ b/crates/mirakurun-rs/src/assets.rs @@ -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>, 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()); + } +} diff --git a/crates/mirakurun-rs/src/cli.rs b/crates/mirakurun-rs/src/cli.rs new file mode 100644 index 0000000..713f6f3 --- /dev/null +++ b/crates/mirakurun-rs/src/cli.rs @@ -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, +} + +#[derive(Debug, Clone, Args)] +pub struct PathArguments { + #[arg(long, env = "SERVER_CONFIG_PATH", global = true)] + pub server_config: Option, + #[arg(long, env = "TUNERS_CONFIG_PATH", global = true)] + pub tuners_config: Option, + #[arg(long, env = "CHANNELS_CONFIG_PATH", global = true)] + pub channels_config: Option, + #[arg(long, env = "SERVICES_DB_PATH", global = true)] + pub services_db: Option, + #[arg(long, env = "PROGRAMS_DB_PATH", global = true)] + pub programs_db: Option, + #[arg(long, env = "LOGO_DATA_DIR_PATH", global = true)] + pub logo_dir: Option, +} + +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 { + value.parse() +} diff --git a/crates/mirakurun-rs/src/commands.rs b/crates/mirakurun-rs/src/commands.rs new file mode 100644 index 0000000..cac78ef --- /dev/null +++ b/crates/mirakurun-rs/src/commands.rs @@ -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(mut input: R) -> Result +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 = + 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")); + } +} diff --git a/crates/mirakurun-rs/src/jobs.rs b/crates/mirakurun-rs/src/jobs.rs new file mode 100644 index 0000000..1df301a --- /dev/null +++ b/crates/mirakurun-rs/src/jobs.rs @@ -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, 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, 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, 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, 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, now: DateTime) { + 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, 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, 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, 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) -> 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::>(); + 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) -> 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) -> Result<()> { + let channels = state + .config + .read() + .await + .channels + .iter() + .filter(|channel| channel.is_disabled != Some(true)) + .cloned() + .collect::>(); + 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, + channel: ConfigChannel, +) -> Result> { + 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, + channel: &ConfigChannel, + mut discovered: Vec, +) -> 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::>(); + 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::>(); + 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) -> 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::>(); + targets.sort_by_key(|(network_id, _)| *network_id); + targets +} + +async fn gather_network( + state: &Arc, + channel: ConfigChannel, + retrieval_time_ms: u64, +) -> Result> { + 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, + network_id: u16, + incoming: Vec, +) -> Result<()> { + let mut programs = state.programs.read().await.clone(); + let existing_ids = programs + .iter() + .map(|program| program.id) + .collect::>(); + let incoming_ids = incoming + .iter() + .map(|program| program.id) + .collect::>(); + 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, + network_id: u16, +} + +impl GatheringNetworkGuard { + async fn new(state: Arc, 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, id: &str, aborted: bool, error: Option) { + 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, + id: &str, + update: impl FnOnce(&mut JobItem), +) -> Option { + 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) { + 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) -> Result { + let fields = expression.split_ascii_whitespace().collect::>(); + 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 { + for part in field.split(',') { + let (range, step) = if let Some((range, step)) = part.split_once('/') { + (range, step.parse::()?) + } 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::()?, end.parse::()?) + } else { + let exact = range.parse::()?; + (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")); + } +} diff --git a/crates/mirakurun-rs/src/main.rs b/crates/mirakurun-rs/src/main.rs new file mode 100644 index 0000000..3ec479d --- /dev/null +++ b/crates/mirakurun-rs/src/main.rs @@ -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, + } +} diff --git a/crates/mirakurun-rs/src/rpc.rs b/crates/mirakurun-rs/src/rpc.rs new file mode 100644 index 0000000..6ddae4b --- /dev/null +++ b/crates/mirakurun-rs/src/rpc.rs @@ -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, + method: String, + #[serde(default)] + params: Value, + #[serde(default)] + id: Option, +} + +pub async fn upgrade(State(state): State>, websocket: WebSocketUpgrade) -> Response { + websocket + .max_message_size(1024 * 1024) + .on_upgrade(move |socket| connection(socket, state)) +} + +async fn connection(socket: WebSocket, state: Arc) { + 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 = 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, + rooms: &mut HashSet, + text: &str, +) -> Option { + 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, + params: &Value, + join: bool, +) -> serde_json::Result { + let room_names: Vec = 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()); + } +} diff --git a/crates/mirakurun-rs/src/scan.rs b/crates/mirakurun-rs/src/scan.rs new file mode 100644 index 0000000..481b665 --- /dev/null +++ b/crates/mirakurun-rs/src/scan.rs @@ -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, + mode: ScanMode, + set_disabled_on_add: bool, + dry_run: bool, + refresh: bool, + asynchronous: bool, +} + +impl ScanOptions { + pub fn parse(query: &HashMap) -> std::result::Result { + 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::().ok()) + .collect::>() + }) + .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::>(); + 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, + options: &ScanOptions, +) -> std::result::Result { + 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, 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, + 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::>(); + 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::>(); + 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::>(), + 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 { + 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 { + let first = services.first()?.name.trim(); + let mut prefix = first.chars().collect::>(); + for service in &services[1..] { + let name = service.name.trim().chars().collect::>(); + let shared = prefix + .iter() + .zip(name) + .take_while(|(left, right)| left == &right) + .count(); + prefix.truncate(shared); + } + nonempty_name(&prefix.into_iter().collect::()) +} + +fn nonempty_name(value: &str) -> Option { + let value = value.trim(); + (!value.is_empty()).then(|| value.to_owned()) +} + +async fn update_progress(state: &Arc, 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, 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, + maximum: Option, + minimum_subchannel: Option, + maximum_subchannel: Option, + use_subchannel: bool, + custom_format: Option<&str>, +) -> std::result::Result, 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) -> 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, + key: &str, +) -> std::result::Result, 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, 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"]); + } +} diff --git a/crates/mirakurun-rs/src/server.rs b/crates/mirakurun-rs/src/server.rs new file mode 100644 index 0000000..19293e2 --- /dev/null +++ b/crates/mirakurun-rs/src/server.rs @@ -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>, + listener: tokio::net::TcpListener, + router: Router, + shutdown: CancellationToken, +) { + servers.spawn(async move { + axum::serve( + listener, + router.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(shutdown.cancelled_owned()) + .await + .context("HTTP server failed") + }); +} + +fn spawn_unix_server( + servers: &mut JoinSet>, + 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) {} diff --git a/crates/mirakurun-rs/src/state.rs b/crates/mirakurun-rs/src/state.rs new file mode 100644 index 0000000..c6c9435 --- /dev/null +++ b/crates/mirakurun-rs/src/state.rs @@ -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, + pub services: RwLock>, + pub programs: RwLock>, + pub tuners: TunerManager, + pub jobs: RwLock>, + pub job_schedules: RwLock>, + pub job_abort_tokens: Mutex>, + pub job_semaphore: Arc, + job_id_prefix: String, + next_job_id: AtomicU64, + pub scan_status: RwLock, + pub scan_cancel: Mutex>, + pub gathering_networks: RwLock>, + pub event_sender: broadcast::Sender, + pub event_history: RwLock>, + pub log_sender: broadcast::Sender, + pub log_history: RwLock>, + 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> { + 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) { + 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(&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 { + 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, +} + +impl RpcConnectionGuard { + pub fn new(state: Arc) -> 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, +} + +pub struct DecoderGuard { + state: Arc, +} + +impl DecoderGuard { + pub fn new(state: Arc) -> 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) -> 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); + } +} diff --git a/crates/mirakurun-types/Cargo.toml b/crates/mirakurun-types/Cargo.toml new file mode 100644 index 0000000..eab64e1 --- /dev/null +++ b/crates/mirakurun-types/Cargo.toml @@ -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 diff --git a/crates/mirakurun-types/src/lib.rs b/crates/mirakurun-types/src/lib.rs new file mode 100644 index 0000000..125411c --- /dev/null +++ b/crates/mirakurun-types/src/lib.rs @@ -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 { + 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub services: Option>, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub has_logo_data: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_control_key_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub epg_ready: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub epg_updated_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub channel: Option>, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub genres: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub video: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub audios: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub series: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub extended: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub related_items: Option>, +} + +#[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, +} + +#[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, + 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, + pub command: Option, + pub pid: Option, + pub users: Vec, + 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, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub disable_decoder: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_setting: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub stream_info: Option>, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub service_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub no_provide: Option, + #[serde(rename = "parseNIT", skip_serializing_if = "Option::is_none")] + pub parse_nit: Option, + #[serde(rename = "parseSDT", skip_serializing_if = "Option::is_none")] + pub parse_sdt: Option, + #[serde(rename = "parseEIT", skip_serializing_if = "Option::is_none")] + pub parse_eit: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_on_abort: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_on_fail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_max: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_delay: Option, + pub is_aborting: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub has_aborted: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub has_skipped: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub has_failed: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub created_at: UnixTimeMs, + pub updated_at: UnixTimeMs, + #[serde(skip_serializing_if = "Option::is_none")] + pub started_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub finished_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub hostname: Option, + #[serde(rename = "disableIPv6", skip_serializing_if = "Option::is_none")] + pub disable_ipv6: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub log_level: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_log_history: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub job_max_running: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub job_max_standby: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub max_buffer_bytes_before_ready: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub event_end_timeout: Option, + #[serde( + rename = "programGCJobSchedule", + skip_serializing_if = "Option::is_none" + )] + pub program_gc_job_schedule: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub epg_gathering_job_schedule: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub epg_retrieval_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub logo_data_interval: Option, + #[serde(rename = "disableEITParsing", skip_serializing_if = "Option::is_none")] + pub disable_eit_parsing: Option, + #[serde(rename = "disableWebUI", skip_serializing_if = "Option::is_none")] + pub disable_web_ui: Option, + #[serde(rename = "allowIPv4CidrRanges", default = "default_ipv4_ranges")] + pub allow_ipv4_cidr_ranges: Vec, + #[serde(rename = "allowIPv6CidrRanges", default = "default_ipv6_ranges")] + pub allow_ipv6_cidr_ranges: Vec, + #[serde(default = "default_origins")] + pub allow_origins: Vec, + #[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub dvb_device_path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_mirakurun_host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_mirakurun_port: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub remote_mirakurun_decoder: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub decoder: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_disabled: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub tsmf_rel_ts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub command_vars: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub is_disabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub satelite: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub satellite: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub space: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub freq: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub polarity: Option, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub dry_run: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub progress: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub current_channel: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scan_log: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub new_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub takeover_count: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub result: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub start_time: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub update_time: Option, +} + +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, + pub env: BTreeMap, + 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, + 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, + pub errors: Vec, +} + +#[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, + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub location: Option, +} + +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 { + [ + "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 { + vec!["fc00::/7".into()] +} + +#[must_use] +pub fn default_origins() -> Vec { + 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); + } +} diff --git a/dist/mirakurun-rs.service b/dist/mirakurun-rs.service new file mode 100644 index 0000000..3663533 --- /dev/null +++ b/dist/mirakurun-rs.service @@ -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 diff --git a/doc/installation.md b/doc/installation.md new file mode 100644 index 0000000..1b534d7 --- /dev/null +++ b/doc/installation.md @@ -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は必須ではなく、現段階では配布対象に含めていません。 diff --git a/doc/migration.md b/doc/migration.md new file mode 100644 index 0000000..20bd601 --- /dev/null +++ b/doc/migration.md @@ -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・追加記号、一部の音声・シリーズ・関連番組記述子、実機での全放送波 +試験は未完了です。受信地域とチューナーごとの比較試験を終えるまでは、本番環境への +切り替えを行わないでください。 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..7e18c85 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.85.0" +components = ["clippy", "rustfmt"] +profile = "minimal" diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..5ca9cbd --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,3335 @@ +{ + "name": "mirakurun-rs-web", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mirakurun-rs-web", + "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" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@blueprintjs/colors": { + "version": "5.1.16", + "resolved": "https://registry.npmjs.org/@blueprintjs/colors/-/colors-5.1.16.tgz", + "integrity": "sha512-P9uX0Aj2TP9+6aUcori1iPl4snxM/Vgq0LZbhUl1l5bHTgNxxwm/0+IoS/SlQg93HBRl8KTAM1evEqtPbwV10A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "~2.6.2" + } + }, + "node_modules/@blueprintjs/core": { + "version": "5.19.1", + "resolved": "https://registry.npmjs.org/@blueprintjs/core/-/core-5.19.1.tgz", + "integrity": "sha512-vjLd9jnuHPunW6zsZtx0f03zb33aRa/k6nnwH+N16NEIs7/wHWX5f5ljtEguSt/M1jx4IT0oescMQMAuXWXAWQ==", + "license": "Apache-2.0", + "dependencies": { + "@blueprintjs/colors": "^5.1.8", + "@blueprintjs/icons": "^5.23.0", + "@popperjs/core": "^2.11.8", + "classnames": "^2.3.1", + "normalize.css": "^8.0.1", + "react-popper": "^2.3.0", + "react-transition-group": "^4.4.5", + "react-uid": "^2.3.3", + "tslib": "~2.6.2", + "use-sync-external-store": "^1.2.0" + }, + "bin": { + "upgrade-blueprint-2.0.0-rename": "scripts/upgrade-blueprint-2.0.0-rename.sh", + "upgrade-blueprint-3.0.0-rename": "scripts/upgrade-blueprint-3.0.0-rename.sh" + }, + "peerDependencies": { + "@types/react": "^16.14.41 || 17 || 18", + "react": "^16.8 || 17 || 18", + "react-dom": "^16.8 || 17 || 18" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@blueprintjs/icons": { + "version": "5.23.0", + "resolved": "https://registry.npmjs.org/@blueprintjs/icons/-/icons-5.23.0.tgz", + "integrity": "sha512-yxQ+A0V79/UsyIw4XYuEvaFqr3FIaRlp79rKh+ZdHLafedqPfp4LO6xtF6EziWFCvuHOTJdFCfSC8AoQeSENMw==", + "license": "Apache-2.0", + "dependencies": { + "change-case": "^4.1.2", + "classnames": "^2.3.1", + "tslib": "~2.6.2" + }, + "peerDependencies": { + "@types/react": "^16.14.41 || 17 || 18", + "react": "^16.8 || 17 || 18", + "react-dom": "^16.8 || 17 || 18" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz", + "integrity": "sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.17.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.11.tgz", + "integrity": "sha512-ZMp1V8ZFcPG5dIWnQLr3NSI1MiCU7UETdS/A0G8V/XWHvJv3ZsFqutJn1Y5RPmAPX6F3BiE397OqveU/9NCuIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@parcel/watcher": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.6.0.tgz", + "integrity": "sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.3", + "is-glob": "^4.0.3", + "node-addon-api": "^7.0.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.6.0", + "@parcel/watcher-darwin-arm64": "2.6.0", + "@parcel/watcher-darwin-x64": "2.6.0", + "@parcel/watcher-freebsd-x64": "2.6.0", + "@parcel/watcher-linux-arm-glibc": "2.6.0", + "@parcel/watcher-linux-arm-musl": "2.6.0", + "@parcel/watcher-linux-arm64-glibc": "2.6.0", + "@parcel/watcher-linux-arm64-musl": "2.6.0", + "@parcel/watcher-linux-x64-glibc": "2.6.0", + "@parcel/watcher-linux-x64-musl": "2.6.0", + "@parcel/watcher-win32-arm64": "2.6.0", + "@parcel/watcher-win32-x64": "2.6.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.6.0.tgz", + "integrity": "sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.6.0.tgz", + "integrity": "sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.6.0.tgz", + "integrity": "sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.6.0.tgz", + "integrity": "sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.6.0.tgz", + "integrity": "sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.6.0.tgz", + "integrity": "sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.6.0.tgz", + "integrity": "sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.6.0.tgz", + "integrity": "sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.6.0.tgz", + "integrity": "sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.6.0.tgz", + "integrity": "sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.6.0.tgz", + "integrity": "sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.6.0.tgz", + "integrity": "sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-gW+Oib+vUtGJBtNC8V9Reww0oIpusw+4m81uncg9REGZAJfqOQHfo/nkabnc7w0QReXyPqjrbWMJk6NuAkiX3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.31", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.31.tgz", + "integrity": "sha512-vfEqpXTvwT91yhmwdfouStN2hSKwTvyRs8qpLfADyrq/kxDw0hZM7Wk9Ug1FELj8hIby+S/+kQCSRFF32nv2Qw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@types/ws": { + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-7.4.7.tgz", + "integrity": "sha512-JQbbmxZTZehdc2iszGKs5oC3NFnjeay7mtAWrdt7qNtAVK0g19muApzAy4bm9byz79xa2ZnO/BOBC2R8RC5Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-3.0.1.tgz", + "integrity": "sha512-u8d0pJ5YFgneF/GuvEiDA61Tf1VDomHHYMjv/wc9XzYj7nopltpG96nXN5dJRstxZhcNpV1g+nT6CydO7pHbjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-3.0.1.tgz", + "integrity": "sha512-coEmDzc2u/ffMvuW9aCjoRzNSPDl/XLuhPdlFRpT9tZHmJ/039az33CE7uH+8s0uL1j5ZNtfdv0HkfaKRBGJsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-3.0.1.tgz", + "integrity": "sha512-sbgw03xQaCLiT6gcY/6u3qBDn01CWw/nbaXl3gTdTFuJJ75Gffv3E3DBpgvY2fkkrdS1fpjaXNOmJlnbtKauKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12.0" + }, + "peerDependencies": { + "webpack": "^5.82.0", + "webpack-cli": "6.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/backo2": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", + "integrity": "sha512-zj6Z6M7Eq+PBZ7PQxl5NT665MvJdAkzp0f60nAJ+sLaSCBPMwVak5ZegFbgVCzFcCJTKFoMizvM5Ld7+JrRJHA==", + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.8.tgz", + "integrity": "sha512-zAgkquC2WYF0PIc6XbNYkA2uuxxFavzgmX61R+dHDUa558V8Ejf8ozTZFR6QzM24RWu4kBcRkhJ5kpz77j9fnQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/big.js": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", + "integrity": "sha512-vyL2OymJxmarO8gxMr0mhChsO9QGwhynfuu4+MHTAW6czfq9humCB7rKpUjDd9YUiDPU4mzpyupFSvOClAwbmQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", + "integrity": "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.44", + "caniuse-lite": "^1.0.30001806", + "electron-to-chromium": "^1.5.393", + "node-releases": "^2.0.51", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/camel-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/camel-case/-/camel-case-4.1.2.tgz", + "integrity": "sha512-gxGWBrTT1JuMx6R+o5PTXMmUnhnVzLQ9SNutD4YqKtI6ap897t3tKECYla6gCWEkplXnlNybEkZg9GEGxKFCgw==", + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001806", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", + "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/capital-case": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/capital-case/-/capital-case-1.0.4.tgz", + "integrity": "sha512-ds37W8CytHgwnhGGTi88pcPyR15qoNkOpYwmMMfnWqqWgESapLqvDx6huFjQ5vqWSn2Z06173XNA7LtMOeUh1A==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case-first": "^2.0.2" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/change-case": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/change-case/-/change-case-4.1.2.tgz", + "integrity": "sha512-bSxY2ws9OtviILG1EiY5K7NNxkqg/JnRnFxLtKQ96JaviiIxi7djMrSd0ECT9AC+lttClmYwKw53BWpOMblo7A==", + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "capital-case": "^1.0.4", + "constant-case": "^3.0.4", + "dot-case": "^3.0.4", + "header-case": "^2.0.4", + "no-case": "^3.0.4", + "param-case": "^3.0.4", + "pascal-case": "^3.1.2", + "path-case": "^3.0.4", + "sentence-case": "^3.0.4", + "snake-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/chokidar": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", + "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readdirp": "^5.0.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/clone-deep/-/clone-deep-4.0.1.tgz", + "integrity": "sha512-neHB9xuzh/wk0dIHweyAXv2aPGZIVk3pLMe+/RNzINf17fe0OG96QroktYAUm7SM1PBnzTabaLboqqxDyMU+SQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/constant-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/constant-case/-/constant-case-3.0.4.tgz", + "integrity": "sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case": "^2.0.2" + } + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/copy-webpack-plugin": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz", + "integrity": "sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob-parent": "^6.0.1", + "normalize-path": "^3.0.0", + "schema-utils": "^4.2.0", + "serialize-javascript": "^7.0.3", + "tinyglobby": "^0.2.12" + }, + "engines": { + "node": ">= 20.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-loader": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-5.2.7.tgz", + "integrity": "sha512-Q7mOvpBNBG7YrVGMxRxcBJZFL75o+cH2abNASdibkj/fffYD8qWbInZrD0S9ccI6vZclF3DsHE7njGlLtaHbhg==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.1.0", + "loader-utils": "^2.0.0", + "postcss": "^8.2.15", + "postcss-modules-extract-imports": "^3.0.0", + "postcss-modules-local-by-default": "^4.0.0", + "postcss-modules-scope": "^3.0.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.1.0", + "schema-utils": "^3.0.0", + "semver": "^7.3.5" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.27.0 || ^5.0.0" + } + }, + "node_modules/css-loader/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/css-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/css-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/debounce": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/debounce/-/debounce-1.2.1.tgz", + "integrity": "sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug==", + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/easy-bem": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/easy-bem/-/easy-bem-1.1.1.tgz", + "integrity": "sha512-GJRqdiy2h+EXy6a8E6R+ubmqUM08BK0FWNq41k24fup6045biQ8NXxoXimiwegMQvFFV3t1emADdGNL1TlS61A==", + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.399", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.399.tgz", + "integrity": "sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==", + "dev": true, + "license": "ISC" + }, + "node_modules/emojis-list": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-3.0.0.tgz", + "integrity": "sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/envinfo": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.21.0.tgz", + "integrity": "sha512-Lw7I8Zp5YKHFCXL7+Dz95g4CcbMEpgvqZNNq3AmlT5XAV6CgAAk6gyAMqn2zjw08K9BHfcNuKrMiCPLByGafow==", + "dev": true, + "license": "MIT", + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", + "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.4.tgz", + "integrity": "sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.16", + "resolved": "https://registry.npmjs.org/fastest-levenshtein/-/fastest-levenshtein-1.0.16.tgz", + "integrity": "sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.9.1" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/flat": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", + "integrity": "sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ==", + "dev": true, + "license": "BSD-3-Clause", + "bin": { + "flat": "cli.js" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/header-case": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/header-case/-/header-case-2.0.4.tgz", + "integrity": "sha512-H/vuk5TEEVZwrR0lp2zed9OCo1uAILMlx0JEMgC26rzyJJ3N1v6XkwHHXJQdR2doSjcGPM6OKPYoJgf0plJ11Q==", + "license": "MIT", + "dependencies": { + "capital-case": "^1.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immutable": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.9.tgz", + "integrity": "sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==", + "dev": true, + "license": "MIT" + }, + "node_modules/import-local": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", + "integrity": "sha512-2SPlun1JUPWoM6t3F0dw0FkCF/jWY8kttcY4f599GLTSjh2OCuuhdTkJQsEcZzBqbXZGKMK2OqW1oZsjtf/gQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/interpret": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-3.1.1.tgz", + "integrity": "sha512-6xwYfHbajpoF0xLW+iwLkhwgvLoZDfjYfoFNu8ftMoXINzwuymNLd9u/KmwtdT2GbR+/Cz66otEGEVVUHX9QLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/ip-num": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/ip-num/-/ip-num-1.3.4.tgz", + "integrity": "sha512-ZlO1YqjR87dsMFTxUJGj1iZR+UV+0QHWeOAMZfTBpGocBsKGc7fJdlFoa7F5xpK/guKW7dkfT6UAVDwvWjLK1Q==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.48" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isomorphic-ws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz", + "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w==", + "license": "MIT", + "peerDependencies": { + "ws": "*" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonrpc2-ws": { + "version": "1.0.0-beta23", + "resolved": "https://registry.npmjs.org/jsonrpc2-ws/-/jsonrpc2-ws-1.0.0-beta23.tgz", + "integrity": "sha512-eSkcO5+HAcPfk46vfT0vTukuW44igB7MGZLi60YWh/Jv8p+4/5RE8Q75dEWSlWksluMWIkId8qjQ3/e+dgIGgw==", + "license": "MIT", + "dependencies": { + "backo2": "^1.0.2", + "eventemitter3": "^4.0.7", + "isomorphic-ws": "^4.0.1", + "uuid": "^8.3.2", + "ws": "^7.5.3" + } + }, + "node_modules/kind-of": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz", + "integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/loader-utils": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-2.0.4.tgz", + "integrity": "sha512-xXqpXoINfFhgua9xiqD8fPFHgkoq1mmmpE92WlDbm9rNRd/EbRb+Gqf908T2DMfuHjjJlksiK2RbHVOdD/MqSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "big.js": "^5.2.2", + "emojis-list": "^3.0.0", + "json5": "^2.1.2" + }, + "engines": { + "node": ">=8.9.0" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/luxon": { + "version": "3.7.2", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", + "integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/micromatch/node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimizer-webpack-plugin": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/minimizer-webpack-plugin/-/minimizer-webpack-plugin-5.6.1.tgz", + "integrity": "sha512-DoeAZz8Q1C1znwsUzej1fdoi4jCf7/+Em27ouLqfK/+3m8G+D7yDhUwrc3CNhjSzGUN1kn7Iv4sWmjflQHenpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/nanoid": { + "version": "3.3.16", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz", + "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true, + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/node-releases": { + "version": "2.0.51", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", + "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/normalize.css": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/normalize.css/-/normalize.css-8.0.1.tgz", + "integrity": "sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==", + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/param-case/-/param-case-3.0.4.tgz", + "integrity": "sha512-RXlj7zCYokReqWpOPH9oYivUzLYZ5vAPIfEmCTNViosC78F8F0H9y7T7gG2M39ymgutxF5gcFEsyZQSph9Bp3A==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/pascal-case/-/pascal-case-3.1.2.tgz", + "integrity": "sha512-uWlGT3YSnK9x3BQJaOdcZwrnV6hPpd8jFH1/ucpiLRPh/2zCVJKS19E4GvYHvaCcACn3foXZ0cLB9Wrx1KGe5g==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/path-case/-/path-case-3.0.4.tgz", + "integrity": "sha512-qO4qCFjXqVTrcbPt/hQfhTQ+VhFsqNKOPtytgNKkKxSoEp3XPUQ8ObFuePylOIok5gjn69ry8XiULxCwot3Wfg==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/postcss": { + "version": "8.5.25", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz", + "integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.16", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "dev": true, + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "dev": true, + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/postcss-selector-parser": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.4.tgz", + "integrity": "sha512-HeP7D2wyhkR+XaK6v4W8oRF62Dsz4flyuczALJp61GckGm42u1saSSJ/0auvcBqxs3jMRFEcPK34At/0JBKdOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, + "node_modules/react-indiana-drag-scroll": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/react-indiana-drag-scroll/-/react-indiana-drag-scroll-2.2.1.tgz", + "integrity": "sha512-aGNJt7fxWzZGVkd0xRF+fPDt5RFuYouQffwOFx3m+CiOlvLZDQtV+4NyPnJqXCRlfbbwP26LI4LclNymTOgDiQ==", + "license": "MIT", + "dependencies": { + "classnames": "^2.2.6", + "debounce": "^1.2.0", + "easy-bem": "^1.1.1" + }, + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react-popper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", + "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", + "license": "MIT", + "dependencies": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + }, + "peerDependencies": { + "@popperjs/core": "^2.0.0", + "react": "^16.8.0 || ^17 || ^18", + "react-dom": "^16.8.0 || ^17 || ^18" + } + }, + "node_modules/react-router": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.2.tgz", + "integrity": "sha512-aUVMjFm3GAPTTZL7oYr5E7ETiqfQCHRLH+B+5afnICvf0r7kkK4eR6SMuwbSTJw/7t+12khT/Kahij49fqOCIg==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.2.tgz", + "integrity": "sha512-AIKJ/jgGlFb3EbfCXk5Gzshiwt+l3mqbCrNjmEWMMjqQxNJ3svBa6bgzFyCC2Sw3RA0VWF1kg3uQf2OFhxb8hw==", + "license": "MIT", + "dependencies": { + "react-router": "7.18.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/react-uid": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/react-uid/-/react-uid-2.4.0.tgz", + "integrity": "sha512-+MVs/25NrcZuGrmlVRWPOSsbS8y72GJOBsR7d68j3/wqOrRBF52U29XAw4+XSelw0Vm6s5VmGH5mCbTCPGVCVg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/readdirp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", + "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/rechoir": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.8.0.tgz", + "integrity": "sha512-/vxpCXddiX8NGfGO/mTafwjq4aFa/71pvamip0++IQk3zG8cbCj0fifNPrjjF1XMXUne91jL9OoxmdykoEtifQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.20.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/resolve-cwd/-/resolve-cwd-3.0.0.tgz", + "integrity": "sha512-OrZaX2Mb+rJCpH/6CpSqt9xFVpN++x01XnN2ie9g6P5/3xelLAkXWVADpdz1IHD/KFfEXyE6V0U01OQ3UO2rEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", + "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sass": { + "version": "1.102.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.102.0.tgz", + "integrity": "sha512-NSOyTnaQF7rTAEOtI2fwb386vL+akyiQLBZu8Na7hXCb+umJy0GAqlcMIaqACZ6Z1VgTBS4K9PG6B3IdjHGJsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chokidar": "^5.0.0", + "immutable": "^5.1.5", + "source-map-js": ">=0.6.2 <2.0.0" + }, + "bin": { + "sass": "sass.js" + }, + "engines": { + "node": ">=20.19.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" + } + }, + "node_modules/sass-loader": { + "version": "16.0.8", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.8.tgz", + "integrity": "sha512-hcov4ZwZJIGbEuyNr9EmiTmZueyrxSToE6GOzoZnq5JM7ecRO7ttyvilPn+VmRsqiP16+VYZzVnGZj/hzZgKBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "neo-async": "^2.6.2" + }, + "engines": { + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || ^1.0.0 || ^2.0.0-0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 || ^9.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sentence-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/sentence-case/-/sentence-case-3.0.4.tgz", + "integrity": "sha512-8LS0JInaQMCRoQ7YUytAo/xUu5W2XnQxV2HI/6uM6U7CITS1RqPElr30V6uIqyMKM9lJGRVFy5/4CuzcixNYSg==", + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3", + "upper-case-first": "^2.0.2" + } + }, + "node_modules/serialize-javascript": { + "version": "7.0.7", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz", + "integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/shallow-clone/-/shallow-clone-3.0.1.tgz", + "integrity": "sha512-/6KqX+GVUdqPuPPd2LxDDxzX6CAbjJehAAOKlNpqqUpAqPM6HeL8f+o3a+JsyGjn2lv0WY8UsTgUJjU9Ok55NA==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sift": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/sift/-/sift-15.1.3.tgz", + "integrity": "sha512-/JZRQtE1pe4t93jKvAKDCgpOSfFX/tFNoYn5hUB4nuVyihGFp5pS5mQu6p7XOo0oQvj+jrgVyIteAI6lO+EE8A==", + "license": "MIT" + }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-loader": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-2.0.0.tgz", + "integrity": "sha512-Z0gYUJmzZ6ZdRUqpg1r8GsaFKypE+3xAzuFeMuoHgjc9KZv3wMyCRjQIWEbhoFSq7+7yoHXySDJyyWQaPajeiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "loader-utils": "^2.0.0", + "schema-utils": "^3.0.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^4.0.0 || ^5.0.0" + } + }, + "node_modules/style-loader/node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/style-loader/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/style-loader/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/style-loader/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.49.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.49.0.tgz", + "integrity": "sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/ts-loader": { + "version": "9.5.2", + "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.2.tgz", + "integrity": "sha512-Qo4piXvOTWcMGIgRiuFa6nHNm+54HbYaZCKqc9eeZCLRy3XqafQgwX2F7mofrbJG3g7EEb+lkiR+z2Lic2s3Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.0.0", + "micromatch": "^4.0.0", + "semver": "^7.3.4", + "source-map": "^0.7.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "typescript": "*", + "webpack": "^5.0.0" + } + }, + "node_modules/tslib": { + "version": "2.6.3", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", + "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz", + "integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/upper-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/upper-case/-/upper-case-2.0.2.tgz", + "integrity": "sha512-KgdgDGJt2TpuwBUIjgG6lzw2GWFRCW9Qkfkiv0DxqHHLYJHmtmdUIKcZd8rHgFSjopVTlw6ggzCm1b8MFQwikg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/upper-case-first": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/upper-case-first/-/upper-case-first-2.0.2.tgz", + "integrity": "sha512-514ppYHBaKwfJRK/pNC6c/OxfGa0obSnAl106u97Ed0I625Nin96KAjttZF6ZL3e1XLtphxnqrOi9iWgm+u+bg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/uuid": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz", + "integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/watchpack": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.2.tgz", + "integrity": "sha512-6i/00NBjP4yGPs+caKSyRfpTF/8Torsu0MOW3mMzIbhgISFder8i7xbqgHlLMwJrdiN8ndBV3UA1/AfzPSr+jg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.109.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.109.2.tgz", + "integrity": "sha512-U9/cvLzxObKNEZ9+TtdqrHM5/9z3lgl2c+c4BzbqGxFQvQvBAq87yql5A8pQ+rrMbS496MZJeF5enVBndIy2hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.24.4", + "es-module-lexer": "^2.1.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "graceful-fs": "^4.2.11", + "mime-db": "^1.54.0", + "minimizer-webpack-plugin": "^5.6.1", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "watchpack": "^2.5.2", + "webpack-sources": "^3.5.1" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-6.0.1.tgz", + "integrity": "sha512-MfwFQ6SfwinsUVi0rNJm7rHZ31GyTcpVE5pgVA3hwFRb7COD4TzjUUwhGWKfO50+xdc2MQPuEBBJoqIMGt3JDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@discoveryjs/json-ext": "^0.6.1", + "@webpack-cli/configtest": "^3.0.1", + "@webpack-cli/info": "^3.0.1", + "@webpack-cli/serve": "^3.0.1", + "colorette": "^2.0.14", + "commander": "^12.1.0", + "cross-spawn": "^7.0.3", + "envinfo": "^7.14.0", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^3.1.1", + "rechoir": "^0.8.0", + "webpack-merge": "^6.0.1" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.82.0" + }, + "peerDependenciesMeta": { + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "12.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz", + "integrity": "sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/webpack-merge": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/webpack-merge/-/webpack-merge-6.0.1.tgz", + "integrity": "sha512-hXXvrjtx2PLYx4qruKl+kyRSLc52V+cCvMxRjmKwoA+CBbbF5GfIBtR6kCvl0fYGqTUPKB+1ktVmTHqMOzgCBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "flat": "^5.0.2", + "wildcard": "^2.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.1", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.1.tgz", + "integrity": "sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/wildcard/-/wildcard-2.0.1.tgz", + "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/ws": { + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", + "license": "MIT", + "engines": { + "node": ">=8.3.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..a2c4468 --- /dev/null +++ b/web/package.json @@ -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" + } +} diff --git a/web/src/components/DateTimeRange.sass b/web/src/components/DateTimeRange.sass new file mode 100644 index 0000000..d98d2c0 --- /dev/null +++ b/web/src/components/DateTimeRange.sass @@ -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 diff --git a/web/src/components/DateTimeRange.tsx b/web/src/components/DateTimeRange.tsx new file mode 100644 index 0000000..e34ec26 --- /dev/null +++ b/web/src/components/DateTimeRange.tsx @@ -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 = ({ 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[] = []; + 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 ( + + {startDate.toFormat("M/d (ccc) HH:mm")} +  –  + {end && <> + {endDate.toFormat("HH:mm")} +  ({durationS / 60}分間) + } +   + {relative} + + {progress && ( + + + + )} + + ); +}; diff --git a/web/src/components/EPGTable.sass b/web/src/components/EPGTable.sass new file mode 100644 index 0000000..cb74702 --- /dev/null +++ b/web/src/components/EPGTable.sass @@ -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 diff --git a/web/src/components/EPGTable.tsx b/web/src/components/EPGTable.tsx new file mode 100644 index 0000000..82e2b54 --- /dev/null +++ b/web/src/components/EPGTable.tsx @@ -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 = ({ date, channelType, globalServiceId, defaultProgramId, defaultTime }) => { + console.debug("components", "EPGTable"); + + const startTime = date.toMillis(); + + const headerRef = useRef(); + const timescaleRef = useRef(); + const timelineRef = useRef(); + const clockRef = useRef(); + const timetableRef = useRef(); + const headerItemRef = useRef(); + const timescaleItemRef = useRef(); + const jumpToTimelineRef = useRef(); + + const [reload, setReload] = useState(0); // リロード用 + const [dimensions, setDimensions] = useState(null); + const [error, setError] = useState(null); + + // null = loading, [] = empty + const [programId, setProgramId] = useState(defaultProgramId || null); + const [time, setTime] = useState(defaultTime || null); + const [services, setServices] = useState(null); + const [serviceItems, setServiceItems] = useState(null); + const [timetableCols, setTimetableCols] = useState(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; + 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 = { + 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(); // イベントグループ検索用 + 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( + + ); + } else { + // 週間番組表 + for (let i = 0; i < 8; i++) { + const cur = date.plus({ days: i }); + + _serviceItems.push( + + ); + } + } + + // 放送終了ダミーデータ挿入 + 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( + ( + + )} + /> + ); + } + + if (!globalServiceId) { + // 全体番組表 + cols.push( +
+ {cells} +
+ ); + } else { + // 週間番組表 + for (let i = 0; i < splitIndexes.length; i++) { + cols.push( +
+ {cells.slice(splitIndexes[i - 1] || 0, splitIndexes[i] || cells.length)} +
+ ); + } + } + } + + setServiceItems(_serviceItems); + setTimetableCols(cols); + }, [startTime, services]); + + const timescaleDateShort = date.toFormat("M/d(ccc)"); + const timescaleDateExtended = date.plus({ days: 1 }).toFormat("M/d(ccc)"); + + return ( +
+
+ {!serviceItems && !error && <> +
+
+
+
+
+
+
+
+
+
+
+
+ } + {serviceItems} +
+ +
+
+
00:00
+
+
{timescaleDateShort} 0時
+
1
+
2
+
{timescaleDateShort} 3時
+
4
+
5
+
{timescaleDateShort} 6時
+
7
+
8
+
{timescaleDateShort} 9時
+
10
+
11
+
{timescaleDateShort} 12時
+
13
+
14
+
{timescaleDateShort} 15時
+
16
+
17
+
{timescaleDateShort} 18時
+
19
+
20
+
{timescaleDateShort} 21時
+
22
+
23
+
{timescaleDateExtended} 0時 (24)
+
1 (25)
+
2 (26)
+
{timescaleDateExtended} 3時 (27)
+ {timetableCols &&
} +
+ +
+ ); +}; diff --git a/web/src/components/Nav.sass b/web/src/components/Nav.sass new file mode 100644 index 0000000..eec8d23 --- /dev/null +++ b/web/src/components/Nav.sass @@ -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 diff --git a/web/src/components/Nav.tsx b/web/src/components/Nav.tsx new file mode 100644 index 0000000..74e8f18 --- /dev/null +++ b/web/src/components/Nav.tsx @@ -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 = ({ pathLv1 }) => { + console.debug("components", "Nav", pathLv1); + + const { navigate, searchParams } = state; + const query = searchParams.get("q") || null; + + const [icon, setIcon] = useState(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(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("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(query || ""); + const executeSearch = useCallback(() => { + state.navigate(`/epg/search?q=${encodeURIComponent(searchQuery.trim())}`); + }, [searchQuery]); + + const [runningJobs, setRunningJobs] = useState(state.jobs.filter((job) => job.status === "running").length); + + const [restartDialogOpen, setRestartDialogOpen] = useState(false); + useEffect(() => { + const onJobs = () => { + setRunningJobs(state.jobs.filter((job) => job.status === "running").length); + }; + state.on("jobs", onJobs); + return () => { + state.off("jobs", onJobs); + }; + }, []); + + return ( + + + {state.statusName} + + Mirakurun + {version} + +
+ + setSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + executeSearch(); + } + }} + /> +
+
+ +
+
+ )} +
+ ); +}; diff --git a/web/src/components/ProgramGenres.sass b/web/src/components/ProgramGenres.sass new file mode 100644 index 0000000..700991e --- /dev/null +++ b/web/src/components/ProgramGenres.sass @@ -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 diff --git a/web/src/components/ProgramGenres.tsx b/web/src/components/ProgramGenres.tsx new file mode 100644 index 0000000..2780dbc --- /dev/null +++ b/web/src/components/ProgramGenres.tsx @@ -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 = ({ genres }) => { + // console.debug("components", "ProgramGenres"); + + const lv1Set = new Set(); + 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({un2Text}); + continue; + } + + if (!lv1Set.has(genre.lv1)) { + lv1Set.add(genre.lv1); + labels.push({lv1Text}); + } + + const lv2Text = Genre2Map[(genre.lv1 * 0x10) + genre.lv2]; + labels.push({lv2Text}); + } + + return ( +
+ {labels} +
+ ); +}; diff --git a/web/src/components/ProgramPopover.sass b/web/src/components/ProgramPopover.sass new file mode 100644 index 0000000..9275e37 --- /dev/null +++ b/web/src/components/ProgramPopover.sass @@ -0,0 +1,4 @@ +.component-program-popover + padding: 15px + min-width: 300px + max-width: 380px diff --git a/web/src/components/ProgramPopover.tsx b/web/src/components/ProgramPopover.tsx new file mode 100644 index 0000000..51068e0 --- /dev/null +++ b/web/src/components/ProgramPopover.tsx @@ -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 = { + program: Program; + key?: string; + className?: string; + portalContainer?: HTMLElement; + defaultIsOpen?: boolean; + renderTarget: (props: PopoverTargetProps & T) => JSX.Element; +}; +export const ProgramPopover: React.FC = ({ program, renderTarget, className = "", defaultIsOpen = false, ...props }) => { + // console.debug("components", "ProgramPopover"); + + const [active, setActive] = useState(defaultIsOpen); + const [content, setContent] = useState(null); + + useEffect(() => { + if (!active) { + return; + } + + setContent(); + + return () => { + setContent(null); + }; + }, [active]); + + if (className) { + className += " "; + } + className += "bp5-dark"; + + return ( + setActive(true)} + onClosed={() => setActive(false)} + content={ +
+ {content} +
+ } + /> + ); +}; diff --git a/web/src/components/ProgramRelatedLinks.sass b/web/src/components/ProgramRelatedLinks.sass new file mode 100644 index 0000000..d6dc2ff --- /dev/null +++ b/web/src/components/ProgramRelatedLinks.sass @@ -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 diff --git a/web/src/components/ProgramRelatedLinks.tsx b/web/src/components/ProgramRelatedLinks.tsx new file mode 100644 index 0000000..8ad4d16 --- /dev/null +++ b/web/src/components/ProgramRelatedLinks.tsx @@ -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 = ({ program }) => { + console.debug("components", "ProgramRelatedLinks"); + + const [links, setLinks] = useState([]); + + 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 = ( +
+ +
+ ); + _links.push(link); + } + + setLinks(_links); + })(); + + return () => { + abort = true; + } + }, [relatedItems]); + + if (links.length === 0) { + return <>; + } + + return ( +
+ {links} +
+ ); +}; diff --git a/web/src/components/ProgramTitle.sass b/web/src/components/ProgramTitle.sass new file mode 100644 index 0000000..0725ede --- /dev/null +++ b/web/src/components/ProgramTitle.sass @@ -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 diff --git a/web/src/components/ProgramTitle.tsx b/web/src/components/ProgramTitle.tsx new file mode 100644 index 0000000..907f1ba --- /dev/null +++ b/web/src/components/ProgramTitle.tsx @@ -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 = ({ 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(); + 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 = ( + + {attribute} + + ); + + if (["新", "再", "終", "生"].includes(attribute)) { + pre.push(label); + } else { + post.push(label); + } + } + + return { pre, post }; + }, [attributes]); + + return ( + + {labels.pre} + {name} + {labels.post} + + ); +}; diff --git a/web/src/components/Restart.tsx b/web/src/components/Restart.tsx new file mode 100644 index 0000000..992acdb --- /dev/null +++ b/web/src/components/Restart.tsx @@ -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 ( + + +
+ Do you want to restart Mirakurun? +
+
+ +
+ ); +}; diff --git a/web/src/components/ServiceLink.sass b/web/src/components/ServiceLink.sass new file mode 100644 index 0000000..3f8c17c --- /dev/null +++ b/web/src/components/ServiceLink.sass @@ -0,0 +1,8 @@ +.component-service-link + display: flex + gap: 10px + align-items: center + + > img + max-height: 18px + border-radius: 1px diff --git a/web/src/components/ServiceLink.tsx b/web/src/components/ServiceLink.tsx new file mode 100644 index 0000000..f8b2f51 --- /dev/null +++ b/web/src/components/ServiceLink.tsx @@ -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; +export const ServiceLink: React.FC = ({ globalId, date, time, ...props }) => { + console.debug("components", "ServiceLink"); + + const [service, setService] = useState(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 ( +
+ {service && service.hasLogoData && } + + + {service ? `${service.name.normalize("NFKC")} (${channelTypeMap[service.channel.type]})` : "サービス名..."} + +
+ ); +}; diff --git a/web/src/components/VersionStatus.tsx b/web/src/components/VersionStatus.tsx new file mode 100644 index 0000000..b631c59 --- /dev/null +++ b/web/src/components/VersionStatus.tsx @@ -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 => { + 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(cachedVersion); + const [loading, setLoading] = useState(!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 ; + } + if (!version) { + return ; + } + if (hasUpdate) { + return ( + { + state.navigate("/about"); + }} + /> + ); + } + return ; + } + + if (loading) { + return アップデートを確認中...; + } + if (!version) { + return 不明 (取得失敗); + } + + if (hasUpdate) { + return ( + + {version.latest} (新しいバージョンが利用可能です) + + ); + } + + return {version.latest} (最新版を実行中です); +}; diff --git a/web/src/components/WatchButton.tsx b/web/src/components/WatchButton.tsx new file mode 100644 index 0000000..450f099 --- /dev/null +++ b/web/src/components/WatchButton.tsx @@ -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; + +export const WatchButton: React.FC = ({ 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 (<> + +
+ ; +}; + +{ + const basename = state.isDev ? "/dev/" : ""; + const root = createRoot(document.getElementById("root")); + root.render(); +} diff --git a/web/src/modules/at.ts b/web/src/modules/at.ts new file mode 100644 index 0000000..9704123 --- /dev/null +++ b/web/src/modules/at.ts @@ -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 = {}; +let count = 0; +let intervalId: ReturnType; + +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(); + } + } +} diff --git a/web/src/modules/common.ts b/web/src/modules/common.ts new file mode 100644 index 0000000..6281de2 --- /dev/null +++ b/web/src/modules/common.ts @@ -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 { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} + +export function inRange(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 { + 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); +} diff --git a/web/src/modules/constants.ts b/web/src/modules/constants.ts new file mode 100644 index 0000000..3ec6550 --- /dev/null +++ b/web/src/modules/constants.ts @@ -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 = { + 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: "その他", +}; diff --git a/web/src/modules/regexp.ts b/web/src/modules/regexp.ts new file mode 100644 index 0000000..e688c46 --- /dev/null +++ b/web/src/modules/regexp.ts @@ -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 diff --git a/web/src/modules/state.ts b/web/src/modules/state.ts new file mode 100644 index 0000000..aeee5b1 --- /dev/null +++ b/web/src/modules/state.ts @@ -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 { + isDev: boolean = /^\/dev\/.*$/.test(location.pathname); + + navigate?: ReturnType; + location?: ReturnType; + 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 { + 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 { + this.services = await this._rpc.call("getServices"); + if (this.services.length > 0) { + this.emit("services", this.services); + } + return this.services; + } + + async fetchTuners(): Promise { + 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 { + 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 { + this.jobSchedules = await this._rpc.call("getJobSchedules"); + if (this.jobSchedules.length > 0) { + this.emit("jobSchedules", this.jobSchedules); + } + return this.jobSchedules; + } + + async fetchPrograms(): Promise { + 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 { + 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 { + 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 { + 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) => { + 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) => { + // 配列から文字列を抽出し、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(); diff --git a/web/src/modules/ui.ts b/web/src/modules/ui.ts new file mode 100644 index 0000000..c7853f2 --- /dev/null +++ b/web/src/modules/ui.ts @@ -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 `${text}`; + }) + .replace(regexp.epgXLinkFormat, (text, username) => { + return text.replace(username, + `${username}` + ); + }) + .replace(regexp.epgInstagramLinkFormat, (text, username) => { + return text.replace(username, + `${username}` + ); + }); +} diff --git a/web/src/redoc-ui.html b/web/src/redoc-ui.html new file mode 100644 index 0000000..a2f4750 --- /dev/null +++ b/web/src/redoc-ui.html @@ -0,0 +1,37 @@ + + + + + + Mirakurun API Documentation + + + + + + + +
+ + + diff --git a/web/src/routes/AboutView.sass b/web/src/routes/AboutView.sass new file mode 100644 index 0000000..06a8cad --- /dev/null +++ b/web/src/routes/AboutView.sass @@ -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 + diff --git a/web/src/routes/AboutView.tsx b/web/src/routes/AboutView.tsx new file mode 100644 index 0000000..f32bce0 --- /dev/null +++ b/web/src/routes/AboutView.tsx @@ -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(state.version); + useEffect(() => { + const onVersion = () => { + setVersion(state.version); + }; + state.on("version", onVersion); + return () => { + state.off("version", onVersion); + }; + }, []); + + const [consented, setConsented] = useState(false); + + const toolbar = ( + + + + + + + + ); + + return ( +
+ {toolbar} + +
+
+ +
+ {state.statusName} +

Mirakurun

+
+ + + +
+ + + + + + + + + + + + + + + + + + + +
Current{version}
Latest
LicenseApache License 2.0
CopyrightCopyright © 2016-2026 kanreisa
+
+ +
+ + Mirakurun comes with ABSOLUTELY NO WARRANTY. USE AT YOUR OWN RISK. + +
+ +
+
+
+ + +
Special Thanks
+ {consented === false ? ( +
+

We sincerely thank you for your continued support.

+

+ This page is attempting to retrieve images from your browser by going directly to{" "} + + opencollective.com + {" "} + in order to display a list of contributors. +

+
+ ) : ( +
+
+
Contributors
+

This project exists thanks to all the people who contribute.

+
+ + Contributors + +
+
+ + + +
+
+ Backers{" "} + + [Become a backer] + +
+

Thank you to all our backers! 🙏

+
+ + Backers + +
+
+ + + +
+
+ Sponsors{" "} + + [Become a sponsor] + +
+

Support this project by becoming a sponsor. Your logo will show up here with a link to your website.

+
+ {[0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((i) => ( + + {`Sponsor + + ))} +
+
+
+ )} +
+
+
+
+ ); +}; diff --git a/web/src/routes/ChannelsConfigView.sass b/web/src/routes/ChannelsConfigView.sass new file mode 100644 index 0000000..76f3d5e --- /dev/null +++ b/web/src/routes/ChannelsConfigView.sass @@ -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 diff --git a/web/src/routes/ChannelsConfigView.tsx b/web/src/routes/ChannelsConfigView.tsx new file mode 100644 index 0000000..849ed00 --- /dev/null +++ b/web/src/routes/ChannelsConfigView.tsx @@ -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 = {}; + 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(null); + const [editing, setEditing] = useState(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("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(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) => { + 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 = {}; + 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 = ( + + + + + + + + + + +
+
+
+ + )} + + {!scanInProgress && scanStatus && (scanStatus.status === "completed" || (scanStatus.scanLog && scanStatus.scanLog.length > 0)) && ( + +
+
+ ステータス: {scanStatus.status} + 新規: {scanStatus.newCount || 0} / 引き継ぎ: {scanStatus.takeoverCount || 0} +
+
+ {scanStatus.status === "completed" && scanStatus.result && ( + + )} + +
+
+
+ )} + + + + + Enable + Name + Type + Channel + Options + + + + + {editing.map((ch, i) => ( + + + { + updateChannel(i, { isDisabled: !e.currentTarget.checked }); + }} + /> + + + { + updateChannel(i, { name: e.target.value }); + }} + onBlur={() => { + if (ch.name === "") { + updateChannel(i, { name: `ch${i}` }); + } + }} + /> + + + { + 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" } + ]} + /> + + + { + updateChannel(i, { channel: e.target.value }); + }} + onBlur={() => { + if (ch.channel === "") { + updateChannel(i, { channel: "0" }); + } + }} + /> + + +
+ + { + 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 }); + } + } + }} + /> + + + + { + const val = e.target.value; + if (val === "") { + deleteChannelProperty(i, "tsmfRelTs"); + } else if (/^[0-9]+$/.test(val)) { + const tsmfRelTs = parseInt(val, 10); + updateChannel(i, { tsmfRelTs }); + } + }} + /> + + +
+
Command Vars
+
+ {ch.commandVars && Object.entries(ch.commandVars).map(([key, value]) => ( +
+ updateCommandVarKey(i, key, e.target.value)} + /> + : + updateCommandVarValue(i, key, e.target.value)} + /> +
+ ))} +
+
+
+ + +
+
+ + + ))} + +
+
+ + {/* 保存確認ダイアログ */} + setShowSaveDialog(false)} + title="Save" + > + +

設定を保存しますか?

+

適用するには再起動が必要です。

+
+ + + + + } + /> +
+ + {/* スキャン設定ダイアログ */} + setShowScanDialog(false)} + title="Channel Scan" + style={{ width: "450px" }} + > + +
+ + { + 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" } + ]} + /> + + +
+ + setScanMinCh(e.target.value)} + /> + + + setScanMaxCh(e.target.value)} + /> + +
+ + + { + const val = e.target.value; + if (val === "" || /^[0-9,\-]+$/.test(val)) { + setScanSkipCh(val); + } + }} + /> + + + {scanType === "BS" && ( +
+ setScanUseSubCh(e.currentTarget.checked)} + /> + {scanUseSubCh && ( +
+ + setScanMinSubCh(e.target.value)} + /> + + + setScanMaxSubCh(e.target.value)} + /> + +
+ )} +
+ )} + + setScanChannelNameFormatEnabled(e.currentTarget.checked)} + /> + + {scanChannelNameFormatEnabled && ( + + setScanChannelNameFormat(e.target.value)} + /> + + )} + + setScanAutoApply(e.currentTarget.checked)} + /> + + setScanSetDisabledOnAdd(e.currentTarget.checked)} + /> + + setScanRefresh(e.currentTarget.checked)} + /> +
+
+ + + + + } + /> +
+ + {/* スキャン結果/ログダイアログ */} + setShowScanResultDialog(false)} + title="Scan Results" + style={{ width: "600px" }} + > + + {scanStatus && ( +
+ {scanStatus.status === "completed" && ( + + スキャンが正常に完了しました! +
新規: {scanStatus.newCount} | 引き継ぎ: {scanStatus.takeoverCount}
+
+ )} + +
+ {scanStatus.scanLog && scanStatus.scanLog.length > 0 ? ( + scanStatus.scanLog.join("\n") + ) : ( +
ログがありません。
+ )} +
+ + {scanStatus.status === "completed" && scanStatus.result && ( + + 「適用」ボタンをクリックすると、現在のスキャン結果を設定に反映します。 + + )} +
+ )} +
+ + + + + } + /> +
+
+ ); +}; diff --git a/web/src/routes/EPGView.tsx b/web/src/routes/EPGView.tsx new file mode 100644 index 0000000..218685d --- /dev/null +++ b/web/src/routes/EPGView.tsx @@ -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("EPG.channelType", "GR"); + const [programId, setProgramId] = useState(null); + const [time, setTime] = useState(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({d}{c}} />); + } + } else { + const id = `epg-toolbar-tabs-item-${date.toISODate()}`; + const title = date.toFormat("yyyy/MM/dd(ccc)"); + toolbarTabs.push(); + } + + const showTodayButton = (!globalServiceId && toolbarTabs.length === 1) || (globalServiceId && date.toMillis() !== startDate.toMillis()); + + return ( +
+ + + + {globalServiceId + ? { + 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 番組表" + } + + + + + {globalServiceId && ( + <> + + + )} + {!globalServiceId && ( + <> + {showTodayButton && ( +
+ ); +}; diff --git a/web/src/routes/HomeView.sass b/web/src/routes/HomeView.sass new file mode 100644 index 0000000..0f25a6f --- /dev/null +++ b/web/src/routes/HomeView.sass @@ -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 diff --git a/web/src/routes/HomeView.tsx b/web/src/routes/HomeView.tsx new file mode 100644 index 0000000..344e79e --- /dev/null +++ b/web/src/routes/HomeView.tsx @@ -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 ; + } + + 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 ( +
+ {items.map((item, i) => ( +
+ {item.label} + {item.text} +
+ ))} +
+ ); +}; + +// --- Services Section --- + +const ServicesSection: React.FC<{ + status: Status; + services: Service[]; + allowPNA: boolean; + tsplayEndpoint: string; +}> = ({ status, services, allowPNA, tsplayEndpoint }) => { + const [showDTV, setShowDTV] = useState(true); + const [showData, setShowData] = useState(false); + const [showOthers, setShowOthers] = useState(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 ( + <> +
+ setShowDTV(!showDTV)} + inline + /> + setShowData(!showData)} + inline + /> + setShowOthers(!showOthers)} + inline + /> +
+
+ {filteredServices.map((service) => ( + +
#{service.id}
+
SID: 0x{service.serviceId.toString(16).toUpperCase()} ({service.serviceId})
+
NID: 0x{service.networkId.toString(16).toUpperCase()} ({service.networkId})
+
Type: 0x{service.type.toString(16).toUpperCase()} ({service.type})
+
Channel: {service.channel?.type} / {service.channel?.channel}
+
+ } + placement="bottom" + hoverOpenDelay={300} + > +
+ + {service.hasLogoData && ( + + )} + {service.name} + + { + status?.epg.gatheringNetworks.includes(service.networkId) && || + service.epgReady && || + + } + + + {service.type === 0x01 && allowPNA && tsplayEndpoint && ( + { + e.stopPropagation(); + window.open( + `${tsplayEndpoint}#${location.protocol}//${location.host}/api/services/${service.id}/stream?decode=1`, + "_blank", + "popup" + ); + }} + title="TSPlay (Experimental)" + > + + + )} +
+ + ))} +
+ + ); +}; + +// --- 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 ; + } + + return ( + + + + + + + + + + {entries.map(([pid, data]) => ( + + + + + + ))} + +
PIDPacketsDrops
{pid}{data.packet.toLocaleString()} 0 ? " color-danger" : ""}`}>{data.drop.toLocaleString()}
+ ); +}; + +// --- Tuners Section --- + +const TunersSection: React.FC<{ + tuners: TunerDevice[]; +}> = ({ tuners }) => { + const [killTarget, setKillTarget] = useState(null); + const [tunersEx, setTunersEx] = useState([]); + 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 = ; + } else if (!tuner.isAvailable) { + tunerIcon = ; + } else if (tuner.isUsing) { + tunerIcon = ; + } else { + tunerIcon = ; + } + + const childNodes: TreeNodeInfo[] = []; + + // device info node + if (tuner.command || tuner.pid) { + childNodes.push({ + id: `tuner-${tuner.index}-device`, + icon: , + label: ( + + {tuner.command || "-"} + {tuner.pid ? (pid={tuner.pid}) : null} + {tuner.command && ( +
+ ); +}; + +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"; +} diff --git a/web/src/routes/LogsView.sass b/web/src/routes/LogsView.sass new file mode 100644 index 0000000..02d1360 --- /dev/null +++ b/web/src/routes/LogsView.sass @@ -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 diff --git a/web/src/routes/LogsView.tsx b/web/src/routes/LogsView.tsx new file mode 100644 index 0000000..dd42ffd --- /dev/null +++ b/web/src/routes/LogsView.tsx @@ -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([]); + const latestRef = useRef(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( +
+ {line} +
+ ); + ++_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 ( +
+
+ {logList} +
+
+
+ ); +}; diff --git a/web/src/routes/ProgramView.sass b/web/src/routes/ProgramView.sass new file mode 100644 index 0000000..0247ca8 --- /dev/null +++ b/web/src/routes/ProgramView.sass @@ -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 diff --git a/web/src/routes/ProgramView.tsx b/web/src/routes/ProgramView.tsx new file mode 100644 index 0000000..ed62659 --- /dev/null +++ b/web/src/routes/ProgramView.tsx @@ -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(null); + const [program, setProgram] = useState(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[] = []; + 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 ( +
+ + + + { + navigate(`/epg?date=${date.toISODate()}&time=${time}`) + } + }, + { + className: isLoading ? "bp5-skeleton" : "", + text: isLoading ? "Loading................................." : (error ? "エラー" : ( + program ? : <> + )) + } + ]} /> + + + + + {isLoading && <> +
+ ); +}; diff --git a/web/src/routes/SearchView.sass b/web/src/routes/SearchView.sass new file mode 100644 index 0000000..8063ac6 --- /dev/null +++ b/web/src/routes/SearchView.sass @@ -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 diff --git a/web/src/routes/SearchView.tsx b/web/src/routes/SearchView.tsx new file mode 100644 index 0000000..2f26704 --- /dev/null +++ b/web/src/routes/SearchView.tsx @@ -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(null); + const [programs, setPrograms] = useState(null); + const [result, setResult] = useState([]); + const [title, setTitle] = useState("検索"); + // 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: , + 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 ( +
+ + + + navigate("/epg") + }, + { + text: "検索", + className: `heading-title ${isLoading ? "bp5-skeleton" : ""}`.trim(), + } + ]} /> + + + + + + + +
+ {!nonIdealState && result} + + {nonIdealState && <> + + } +
+
+ ); +}; + +function createResultItem(program: Program) { + return ( + + + + ); +} diff --git a/web/src/routes/ServerConfigView.sass b/web/src/routes/ServerConfigView.sass new file mode 100644 index 0000000..3482f48 --- /dev/null +++ b/web/src/routes/ServerConfigView.sass @@ -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 + diff --git a/web/src/routes/ServerConfigView.tsx b/web/src/routes/ServerConfigView.tsx new file mode 100644 index 0000000..ec4267d --- /dev/null +++ b/web/src/routes/ServerConfigView.tsx @@ -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(null); + const [editing, setEditing] = useState(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 = ( + + + + + + + + +