テレビ録画メディアサーバー構築入門(第23回)
PX-Q1UD
本記事で用いているPX-S1UD v2 (代替品含む)が、品切れにより入手困難となっています。同機種の4チューナー同梱版であるPX-Q1UDには、残りわずかながら在庫がある。執筆時点で残り3点、¥14,800 税込。
もし、
チューナーは3本ほしい
3局同時録画する機会はそれほど多くない
のであれば(S1UDを3本使うよりは)Q1UD1本で運用する方がよい。
ただし、アマゾンコメントにあるとおり、おもむろにPCに刺すだけで4チューナーすべてが問題なく稼働するとは期待しないほうがよい。
takya@fearow22:~$ ls -l /dev/dvb/
合計 0
drwxr-xr-x 2 root root 100 10月 13 17:27 adapter0
drwxr-xr-x 2 root root 100 10月 13 17:27 adapter1
drwxr-xr-x 2 root root 100 10月 13 17:27 adapter2
drwxr-xr-x 2 root root 100 10月 13 17:27 adapter3
ネット上には、S1UDもそうですが、Q1UDは個体差が大きいとの記述が見られます。わたしの体感的には、個体差が大きいのではなく、S1UD/Q1UDはそもそもポンコツ(USB規格非準拠)がゆえに、使用環境に大きく依存するのではないか。 録画が開始する瞬間にチューナーが要求する電力を安定的に供給できるかが鍵を握ります。
まず、すべてのチューナーが等しく同性能とは限らないので、adapter0からadapter3までの4本の安定度の順位付けを確認します。
mirakurunのStatusより各チューナーが取得するチャンネル番号を確認しながら、4チューナー同時にそれぞれ異なるチャンネルを録画して、録画結果よりドロップしがちなチューナーを判定します。たとえば次のスクリプトを使う。
複数局同時録画チェックスクリプト reccheck.py
1分後から1分間だけ複数局を録画予約してドロップしてないかチェックする。
使用法
takya@fearow22:~$ ./reccheck.py --help
usage: reccheck.py [-h] [-c CHANNELS] [--no-check]
複数局同時録画チェックスクリプト
options:
-h, --help show this help message and exit
-c CHANNELS, --channels CHANNELS
録画する局数(デフォルト: 4)
--no-check 録画ファイルのチェックをスキップする
takya@fearow22:~$ cat ./reccheck.py
#!/usr/bin/env python3
import requests
import time
from datetime import datetime, timedelta
import re
import subprocess
import os
import glob
import argparse
import sys
# EPGStationの設定
BASE_URL = "http://localhost:8888"
RECORDED_DIR = os.path.expanduser("~/docker-mirakurun-epgstation/recorded")
# デフォルトの局数
DEFAULT_CHANNEL_COUNT = 4
# ヘッダー
headers = {
'accept': 'application/json',
'Content-Type': 'application/json'
}
def should_exclude_channel(channel_name):
"""除外するべきチャンネルかどうかを判定する"""
# 条件1: 「携帯」を含むチャンネルを除外
if "携帯" in channel_name:
return True
# 条件2: 数字の2,3,2,3を含むチャンネルを除外
# 正規表現で文字列中のどこかにこれらの数字があるかチェック
if re.search(r'[2323]', channel_name):
return True
return False
def get_different_channel_ids(channel_count):
"""APIから異なる放送局のチャンネルIDを取得する(除外条件付き)"""
try:
url = f"{BASE_URL}/api/channels"
response = requests.get(url, headers=headers)
response.raise_for_status()
channels = response.json()
if not channels or len(channels) < channel_count:
print(f"十分な数のチャンネルが見つかりませんでした。必要な数: {channel_count}, 利用可能なチャンネル数: {len(channels) if channels else 0}")
return None
# 異なる放送局を選択(除外条件付き)
selected_channels = []
channel_names = set()
for channel in channels:
channel_name = channel.get('name', '')
# 除外条件をチェック
if channel_name and channel_name not in channel_names and not should_exclude_channel(channel_name):
selected_channels.append(channel)
channel_names.add(channel_name)
if len(selected_channels) >= channel_count:
break
if len(selected_channels) < channel_count:
print(f"異なる放送局が{channel_count}個見つかりませんでした。見つかった異なる放送局数: {len(selected_channels)}")
return None
channel_ids = [channel['id'] for channel in selected_channels]
# チャンネル情報を表示
channel_info = [f"{channel['id']} ({channel['name']})" for channel in selected_channels]
print(f"取得したチャンネルID: {channel_info}")
return channel_ids
except requests.exceptions.HTTPError as e:
print(f"チャンネル取得中にHTTPエラーが発生しました: {e}")
return None
except requests.exceptions.RequestException as e:
print(f"チャンネル取得中にエラーが発生しました: {e}")
return None
except (KeyError, IndexError) as e:
print(f"APIレスポンスの形式が期待と異なります: {e}")
return None
def check_existing_reservations(start_at, end_at, channel_id):
"""指定したチャンネルの既存録画予約を確認し、時間帯が衝突しないかチェックする"""
try:
# 既存の予約一覧を取得
url = f"{BASE_URL}/api/reserves?offset=0&limit=100&isHalfWidth=false"
response = requests.get(url, headers=headers)
response.raise_for_status()
data = response.json()
reservations = data.get("reserves", [])
print(f"既存の予約数: {len(reservations)}")
# 指定したチャンネルかつ時間帯が重複する予約がないか確認
for reservation in reservations:
reserve_start = reservation.get('startAt', 0)
reserve_end = reservation.get('endAt', 0)
reserve_channel_id = reservation.get('channelId')
if reserve_channel_id == channel_id and not (end_at <= reserve_start or start_at >= reserve_end):
print(f"時間帯が重複する予約が見つかりました: {reservation.get('name', '無名')}")
print(f"既存予約: {datetime.fromtimestamp(reserve_start/1000)} ~ {datetime.fromtimestamp(reserve_end/1000)}")
print(f"新規予約: {datetime.fromtimestamp(start_at/1000)} ~ {datetime.fromtimestamp(end_at/1000)}")
return False
print(f"チャンネル {channel_id} で時間帯が重複する予約は見つかりませんでした")
return True
except requests.exceptions.HTTPError as e:
print(f"予約確認中にHTTPエラーが発生しました: {e}")
if 'response' in locals():
print(f"レスポンス内容: {response.text}")
return False
except requests.exceptions.RequestException as e:
print(f"予約確認中にエラーが発生しました: {e}")
return False
except KeyError as e:
print(f"APIレスポンスの形式が期待と異なります: {e}")
print(f"レスポンス内容: {response.text}")
return False
def create_reservation(channel_id, channel_name, start_at, end_at):
"""録画予約を作成する"""
payload = {
"allowEndLack": True,
"tags": [0],
"saveOption": {
"parentDirectoryName": "",
"directory": "",
"recordedFormat": f"録画テスト_{channel_name}"
},
"encodeOption": {
"mode1": "",
"encodeParentDirectoryName1": "",
"directory1": "",
"mode2": "",
"encodeParentDirectoryName2": "",
"directory2": "",
"mode3": "",
"encodeParentDirectoryName3": "",
"directory3": "",
"isDeleteOriginalAfterEncode": False
},
"timeSpecifiedOption": {
"name": f"録画テスト_{channel_name}",
"channelId": channel_id,
"startAt": start_at,
"endAt": end_at
}
}
url = f"{BASE_URL}/api/reserves"
try:
response = requests.post(url, json=payload, headers=headers)
response.raise_for_status()
print(f"チャンネル {channel_id} ({channel_name}) の録画予約が成功しました")
return True
except requests.exceptions.HTTPError as e:
print(f"録画予約中にHTTPエラーが発生しました: {e}")
print(f"レスポンス内容: {response.text}")
return False
except requests.exceptions.RequestException as e:
print(f"録画予約中にエラーが発生しました: {e}")
return False
def check_recorded_files_with_ffmpeg(channel_names):
"""録画ファイルをffmpegでチェックする(厳密にすべて小文字の"error"のみ検出)"""
print("\n" + "="*50)
print("録画ファイルのエラーチェックを開始します")
print("="*50)
# 録画完了を待つ(3分待機)
print("録画完了待機中...(3分間待機)")
time.sleep(3 * 60)
all_good = True
for channel_name in channel_names:
# 録画ファイル名のパターン
pattern = f"録画テスト_{channel_name}*.m2ts"
search_pattern = os.path.join(RECORDED_DIR, pattern)
# ファイルを検索
files = glob.glob(search_pattern)
if not files:
print(f"❌ 録画ファイルが見つかりません: {pattern}")
all_good = False
continue
for file_path in files:
print(f"\nチェック中: {os.path.basename(file_path)}")
try:
# ffmpegでエラーチェックを実行
cmd = [
'ffmpeg',
'-v', 'error',
'-i', file_path,
'-f', 'null',
'-'
]
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=30 # 30秒タイムアウト
)
# 厳密にすべて小文字の"error"という文字列のみを検出
has_error = False
error_lines = []
for line in result.stderr.split('\n'):
# すべて小文字の"error"という単語を検索(単語境界考慮)
if re.search(r'\berror\b', line):
has_error = True
error_lines.append(line)
if not has_error:
print(f"✅ 正常: {os.path.basename(file_path)} にエラーはありません")
# 警告メッセージがある場合は表示
if result.stderr.strip():
print("⚠️ 警告メッセージ(エラーではありません):")
print(result.stderr)
else:
print(f"❌ エラー検出: {os.path.basename(file_path)}")
print("エラー内容:")
for line in error_lines:
print(line)
cmd = [
'curl',
'-H', 'X-Priority: 4',
'-d', f'ERROR: {line}',
'https://fearow22.taile153a7.ts.net/PX-S1UD'
]
result = subprocess.run(cmd, capture_output=True, text=True)
print(result.stdout)
all_good = False
except subprocess.TimeoutExpired:
print(f"⚠️ タイムアウト: {os.path.basename(file_path)} のチェックに時間がかかりすぎています")
all_good = False
except Exception as e:
print(f"❌ チェック失敗: {os.path.basename(file_path)} - {e}")
all_good = False
# print("\n" + "="*50)
# if all_good:
# print("🎉 全ての録画ファイルが正常です!")
# else:
# print("⚠️ 一部の録画ファイルに問題があります")
# print("="*50)
return all_good
def parse_arguments():
"""コマンドライン引数を解析する"""
parser = argparse.ArgumentParser(description='複数局同時録画チェックスクリプト')
parser.add_argument(
'-c', '--channels',
type=int,
default=DEFAULT_CHANNEL_COUNT,
help=f'録画する局数(デフォルト: {DEFAULT_CHANNEL_COUNT})'
)
parser.add_argument(
'--no-check',
action='store_true',
help='録画ファイルのチェックをスキップする'
)
return parser.parse_args()
# メイン処理
if __name__ == "__main__":
# コマンドライン引数を解析
args = parse_arguments()
channel_count = args.channels
skip_check = args.no_check
print(f"録画局数: {channel_count}")
# 異なる放送局のチャンネルIDを指定された局数分取得(除外条件付き)
CHANNELS = []
try:
url = f"{BASE_URL}/api/channels"
response = requests.get(url, headers=headers)
response.raise_for_status()
all_channels = response.json()
# デバッグ用: すべてのチャンネルを表示
print("利用可能なすべてのチャンネル:")
for i, channel in enumerate(all_channels):
channel_name = channel.get('name', '')
excluded = should_exclude_channel(channel_name)
print(f" {i}: {channel['id']} ({channel_name}) {'[除外]' if excluded else ''}")
# 異なる放送局を選択(除外条件付き)
selected_channels = []
channel_names = set()
for channel in all_channels:
channel_name = channel.get('name', '')
# 除外条件をチェック
if channel_name and channel_name not in channel_names and not should_exclude_channel(channel_name):
selected_channels.append((channel['id'], channel_name))
channel_names.add(channel_name)
if len(selected_channels) >= channel_count:
break
if len(selected_channels) < channel_count:
print(f"異なる放送局が{channel_count}つ見つかりませんでした。見つかった異なる放送局数: {len(selected_channels)}")
exit(1)
CHANNELS = selected_channels
channel_names_only = [name for _, name in CHANNELS]
print(f"選択したチャンネル: {[f'{id} ({name})' for id, name in CHANNELS]}")
except Exception as e:
print(f"チャンネル情報の取得中にエラーが発生しました: {e}")
exit(1)
# 録画時間の計算(現在時刻の1分後から1分間)
start_at = int((datetime.now() + timedelta(minutes=1)).timestamp() * 1000)
end_at = int((datetime.now() + timedelta(minutes=2)).timestamp() * 1000)
print(f"録画予約時間: {datetime.fromtimestamp(start_at/1000)} ~ {datetime.fromtimestamp(end_at/1000)}")
# 各チャンネルに対して予約実行
for channel_id, channel_name in CHANNELS:
print(f"\nチャンネル {channel_id} ({channel_name}) の処理を開始します")
if check_existing_reservations(start_at, end_at, channel_id):
create_reservation(channel_id, channel_name, start_at, end_at)
else:
print(f"チャンネル {channel_id} ({channel_name}) で時間帯が重複する予約があるため、録画予約をスキップします")
# 録画ファイルのエラーチェックを実行(スキップオプションが指定されていない場合)
if not skip_check:
check_recorded_files_with_ffmpeg(channel_names_only)
else:
print("録画ファイルのチェックをスキップします")
わたしの場合は、adapter0は使うのをやめました。adapter1もドロップしやすいので、adapter3 と2 (レギュラー)に次ぐ予備用(補欠)にしています。mirakurunの設定は次のとおり(adapter1の優先度を下げている):
takya@fearow22:~$ cat docker-mirakurun-epgstation/mirakurun/conf/tuners.yml
- name: adapter3
types:
- GR
dvbDevicePath: /dev/dvb/adapter3/dvr0
decoder: arib-b25-stream-test
command: >-
dvbv5-zap -a 3 -c ./config/dvbconf-for-isdb/conf/dvbv5_channels_isdbt.conf
-r -P <channel>
isDisabled: false
- name: adapter2
types:
- GR
dvbDevicePath: /dev/dvb/adapter2/dvr0
decoder: arib-b25-stream-test
command: >-
dvbv5-zap -a 2 -c ./config/dvbconf-for-isdb/conf/dvbv5_channels_isdbt.conf
-r -P <channel>
isDisabled: false
- name: adapter1
types:
- GR
dvbDevicePath: /dev/dvb/adapter1/dvr0
decoder: arib-b25-stream-test
command: >-
dvbv5-zap -a 1 -c ./config/dvbconf-for-isdb/conf/dvbv5_channels_isdbt.conf
-r -P <channel>
isDisabled: false
- name: adapter0
types:
- GR
dvbDevicePath: /dev/dvb/adapter0/dvr0
decoder: arib-b25-stream-test
command: >-
dvbv5-zap -a 0 -c ./config/dvbconf-for-isdb/conf/dvbv5_channels_isdbt.conf
-r -P <channel>
isDisabled: true
PCからの電力供給が不安定・不十分だと不具合の要因になります。その場合は、セルフパワーのUSBハブを使用します。このあたりはPCによるとしか言えません。チューナーはUSB2.0なのですが、3.0対応がよい。よくわからない中国メーカーは避ける。電源供給は最重要項目です。ここはケチらないほうが良い。チューナー同梱のアダプターを使わないのも同じ理由です。
余談ながら、S1UDを複数並べるのならば、よりパワフルな、
熱が気になるならUSB fanをつなげる。これは安物でかまわない。
カードリーダー等の他のUSB機器は、チューナーと同じハブにはつながない。チューナーは作動時に大きな電力を消費するので、他のUSB機器とは離れたポートにつなぐ。
Q1UDでは、usbresetが有効なようです。dockerを停止させてからリセットするので、root権限で実行するスクリプト reset.shをcronに登録します。
takya@fearow22:~$ sudo crontab -l
# m h dom mon dow command
35 4 * * * /home/takya/reset.sh >> /home/takya/work/encode.log 2>&1録画がなさそうな時刻4:35に実行していますが、録画中の場合はリセットされないようにしています。
takya@fearow22:~$ cat reset.sh
#!/bin/bash
DIR="/home/takya/docker-mirakurun-epgstation"
# 現在Unix時刻
timenow=$(date +'%s')
epgstation_url=http://localhost:8888/
# 録画中件数
count=$(echo $(curl -s $epgstation_url"api/recording?isHalfWidth=false") | rev | cut -c 2-2 | rev )
if [ "$count" = "0" ]; then
#次の予約までの時間 (録画中は負
def=$(curl -s $epgstation_url"api/reserves?type=normal&limit=1&isHalfWidth=false" | jq -c '.reserves[] | [.startAt]' | cut -c 2- | rev | cut -c 2- | rev)
if (( $(echo "scale=2; ($def/1000-$timenow)/60 > 3" | bc -l) )); then
cd $DIR
echo "Stopping Docker containers..."
sudo docker-compose down
# 対象デバイスの数を確認
devices=$(lsusb | grep "3275:0080" | wc -l)
echo "Found $count PX-S1UD devices"
# すべてリセット
for ((i=1; i<=$devices; i++)); do
echo "Resetting device $i of $count"
sudo usbreset 3275:0080
sleep 3 # リセット間隔をあける
done
echo "All PX-S1UD devices reset completed"
echo "Restarting Docker containers..."
sudo docker-compose up -d
fi
fidocker-mirakurun-epgstationの DIRは 適宜変更のこと。
usbreset 3275:0080の数字はデバイスのID。
takya@fearow22:~$ usbreset |grep PX-S1UD
Number 001/019 ID 3275:0080 PX-S1UD Digital TV Tuner
Number 001/017 ID 3275:0080 PX-S1UD Digital TV Tuner
Number 001/020 ID 3275:0080 PX-S1UD Digital TV Tuner
Number 001/018 ID 3275:0080 PX-S1UD Digital TV Tunerハードウェアエンコードを使うと不安定化の頻度が増す(ような気がする)ので、Q1UDを使うならソフトエンコード libx264を使うほうがよい。
分配器のネジにアース線をつけてPC筐体のネジ金属部につなげる
10/17追記: MyGica製品入荷



コメント