ビデオデッキが誕生して何年経つのでしょうか。

その間、VHSとベータの戦争があり、DVDが誕生し、BlueRayやHDDレコーダーへと進化を遂げ続けたTV録画環境

ついに最終話を迎えつつありますよ

我が家は、HDDレコーダを捨て、テレビサーバーに進化することにしました。

と、大げさなこと書いてますが、要するにTS抜きサーバー環境を構築しようって話しです。

HDDレコーダーも7年ぐらい経ったので、買い替えの時期だなーと思いつつ、市場を見ると、代わり映えのない機器ばかりの割には高いなと

TVは、TVerで良いのじゃないかと思うのですが、家族でめっちゃ使う人が、必須アイテムらしいので解決することにしました。

システム構成としては、電気代のかから無さそうなPCに、TS抜きチューナーを複数付けて、MirakurunとEPGStationで運用しようかと

出先からも視聴出来るように、nginxで公開し、Google認証機能も付けます。

録画フォルダをDLNAで公開したり、sambaでWindows共有も有りです。

FireStickにVLCとかをインストールして見たりも有りです。

ということで、行ってみよう


必要なハードウェア

PC

FUJITSU ESPRIMO Q556/R Core i5 のやつ メモリ 8GBぐらい 小さくってちゃんと4コアでお手頃です <- 当然、ヤフオクでゲットする

こいつは、nvmeなSSDとSATAなSSDを同時に載せれますよ。録画データの保存とOS領域を分けておきたいよね

キーボードを接続しないと起動時にうるさいので、BIOSでkeyboard error を無効にしましょうね

最初、RaspberryPI4で構築したんですが、エンコードパワーが足りないので、Intelアーキテクチャに頼りました

TS抜きチューナと変換コネクタ

MyGica T230C x 必要なだけ

このUSBドングル型のTS抜きチューナーは、AliExpressの公式ショップでのみ販売しているようで、入荷しては売り切れてますね。

在庫があったら、即購入しましょう。

変換コネクタ PAL型 オス - F型

PX-Q1UD を使ってみたのですが、ドロップ酷くって使えませんでした。良い凡ドライバを見つけれなかったか、Ubuntuとの相性か?

ICカードリーダ

Amazonで適当な物

B-CASカード

どうにかして手に入れる

 

必要なスキル

Ubuntuのセットアップ

Dockerの簡単な使い方

Linuxでのテキスト編集

パーミッションの変更の仕方

nginxを構築出来る知識

httpsなサイトを構築出来る知識

 


1.Ubuntuインストール

とにかく、サーバーを構築しないと話が進みませんよね

こんな記事よんでる人なら、わかるでしょなことは、書きませんよ。厳し目に行きます。

とっとと、PCにUbuntu 24.04をインストールして下さい。

出来ればLVMで論理ボリュームを作成しておきましょうね。<- 録画領域を後で増やしたい人は、LVMが良いですね

SSHとか、日本語とかtimezoneを良い感じに設定しましょう。

Dockerが必須なので、ここを参考にして、インストールしてね。 docker composeも必須です。

わからない人は、chatgptに聞いて下さい。

 

※蟹さんドライバ(r8169)が違うらしいので修正する場合、以下で切り替えることが出来ます。必須ではないです。

apt install r8168-dkms

確認

ethtool -i enp1s0

 

作業は、rootでやらないとダメなので、

sudo -s 

です。

 


2.T230c用ドライバ インストール

これは、簡単で、armでもx64でも同じ物を使用出来ます。

 

git clone https://github.com/osmc/dvb-firmware-osmc.git
cp dvb-firmware-osmc/{dvb-demod-si2168-d60-01.fw,dvb-tuner-si2141-a10-01.fw} /lib/firmware/


3.ICカードリーダの確認

ICカードリーダを用意していますが、B-CASが認識出来ているかチェックします。

ダメなら、もっとまともな ICカードリーダを購入しましょう。

 

apt install -y pcscd libpcsclite-dev libccid pcsc-tools

pcsc_scan

> Japanese Chijou Digital B-CAS Card (pay TV) と出ればOK


4.チャンネルを作っていきまーす

ここは、結構、長旅になります。

 

まず、スクリプトを作成します。

mkchconf.sh

#!/bin/sh
for ch in `seq 1 3`; do
        fr=`expr \( $ch - 1 \) \* 6 + 93`
        echo "[${ch}]"
        echo "\\tFREQUENCY = ${fr}000000"
        echo "\\tSYMBOL_RATE = 5274000"
        echo "\\tDELIVERY_SYSTEM = DVBC/ANNEX_A"
        echo ""
done
for ch in `seq 4 12`; do
        if [ $ch -lt 8 ]; then
                fr=`expr \( $ch - 4 \) \* 6 + 173`
        else
                fr=`expr \( $ch - 8 \) \* 6 + 195`
        fi
        echo "[${ch}]"
        echo "\\tFREQUENCY = ${fr}000000"
        echo "\\tSYMBOL_RATE = 5274000"
        echo "\\tDELIVERY_SYSTEM = DVBC/ANNEX_A"
        echo ""
done
for ch in `seq 13 62`; do
        fr=`expr \( $ch - 13 \) \* 6 + 473`
        echo "[${ch}]"
        echo "\\tFREQUENCY = ${fr}000000"
        echo "\\tSYMBOL_RATE = 5274000"
        echo "\\tDELIVERY_SYSTEM = DVBC/ANNEX_A"
        echo ""
done
for ch in `seq 13 22`; do
        if [ $ch -lt 22 ]; then
                fr=`expr \( $ch - 13 \) \* 6 + 111`
        else
                fr=`expr \( $ch - 22 \) \* 6 + 167`
        fi
        echo "[C${ch}]"
        echo "\\tFREQUENCY = ${fr}000000"
        echo "\\tSYMBOL_RATE = 5274000"
        echo "\\tDELIVERY_SYSTEM = DVBC/ANNEX_A"
        echo ""
done
for ch in `seq 23 63`; do
        if [ -n "$1" -a $ch -gt 23 -a $ch -lt 28 ]; then
                fr=`expr \( $ch - 24 \) \* 6 + 233`
        else
                fr=`expr \( $ch - 23 \) \* 6 + 225`
        fi
        echo "[C${ch}]"
        echo "\\tFREQUENCY = ${fr}000000"
        echo "\\tSYMBOL_RATE = 5274000"
        echo "\\tDELIVERY_SYSTEM = DVBC/ANNEX_A"
        echo ""
done

 

catvrec.sh

#!/bin/sh

DEVNO=$1

for ch in `seq 1 62`; do
        dvbv5-zap -C JP -a ${DEVNO} -c channels.conf -r -P ${ch} -t 4 -o ${ch}.ts
done
for ch in `seq 13 63`; do
        dvbv5-zap -C JP -a ${DEVNO} -c channels.conf -r -P C${ch} -t 4 -o C${ch}.ts
done

 

chlist.sh

#!/bin/sh
for ch in `seq 1 62`; do
        if [ -f ${ch}.ts ]; then
                ./tschput.py ${ch}.ts | sed -e "s/^ */${ch}\t/;s/  */\t/g"
        fi
done
for ch in `seq 13 63`; do
        if [ -f C${ch}.ts ]; then
                ./tschput.py C${ch}.ts | sed -e "s/^ */C${ch}\t/;s/  */\t/g"
        fi
done

 

mkchyml.sh

#!/bin/sh
TTYPE=$1
lch=""
while read ch onid tsid sid scramble type channel; do
        if [ ! "$ch" = "$lch" ]; then
                echo "- name: $channel"
                echo "  type: $TTYPE"
                echo "  channel: '${ch}'"
                echo ""
        fi
        lch="$ch"
done

 

tschput.py

#!/bin/python3

import argparse

class TSParser:
    def __init__(self, filename):
        self.filename = filename
        self.buffer = bytearray(2048)
        self.last_packet_buffer = bytearray(188)
        self.last_continuity_counter = [-1] * 8192
        self.buffer_size = 2048           # バッファサイズ
        self.buffer_position = 2048       # バッファ位置
        self.section_position = -1        # セクション位置

    def __enter__(self):
        self.fp = open(self.filename, 'rb')
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.fp.close()

    def read_packet(self):
        """
        バッファまたはファイルから1つのTSパケットを読み込みます。
        更新されたバッファ、バッファの位置、バッファのサイズ、および読み込んだパケットを返します。
        """
        while self.buffer_position < self.buffer_size and self.buffer[self.buffer_position:self.buffer_position + 1] != b'\x47':
            self.buffer_position += 1
       
        if (self.buffer_size - self.buffer_position) < 188:
            # Move remaining data to the start of the buffer
            remaining_data_size = self.buffer_size - self.buffer_position
            self.buffer[:remaining_data_size] = self.buffer[self.buffer_position:self.buffer_size]
            new_data = self.fp.read(2048 - remaining_data_size)
            self.buffer[remaining_data_size:remaining_data_size + len(new_data)] = new_data
            self.buffer_size = remaining_data_size + len(new_data)
            self.buffer_position = 0

        if (self.buffer_size - self.buffer_position) >= 188:
            packet = self.buffer[self.buffer_position:self.buffer_position + 188]
            self.buffer_position += 188
            return packet
        return None

    def parse_packet_header(self, packet_buffer):
        duplicate_count = 0          # 重複カウンタ
       
        # パケットヘッダーの解析
        payload_unit_start_indicator = (packet_buffer[1] & 0x40) >> 6     #ペイロード開始インジケータ
        packet_id = ((packet_buffer[1] << 8) | packet_buffer[2]) & 0x1fff #パケットID
        adaptation_field_control = (packet_buffer[3] & 0x30) >> 4         #適応フィールド制御
        continuity_counter = packet_buffer[3] & 0x0f                      #連続性カウンタ
        # 適応フィールドの解析
        adaptation_field_length = -1                                      #適応フィールド長
        if (adaptation_field_control & 0x02) != 0:
            adaptation_field_length = packet_buffer[4]
           
        discontinuity_indicator = -1                                      #不連続インジケータ
        pcr_flag = -1                                                     #PCRフラグ
        if adaptation_field_length >= 1:
            discontinuity_indicator = (packet_buffer[5] & 0x80) >> 7
            pcr_flag = (packet_buffer[5] & 0x10) >> 4

        # 重複パケットを確認する
        if pcr_flag == 1 and adaptation_field_length >= 7:
            for i in range(6):
                self.last_packet_buffer[6+i] = packet_buffer[6+i]
       
        is_duplicate = all(packet_buffer[i] == self.last_packet_buffer[i] for i in range(188))
        if not is_duplicate:
            duplicate_count = 0
        else:
            duplicate_count += 1
        if duplicate_count > 1:
            duplicate_count = 0
        if (adaptation_field_control & 0x01) == 0 or packet_id == 0x1fff:
            duplicate_count = 0
        self.last_packet_buffer[:188] = packet_buffer[:188]

        # ドロップを確認する
        df = 0
        if packet_id != 0x1fff:
            if self.last_continuity_counter[packet_id] >= 0 and discontinuity_indicator != 1:
                if (adaptation_field_control & 0x01) != 0 and duplicate_count == 0:
                    if continuity_counter != ((self.last_continuity_counter[packet_id] + 1) & 0x0f):
                        df = 1
                else:
                    if continuity_counter != self.last_continuity_counter[packet_id]:
                        df = 1
            self.last_continuity_counter[packet_id] = continuity_counter
           
        if packet_id == 0x0011 and (adaptation_field_control & 0x01) != 0 and df == 1:
            self.section_position = -1
                               
        return payload_unit_start_indicator,packet_id,adaptation_field_control,adaptation_field_length,duplicate_count
   
# ARIB 8単位符号をシフトJISに変換して出力する
def arib_to_sjis(characters,length):
   
    result=""
   
    g = [0x0142, 0x004a, 0x0030, 0x0031]  # G0-G3
    gl = 0  # GL=G0
    gr = 2  # GR=G2
    ss = -1  # シングルシフト解除
    ac = 0
    k = 0

    while k < length:
        jc = 0
        ac = (ac << 8) | characters[k]
        k += 1

        # 制御コード処理
        if ac == 0x0e:  # LS1
            gl = 1
            ac = 0
        elif ac == 0x0f:  # LS0
            gl = 0
            ac = 0
        elif ac == 0x19:  # SS2
            ss = gl
            gl = 2
            ac = 0
        elif ac == 0x1d:  # SS3
            ss = gl
            gl = 3
            ac = 0
        elif ac == 0x1b6e:  # LS2
            gl = 2
            ac = 0
        elif ac == 0x1b6f:  # LS3
            gl = 3
            ac = 0
        elif ac == 0x1b7c:  # LS3R
            gr = 3
            ac = 0
        elif ac == 0x1b7d:  # LS2R
            gr = 2
            ac = 0
        elif ac == 0x1b7e:  # LS1R
            gr = 1
            ac = 0
        elif ac == 0x20:  # SP
            jc = 0x2121
            ac = 0
        elif ac == 0x89 or ac == 0x8a:  # MSZ, NSZ
            ac = 0
        elif 0x21 <= ac <= 0x7e:  # GL (1バイト文字)
            if g[gl] == 0x004a:
                jc = 0x2300 + ac
            elif g[gl] == 0x0030:
                jc = 0x2400 + ac
            elif g[gl] == 0x0031:
                jc = 0x2500 + ac
            if (g[gl] & 0x0f00) == 0x0000:
                ac = 0
        elif 0x2100 <= ac <= 0x7eff:  # GL (2バイト文字)
            if (ac & 0xff) >= 0x21 and (ac & 0xff) <= 0x7e and g[gl] == 0x0142:
                jc = ac
            ac = 0
        elif 0xa1 <= ac <= 0xfe:  # GR (1バイト文字)
            if g[gr] == 0x004a:
                jc = 0x2300 + (ac & 0x7f)
            elif g[gr] == 0x0030:
                jc = 0x2400 + (ac & 0x7f)
            elif g[gr] == 0x0031:
                jc = 0x2500 + (ac & 0x7f)
            if (g[gr] & 0x0f00) == 0x0000:
                ac = 0
        elif 0xa100 <= ac <= 0xfeff:  # GR (2バイト文字)
            if (ac & 0xff) >= 0xa1 and (ac & 0xff) <= 0xfe and g[gr] == 0x0142:
                jc = ac & 0x7f7f
            ac = 0

        # 文字表示
        if jc > 0:
            # 文字変換
            if jc == 0x2321:
                jc = 0x212a
            elif jc == 0x2328:
                jc = 0x214a
            elif jc == 0x2329:
                jc = 0x214b
            elif jc == 0x232d:
                jc = 0x215d
            elif jc == 0x232f:
                jc = 0x213f
            elif jc == 0x2479:
                jc = 0x213c
            elif jc == 0x2579:
                jc = 0x213c
            elif jc == 0x247e:
                jc = 0x2126
            elif jc == 0x257e:
                jc = 0x2126
            elif jc == 9018:
                result+="∗"
                continue
            elif jc == 9055:
                result+="〒"
                continue

            # シフトJIS変換
            jh = (((jc // 256) - 0x21) // 2) + 0x81
            if jh >= 0xa0:
                jh = jh + 0x40
            if ((jc // 256) % 2) == 0:
                jl = (jc % 256) - 0x21 + 0x9f
            else:
                jl = (jc % 256) - 0x21 + 0x40
                if jl >= 0x7f:
                    jl = jl + 0x01

            #文字出力
            result+=bytes([jh, jl]).decode('cp932', errors='ignore')

            # シングルシフト解除
            if ss >= 0:
                gl = ss
                ss = -1

        if ac > 0xff:
            ac = 0

    return result

def analyze_ts_file(filename):

    with TSParser(filename) as reader:
        # 初期化
        section_buffer = bytearray(4096)
        # 状態変数
        current_version_number = -1  # 現在のバージョン番号
        original_network_id = -1     # 現在のオリジナルネットワークID
        transport_stream_id = -1     # 現在のトランスポートストリームID
        current_section_number = 0   # 現在のセクション番号
        is_complete = False     # 完了フラグ
       
        while True:
            # パケットを読み込む
            packet_buffer = reader.read_packet()
            if packet_buffer is None:
                break

            # パケットヘッダーを解析
            payload_unit_start_indicator,packet_id,adaptation_field_control,adaptation_field_length,duplicate_count = reader.parse_packet_header(packet_buffer)

            try:
                # セクションを取得する
                while packet_id == 0x0011 and (adaptation_field_control & 0x01) != 0 and (payload_unit_start_indicator == 1 or reader.section_position >= 0) and duplicate_count == 0:
                    pp = 4
                    ps = 188
                    if adaptation_field_length >= 0:
                        pp = pp + 1 + adaptation_field_length
                   
                    if payload_unit_start_indicator == 1:
                        pf = packet_buffer[pp]
                        pp += 1
                        if reader.section_position < 0:
                            # セクションの先頭の場合
                            pp = pp + pf
                            section_buffer[:ps-pp] = packet_buffer[pp:ps]
                            reader.section_position = ps - pp
                        else:
                            # セクションの末尾の場合
                            ps = min(pp + pf, 188)
                            for i in range(min(ps-pp, 4096-reader.section_position)):
                                section_buffer[reader.section_position+i] = packet_buffer[pp+i]
                            reader.section_position = -1
                    else:
                        # セクションの中間の場合
                        for i in range(min(ps-pp, 4096-reader.section_position)):
                            section_buffer[reader.section_position+i] = packet_buffer[pp+i]
                        reader.section_position = reader.section_position + (ps-pp)

                    if reader.section_position >= 3:
                        section_length = ((section_buffer[1] << 8) | section_buffer[2]) & 0x0fff
                        if reader.section_position >= (section_length + 3):
                            reader.section_position = -1

                    if reader.section_position < 0:
                        # セクション内容を取得する
                        table_id = section_buffer[0]                                              #テーブルID
                        section_syntax_indicator = (section_buffer[1] & 0x80) >> 7                #セクション構文インジケータ
                        section_length = ((section_buffer[1] << 8) | section_buffer[2]) & 0x0fff  #セクション長
                        table_id_extension = -1                                                   #テーブルID拡張
                        version_number = -1                                                       #バージョン番号
                        current_next_indicator = -1                                               #現在/次インジケータ
                        section_number = -1                                                       #セクション番号
                        last_section_number = -1                                                  #最終セクション番号
                        if section_syntax_indicator == 1 and section_length >= 5:
                            table_id_extension = (section_buffer[3] << 8) | section_buffer[4]
                            version_number = (section_buffer[5] & 0x3e) >> 1
                            current_next_indicator = section_buffer[5] & 0x01
                            section_number = section_buffer[6]
                            last_section_number = section_buffer[7]

                        # サービス記述テーブルの内容を取得する
                        tsid = table_id_extension
                        onid = (section_buffer[8] << 8) | section_buffer[9]
                        if table_id == 0x42 and original_network_id < 0:
                            original_network_id = onid
                        if table_id == 0x42 and transport_stream_id < 0:
                            transport_stream_id = tsid
                       
                        if (table_id == 0x42 and section_length >= (5+3+5+4) and version_number != current_version_number and
                            current_next_indicator == 1 and section_number == current_section_number and onid == original_network_id and tsid == transport_stream_id):
                            current_version_number = version_number
                            i = 0
                            while i < (section_length-5-4-3):
                                service_id = (section_buffer[11+i] << 8) | section_buffer[12+i]           #サービスID
                                eudf = (section_buffer[13+i] & 0x1c) >> 2
                                esf = (section_buffer[13+i] & 0x02) >> 1
                                epff = section_buffer[13+i] & 0x01
                                rs = (section_buffer[14+i] & 0xe0) >> 5
                                fcm = (section_buffer[14+i] & 0x10) >> 4
                                dll = ((section_buffer[14+i] << 8) | section_buffer[15+i]) & 0x0fff

                                # 記述子領域の内容を取得する
                                j = 0
                                service_type = -1
                                service_provider_name_length = -1
                                service_provider_name_characters = None
                                service_name_length = -1
                                service_name_characters = None
                                while rs == 0 and j < dll:
                                    # 記述子の内容を取得する
                                    descriptor_tag = section_buffer[16+i+j]     #記述子タグ
                                    descriptor_length = section_buffer[17+i+j]  #記述子長
                                    if descriptor_tag == 0x48:
                                        # サービス記述子の内容を取得する
                                        service_type = section_buffer[18+i+j]                                           #サービスタイプ
                                        service_provider_name_length = section_buffer[19+i+j]                           #サービスプロバイダ名長
                                        service_provider_name_characters = section_buffer[20+i+j:]                      #サービスプロバイダ名
                                        service_name_length = section_buffer[20+i+j+service_provider_name_length]       #サービス名長
                                        service_name_characters = section_buffer[21+i+j+service_provider_name_length:]  #サービス名

                                        # 内容を表示する
                                        is_complete = True
                                        service_name = arib_to_sjis(service_name_characters,service_name_length)
                                        print(f"{onid:5d} {tsid:5d} {service_id:5d} {fcm:1d} 0x{service_type:02X} {service_name}")

                                    j = j + 2 + descriptor_length
                                i = i + 5 + dll

                            if current_section_number >= last_section_number:
                                current_section_number = 0
                            else:
                                current_section_number += 1
                            if is_complete :
                                break  # サービス名が取得できていたらループから抜ける

                    if ps==188:
                        break
                if is_complete :
                    break
            except Exception as e:
                print(e)


DEBUG = False

# オプション処理
parser = argparse.ArgumentParser(description='using \n \nex)\n tschput.py xx.ts ')
parser.add_argument('tsfile', help='tsfile')
args = parser.parse_args()
tsfile = args.tsfile

analyze_ts_file(tsfile)

 

dvb-toolsインストール
apt-get install -y dvb-tools

 

ザッピングしてチューナーからチャンネルを取得

channels.confの作成
./mkchconf.sh > channels.conf

 

dvb_channel.confの作成
チャンネルスキャン -> dvb_channel.conf が作成される
dvbv5-scan -C JP -a 0 -N channels.conf

 

チャネル情報取得 4秒 受信した x.TS 達が出来る
./catvrec.sh 0

 

さらに、TS 達から 日本語チャンネル情報を抽出する
./chlist.sh > channels.txt

 

最後にMirakurun用のchannels.yml を作成する。
./mkchyml.sh GR < channels.txt > channels.yml

 

この channels.yml は、全て GR として記録されているし、スクランブルされていて見れないチャンネルも含まれているので、適宜編集して自分に合う物にしておこう。

例えば、見れないチャンネルは、削除し、BSの場合、GRのとこをBSに変更等です


5.tunner.ymlの作成

TS抜きチューナーの登録用ファイルです。

 

- name: T230_1
  types:
    - BS
    - GR
  command: dvbv5-zap -C JP -a 0 -c /app-config/channels.conf -r -P <channel>
  dvbDevicePath: /dev/dvb/adapter0/dvr0
  decoder: arib-b25-stream-test   

- name: T230_2
  types:
    - BS
    - GR
  command: dvbv5-zap -C JP -a 1 -c /app-config/channels.conf -r -P <channel>
  dvbDevicePath: /dev/dvb/adapter1/dvr0
  decoder: arib-b25-stream-test   

 

この例では、2つのチューナーを接続する場合です。

1つの場合、2つ目は、削除し、3つ以上だと、T230_3 等というように増やしましょう。


6.mirakurun と epgstation のセットアップ

まず、docker compose を使用します。

Ubuntuにセットアップしておいて下さい。

 

セットアップだ

cd ~
git clone https://github.com/l3tnun/docker-mirakurun-epgstation.git
cd docker-mirakurun-epgstation
cp docker-compose-sample.yml docker-compose.yml
cp epgstation/config/enc.js.template epgstation/config/enc.js
cp epgstation/config/config.yml.template epgstation/config/config.yml
cp epgstation/config/operatorLogConfig.sample.yml epgstation/config/operatorLogConfig.yml
cp epgstation/config/epgUpdaterLogConfig.sample.yml epgstation/config/epgUpdaterLogConfig.yml
cp epgstation/config/serviceLogConfig.sample.yml epgstation/config/serviceLogConfig.yml
docker compose run --rm -e SETUP=true mirakurun

 

設定をコピーだ

cp channels.conf ~/docker-mirakurun-epgstation/mirakurun/conf/.
cp tuners.yml ~/docker-mirakurun-epgstation/mirakurun/conf/.
cp channels.yml ~/docker-mirakurun-epgstation/mirakurun/conf/channels.yml


7.実行だ

起動方法

cd ~/docker-mirakurun-epgstation

docker compose up -d

 

停止方法

cd ~/docker-mirakurun-epgstation

docker compose down


8.アクセスだ

http://サーバーIP:8888/

 

 

見れたら、おめでとうだ

番組表は、数分間待つと出てくるはず

番組表が更新されれば、放送中も見れるようになります。

 

 

そうそう、動画の格納場所ですが、docker-compose.yml に指定されているので、これを修正します。

version: '3.7'
services:

    epgstation:

        volumes:

            - /video:/app/recorded

 


9.nginxでリバースプロキシして、google認証で守られた epgstation を外から見れるようにしよう

まず、google認証するのは、oauth2-proxy でやります。

こいつは、nginxにgoogle認証機能を付加する代理認証proxyです。

動作させるのは簡単で、oauth2_proxy.cfg を作成して、このファイルを引数に起動すれば良いだけです。

 

google認証を行うには、認証用のOAuth2.0 キーが必要です。

なので、google cloud の APIとサービス - 認証情報 - OAuth 2.0 クライアント ID(ウェブアプリケーション用) とシークレットを取得して下さい。

承認済みの JavaScript 生成元:https://[外用ドメイン]

承認済みのリダイレクト URI:https://[外用ドメイン]/oauth2/callback

外用ドメインは、ドメイン屋さんで買って下さい。

 

google認証サンプル

/etc/oauth2_proxy/oauth2_proxy.cfg

http_address = "127.0.0.1:4180"
redirect_url = "https://[外用ドメイン]/oauth2/callback"
upstreams = ["http://localhost:4180/"]
client_id = "hogehoge.apps.googleusercontent.com"
client_secret = "hogehogesecret"
oidc_issuer_url = "https://[外用ドメイン]"
provider = "google"
cookie_secret = "hogehogerandom"
authenticated_emails_file = "/etc/oauth2_proxy/epgemail.txt"

 

cookie_secretは、コマンドで出力しても良いですし、どこぞのサイトで24バイトで出力させた物を使えば良いです。

head -c 16 /dev/urandom | base64

 

自分のサーバーで、TV視聴するのに、誰でもかれでも観れるのは良くないですよね。

なので、以下のファイルを作って、必要なアカウントを絞り込みましょう

認証ファイルのサンプル

/etc/oauth2_proxy/epgemail.txt

このメールアドレスはスパムボットから保護されています。閲覧するにはJavaScriptを有効にする必要があります。

このメールアドレスはスパムボットから保護されています。閲覧するにはJavaScriptを有効にする必要があります。

 

起動方法

nohup oauth2-proxy --config /etc/oauth2_proxy/oauth2_proxy.cfg &

こいつを Docker化しておくのがお勧めです。

 

Dockerfile

FROM ubuntu:24.04
EXPOSE 4180/tcp
RUN apt -y update
ENV DEBIAN_FRONTEND=noninteractive
ENV TZ=Asia/Tokyo
RUN apt install -y tzdata
RUN apt install -y wget vim
RUN wget https://github.com/oauth2-proxy/oauth2-proxy/releases/download/v7.5.1/oauth2-proxy-v7.5.1.linux-amd64.tar.gz
RUN tar -xvf oauth2-proxy-v7.5.1.linux-amd64.tar.gz

docker-compose.yml

services:
 oauth:
  build:
   context: .
  ports:
    - "0.0.0.0:4180:4180"    
  tty: true
  command: /oauth2-proxy-v7.5.1.linux-amd64/oauth2-proxy --config /oauth2_proxy/oauth2_proxy.cfg
  volumes:
   - ./oauth2_proxy:/oauth2_proxy

networks:
 app-tier:
  driver: host

 

これを nginx で定義すると、以下です。

SSLは、Let's encryptを使ってます。

 

server {
        listen       443 ssl;
        listen  [::]:443 ssl;
        server_name [外用ドメイン];
        index index.html index.htm;

        location / {
                auth_request /oauth2/auth;
                error_page 401 = /oauth2/sign_in;

                proxy_pass    http://[内部epgstationアドレス]:8888;

                proxy_set_header    Host    $host;
                proxy_set_header    X-Real-IP    $remote_addr;
                proxy_set_header    X-Forwarded-Host       $host;
                proxy_set_header    X-Forwarded-Proto     https;
                proxy_set_header    X-Forwarded-Server    $host;
                proxy_set_header    X-Forwarded-For    $proxy_add_x_forwarded_for;

        }

        location /oauth2/ {
            proxy_pass       http://127.0.0.1:4180;
                proxy_set_header Host                    $host;
                proxy_set_header X-Real-IP               $remote_addr;
                proxy_set_header X-Scheme                $scheme;
        }
        location = /oauth2/auth {
                proxy_pass       http://127.0.0.1:4180;
                proxy_set_header Host             $host;
                proxy_set_header X-Real-IP        $remote_addr;
                proxy_set_header X-Scheme         $scheme;
                proxy_set_header Content-Length   "";
                proxy_pass_request_body           off;
        }
        location /api/config {
                proxy_pass    http://[内部epgstationアドレス]:8888;
                proxy_set_header Host                    $host;
                proxy_set_header X-Real-IP               $remote_addr;
                proxy_set_header X-Scheme                $scheme;
        }

    ssl_certificate /etc/letsencrypt/live/[外用ドメイン]/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/[外用ドメイン]/privkey.pem;
 # managed by Certbot
    include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
    ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; # managed by Certbot

}

 

後は、ルーターで、TCP:443を、作ったサーバーに向ければ完成です

 


10.ハードウェアエンコーディングしてみたい

 

色々と しっかり動作するようになると欲をかきますよね

じゃー、エンコードをハードウェアでやってみたいですよねっねっ 早いし CPU食わないし

ということで、CPUの底力を使ってハードエンコへ誘いましょう

 

手順としては、ホストで、ハードエンコ用のデバイスファイルを作成し、それをコンテナへ公開します。

コンテナ内で、ハードエンコ用の設定定義を行います。

ffmpegを使うコンテナを再構成し、再起動すれば完成です。

 

修正ポイントを色替えしてます。

まず、ホスト側での準備です。

apt install vainfo
apt-get -y install i965-va-driver

echo 'KERNEL=="render*" GROUP="render", MODE="0666"' | sudo tee /etc/udev/rules.d/99-render.rules
udevadm control --reload-rules && sudo udevadm trigger

これで、準備Ok

vainfoで、使えるハードエンコを見ておきましょうね。

 

次にDocker側です

docker-mirakurun-epgstation/docker-compose.yml の追記修正です

version: '3.7'
  services:
    epgstation:
      devices:
         - /dev/dri/renderD128:/dev/dri/renderD128

 

docker-mirakurun-epgstation/epgstation/debian.Dockerfile の追記修正です

    apt-get -y install yasm libx264-dev libmp3lame-dev libopus-dev libvpx-dev && \
    apt-get -y install libx265-dev libnuma-dev i965-va-driver && \

#ffmpeg build
    mkdir /tmp/ffmpeg_sources && \
    cd /tmp/ffmpeg_sources && \
    curl -fsSL http://ffmpeg.org/releases/ffmpeg-${FFMPEG_VERSION}.tar.bz2 | tar -xj --strip-components=1 && \
    ./configure \
      --prefix=/usr/local \
      --disable-shared \
      --pkg-config-flags=--static \
      --enable-gpl \
      --enable-libass \
      --enable-vaapi \
      --enable-libfreetype \
      --enable-libmp3lame \
      --enable-libopus \
      --enable-libtheora \
      --enable-libvorbis \
      --enable-libvpx \
      --enable-libx264 \
      --enable-libx265 \
      --enable-version3 \
      --enable-libaribb24 \
      --enable-nonfree \
      --disable-debug \
      --disable-doc \

 

docker-mirakurun-epgstation/epgstation/config/enchw.js の作成です

まず、エンコードスクリプトをコピーして、それを修正します。

copy enc.js enchw.js

enchw.js

~~~~~

// 字幕用
Array.prototype.push.apply(args, ['-fix_sub_duration']);
// input,ビデオストリーム設定
Array.prototype.push.apply(args, ['-hwaccel', 'vaapi', '-vaapi_device', '/dev/dri/renderD128']);
Array.prototype.push.apply(args, ['-i', input]);
Array.prototype.push.apply(args, ['-map', '0:v', '-c:v', 'h264_vaapi']);
Array.prototype.push.apply(args, ['-vf', 'format=nv12,hwupload']);
// オーディオストリーム設定
if (isDualMono) {
    Array.prototype.push.apply(args, [
        '-filter_complex',
        'channelsplit[FL][FR]',
        '-map', '[FL]',
        '-map', '[FR]',
        '-metadata:s:a:0', 'language=jpn',
        '-metadata:s:a:1', 'language=eng',
        '-ac', '1',
    ]);
} else {
    Array.prototype.push.apply(args, ['-map', '0:a']);
}
Array.prototype.push.apply(args, ['-c:a', 'aac']);
// 字幕ストリーム設定
Array.prototype.push.apply(args, ['-map', '0:s?', '-c:s', 'mov_text']);
// 品質設定
Array.prototype.push.apply(args, ['-profile:v', '77', '-level', '40', '-qp', '26']);
// 出力ファイル
Array.prototype.push.apply(args, [output]);

~~~~~

(async () => {

~~~~~

    child.on('close', (code) => {
        if( code == 251 ) {
                //仕方無いか、誰か解決して
                process.exitCode = 0;
        } else {
                process.exitCode = code;
        }

~~~~~
    });

})();

 

docker-mirakurun-epgstation/epgstation/config.yml の追記修正です

~~

encode:

    - name: H.264Hard
      cmd: '%NODE% %ROOT%/config/enchw.js'
      suffix: .mp4
      rate: 4.0

~~

 

これで、docker-mirakurun-epgstation-epgstation のイメージを破棄して、再起動すればOkのはずです。

docker compose down

docker image rm docker-mirakurun-epgstation-epgstation:latest

docker compose up -d

 

エンコードメニューで、H.264Hard が選択出来ればOkです。

enchw.jsは、コピーして色々とバリエーションを作ることで、さまざまなエンコを楽しめますよ

 

ついでに、放映中のとこも、ハードエンコにしてしまいましょう。

※チューニング所を赤くしておきました。

stream:
    live:
        ts:
            mp4:
                - name: Hard
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -hwaccel vaapi -vaapi_device /dev/dri/renderD128 -i pipe:0
                      -sn -c:a aac -ar 48000 -b:a 128k -ac 2 -c:v h264_vaapi -vf format=nv12,hwupload
                      -tune fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof
                      -y -f mp4 pipe:1'
            hls:
                - name: Hard
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -hwaccel vaapi -vaapi_device /dev/dri/renderD128
                      -i pipe:0 -sn -map 0 -ignore_unknown
                      -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 17 -hls_allow_cache 1
                      -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a
                      aac -ar 48000 -b:a 192k -ac 2 -c:v h264_vaapi -vf format=nv12,hwupload  
                      -flags +loop-global_header %OUTPUT%'
                - name: Hardlow
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -hwaccel vaapi -vaapi_device /dev/dri/renderD128
                      -i pipe:0 -sn -map 0 -ignore_unknown
                      -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 17 -hls_allow_cache 1
                      -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a
                      aac -ar 24000 -b:a 64k -ac 2 -c:v h264_vaapi -qp 40 -vf format=nv12,hwupload  
                      -flags +loop-global_header %OUTPUT%'
 

 

 


小ネタ

 

動画ファイル移動

そういったインターフェースが無いので、データベースを修正して移動します。

動画の格納場所を調査します。

docker exec -it docker-mirakurun-epgstation-mysql-1 mysql -u epgstation -pepgstation epgstation -e "select id, filepath from video_file;"

+-----+------------------------------------------------------------------------------------------------------------------+
| id  | filepath                                                                                                                                           |
+-----+-------------------------------------------------------------------------------------------------------------------+
|   2 | 2024年06月13日08時00分00秒-hogehoge[解][字].mp4                                                                         |
|   6 | 2024年06月14日08時00分00秒-hogehoge2[解][字].mp4                                                                        |

対象動画のidを指定して、ディレクトリ等(hogedir/)を付け加えます。

docker exec -it docker-mirakurun-epgstation-mysql-1 mysql -u epgstation -pepgstation epgstation -e "update video_file set filepath = concat('hogedir/' ,filepath) where id = 6;"

この後、実際の動画ファイルを移動しましょう。

 

一括動画削除とリスト

APIを使って動画をリストアップしたり、一括削除します。

python3でスクリプト化して、日々使いましょう。

 

第1引数:キーワード

オプション引数:-delete  削除

オプション引数:--yes  削除の時、問答無用

 

listrecorded.py

#!/bin/python3

import urllib.parse
import urllib.request
import json
import argparse

parser = argparse.ArgumentParser(description="using \n [Keyword] \nex)\n listrecorded.py -delete -y 'キーワード' ")
parser.add_argument('keyword', help='keyword')
parser.add_argument('-delete', help='delete', action='store_true')
parser.add_argument('-y', '--yes', help='delete yes', action='store_true')
args = parser.parse_args()
keyword = args.keyword
isdelete = args.delete
isdeleteyes = args.yes

urlget = "http://localhost:8888/api/recorded?isHalfWidth=false&offset=0&limit=10000&keyword="+urllib.parse.quote(keyword)
urldelete = "http://localhost:8888/api/recorded/"

response = urllib.request.urlopen(urlget)
jsonData = json.load(response)

for jsonObj in jsonData["records"]:
    id = jsonObj["id"]
    for videoFileObj in jsonObj["videoFiles"]:
        name=videoFileObj["filename"]
       
        print("{0}:{1}".format(id,name))
       
        if isdelete == True :
            yesno = 'N'
            if isdeleteyes == False:
                yesno = input("Delete Files. OK? [y/N]: ").lower()
       
            if yesno in ['y', 'yes']:
                url=urldelete+str(id)
                headers = {"Content-Type": "application/json"}
                res=urllib.request.Request(url,json.dumps(jsonObj).encode(),headers,method="DELETE")
                try:
                    with urllib.request.urlopen(res) as f:
                        print(f.status,end="")
                        if (200==f.status):
                            print(" DELETE",end="")
                            print(":",id)
                except Exception:
                    pass
 

番組リスト表示と予約

番組表を検索し、ルールに追加します。

番組改編の時とかに、新ドラマを一気に予約する時とかに使います

使い方)

検索日時範囲:7

ジャンル:3

キーワード:[新]

予約

./gettv.py -dayrange 7 -genre 3 -keyword '[新]' -reserve

 

#!/bin/python3

import time
import urllib.parse
import urllib.request
import json
import datetime
import pytz
import argparse
 
DAYRANGE = 7
ISFREE = True
GR = True
BS = False
CS = False
SKY = False
GENRE = None
KEYWORD = None
ISRESERVE = False
ENCODECODEC = "H.264"

DEBUG = False

# ベースURL
schedule_url = "http://localhost:8888/api/schedules"
rule_url = "http://localhost:8888/api/rules"

# オプション処理
parser = argparse.ArgumentParser(description='using \n \nex)\n gettv.py -dayrange 7 -genre 3 -keyword "[新]" -reserve ')
parser.add_argument('-dayrange', help='dayrange', type=int)
parser.add_argument('-free', help='isfree', action='store_true', default=True)
parser.add_argument('-gr', help='gr', action='store_true', default=True)
parser.add_argument('-bs', help='bs', action='store_true')
parser.add_argument('-cs', help='cs', action='store_true')
parser.add_argument('-sky', help='sky', action='store_true')
parser.add_argument('-genre', help='genre', type=int)
parser.add_argument('-keyword', help='keyword')
parser.add_argument('-reserve', help='reserve', action='store_true', default=False)
parser.add_argument('-encodecodec', help='encodecodec')
if DEBUG == False :
    args = parser.parse_args()
    if args.dayrange != None:
        DAYRANGE = args.dayrange
    ISFREE=args.free
    GR=args.gr
    BS=args.bs
    CS=args.cs
    SKY=args.sky
    if args.genre != None:
        GENRE = args.genre
    if args.keyword != None:
        KEYWORD = args.keyword
    ISRESERVE=args.reserve
    if args.encodecodec != None:
        ENCODECODEC = args.encodecodec

#時間範囲
jst = pytz.timezone('Asia/Tokyo')
t1 = int(time.time()*1000)
t2 = t1+ DAYRANGE*3600*24*1000

#ジャンル
genre = {
    0:'ニュース/報道',
    1:'スポーツ',
    2:'情報/ワイドショー',
    3:'ドラマ',
    4:'音楽',
    5:'バラエティ',
    6:'映画',
    7:'アニメ/特撮',
    8:'ドキュメンタリー/教養',
    9:'劇場/公演',
    10:'趣味/教育',
    11:'福祉',
    12:'スポーツ(CS)',
    13:'映画(CS)',
    14:'拡張',
    15:'その他'
}

# クエリパラメータ
params = {
    "startAt": t1,
    "endAt": t2,
    "isHalfWidth" : False,
    "isFree" : ISFREE,
    "GR" : GR,
    "BS" : BS,
    "CS" : CS,
    "SKY" : SKY
}

# クエリパラメータをエンコード
query_string = urllib.parse.urlencode(params)

# URL構築
urlget = f"{schedule_url}?{query_string}"

# GETリクエストを発行
response = urllib.request.urlopen(urlget)
jsonData = json.load(response)
for chanObj in jsonData:
    channel = chanObj["channel"]
    for prgObj in chanObj["programs"]:
        s = prgObj["startAt"]/1000
        e = prgObj["endAt"]/1000
        g = 15
        if "genre1" in prgObj :
            g = prgObj["genre1"]
        sg = 0
        if "subGenre1" in prgObj :
            sg = prgObj["subGenre1"]
       
        #色々フィルタ        
        if GENRE != None:
            if g != GENRE :
                continue
       
        ctype = channel["channelType"]
        hit = False
        if ctype == "GR" and GR == True :
            hit=True
        if ctype == "BS" and BS == True :
            hit=True
        if ctype == "CS" and CS == True :
            hit=True
        if ctype == "SKY" and SKY == True :
            hit=True
        if hit == False:
            continue
       
        if KEYWORD != None:
           if (KEYWORD in prgObj["name"]) == False :
                continue
       
        s_jst = datetime.datetime.fromtimestamp(s, tz=datetime.timezone.utc).astimezone(jst)
        e_jst = datetime.datetime.fromtimestamp(e, tz=datetime.timezone.utc).astimezone(jst)
       
        print("{0}\t{1}\t[{2}]\t[{3}]\t{4}\t{5}".format(
                                        s_jst.strftime('%Y-%m-%d %H:%M:%S'),
                                        e_jst.strftime('%Y-%m-%d %H:%M:%S'),
                                        genre[g],
                                        ctype,
                                        channel["name"],
                                        prgObj["name"]))

        if ISRESERVE == True:
            yesno = input("Add reserve rule. OK? [y/N]: ").lower()
            if yesno in ['y', 'yes']:

                #予約情報
                reserveinfo = {
                    "isTimeSpecification": False,
                    "searchOption": {
                        "keyword": prgObj["name"],
                        "keyCS": False,
                        "keyRegExp": False,
                        "name": True,
                        "description": False,
                        "extended": False,
                        "ignoreKeyCS": False,
                        "ignoreKeyRegExp": False,
                        "ignoreName": False,
                        "ignoreDescription": False,
                        "ignoreExtended": False,
                        "GR": True if ctype == "GR" else False,
                        "BS": True if ctype == "BS" else False,
                        "CS": True if ctype == "CS" else False,
                        "SKY": True if ctype == "SKY" else False,
                        "genres": [
                        {
                            "genre": g,
                            "subGenre": sg
                        }
                        ],
                        "isFree": prgObj["isFree"],
                    },
                    "reserveOption": {
                        "enable": True,
                        "allowEndLack": True,
                        "avoidDuplicate": False
                    },
                    "saveOption": {
                        "directory": prgObj["name"]
                    },
                    "encodeOption": {
                        "mode1": ENCODECODEC,
                        "directory1": prgObj["name"],
                        "isDeleteOriginalAfterEncode": True
                    }
                }
               
                reserve_str = json.dumps(reserveinfo, ensure_ascii=False, indent=4)

                # URL構築
                urlpost = f"{rule_url}"

                # 予約ルール 追加
                headers = {"Content-Type": "application/json"}
                res=urllib.request.Request(urlpost,reserve_str.encode(),headers,method="POST")
                try:
                    with urllib.request.urlopen(res) as f:
                        print(f.status,end="")
                        if (200==f.status):
                            print(" POST",end="")
                            print(":",id)
                except Exception as e:
                    print(e)
                    pass
 

空ディレクトリ削除

不要になったサブディレクトリを削除します。

予約でディレクトリを作ったけど、番組を消して不要になったディレクトリが残るので、それを削除するスクリプトです。

使い方)

削除親ディレクトリ

お試し

./clearfolder.py '/mnt/mirakurun' -dryrun

 
#!/bin/python3


import os
import argparse


TARGETDIR="/mnt/mirakurun"


DEBUG = False
DRYRUN = False


# オプション処理
parser = argparse.ArgumentParser(description='using \n \nex)\n clearfolder.py "/mnt/mirakurun" ')
parser.add_argument('target', help='target')
parser.add_argument('-dryrun', help='dryrun', action='store_true')
if DEBUG == False :
    args = parser.parse_args()
    TARGETDIR=args.target
    DRYRUN=args.dryrun


#指定フォルダ以下の空フォルダを削除
def remove_empty_dirs(dir_path):
    for root, dirs, files in os.walk(dir_path, topdown=False):
        for dir in dirs:
            dir_path = os.path.join(root, dir)


            # ディレクトリ内にファイルまたはサブディレクトリが存在するか確認
            if os.listdir(dir_path):
                continue
            
            try:
                if DRYRUN :
                    print(f"削除予定: {dir_path}")
                else:
                    os.rmdir(dir_path)
                    print(f"削除: {dir_path}")
            except OSError as e:
                print(f"削除に失敗しました: {dir_path}, {e}")


remove_empty_dirs(TARGETDIR)

 
Joomla templates by a4joomla