MirakurunクローンをRustで実装しました

**今後，本記事を更新することはありません．最新の情報については，[Twitter](https://twitter.com/masnagam)もしくは[mirakc/mirakc](https://github.com/mirakc/mirakc)を見てください．**

[mirakc](https://github.com/masnagam/mirakc)という[Mirakurun](https://github.com/Chinachu/Mirakurun)クローンをRustで実装しました．

まだ，バージョンもつけていない状態なので，今後大きな変更を行う可能性はあります（~~async/awaitに変更したり，~~MPEG TSパーサーをRustで実装したり）．

# 特徴

* ８チャンネル同時ストリーミング時のCPU使用率が2/3，メモリ使用量が1/60になります（**要再計測**）
    * 詳細は[こちら](https://github.com/masnagam/mirakc#performance-comparison)
    * GCに起因するリソース消費がないため，CPU使用率やメモリ使用量がより安定的に推移します
* TSパケットはC++で実装した[外部コマンド](https://github.com/masnagam/mirakc-arib)で処理します
    * [TSDuckを改造したもの](https://github.com/masnagam/tsduck-arib)を使って実装しています
* [EPGStation](https://github.com/l3tnun/EPGStation)を動かすのに必要なREST APIのみサポートしています
* LinuxとmacOSで動きます
    * macOSとArmbian/ROCK64で動作確認済み
    * Raspberry Pi 2/3でも動くと思いますが，確認はしていません
        * ８チャンネル同時ストリーミングには120Mbps以上の転送速度が必要なので，Raspberry Pi 2/3では不可能です

あと，EPG関係の細かい改善として

* 受信したEITセクションを保持するだけの単純な実装にしました
    * Mirakurunは，過去のEPG情報に新しいEPG情報を[マージする](https://github.com/Chinachu/Mirakurun/blob/master/src/Mirakurun/Program.ts#L74)ため，より多くのCPUリソースを使用します
    * Mirakurunは，緊急特番などで番組スケジュールが変更された場合，過去の番組情報が残ってしまいEPG表示がおかしくなることがあります
* 放送が終了した番組情報は翌日まで残り続けます
    * その結果，EPGStationでの番組表示が少し綺麗になります（主観ですが）
    * Mirakurunは，終了した番組情報を定期的に削除します
* [aribb24](https://github.com/masnagam/aribb24)に改行処理を追加しています

# インストール

`--b25`オプションを無効化した`recdvb`と`recpt1`を同梱した[Dockerイメージ](https://hub.docker.com/r/masnagam/mirakc)を用意してあります．amd64，armv5，armv7およびarm64をサポートしています．


以下のような`docker-compose.yml`を使って`docker-compose up`すれば起動します．

```yml:docker-compose.yml
version: '3.7'

services:
  mirakc:
    image: masnagam/mirakc
    container_name: mirakc
    init: true
    restart: unless-stopped
    devices:
      # PLEX PX-W3U4/PX-Q3U4 with nns779/px4_drv
      # 設定ファイルが長くなるので，ここでは１つだけ登録していますが，通常は全部登録します
      - /dev/px4video2
      # PLEX PX-S1UD with Linux DVB devices
      # - /dev/dvb
    networks:
      - default-network
    ports:
      - 40772:40772
    volumes:
      - mirakc-epg:/var/lib/mirakc/epg
      - ./config.yml:/etc/mirakc/config.yml:ro
    environment:
      TZ: Asia/Tokyo
      RUST_LOG: info

networks:
  default-network:
    name: mirakc_network
    driver: bridge

volumes:
  mirakc-epg:
    name: mirakc_epg
    driver: local

```

`config.yml`は以下の通り．Mirakurunでは複数の設定ファイルがありますが，mirakcでは１つのファイルにすべての設定を記述します．

```yml:config.yml
server:
  address: '0.0.0.0'
  port: 40772
  workers: 3

channels:
  # 設定ファイルが長くなるので，チャンネル数を減らしています
  # `channel`の値は地域に合わせて書き換えてください（以下の設定は関東地方向け）
  - name: NHK E
    type: GR
    channel: '26'
  - name: NHK G
    type: GR
    channel: '27'

tuners:
  # PLEX PX-W3U4/PX-Q3U4 & nns779/px4_drv
  - name: px-w3u4
    types: [GR]
    command: >-
      recpt1 --device /dev/px4video2 {{channel}} {{duration}} -

  # PLEX PX-S1UD & Linux DVB devices
  - name: px-s1ud
    types: [GR]
    command: >-
      recdvb {{channel}} {{duration}} -

  # SSHでアクセス可能なLinuxマシン(REMOTE)にチューナーが接続済みであれば，以下のような設定でも動作します
  # ただし，SSHキーを配置したり，REMOTE-HOSTが名前解決できるように追加で設定する必要があります
  - name: ssh
    types: [GR]
    command: >-
      ssh REMOTE recpt1 --device /dev/px4video2 {{channel}} {{duration}} -

  # 稼働中のMirakurun/mirakcサーバーをチューナーとして定義することも可能です
  # リクエスト先がmirakcなら，durationをクエリーパラメーターに指定すると機能します
  - name: upstream
    types: [GR, BS]
    command: >-
      curl -sG http://mirakurun:40772/api/channels/{{channel_type}}/{{channel}}/stream
      -d duration={{duration}}

tools:
  scan-services: >-
    mirakc-arib scan-services{{#xsids}} --xsid={{.}}{{/xsids}}
  collect-eits: >-
    mirakc-arib collect-eits{{#xsids}} --xsid={{.}}{{/xsids}}
  filter-service: >-
    mirakc-arib filter-packets --sid={{sid}}
  filter-program: >-
    mirakc-arib filter-packets --sid={{sid}} --eid={{eid}}

epg-cache-dir: /var/lib/mirakc/epg
```

[px4_drv](https://github.com/nns779/px4_drv)のインストールが面倒だという人は，[masnagam/sbc-scripts](https://github.com/masnagam/sbc-scripts)にあるスクリプトを使ってください．Armbian/ROCK64にpx4_drvをインストールする場合には，インストール前に[rock64-fixup-armbian-linux-headers](https://raw.githubusercontent.com/masnagam/sbc-scripts/master/rock64-fixup-armbian-linux-headers)を実行して一部のMakefileを書き換えてファイルを再生成する必要があるかもしれません．

`docker-compose`をARM環境にインストールする場合，`pip`でインストールできます．ただ，依存パッケージのバージョンが衝突する可能性があるようで，`virtualenv`を使用することが推奨されています．

* [Install Docker Compose | Docker Documentation](https://docs.docker.com/compose/install/#install-compose)のAlternative Install Optionsを参照

ARM向けバイナリーは配布されていないため，私は以下のスクリプトでDockerを使ってビルドしています．

```sh
curl -fsSL https://raw.githubusercontent.com/masnagam/sbc-scripts/master/install-docker-compose \
  | sh -s -- -b 1.24.1 -c && sudo mv docker-compose /usr/local/bin/
```

`/usr/local/bin`に`docker-compose`がコピーされます．ビルドには数時間かかります．PC上でのクロスビルドもできますが，QEMUでのエミュレーションが原因で結局はビルドに数時間かかります．

EPGStationでの表示結果を確認したい方は，[こちら](https://github.com/masnagam/mirakc/tree/master/docker/testbed)を使ってください．

## ビルド方法

ソースからビルドする場合の手順については，[masnagam/docker-mirakc](https://github.com/masnagam/docker-mirakc)にある以下のファイルを参照してください．

* [Dockerfile](https://github.com/masnagam/docker-mirakc/blob/master/Dockerfile)
    * amd64 native build
* [Dockerfile.cross](https://github.com/masnagam/docker-mirakc/blob/master/Dockerfile.cross)
    * クロスコンパイル用

ターゲットSBC向けにコンパイルする場合は，ある程度高速なPC上でクロスコンパイルすることを推奨します．ターゲットSBC上でコンパイルする場合は数時間かかることを覚悟してください．

利用している[TSDuck](https://tsduck.io/)の対応環境やコンパイラーに関する情報は，以下を参照してください．

* https://tsduck.io/doxy/building.html

特にGCCのバージョンに関する制約があるので注意してください．

[masnagam/mirakc-arib](https://github.com/masnagam/mirakc-arib)には以下の環境向けのCMakeツールチェインファイルがすでに含まれています．

* [aarch64-debian-linux-gnu](https://github.com/masnagam/mirakc-arib/blob/master/toolchain.cmake.d/debian-aarch64-linux-gnu.cmake)
* [arm-debian-linux-gnueabihf](https://github.com/masnagam/mirakc-arib/blob/master/toolchain.cmake.d/debian-arm-linux-gnueabihf.cmake)

Debian/Buster推奨です．Debian/StretchはデフォルトのGCCバージョンが6なので，別途TSDuckが対応しているバージョンのGCCをインストールした上で，CMakeツールチェインファイルを適切に修正する必要があります．

動作確認ができる環境がLinuxとmacOSしかないため，現状ではこの２つしかサポートしていません．これ以外の環境でビルドを試みた方がいましたら，情報やパッチを送ってください．ビルド確認やテスト方法などを検討の上，マージを試みようと思います．

# 経緯

ROCK64は，コストパフォーマンスに優れたシングルボードコンピューターの１つです．消費電力も少なく，２４時間稼働する軽量サービスの運用に向いています．

そこで，ROCK64上でMirakurunを動かし，PX-Q3U4で８チャンネル同時にストリーミングできるか試してみました．結果は以下の通りです．

* CPU使用率やデータ送信量には余裕がある
* 1GB程度のメモリを消費する

素直にROCK64（2GB/4GB）を使えば何も問題ないのですが，ROCK64（1GB）で８本同時ストリーミングしたかったのでRustでクローンを作ることにしました．

Rustで開発するのがほぼ初めてだったこともあり，数千円の価格差に見合わない結構な時間を消費してしまいました．．

# Technical Notes

参考になるかは疑問ですが，技術的な内容も多少書いておかないと，単なる宣伝になってしまうので．．

## Feasibility Study

頑張ったけど改善しませんでしたという事態を避けるため，本格的な実装に取り掛かる前に，どの程度改善が見込まれるのか簡単な実装で計測しました．

以下，Raspberry Pi 3Bでの計測結果です．TSストリームは１本のみ．

|            | %CPU  | MEM(MB) |
|------------|-------|---------|
| Express.js | 10~15 | < 60    |
| Rocket     |  5~10 | < 0.7   |

CPUは1/2から2/3程度．メモリは1/10くらい．

```js:実験に使用したExpress.jsのコード
const express = require('express');
const app = express();
const port = 3000;
const spawn = require('child_process').spawn;
const debug = require('debug')('app');

app.get('/:channel', (req, res) => {
  const rec = spawn('recdvb', [req.params.channel, '-', '-'], {
    stdio: ['ignore', 'pipe', 'ignore'],
  });

  res.status(200).header("Content-Type", "video/MP2T");
  res.on('close', () => {
    debug('Kill recdvb...');
    rec.kill('SIGINT');
  });

  debug('Streaming...');
  rec.stdout.pipe(res);
});

app.listen(port, () => console.log(`Example app listening on port ${port}!`))
```

```rust:実験に使用したRocketのコードの一部
#[get("/channels/<_ctype>/<channel>/stream")]
fn get_channel_stream(_ctype: String, channel: String, _server: rocket::State<Server>) -> Stream<ChildStdout> {
    let child = Command::new("recdvb")
                            .args(&[&channel, "-", "-"])
                            .stdout(Stdio::piped())
                            .stderr(Stdio::null())
                            .spawn()
                            .unwrap();
    Stream::from(child.stdout.unwrap())
}
```

最終的にはRocketからactix-webに変更しましたが，開発前に想定した程度の改善結果が得られています．

小さなプロジェクトではフィージビリティ・スタディを行うことは稀だとは思いますが，規模が大きなプロジェクトではモックを作って事前に検証することの重要性が増します．

## EITのバージョンとsub-table

TSストリームから番組スケジュール情報を抽出するソフトウェアは幾つか存在しますが，どれもsub-tableを正しく扱っていないように見えます．

* Mirakurun
    * 異なるバージョンを見つけると，各セクションの受信状況を管理するフラグをリセットする（[TSFilter.ts](https://github.com/Chinachu/Mirakurun/blob/master/src/Mirakurun/TSFilter.ts#L669)）
* [rndomhack/node-aribts](https://github.com/rndomhack/node-aribts)
    * バージョンをチェックしない（[event_manager.js](https://github.com/rndomhack/node-aribts/blob/master/lib/event_manager.js#L35)）
* [stz2012/epgdump](https://github.com/stz2012/epgdump), [murakamiy/epgdump_py](https://github.com/murakamiy/epgdump_py)
    * 見つかったEITセクションを出力するだけなので，バージョンはチェックしない（そもそもsub-tableを処理する必要がない）

ARIBやETSIの記述によると，EITは以下の４つの情報で特定される複数のsub-tableから構成されています．

* service_id
* transport_stream_id
* original_network_id
* vesion_number

つまり，以下のようなEITを作ることが可能なように読めます．

* ベースとなるセクションを含むsub-table（version_number(X)）
* 特定のセクションのみを含むsub-table（version_number(Y)）
    * 放送時間延長とか緊急特番とか

実際，mirakcの動作確認をしているときに，

```
Section(00): version(12)
Section(08): version(12)
Section(10): version(11)
Section(18): version(11)
```

のような状況が発生することを確認しました．このような状況では，MirakurunのEPG更新処理はタイムアウトします．なぜなら

* version(12)を見つけると，version(11)のセクションをリセット
* version(11)を見つけると，version(12)のセクションをリセット

を繰り返すためです．その結果，特定の時間帯の番組が取得できないということが起きています．

mirakcは，node-aribtsと同じ方針となっており，バージョン番号に関わらず全てのセクションを受信すると処理が完了するようになっています．正しい実装ではないですが，確実に処理が停止するのでこの方針にしました．

できれば，sub-tableを正しく処理したほうがいいのでしょうが，検証のためのTSファイルがなかったり，実際にどのように運用されているのか知らないという状況なので，修正するのは難しいかもしれません．

## 番組の録画開始位置の判定方法

私が調べた限りでは，番組の録画開始位置を判定する確実な方法はありません．

### PCRを使った判定方法 (2019-12-29対応)

動画再生時の同期に利用するPCRというものがありますが，これとTOTを使って録画期間を計算できます．

1. TOTの直後のPCRを，その日時のPCR値とする
2. EIT (TID#4E) から番組放送日時を取得
3. 1で取得した対応関係から，開始位置と終了位置のPCR値を計算

動画データは，対応するPCR値に達する前に送られてきます．そのため，３で計算した開始PCR値よりも前から録画を開始する必要があります．これを録画前マージンと呼ぶことにします．また，終了PCR値後に設けるマージンを録画後マージンと呼ぶことにします．

このマージンはTOT/PCR同期誤差への対応にも利用できます．TOT/PCR同期時に誤差が生じます．これを補正するのはとても面倒です．面倒なので，このマージンで吸収します．

前後２秒程度マージンを設ければ十分だと楽観的に考えていたのですが，実際には安全のため前後５秒程度必要だという結論に至りました．

一番の要因は，TOTと動画データが同期されていないという点があります．例えば，NHKのTOTは実際には２秒近く早い日時が送られているように見えます．このずれはサービスごとに異なるため，きちんと対応しようとすると個別に補正する必要があります．もちろん，受信環境ごとに．やればできるんでしょうが，面倒なのでマージンを増やすことで調整することにしました．

PCR値には最大値があります:

```c++
constexpr int64_t kMaxPcr = ((static_cast<int64_t>(1) << 33) - 1) * 300 + 299;
```

この値を超えると0に戻ります．そのため，0および最大値近辺での大小比較には工夫が必要です．何の仮定もせず比較することは不可能なので，放送時間は最大PCR値の半分よりも少ないことを仮定して[比較します](https://github.com/masnagam/mirakc-arib/blob/f2acc91273d216e92506bbad2282cd6e9501f809/src/program_filter.hh#L86)．

後述のEIT (TID#4E) を使った判定方法でも十分機能しますが，PCRを使った判定方法だと一時バッファーが不要になるという利点があります．まあ，ストリーミング開始前にストリームあたり数MB減る程度ですが．

### EIT (TID#4E) を使った判定方法 (2019-12-03対応の旧バージョン)

番組を録画する最も単純な方法は，番組の時間（もしくは開始の少し前）になったら録画開始するという方法です．しかし，何らかの理由で開始時間が変わってしまうと，期待通りに録画できない場合があります．これを回避するために，PSI/SIパケットの情報を利用することが考えられます．

例えば，TID#0x4Eのテーブルは現在および次の番組情報を保持しています．そのため，現在の番組のEIDが録画対象のEIDに一致したら録画を開始するという方法が考えられます．実際，Mirakurunはこれを判断材料として使っています．しかし，残念ながらこの方法は以下の理由により完璧ではありません．

* 多くの場合，PSI/SIパケットは一定のビットレートで送信されているため，基本的には一定のパケット間隔で出現します
    * 例外として，テーブルのバージョンを更新する場合，速やかに送信するシステムが存在するようです
* PSI/SIとPESの送信タイミングは同期されていないようです

TID#4E以外にもPAT/PMTのバージョン番号が更新されたタイミングなども検討してみましたが，どれもうまく機能しないという結論に至りました．

最終的には以下のようなMirakurunと同じ方法にしました．

* TID#4Eを使ってストリーミング開始タイミングを判定
* ストリーミング開始前のパケットをバッファリング（デフォルト8MiB）
    * ストリーミング開始時にブロッキングで出力し，その後，バッファを解放

ドキュメント，コミットログ，コードコメントには説明が見当たらないので，目的を誤認している人もいるかもしれませんが，Mirakurunのserver.ymlで設定する`maxBufferBytesBeforeReady`は上記バッファサイズを指定します．録画対象番組の録画開始前のマージンを指定するためのものという考えは誤っています．

```text
正しい認識
+---------------------------------------------------+--------------------------
|  Previous TV Program                              |  Target TV Program
|                                    | maxBufferBytesBeforeReady | TID#0x4E |          
+---------------------------------------------------+--------------------------

誤った認識
+---------------------------------------------------+---------------------
|  Previous TV Program  | maxBufferBytesBeforeReady |  Target TV Program
+---------------------------------------------------+---------------------
```

Mirakurunの`maxBufferBytesBeforeReady`のデフォルト値は3MBです．私が調査した限りでは

* 地デジ
    * 3MiBは必要
* BS
    * 8MiBは必要

だったので，Mirakurunのデフォルト値のままだとBSの先頭部分のパケットが保存できない可能性があります．

以下は，ある番組でのTOTおよびEIT（TID#0x4E）のパケット番号（先頭のパケット番号は1）です．

| TOT (番組開始時刻) | EIT (TID#0x4E)  | TOT (５秒後)    |
|------------------|-----------------|----------------|
| 53,718           | 98,698..111,179 | 118,605        |

EITの最後のパケットが番組開始からかなり経過してから現れています．その間に58Kiパケットが送られてきています．関係ないパケットが間引かれるとしても，3MiBでは16Kiパケットしか保存できません．

## actix-webのApplication State

actix-webでデータベースなどにアクセスする場合，普通は`App::data()`を使ってごにょごにょすることが想定されていると考えられます．また，REST APIのテストを行う場合，一般的にデータベースアクセス部をスタブ化するなどしてテストを行います．

なので最初は，静的ディスパッチ用の適当なトレイトを定義して，テスト用にはモックを実装することを考えたのですが，あまりうまく行きませんでした．

トレイトによる静的ディスパッチを行う場合，`App::data()`呼び出し部とREST API実装部との間になんからの「関連」を持たせる必要があります．そうしないと，コンパイラは`App::data()`で登録された型と，REST API実装部で取得したデータの型の間の関係を理解できないためです．もしかすると他にも方法があるかもしれませんが，以下のように`App::data()`呼び出し部とREST API実装部をメソッドとして実装するジェネリック型を定義する方法があります．

```rust
// 数ヶ月前の記憶を掘り起こしているため，かなり適当なコードです．．
struct Server<T> {}

impl<T: Trait> Server<T> {
    fn start(data: T) {
        // サーバー起動
        // App::new().data(data)でデータ登録
    }

    fn index(data: web::Data<T>) -> String {
        // ジェネリックな型のメソッドにすればTが使える
    }
}
```

ただ，このようにした場合，`#[get("/")]`のようなマクロが使えなくなるため，自力でルーティング設定を行う必要があります．

試してはいませんが，動的ディスパッチや`Any`を使った実行時型変換を使えばうまくいくかもしれません．

## モックライブラリ

actix-webのテストで，モックライブラリを幾つか試してみましたが

* 関数のモックを作成した場合，大域変数が使用されるライブラリが多く，テストを並列実行できなくなったり，複数スレッドの動作に関するテストができなくなる
* Nightly版じゃないと動かないライブラリが幾つかある

などの問題があり，現時点でのモックライブラリの使用を見送りました．

紆余曲折の後，最終的に現実装のようなかなり強引な方法で関数を差し替えてテストする形になりました．[actix-broker](https://github.com/chris-ricketts/actix-broker)とか使えば，もう少し綺麗にテストできるのかもしれませんが，テストのためだけに導入するのもどうかと思ってやめました．

## tokio_processとパイプ

tokio_processからの出力をパイプする場合，通常`tokio::io::copy()`を使います．`tokio::io::copy()`は中間バッファーを使うので余計なコピーが発生します．

`std::os::unix::io::RawFd`とか使えば直接繋げられるのではないかと思って試してみましたが，無理でした．

### 少し頑張ればなんとかなりました（2020-02-03追記）

`tokio::process::Command::spawn()`を使うと無理ですが，`std::process::Command::spawn()`と`tokio::process::unix::stdio()`という内部関数を使えば可能です．

詳細は以下の実装を参照してください．

* [src/command_util.rs](https://github.com/masnagam/mirakc/blob/master/src/command_util.rs)
* [src/tokio_snippet.rs](https://github.com/masnagam/mirakc/blob/master/src/tokio_snippet.rs)

`stdio()`を公開したほうが，いろいろ便利な気がします．

## x64マシンでARM用Dockerイメージの作成

最近のDocker Desktop for Macだと，QEMUのユーザーモードエミュレーションのおかげで，特別な設定を行わなくてもARM用Dockerイメージのビルド・実行が可能です．これを使えば１つのDockerfileを使って複数アーキテクチャで動かせるDockerイメージを簡単に作成可能です．

* [Working with Docker Buildx (experimental)](https://docs.docker.com/buildx/working-with-buildx/)

ただ，大量のC++ファイルをコンパイルする場合などは，強力なPC上でもイメージのビルドに結構時間が掛かります．あと，エミュレーションのためにより多くのメモリーを使用するため，場合によってはメモリーの割当量を増やしておかないとエラーが発生します．

最終的には，実行可能ファイルをクロスコンパイルしてコピーするマルチステージビルドを行うことにしました．マルチアーキテクチャイメージは，アーキテクチャごとのイメージをビルドした後に`docker buildx --platform ...`で作成しました．

## GitHub ActionsでARM用Dockerイメージの作成

そのままの`ubuntu`仮想マシンでは，ARM用Dockerイメージの作成に失敗する場合があります．

* 成功例
    * https://github.com/masnagam/docker-mirakc/runs/303067717
* 失敗例
    * https://github.com/masnagam/docker-mirakc/runs/305855981

しばらく原因に気付かず，検討違いの修正を行いましたが，エラーログを確認してQEMUユーザーモード・エミュレーションが準備されていないことに気が付きました（Docker Desktop for Macだと最初から機能します）．

```text:失敗例のログからの抜粋
#53 0.196 standard_init_linux.go:211: exec user process caused "exec format error"
```

成功例では，`RUN`が含まれていなかったため問題が起こりませんでしたが，失敗例では`RUN`を追加したため上記のエラーが発生しました．

基本的には，以下に書かれている手順を追加すればGitHub ActionsでもARM用Dockerイメージを作成できるようになります．

* [multiarch/qemu-user-static](https://github.com/multiarch/qemu-user-static)

```yaml:masnagam/docker-mirakc/.github/workflow/docker.ymlより抜粋
    steps:
      - uses: actions/checkout@v1
      - name: Setup QEMU user-mode emulation
        run: |-
          sudo apt-get update
          sudo apt-get install -y qemu qemu-user-static
          docker run --rm --privileged multiarch/qemu-user-static --reset -p yes
      - name: Build image
```