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

前回、表題の「テレビ録画メディアサーバー」の基本構成が整いました。

ここから先は、不具合による不便さの解消や、便利さや快適性の向上を追求していくことになります。

まず、最初の改善点は、処理済み動画の判定方法です。

前回のスクリプトenc01.sh では、処理済み動画mp4ファイルがメディアフォルダに存在しない場合にのみ、ffmpeg処理することにしていました。これでは、動画mp4ファイルのファイル名を変更したり、保存ディレクトリを変更できません。

蓄積されていくメディアコンテンツ(動画ファイル)は、番組ごとに仕分け分類をして、わかりやすく管理したい。このためには、処理済みファイル名をカタログとして残しておけばよい。

EPGStationの WebAPIから録画情報を取得して、処理済み情報は作業フォルダ内のローカルファイル(カタログ)にて管理することにします。

processes.py

まずは、動画ファイル名を引数として受け取り、「処理済み」にするスクリプト。作業ディレクトリ$HOME/workに作成します。以下同様。

処理済み番組名のリストは、作業ディレクトリ$HOME/work のprocessed_filenames.json (カタログ)に格納します。いつもローカルに実行するとは限らないので、このリストjson(カタログ)の場所はフルパスで指定しています。

#!/usr/bin/env python3
import requests
import json
import os

# 処理済み番組名ファイル
PROCESSED_FILE = os.path.expanduser("~") + "/work/processed_filenames.json"

# 録画サーバのURLとAPIエンドポイント
BASE_URL = 'http://localhost:8888/api'
RECORDED_ENDPOINT = '/recorded?isHalfWidth=false'

# APIヘッダー
headers = {
    'accept': 'application/json',
}

def load_processed_filenames():
    """ローカルから処理済みのファイル名を読み込む"""
    if os.path.exists(PROCESSED_FILE):
        with open(PROCESSED_FILE, 'r', encoding='utf-8') as f:
            return set(json.load(f))
    else:
        print("processed_filenames.json does not exist. Creating an empty one.")
        with open(PROCESSED_FILE, 'w', encoding='utf-8') as f:
            json.dump([], f)
        return set()

def save_processed_filenames(processed_filenames):
    """ローカルに処理済みのファイル名を保存する"""
    with open(PROCESSED_FILE, 'w', encoding='utf-8') as f:
        json.dump(list(processed_filenames), f, ensure_ascii=False, indent=4)

def add_filename_to_processed(filename):
    """指定したファイル名を「処理済み」に追加する"""
    processed_filenames = load_processed_filenames()
    
    if filename in processed_filenames:
        print(f"File '{filename}' is already marked as processed.")
    else:
        processed_filenames.add(filename)
        save_processed_filenames(processed_filenames)
        print(f"File '{filename}' has been marked as processed.")

if __name__ == '__main__':
    import sys
    if len(sys.argv) != 2:
        print("Usage: python processed.py <filename>")
        sys.exit(1)
    
    filename = sys.argv[1]
    add_filename_to_processed(filename)

実行可能にしておく。

sudo apt install python3-pip
chmod +x processed.py

使い方は

processed.py 番組名.m2ts

(手入力での使用は想定していません。)

実行すると、カタログファイルprocessed_filenames.jsonができる。

takya@fearow22:~/work$ cat processed_filenames.json
[
"番組名.m2ts"
]

toprocess.py

次に、EPGStationから「未処理」の録画済みファイル名を取得するスクリプトを作成する。PROCESSED_FILE は上と同じにする。

#!/usr/bin/env python3
import requests
import json
import os

# 処理済み番組名ファイル
PROCESSED_FILE = os.path.expanduser("~") + "/work/processed_filenames.json"

# 録画サーバのURLとAPIエンドポイント
BASE_URL = 'http://localhost:8888/api'
RECORDED_ENDPOINT = '/recorded?isHalfWidth=false&offset=0&limit=1000'

# APIヘッダー
headers = {
    'accept': 'application/json',
}

def load_processed_filenames():
    """ローカルから処理済みのファイル名を読み込む"""
    if os.path.exists(PROCESSED_FILE):
        with open(PROCESSED_FILE, 'r', encoding='utf-8') as f:
            return set(json.load(f))
    else:
        print("processed_filenames.json does not exist. Creating an empty one.")
        with open(PROCESSED_FILE, 'w', encoding='utf-8') as f:
            json.dump([], f)
        return set()

def save_processed_filenames(processed_filenames):
    """ローカルに処理済みのファイル名を保存する"""
    with open(PROCESSED_FILE, 'w', encoding='utf-8') as f:
        json.dump(list(processed_filenames), f, ensure_ascii=False, indent=4)

def get_recorded_programs():
    """録画済みの番組情報を取得"""
    try:
        response = requests.get(BASE_URL + RECORDED_ENDPOINT, headers=headers, timeout=5)
        response.raise_for_status()  # HTTPエラーを例外として投げる
        return response.json().get('records', [])
    except requests.exceptions.RequestException as e:
        print(f"Error fetching recorded programs: {e}")
        return []
    
def get_unprocessed_filenames(processed_filenames):
    """未処理の番組のファイル名を取得"""
    unprocessed_filenames = []
    for record in get_recorded_programs():
        video_files = record.get('videoFiles', [])
        for video_file in video_files:
            filename = video_file.get('filename')
            if filename not in processed_filenames:
                unprocessed_filenames.append(filename)
    return unprocessed_filenames

def main():
    processed_filenames = load_processed_filenames()
    unprocessed_filenames = get_unprocessed_filenames(processed_filenames)
    if unprocessed_filenames:
        for filename in unprocessed_filenames:
            print(filename)

if __name__ == '__main__':
    main()

実行すると、未処理動画がある場合は、それらが表示される。

chmod +x toprocess.py
./toprocess.py

これを実行すると、EPGStationの「録画済み」一覧のうち、処理済みリストprocessed_filenames.json に含まれない動画のみが表示される。未処理動画の確認に手作業でも使います。

enc01.sh にて動画を変換した後に processed.pyを実行しておけば、「録画済み」一覧から消去しないでも、次回のenc01.sh でスキップされる。

mvjf.sh

前回(第6回)の設定では、Jellyfinの「マイメディア」の「番組」フォルダをみても、「エピソード」タブを開かないと、編集済み動画が見えません。理由は、メディアファイルの保存の仕方がJellyfinの想定する構造をとっていないためです。

Jellyfinでは、テレビ番組の場合、シリーズ、シーズン、エピソードの階層で、以下のようにメディアファイルが配置されるものと想定されています。

[ライブラリルート]
└─ テレビ番組
├─ シリーズA
│ ├─ シーズン 1
│ │ ├─ 01 - エピソード名.mp4
│ │ └─ 02 - 番組タイトル - S01E02.avi
│ └─ etc...
└─ シリーズB
└─ ...

これに対応するために、ファイル名をもとに、「番組」や「エピソード」に当たる情報を抽出して、階層的にフォルダを掘ってファイルを配置するスクリプト mvjf.sh を用意する。

追記:このスクリプトは、録画用サーバと視聴用サーバを繋ぐ、まさに本入門講座の要となる部分です。実際の運用状況をみながら最適化しており、随時更新しています。

追記2:以下にて更新することにした。


#!/bin/bash

DRY_RUN=false
while getopts ":n" opt; do
    case ${opt} in
        n )
            DRY_RUN=true
            ;;
        \? )
            echo "Invalid option: -$OPTARG"
            exit 1
            ;;
    esac
done

shift $((OPTIND -1))
# 移動対象ファイル名と出力先ディレクトリを受け取る
input_file="$1"
outdir="$2"

# ドライランモードならチェックしない
if [ "$DRY_RUN" = "false" ]; then
    # 引数のチェック
    if [ "$#" -ne 2 ]; then
	echo "Usage: $0 [-n] <input_file> <output_directory>"
	exit 1
    fi
    # ファイルが存在するか
    if [ ! -f "$input_file" ]; then
	echo "指定されたファイルが存在しません: $input_file"
	exit 1
    fi
    # 出力ディレクトリが存在するか
    if [ ! -d "$outdir" ]; then
	echo "指定された出力ディレクトリが存在しません: $outdir"
	exit 1
    fi
fi

# 区切り文字の優先順位リスト
delimiter_order=("#" "♯" "#" "第" "最終回" "最終話" "最終首" "(" "(" "話"  "★" "☆" "▼" "◆"  "▽" "【" "「" "『" " "  " "  "_" "[")
# 優先順序: "【"  or " "
# 【を優先させるが、先頭の場合だけ別途対処

# 区切り文字と対応するセカンドデリミタを定義
declare -A delimiter_pairs=(
    # ドラマ、シリーズもの
    ["話"]=""  # 「話」にはセカンドデリミタ不要
    ["#"]="_"
    ["♯"]="_"
    ["#"]="_"
    ["第"]="_"
    ["最終回"]="_"
    ["最終話"]="_"
    ["最終首"]="_"
    ["("]=")"
    ["("]=")"
    # バラエティーで多い
    ["★"]="_"
    ["☆"]="_"
    ["▼"]="_"
    ["▽"]="_"
    ["◆"]="_"
    [" "]="_"
    # アニメで多い
    ["【"]="】"
    ["「"]="」"
    ["『"]="』"
    # 要検討
    ["["]="."
    [" "]="_"
    ["_"]="."
)

BASENAME=$(basename "$input_file")

#先頭の【夜ドラ】 は削除: NHKに多い
FILENAME=$(echo "$BASENAME" | sed 's/^【[^】]*】//')


found_delimiter=""
for delimiter in "${delimiter_order[@]}"; do
    if [[ "$FILENAME" == *"$delimiter"* ]]; then
        found_delimiter="$delimiter"
        break
    fi
done

if [[ -n "$found_delimiter" ]]; then
    delimiter=$found_delimiter
    if [[ "$delimiter" == "話" ]]; then
        # 「数字+話」で分割(全角・半角数字対応)
        if [[ "$FILENAME" =~ ([0-90-9]{1,2}話) ]]; then
            pos=${BASH_REMATCH[0]}  # マッチした部分(例: "2話")
            PROGRAM="${FILENAME%%$pos*}"
            EPISODE="$pos"
	else
            # 「話」の前が数字でない場合:  孤独のグルメ全話イッキ見!
            if [[ "$FILENAME" == *"話"* ]]; then
		temp="${FILENAME%話*}"
                PROGRAM="${temp%?}"  
                EPISODE="${FILENAME#${PROGRAM}}"
            fi
	fi	
    elif [[ "$FILENAME" == *"$delimiter"* ]]; then
        PROGRAM="${FILENAME%%$delimiter*}"
        rest="${FILENAME#*$delimiter}"
        second_delimiter="${delimiter_pairs[$delimiter]}"
        if [[ -n "$second_delimiter" && "$rest" == *"$second_delimiter"* ]]; then
            EPISODE="${rest%%$second_delimiter*}"
        else
            EPISODE="$rest"
        fi
        if [[ "$delimiter" == "第" || "$delimiter" == "[" || "$delimiter" == "最終回" || "$delimiter" == "最終話" || "$delimiter" == "最終首" ]]; then
            EPISODE="$delimiter$EPISODE"
        fi
#        break
    fi
else
    PROGRAM="$FILENAME"    
fi

# 番組名抽出
ORIGINAL=$(echo "$PROGRAM" | sed -e 's/^\[[^]]\]//' -e 's/^\[[^]]\]//' -e 's/\[.*//' ) #  [字] 削除

if echo "$ORIGINAL" | grep -qE 'ドラマ[^「『]*[「『][^」』]+[」』]'; then
    PROGRAM=$(echo "$ORIGINAL" | sed -n 's/.*ドラマ[^「『]*[「『]\([^」』]*\)[」』].*/\1/p') #木曜ドラマ「恋愛禁止」…
elif echo "$ORIGINAL" | grep -qE '劇場[^「『]*[「『][^」』]+[」』]'; then
    PROGRAM=$(echo "$ORIGINAL" | sed -n 's/.*劇場[^「『]*[「『]\([^」』]*\)[」』].*/\1/p') #日曜劇場「19番目のカルテ」…
elif echo "$ORIGINAL" | grep -qE '^[「『][^」』]+[」』]'; then
    PROGRAM=$(echo "$ORIGINAL" | sed -n 's/^[「『]\([^」』]*\)[」』].*/\1/p') #「放送局占拠」…
elif echo "$ORIGINAL" | grep -qE 'アニメ[^「『]*[「『][^」』]+[」』]'; then
    PROGRAM=$(echo "$ORIGINAL" | sed -n 's/.*アニメ[^「『]*[「『]\([^」』]*\)[」』].*/\1/p') #アニメ「暗殺教室」
elif echo "$ORIGINAL" | grep -qE '日5[^「『]*[「『][^」』]+[」』]'; then
    PROGRAM=$(echo "$ORIGINAL" | sed -n 's/.*日5[^「『]*[「『]\([^」』]*\)[」』].*/\1/p') #日5「魔法少女まどか☆マギカ
else
    PROGRAM=$(echo "$ORIGINAL" | sed -e 's/【[^】]*】//g' \
                                     -e 's/<[^>]*>//g' \
                                     -e 's/[「『][^」』]*[」』].*//g' \
                                     -e 's/◆.*$//' \
                                     -e 's/▼.*$//' \
                                     -e 's/▽.*$//' \
                                     -e 's/^アニメ//' \
                                     -e 's/[  ]*$//g' \
                                     -e 's/^[  ]*//g' \
                                     -e 's/[  ].*$//' \
	   )
fi

EPISODE=$(echo "$EPISODE" | sed -e 's/\[[^]]*\]//g' -e 's/__.*$//' )  # [字] 削除

#確認
echo "delimiter: '$delimiter'"
echo "PROGRAM: $PROGRAM"
echo "EPISODE: $EPISODE"

#却下するPROGRAM名
MATCH_LIST=("アニメ" "プチプチ・アニメ" "ミニアニメ")
if [ -z "$PROGRAM" ]  || [[ " ${MATCH_LIST[@]} " =~ " ${PROGRAM} " ]]; then
    PROGRAM=$EPISODE
fi

# 最終的なディレクトリパス
#final_dir="$outdir/$PROGRAM/$EPISODE"
final_dir="$outdir/$PROGRAM"

if [ "$DRY_RUN" = "true" ]; then
   # ドライランモードの場合、移動操作を行わない
   echo "Dry run mode: $final_dir/$(basename "$input_file")"
else
    # ディレクトリを作成(存在しない場合のみ)
    mkdir -p "$final_dir" || {
	echo "ディレクトリの作成に失敗しました: $final_dir"
	exit 1
    }
    # ファイルを移動
    mv "$input_file" "$final_dir" || {
	echo "ファイルの移動に失敗しました: $input_file -> $final_dir"
	exit 1
    }
    echo "ファイルが正常に移動されました: $final_dir/$(basename "$input_file")"
fi

#https://note.com/leal_walrus5520/n/n8ae31f665314
#Time stamp: 2025/10/27

実行権限を与えて次のように使用する。

chmod +x ./mvjf.sh
./mvjf.sh 番組名.m2ts ~/media/

万能な分類法は存在しない

上のスクリプトでは、特定の区切り文字をもとに、ファイル名を分解しています。ただ、実際に遭遇するファイル名は多種多様で、分類法はユニークではなく、万能な方法は存在しないことに気付かされるでしょう。自分流にカスタマイズする必要が生じるかもしれません。

上記コードでは、優先順位リスト delimiter_order にて区切り文字の優先順位を指定しています。

しかし、たとえば、「全角空白」を区切り文字とみなすべきでしょうか?

「視点・論点 老いの心構え」のように、「全角空白」を区切り文字とするケースがある一方で、「伊藤政則のROCK CITY」のように、番組名に「全角空白」を含むケースもあります。

いろいろ録画していると、優先順位だけでは、解決できないケースがでてきます。

手作業を最大限なくすのが目的ですが、完璧な自動化は困難なようです。

EPGStation設定変更

フォルダによる分類とは別に、Jellyfinはファイル名だけから、自動的にシーズン番号・エピソード番号を勝手に推測して割り当てる機能をもっています。第3回でEPGStationの出力ファイル名を特定の形に変更した理由です。


enc01.sh

以上を用いた、新しい enc01.shになります。

#!/bin/bash

SOURCEDIR="$HOME/docker-mirakurun-epgstation/recorded/"
WORKDIR="$HOME/work/"
OUTDIR="$HOME/media/"
NHK1="GR20"
NHK2="GR13"

$WORKDIR/toprocess.py | while IFS= read -r FILE; 
do
    cd $SOURCEDIR
    if [ -f "$FILE" ]; then
	FILENAME=${FILE%.*}
	GRSTRING=`echo $FILENAME | sed -n 's/.*\(GR[0-9][0-9]\).*/\1/p'`

	cd $WORKDIR
	if [ -z $GRSTRING ] || [ $GRSTRING = $NHK1 ] || [ $GRSTRING = $NHK2 ]; then
	    # NHKならば
	    ./enc0.sh $SOURCEDIR/"$FILE" ${FILENAME}.mp4
	else
	    # NHK以外
	    ./enc1.sh $SOURCEDIR/"$FILE" ${FILENAME}.mp4
	fi

	if [ -f ${FILENAME}.mp4 ] ; then
	    ./mvjf.sh ${FILENAME}.mp4 $OUTDIR
	else
	    echo ERROR: mp4 not created 
	fi

	./processed.py "$FILE"
	
    fi
done

これで Jellyfinの「番組」フォルダに、ファイル名から自動的に切り出された「番組名」が並ぶようになります。「番組」ライブラリに、録画済み番組のポスター画像が並びます。

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

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

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

  • 26本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
予約録画、カット編集、番組整理を全自動化し、複数デバイスで手軽に視聴可能な、自分だけのメディアサーバーを、ゼロから作り上げる過程の全記録。誰でもたどれるよう必要なすべてを書き残す。https://github.com/takyaO/epg2jelly/
テレビ録画メディアサーバー構築入門(第7回)|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