31
29

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Raspberry Pi 4 で地デジ録画サーバーを構築する(Mirakurun + EPGStation + HWエンコード)

31
Posted at

この記事は Claude Code(Anthropic) を活用して執筆しています。システム構築自体もClaude Codeと対話しながら設定ファイルの作成・デバッグ・スクリプト開発を行いました。

はじめに

子供にテレビを占領されてしまい、自分の見たい番組が見られない ── そんな切実な動機から、Raspberry Pi 4で自分専用の録画サーバーを構築しました。

格安USBチューナー PX-S1UD と Docker で、地上デジタル放送の録画・視聴・自動エンコードをすべて完結させるシステムです。外出先からもVPN経由でリアルタイム視聴・録画番組の再生ができ、エンコードもRaspberry PiのハードウェアエンコーダーでCPU負荷を抑えて実現しています。

このシステムでできること

  • EPG(電子番組表)からの予約録画
  • キーワード自動録画(ルール予約)
  • 録画後の自動HWエンコード(TS → MP4)
  • 番組名で自動フォルダ整理
  • PC・スマホからのライブ視聴(VLC)
  • VPN経由で外出先からも視聴
  • 古い録画TSの自動削除

システム構成

ハードウェア

パーツ 製品 備考
本体 Raspberry Pi 4 Model B Rev 1.4 Debian 13 (trixie), aarch64
TVチューナー PX-S1UD V2.0 x 2台 地デジ専用、USB接続
ICカードリーダー SCM SCR3310-NTTCom B-CASカード用
録画用HDD USB-HDD 2TB /mnt/hdd1(TS保存)
エンコード用HDD USB-HDD 2TB /mnt/hdd2(MP4保存)

ソフトウェア構成

┌──────────────────────────────────────────┐
│  Raspberry Pi 4                          │
│                                          │
│  ┌─────────────┐    ┌─────────────────┐  │
│  │  Mirakurun   │    │   EPGStation     │  │
│  │  (Docker)    │◄──►│   (Docker)       │  │
│  │  port:40772  │    │   port:8888      │  │
│  └──────┬───────┘    └────────┬─────────┘  │
│         │                     │            │
│    PX-S1UD x2           enc.js (HW)       │
│    + B-CAS              h264_v4l2m2m      │
│                                           │
│  /mnt/hdd1 (TS)    /mnt/hdd2 (MP4)       │
└──────────────────────────────────────────┘
  • Mirakurun: TVチューナーのミドルウェア。チューナーの管理とTSストリーミングを担当
  • EPGStation: 録画管理WebUI。EPG取得、予約、録画、エンコードを管理

両方ともDockerコンテナで動作しています。

セットアップ

1. USBチューナーの準備

PX-S1UD V2.0は、Linuxでは siano ドライバで自動認識されます。

# チューナーが認識されているか確認
lsusb | grep VidzMedia
# Bus 001 Device 004: ID 3275:0080 VidzMedia Pte Ltd PX-S1UD Digital TV Tuner
# Bus 001 Device 005: ID 3275:0080 VidzMedia Pte Ltd PX-S1UD Digital TV Tuner

# DVBデバイスの確認
ls /dev/dvb/
# adapter0  adapter1

ファームウェアが必要な場合:

sudo apt install wget
wget http://plex-net.co.jp/plex/px-s1ud/s1ud_driver.zip
unzip s1ud_driver.zip
sudo cp isdbt_rio.inp /lib/firmware/

2. ICカードリーダーの設定

sudo apt install pcscd pcsc-tools libccid
sudo systemctl enable pcscd
sudo systemctl start pcscd

# B-CASカードが読めるか確認
pcsc_scan

3. Docker Composeの構成

docker-compose.yml
version: '3.8'
services:
  mirakurun:
    image: chinachu/mirakurun:latest
    container_name: mirakurun
    restart: always
    privileged: true
    network_mode: host
    volumes:
      - ./mirakurun/conf:/app-config
      - ./mirakurun/data:/app-data
      - /dev:/dev
    devices:
      - /dev/bus/usb:/dev/bus/usb
      - /dev/dvb:/dev/dvb

  epgstation:
    image: l3tnun/epgstation:latest
    container_name: epgstation
    restart: always
    network_mode: host
    group_add:
      - video
    devices:
      - /dev/video10:/dev/video10
      - /dev/video11:/dev/video11
      - /dev/video12:/dev/video12
    volumes:
      - ./epgstation/config:/app/config
      - ./epgstation/data:/app/data
      - ./epgstation/thumbnail:/app/thumbnail
      - ./epgstation/logs:/app/logs
      - /mnt/hdd1:/app/recorded
      - /mnt/hdd2:/app/recorded_mp4
    entrypoint: >
      /bin/sh -c "apt-get update && apt-get install -y ffmpeg tzdata
      && cp /usr/share/zoneinfo/Asia/Tokyo /etc/localtime
      && echo 'Asia/Tokyo' > /etc/timezone && npm start"
    depends_on:
      - mirakurun

ポイント:

  • EPGStationコンテナに /dev/video10/dev/video12 をマッピングしています。これがRaspberry Pi 4のハードウェアエンコーダー(bcm2835-codec)です
  • group_add: video でvideoグループ権限を付与
  • 録画TS用HDD(hdd1)とエンコードMP4用HDD(hdd2)を分離しているのは、同時読み書きの負荷分散のためです

4. Mirakurunのチューナー設定

mirakurun/conf/tuners.yml
- name: PX-S1UD-1
  types:
    - GR
  command: recdvb --dev 0 <channel> - -
  decoder: arib-b25-stream-test
  isDisabled: false

- name: PX-S1UD-2
  types:
    - GR
  command: recdvb --dev 1 <channel> - -
  decoder: arib-b25-stream-test
  isDisabled: false

重要: decoder: arib-b25-stream-test の指定が必須です。recdvb にはB25デコード機能がないため、これがないとスクランブル解除されません。

5. EPGStationの設定(主要部分)

epgstation/config/config.yml
port: 8888
mirakurunPath: http://127.0.0.1:40772
dbtype: sqlite

recordedFormat: '%YEAR%年%MONTH%月%DAY%日%HOUR%時%MIN%分%SEC%秒-%TITLE%'
recordedFileExtension: .m2ts
recorded:
    - name: recorded
      path: '%ROOT%/recorded'
    - name: recorded_mp4
      path: /app/recorded_mp4

ffmpeg: /usr/bin/ffmpeg
ffprobe: /usr/bin/ffprobe

# エンコード設定
encodeProcessNum: 4
concurrentEncodeNum: 1
encode:
    - name: H.264
      cmd: '%NODE% %ROOT%/config/enc.js'
      suffix: .mp4
      rate: 4.0
      recordedFolder: recorded_mp4

注意: encodeOnRecordedFinished をconfig.ymlに書くだけでは自動エンコードは動きません。各録画ルールの設定で「エンコードモード」を H.264 に指定する必要があります。EPGStation WebUIのルール編集画面から設定するか、APIで更新できます。

# APIでルールにエンコード設定を追加する例
RULE_JSON=$(curl -s http://localhost:8888/api/rules/1)
UPDATED=$(echo "$RULE_JSON" | python3 -c "
import json, sys
rule = json.load(sys.stdin)
rule.pop('id', None)
rule['encodeOption'] = {
    'mode1': 'H.264',
    'encodeParentDirectoryName1': 'recorded_mp4',
    'isDeleteOriginalAfterEncode': False
}
print(json.dumps(rule))
")
curl -X PUT "http://localhost:8888/api/rules/1" \
  -H "Content-Type: application/json" -d "$UPDATED"

ハードウェアエンコード(h264_v4l2m2m)

Raspberry Pi 4には bcm2835-codec というハードウェアエンコーダーが搭載されています。ソフトウェアエンコード(libx264)と比べてCPU使用率が大幅に低く、Piに優しいエンコードが可能です。

# 使えるエンコーダーの確認
ffmpeg -encoders 2>/dev/null | grep v4l2
#  V..... h264_v4l2m2m   V4L2 mem2mem H.264 encoder wrapper

# どのデバイスを使っているか
v4l2-ctl --list-devices
# bcm2835-codec-decode (platform:bcm2835-codec):
#     /dev/video10
#     /dev/video11  ← エンコーダー
#     /dev/video12

enc.js — エンコードスクリプト

EPGStationからエンコード時に呼ばれるスクリプトです。ポイントは2つ:

  1. h264_v4l2m2m によるHWエンコード
  2. 番組名の共通部分で自動フォルダ整理
epgstation/config/enc.js
const spawn = require("child_process").spawn;
const path = require("path");
const fs = require("fs");
const ffmpeg = process.env.FFMPEG;

const input = process.env.INPUT;
let output = process.env.OUTPUT;
const analyzedurationSize = "10M";
const probesizeSize = "32M";
const dualMonoMode = "main";
const videoHeight = parseInt(process.env.VIDEORESOLUTION, 10);
const isDualMono = parseInt(process.env.AUDIOCOMPONENTTYPE, 10) == 2;
const audioBitrate = videoHeight > 720 ? "192k" : "128k";
const videoBitrate = videoHeight > 720 ? "3000k" : "2000k";

// --- 番組タイトルで共通フォルダにまとめる ---

/**
 * 2つの文字列の最長共通先頭部分を返す
 */
function commonPrefix(a, b) {
    let i = 0;
    while (i < a.length && i < b.length && a[i] === b[i]) i++;
    return a.substring(0, i);
}

/**
 * 共通部分の末尾を綺麗にする(半端な記号・スペースを除去)
 */
function cleanSuffix(s) {
    return s
        .replace(/[\s 、。・~~((【「\[]+$/, "")
        .replace(/\s+$/, "")
        .trim();
}

/**
 * 既存フォルダと最長共通先頭文字列を比較し、
 * 十分長い共通部分があればそのフォルダを使う(必要ならリネーム)
 */
function resolveFolder(baseDir, safeTitle) {
    const MIN_PREFIX_LEN = 4;

    let dirs;
    try {
        dirs = fs.readdirSync(baseDir, { withFileTypes: true })
            .filter(d => d.isDirectory() && d.name !== "System Volume Information")
            .map(d => d.name);
    } catch (e) {
        dirs = [];
    }

    let bestMatch = null;
    let bestPrefixLen = 0;
    let bestPrefix = "";

    for (const dirName of dirs) {
        const prefix = cleanSuffix(commonPrefix(safeTitle, dirName));
        if (prefix.length >= MIN_PREFIX_LEN && prefix.length > bestPrefixLen) {
            bestPrefixLen = prefix.length;
            bestPrefix = prefix;
            bestMatch = dirName;
        }
    }

    if (bestMatch) {
        if (bestMatch !== bestPrefix) {
            const oldPath = path.join(baseDir, bestMatch);
            const newPath = path.join(baseDir, bestPrefix);
            try {
                fs.renameSync(oldPath, newPath);
                console.error(`[enc.js] Renamed folder: "${bestMatch}" -> "${bestPrefix}"`);
            } catch (e) {
                console.error(`[enc.js] Rename failed, using existing: "${bestMatch}"`);
                return path.join(baseDir, bestMatch);
            }
        }
        return path.join(baseDir, bestPrefix);
    }

    return path.join(baseDir, safeTitle);
}

const title = process.env.HALFWIDTHTITLE || process.env.NAME || "";
if (title) {
    const safeTitle = title
        .replace(/[\/\:*?"<>|]/g, "_")
        .replace(/\s+/g, " ")
        .trim();
    if (safeTitle) {
        const dir = path.dirname(output);
        const basename = path.basename(output);
        const titleDir = resolveFolder(dir, safeTitle);
        if (!fs.existsSync(titleDir)) {
            fs.mkdirSync(titleDir, { recursive: true });
        }
        output = path.join(titleDir, basename);
    }
}

// --- ffmpeg引数の構築 ---
const args = ["-y", "-analyzeduration", analyzedurationSize, "-probesize", probesizeSize];

if (isDualMono) {
    Array.prototype.push.apply(args, ["-dual_mono_mode", dualMonoMode]);
}

Array.prototype.push.apply(args, ["-i", input]);
Array.prototype.push.apply(args, ["-movflags", "faststart"]);

let videoFilter = "yadif";
if (videoHeight > 720) {
    videoFilter += ",scale=-2:720";
}
Array.prototype.push.apply(args, ["-vf", videoFilter]);

// HW encode
Array.prototype.push.apply(args, [
    "-c:v", "h264_v4l2m2m",
    "-b:v", videoBitrate,
    "-aspect", "16:9",
    "-f", "mp4",
    "-c:a", "aac",
    "-ar", "48000",
    "-ab", audioBitrate,
    "-ac", "2",
    output
]);

console.error(args.join(" "));

const child = spawn(ffmpeg, args);
child.stderr.on("data", (data) => { console.error(String(data)); });
child.on("error", (err) => { console.error(err); throw new Error(err); });
child.on("close", (code) => { process.exitCode = code; });

フォルダ自動整理の仕組み

エンコード時に番組タイトルからフォルダ名を決定しますが、毎回タイトル全体をフォルダ名にすると、同じ番組でもエピソードごとに別フォルダになってしまいます。

# こうなってしまう(1フォルダ1ファイル問題)
/mnt/hdd2/
├── 【連続テレビ小説】風、薫る(2)第1週「翼と刀」[解][字]/
│   └── ....mp4
├── 【連続テレビ小説】風、薫る(3)第1週「翼と刀」[解][字]/
│   └── ....mp4
└── 新プロジェクトX 英国を救った高速鉄道~崖っぷち鉄道車両部門の逆転劇~/
    └── ....mp4

resolveFolder 関数は、既存フォルダ名と新しいタイトルの最長共通先頭文字列を比較し、十分長ければそのフォルダにまとめます。2つ目のファイルが来た時点でフォルダ名を共通部分にリネームします。

# こうなる(番組ごとにまとまる)
/mnt/hdd2/
├── 【連続テレビ小説】風、薫る/
│   ├── ...(2)....mp4
│   ├── ...(3)....mp4
│   └── ...(4)....mp4
├── 新プロジェクトX/
│   ├── ...英国を救った高速鉄道....mp4
│   └── ...東京スカイツリー....mp4
└── マツコの知らない世界/
    └── ....mp4

バッチエンコード(cron)

EPGStationの自動エンコードが何らかの理由で失敗した場合の保険として、毎朝3時にcronで未エンコード録画を一括処理するスクリプトを用意しています。

batch-encode.sh
#!/bin/bash
# batch-encode.sh - 未エンコード録画を一括HWエンコード
# cron: 0 3 * * * /home/matsu/batch-encode.sh >> /home/matsu/batch-encode.log 2>&1

set -euo pipefail

API="http://localhost:8888/api"
TS_DIR="/mnt/hdd1"
MP4_DIR="/mnt/hdd2"
CONTAINER="epgstation"
LOCK="/tmp/batch-encode.lock"
LOG_PREFIX="[batch-encode]"

# 多重起動防止
if [ -f "$LOCK" ]; then
    PID=$(cat "$LOCK")
    if kill -0 "$PID" 2>/dev/null; then
        echo "$LOG_PREFIX $(date '+%Y-%m-%d %H:%M:%S') Already running (PID=$PID), skipping."
        exit 0
    fi
    rm -f "$LOCK"
fi
echo $$ > "$LOCK"
trap 'rm -f "$LOCK"' EXIT

echo "$LOG_PREFIX $(date '+%Y-%m-%d %H:%M:%S') === Batch encode start ==="

# EPGStation APIで全録画を取得
TOTAL=$(curl -sf "${API}/recorded?isHalfWidth=true&offset=0&limit=1" \
  | python3 -c "import json,sys; print(json.load(sys.stdin).get('total',0))")

if [ "$TOTAL" -eq 0 ]; then
    echo "$LOG_PREFIX No recordings found."; exit 0
fi

# TSのみ(未エンコード)の録画を抽出
UNENCODED=$(curl -sf "${API}/recorded?isHalfWidth=true&offset=0&limit=${TOTAL}" \
  | python3 -c "
import json, sys
d = json.load(sys.stdin)
for r in d.get('records', []):
    vfiles = r.get('videoFiles', [])
    types = [v.get('type', '') for v in vfiles]
    if types == ['ts']:
        ts_file = vfiles[0]
        rid = r.get('id')
        name = r.get('halfWidthName', r.get('name', ''))
        filename = ts_file.get('filename', '')
        print(f'{rid}\t{name}\t{filename}')
")

if [ -z "$UNENCODED" ]; then
    echo "$LOG_PREFIX All recordings already encoded."; exit 0
fi

# 以降、enc.jsと同じフォルダ整理ロジック + docker exec ffmpeg で1件ずつエンコード

全文は GitHub Gist を参照してください。

cronの設定

# 毎朝3:00 - 未エンコード録画のバッチ処理
0 3 * * * /home/matsu/batch-encode.sh >> /home/matsu/batch-encode.log 2>&1

# 毎週日曜4:00 - 半年経過したTSの自動削除(MP4が存在する場合のみ)
0 4 * * 0 /home/matsu/cleanup-old-ts.sh

古いTSの自動削除

TSファイルは1番組あたり数GBになるため、エンコード済みMP4が存在する録画のTSを半年後に自動削除するスクリプトを用意しています。

cleanup-old-ts.sh
#!/bin/bash
# MP4が存在し、180日以上経過した録画のTSのみをEPGStation API経由で削除

EPGSTATION="http://localhost:8888"
DAYS=180
NOW=$(date +%s)
THRESHOLD=$(( NOW - DAYS * 86400 ))
THRESHOLD_MS=$(( THRESHOLD * 1000 ))

RECORDS=$(curl -s "${EPGSTATION}/api/recorded?isHalfWidth=true&offset=0&limit=10000")

echo "$RECORDS" | python3 -c "
import sys, json, urllib.request

data = json.load(sys.stdin)
threshold_ms = ${THRESHOLD_MS}
epgstation = '${EPGSTATION}'
count = 0

for rec in data.get('records', []):
    start_at = rec.get('startAt', 0)
    if start_at > threshold_ms:
        continue

    video_files = rec.get('videoFiles', [])
    has_ts = has_encoded = False
    ts_file_id = None

    for vf in video_files:
        if vf.get('type') == 'ts':
            has_ts = True
            ts_file_id = vf.get('id')
        elif vf.get('type') == 'encoded':
            has_encoded = True

    # TSとMP4の両方が存在する場合のみTSを削除
    if has_ts and has_encoded and ts_file_id:
        url = f'{epgstation}/api/videofiles/{ts_file_id}'
        req = urllib.request.Request(url, method='DELETE')
        try:
            urllib.request.urlopen(req)
            print(f'Deleted TS (videoFile:{ts_file_id}) for record:{rec.get(\"id\")}')
            count += 1
        except Exception as e:
            print(f'ERROR: {e}')

print(f'Total: {count} TS files deleted')
"

ポイント: ファイルを直接 rm するのではなく、EPGStation APIの /api/videofiles/{id} を使って削除しています。これによりDBとの整合性が保たれます。

視聴方法

PCからのライブ視聴(VLC)

M3Uプレイリストを作成して、VLCで開くだけで視聴できます。

channels.m3u
#EXTM3U
#EXTINF:-1,NHK総合
http://<PiのIPアドレス>:40772/api/services/<サービスID>/stream
#EXTINF:-1,Eテレ
http://<PiのIPアドレス>:40772/api/services/<サービスID>/stream

サービスIDは curl http://localhost:40772/api/services で確認できます。お住まいの地域のチャンネルに合わせて設定してください。

スマホからの視聴(VLC + VPN)

  1. 自宅ルーターでL2TP VPNを設定
  2. スマホからVPN接続
  3. VLCアプリでMirakurunのストリームURLを開く

おすすめ: ヤマハ RTX1210 は中古で1万円前後から入手でき、L2TP/IPsec VPNの設定もWebGUIから簡単に行えます。NetVolante DNSを使えばDDNSも無料で利用でき、外出先からの視聴環境を手軽に構築できます。業務用ルーターだけあって安定性も抜群です。

ハマりどころ・Tips

1. decoder: arib-b25-stream-test を忘れない

recdvb にはB25デコード機能がないため、tuners.ymlで decoder を指定しないとスクランブルが解除されず、真っ黒な映像になります。

2. EPGStationのルールにエンコードモードを設定する

config.ymlencodeOnRecordedFinished を書いただけでは自動エンコードは動きません。EPGStationの内部実装では、エンコードのトリガーは各予約の encodeMode1 フィールドに依存しています。録画ルールにエンコード設定を追加する必要があります。

3. Dockerコンテナ内のHWエンコーダーデバイス

Raspberry Pi 4のHWエンコーダーは /dev/video11(bcm2835-codec-encode)です。docker-compose.ymlで明示的にマッピングしないとコンテナ内から使えません。

# どのデバイスがエンコーダーか確認
v4l2-ctl --list-devices

4. EPGStationコンテナ再起動時のffmpegインストール

公式のEPGStationイメージにはffmpegが含まれていないため、entrypointで毎回インストールしています。気になる場合はカスタムDockerイメージをビルドするのがおすすめです。

5. HWエンコードの速度

Raspberry Pi 4のh264_v4l2m2mは実時間の約1.16倍速(30分番組で約26分)です。CPUにほとんど負荷がかからないため、録画と同時エンコードも可能です。

6. チューナー2台でもパフォーマンスは問題なし

PX-S1UD を2台同時にUSB接続しても、Raspberry Pi 4のUSB 3.0バスには十分な帯域があります。地デジ1チャンネルあたり約17Mbps程度なので、2番組同時録画+HWエンコードを同時に走らせてもCPU使用率・I/O負荷ともに問題ありません。USB延長ケーブルを使っても信号品質に影響はありませんでした。

まとめ

項目 内容
初期費用 Pi4 + チューナー2台 + HDD2台で約2万円〜
消費電力 約5〜10W(24時間稼働でも月数十円)
録画品質 地デジそのまま(MPEG-2 TS)
エンコード HW(h264_v4l2m2m)でCPU負荷なし
同時録画 2番組(チューナー2台、パフォーマンス問題なし)
外出先視聴 VPN経由でリアルタイム+録画再生
VPN構築 ヤマハ RTX1210(中古1万円〜)がおすすめ

おわりに

子供にテレビを取られたことがきっかけで始めたプロジェクトですが、結果的に市販のレコーダーでは実現できない柔軟なシステムが出来上がりました。キーワード自動録画、番組名での自動フォルダ整理、VPN経由の外出先視聴など、自作ならではの利点が多くあります。

Raspberry Piの低消費電力(24時間稼働でも電気代は月数十円)と、Dockerによるポータビリティのおかげで、運用の手間もほとんどかかりません。同じような悩みを持つ方の参考になれば幸いです。

参考リンク


この記事は Claude Code(Anthropic)を活用して執筆しました。システム構築・デバッグ・スクリプト開発もAIと対話しながら進めています。

31
29
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
31
29

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?