テレビ録画メディアサーバー構築入門(第23回)

PX-Q1UD

本記事で用いているPX-S1UD v2 (代替品含む)が、品切れにより入手困難となっています。同機種の4チューナー同梱版であるPX-Q1UDには、残りわずかながら在庫がある。執筆時点で残り3点、¥14,800 税込。

もし、

  1. チューナーは3本ほしい

  2. 3局同時録画する機会はそれほど多くない

のであれば(S1UDを3本使うよりは)Q1UD1本で運用する方がよい。

ただし、アマゾンコメントにあるとおり、おもむろにPCに刺すだけで4チューナーすべてが問題なく稼働するとは期待しないほうがよい。

takya@fearow22:~$ ls -l /dev/dvb/
合計 0
drwxr-xr-x 2 root root 100 1013 17:27 adapter0
drwxr-xr-x 2 root root 100 1013 17:27 adapter1
drwxr-xr-x 2 root root 100 1013 17:27 adapter2
drwxr-xr-x 2 root root 100 1013 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
fi

docker-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製品入荷


いいなと思ったら応援しよう!

ピックアップされています

全自動TV録画 視聴環境構築

  • 26本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
テレビ録画メディアサーバー構築入門(第23回) |takya
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1