この記事は 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の構成
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のチューナー設定
- 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の設定(主要部分)
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つ:
- h264_v4l2m2m によるHWエンコード
- 番組名の共通部分で自動フォルダ整理
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で未エンコード録画を一括処理するスクリプトを用意しています。
#!/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を半年後に自動削除するスクリプトを用意しています。
#!/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で開くだけで視聴できます。
#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)
- 自宅ルーターでL2TP VPNを設定
- スマホからVPN接続
- 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.yml に encodeOnRecordedFinished を書いただけでは自動エンコードは動きません。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によるポータビリティのおかげで、運用の手間もほとんどかかりません。同じような悩みを持つ方の参考になれば幸いです。
参考リンク
- Mirakurun (GitHub)
- EPGStation (GitHub)
- PX-S1UD V2.0 (PLEX)
- ヤマハ RTX1210 — 中古で手軽にVPN環境を構築できるおすすめルーター
この記事は Claude Code(Anthropic)を活用して執筆しました。システム構築・デバッグ・スクリプト開発もAIと対話しながら進めています。