見出し画像

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

MP4にメタデータを埋め込む

Netflix等、大手動画配信サービスの動画一覧リストには、それぞれの動画ごとに、どんな内容の動画か、説明が書いてあります。読むのに大した時間はかからないので、まったく知らないコンテンツの場合など、とても便利です。

読む読まないを別にしても、見た目の充実感が増すので、盛れる情報は片っ端から盛り込むのが最近の傾向なのでしょう。

プライベートな動画コレクションについても、盛り込める情報は、できるだけ取り込んでおくほうがよいのは、言うまでもありません。


今回は、第21回のスクリプトを拡張して、録画サーバーから取得した(放送波に含まれている)番組名 title 、放送日 date、 概要 description、ジャンル genre をmp4に埋め込むようにします。これらメタデータがどう表示されるかは、再生手段(プレーヤー)によります。個人メディアサーバーJellyfinでは、思いのほか役に立っています。

効能はあまり意識していなかったのですが、Jellyfinの見た目が、ますます大手ネット動画配信サイトなみに、充実してきました。

ということで、文字を大にして言いたい(AI生成('◇')ゞ)。

MP4にメタデータを埋め込まないと損する3つの理由:知って得する情報活用術!

MP4へのメタデータ埋め込みは、手間をかけるだけの価値がある、非常に重要な作業です。MP4ファイルにメタデータを埋め込むことは、まるで動画に「自己紹介」をさせるようなもの。動画の検索性向上、管理効率化、プロフェッショナルな印象など、様々なメリットがあります。

今すぐメタデータを見直して、あなたの動画を最大限に活用しましょう!

画像
動画ごとに、説明(番組情報)が表示される。

epg.sh

説明:EPGStationのデータベースに登録された録画番組のメタデータを出力する

使用法: epg.sh 録画番組ファイル名(拡張子含む)

takya@fearow22:~/work$ cat epg.sh
#!/bin/bash

#番組名を引数として受取り、metadataを出力
BASE_URL="${EPGSTATION_URL:-http://localhost:8888}"
URL="${BASE_URL}/api/recorded?isHalfWidth=false&offset=0&limit=1000"
TARGET_NAME="$1"
curl -s -X 'GET' $URL | jq --arg filename "$TARGET_NAME" '
  .records[] | select(.videoFiles[0].filename == $filename)
'

第21回のact.sh に対応するものですが、一部情報だけを抜き出すのではなく、根こそぎ取ってくることにしました。EGPStationは実行環境(localhost)で稼働している前提です。EPGStationが書き出したファイル名で検索しているので、拡張子m2tsも含める必要があります。

このスクリプト(epg.sh)の出力ファイル epg.jsonを、以下の新enc.js の第2引数として渡します。

enc.js

説明:地上波テレビ録画(m2ts) を mp4に変換するスクリプト

使用法: enc.js /path/to/番組.m2ts [メタデータファイルepg.json、または数値]

第2引数のepg.json は epg.shの出力結果( epg.sh 番組.m2ts > epg.json )。省略可。ファイルでなければ、前のバージョンと同じように(audioComponentTypeの値として)動作する。第21回参照。

使用例:

./epg.sh 孤独のグルメ全話イッキ見!【過去作 一挙放送・Season2】\[字\]\[解\].m2ts >epg.json
./enc.js 孤独のグルメ全話イッキ見!【過去作 一挙放送・Season2】\[字\]\[解\].m2ts epg.json


#!/usr/bin/env node

// モジュールの読み込み
const { spawn } = require('child_process');
const { execFileSync } = require('child_process');
const { spawnSync } = require('child_process');
const path = require('path');
const fs = require('fs');

// コマンドライン引数の解析
const args = process.argv.slice(2);
const inputFile = args[0];
const jsonFilePath = args[1];

let audioComponentType = '0';
let metadataDescription = null;
let metadataTitle = null;
let metadataDate = null;
let metadataGenre = null;

// ジャンル分類表(ARIB STD-B10)
const genreMap = {
    0: "ニュース/報道",
    1: "スポーツ",
    2: "情報/ワイドショー",
    3: "ドラマ",
    4: "音楽",
    5: "バラエティ",
    6: "映画",
    7: "アニメ/特撮",
    8: "ドキュメンタリー/教養",
    9: "劇場/公演",
    10: "趣味/教育",
    11: "福祉",
    12: "予備(未使用・その他)",
    13: "予備(未使用・その他)",
    14: "予備(未使用・その他)",
    15: "予備(未使用・その他)"
};

const subGenreMap = {
    0: "一般",
    1: "天気",
    2: "特集/ドキュメント",
    3: "解説",
    4: "討論",
    5: "会見",
    6: "特別番組",
    7: "その他"
};

// JSONファイルが指定された場合の処理
if (jsonFilePath && fs.existsSync(jsonFilePath)) {
    try {
        const fileContent = fs.readFileSync(jsonFilePath, 'utf8').trim();
        
        // ファイルが空でない場合のみ処理
        if (fileContent) {
            const jsonData = JSON.parse(fileContent);
            
            // audioComponentTypeの取得
            if (jsonData.audioComponentType !== undefined) {
                audioComponentType = jsonData.audioComponentType.toString();
            }
            
            // 概要メタデータの生成
            const description = jsonData.description || '';
            const extended = jsonData.extended || '';
            if (description || extended) {
                metadataDescription = [description, extended].filter(Boolean).join('\n');
                console.log('Metadata description will be added:', metadataDescription);
            }
            
            // タイトルメタデータの生成
            if (jsonData.name) {
                metadataTitle = jsonData.name;
                console.log('Metadata title will be added:', metadataTitle);
            }
            
            // 日付メタデータの生成
            if (jsonData.startAt) {
                const date = new Date(jsonData.startAt);
                metadataDate = date.toISOString().split('T')[0]; // YYYY-MM-DD形式
                console.log('Metadata date will be added:', metadataDate);
            }

	    // ジャンルメタデータの生成
	    const genres = [];
	    console.log('Genre data from JSON:', {
		genre1: jsonData.genre1,
		subGenre1: jsonData.subGenre1,
		genre2: jsonData.genre2,
		subGenre2: jsonData.subGenre2,
		genre3: jsonData.genre3,
		subGenre3: jsonData.subGenre3
	    });
	    
	    for (let i = 1; i <= 3; i++) {
		const genreKey = `genre${i}`;
		const subGenreKey = `subGenre${i}`;
    
		console.log(`Processing ${genreKey}:`, jsonData[genreKey], `${subGenreKey}:`, jsonData[subGenreKey]);
    
		// genreがundefinedまたはnullでない場合に処理(0は有効な値)
		if (jsonData[genreKey] !== undefined && jsonData[genreKey] !== null) {
		    const mainGenre = genreMap[jsonData[genreKey]] || `ジャンル${jsonData[genreKey]}`;
		    const subGenre = (jsonData[subGenreKey] !== undefined && jsonData[subGenreKey] !== null) 
			  ? (jsonData[subGenreKey] !== 0 ? subGenreMap[jsonData[subGenreKey]] || `サブジャンル${jsonData[subGenreKey]}` : null)
			  : null;
        
		    const genreText = subGenre ? `${mainGenre} - ${subGenre}` : mainGenre;
		    genres.push(genreText);
		    console.log(`Added genre: ${genreText}`);
		} else {
		    console.log(`Skipping ${genreKey} - undefined or null`);
		}
	    }

	    if (genres.length > 0) {
		metadataGenre = genres.join(' / ');
		console.log('Final metadata genre:', metadataGenre);
	    } else {
		console.log('No valid genres found');
	    }

            if (genres.length > 0) {
                metadataGenre = genres.join(' / ');
                console.log('Metadata genre will be added:', metadataGenre);
            }
            
            console.log('Using JSON config - audioComponentType:', audioComponentType);
        } else {
            console.log('JSON file is empty, using legacy mode');
            // 空の場合は従来の動作: 第2引数がaudio_component_type
            audioComponentType = args[1] || '0';
        }
    } catch (error) {
        console.error('Error parsing JSON file:', error.message);
        console.log('Fallback to legacy mode due to JSON parse error');
        // エラー時は従来の動作: 第2引数がaudio_component_type
        audioComponentType = args[1] || '0';
    }
} else if (jsonFilePath) {
    console.error(`Error: JSON file not found: ${jsonFilePath}`);
    process.exit(1);
} else {
    // 従来の動作: 第2引数がaudio_component_type
    audioComponentType = args[1] || '0';
    console.log('Using legacy mode - audioComponentType:', audioComponentType);
}

if (!inputFile) {
    console.error('Usage: node enc.js <input_file_path> [input_file.json]');
    console.error('Example: node enc.js /path/to/番組名.m2ts input_file.json');
    console.error('Legacy: node enc.js /path/to/番組名.m2ts 2');
    process.exit(1);
}

// 入力ファイルの存在確認
if (!fs.existsSync(inputFile)) {
    console.error(`Error: Input file not found: ${inputFile}`);
    process.exit(1);
}

// 出力ファイル名の生成(カレントディレクトリに出力)
const inputFileName = path.parse(inputFile).name;
const outputFile = `./${inputFileName}.mp4`;

// 固定設定
const epgsConfig = {
    recordedFileExtension: '.m2ts'
};

// エンコード設定
const ffmpegLogOutOnlyOnError = true;
const progressLogOutMax = 0; // 進捗表示を無効化
// デフォルトコーデック設定
//const useCodec = 'h264_qsv'; //libx264, h264_qsv, h264_vaapi
const useCodec = 'libx264'; //libx264, h264_qsv, h264_vaapi
// 固定で3秒カット
const fixedCutSecond = 3;

// 環境変数代替関数
function getEnv(variableName) {
    const envs = {
        INPUT: inputFile,
        OUTPUT: outputFile,
        NAME: inputFileName,
        AUDIOCOMPONENTTYPE: audioComponentType,
        FFMPEG: 'ffmpeg',
        FFPROBE: 'ffprobe'
    };
    return envs[variableName];
}

// libaribb24の利用可能性をチェック
function checkLibaribb24Availability() {
    try {
        // シンプルにバージョン情報だけでチェック
        const versionResult = execFileSync(getEnv('FFMPEG'), ['-version'], { encoding: 'utf8' });
        const isAvailable = versionResult.includes('libaribb24');
        
        console.log('libaribb24 available:', isAvailable);
        
        // デバッグ用に詳細を出力
        if (!isAvailable) {
            const configLine = versionResult.split('\n').find(line => line.includes('configuration:'));
            console.log('Build configuration:', configLine);
        }
        
        return isAvailable;
    } catch (error) {
        console.error('Error in libaribb24 check, assuming available:', error.message);
        return true; // エラー時は利用可能と仮定
    }
}

// libfdk_aacの利用可能性をチェック
function checkLibfdkAacAvailability() {
    try {
        // エンコーダーリストを確認
        const encodersOptions = ['-encoders'];
        const encodersResult = execFileSync(getEnv('FFMPEG'), encodersOptions, { encoding: 'utf8' });
        const hasLibfdkAac = encodersResult.includes('libfdk_aac') && encodersResult.includes('AAC');
        
        console.log(`libfdk_aac detection - Encoders: ${hasLibfdkAac}`);
        
        return hasLibfdkAac;
    } catch (error) {
        console.error('Error checking libfdk_aac availability:', error.message);
        return false;
    }
}

// 利用可能な音声コーデックを決定
function getAudioCodec() {
    const hasLibfdkAac = checkLibfdkAacAvailability();
    const audioCodec = hasLibfdkAac ? 'libfdk_aac' : 'aac';
    console.log(`Using audio codec: ${audioCodec}`);
    return audioCodec;
}

// メイン処理
(() => {
    // デバッグモードを有効にする
    const DEBUG_MODE = true;
    
    if (DEBUG_MODE) {
        debugAllStreams();
    }
    
    const useCodecPreArgs = [];
    const useCodecPostArgs = [];

    if (useCodec === 'h264_qsv') {
        useCodecPostArgs.push('-vf', 'yadif'); //cmcutで最適
	useCodecPostArgs.push('-preset', 'fast');
        useCodecPostArgs.push('-global_quality', '20');
    } else if (useCodec === 'libx264') {
	useCodecPreArgs.push('-fflags', '+genpts'); 
        useCodecPostArgs.push('-vf', 'yadif');
        useCodecPostArgs.push('-preset', 'fast');
        useCodecPostArgs.push('-crf', '23');
    } else if (useCodec === 'h264_vaapi') {
        useCodecPreArgs.push('-hwaccel', 'vaapi');
        useCodecPreArgs.push('-hwaccel_device', '/dev/dri/renderD128');
        useCodecPostArgs.push('-vf', 'format=nv12,hwupload,deinterlace_vaapi,scale_vaapi=w=1280:h=720');
        useCodecPostArgs.push('-compression_level', '1');
        useCodecPostArgs.push('-global_quality', '20');
    }

    // 音声コーデックを決定
    const audioCodec = getAudioCodec();
    
    // 字幕設定 - libaribb24の可用性をチェックしてから取得
    const hasLibaribb24 = checkLibaribb24Availability();
    const sub = getSubTitlesArg(hasLibaribb24);
    const audio = getAudioArgs(audioCodec);
    
    // 固定で3秒カット
    const cutSecond = fixedCutSecond;
    const ss = cutSecond > 0 ? ['-ss', cutSecond.toString()] : [];

    // 字幕ストリームが有効かどうかをチェック
    const hasValidSubtitles = sub.map.length > 0;
    
    // メタデータ引数の準備
    const metadataArgs = [];
    if (metadataDescription) {
        // メタデータの改行をスペースに置換して問題を回避
        const cleanDescription = metadataDescription.replace(/\n/g, ' ');
        metadataArgs.push('-metadata', `description=${cleanDescription}`);
    }
    if (metadataTitle) {
        metadataArgs.push('-metadata', `title=${metadataTitle}`);
    }
    if (metadataDate) {
        metadataArgs.push('-metadata', `date=${metadataDate}`);
    }
    if (metadataGenre) {
        metadataArgs.push('-metadata', `genre=${metadataGenre}`);
    }
    
    // FFmpeg引数の組み立て
    let outputArgs;
    if (hasValidSubtitles) {
        // 字幕ありでエンコード
        outputArgs = [
            '-y',
            ...getAnalyze(),
            ...sub.fix,
            ...useCodecPreArgs,  // 入力ファイルの前に追加
            ...ss,
            '-i', getEnv('INPUT'),
            '-map', '0:v',
            '-c:v', useCodec,
            ...audio.args,
            ...useCodecPostArgs, // 入力ファイルの後に追加
            ...metadataArgs,     // メタデータを追加
            ...sub.map,
            getEnv('OUTPUT')
        ];
        console.log('Encoding with subtitles');
    } else {
        // 字幕なしでエンコード
        outputArgs = [
            '-y',
            ...getAnalyze(),
            ...sub.fix,
            ...useCodecPreArgs,  // 入力ファイルの前に追加
            ...ss,
            '-i', getEnv('INPUT'),
            '-map', '0:v',
            '-c:v', useCodec,
            ...audio.args,
            ...useCodecPostArgs, // 入力ファイルの後に追加
            ...metadataArgs,     // メタデータを追加
            getEnv('OUTPUT')
        ];
        console.log('Encoding without subtitles');
    }

    console.log('Input file:', getEnv('INPUT'));
    console.log('Output file:', getEnv('OUTPUT'));
    console.log('FFmpeg command:', 'ffmpeg', outputArgs.join(' '));

    // 実行処理
    const durationInfo = getFFprobe('-show_format');
    const duration = durationInfo && durationInfo.format ? durationInfo.format.duration : 0;
    const startTime = process.uptime();
    const logBuffers = [];

    const child = spawn(getEnv('FFMPEG'), outputArgs, { 
        stdio: ['ignore', 'pipe', 'pipe'] 
    });

    // 標準出力も捕捉
    child.stdout.on('data', data => {
        logBuffers.push(String(data).trim());
    });

    child.stderr.on('data', data => {
        const dataStr = String(data);
        if (!dataStr.startsWith('frame=')) {
            logBuffers.push(dataStr.trim());
        }
    });

    child.on('exit', code => {
        const isError = code !== 0;
        
        if (!ffmpegLogOutOnlyOnError || isError) {
            console.log('FFmpeg messages:', logBuffers.join('\n'));
        }

        const elapsed = parseFloat(process.uptime() - startTime);
        const logs = {
            outputArgs: outputArgs.join(' '),
            duration: convertSecToTime(duration),
            elapsedTime: convertSecToTime(elapsed),
            averageSpeed: duration > 0 ? Math.floor(duration / elapsed) + 'x' : 'N/A',
            useCodec, cutSecond,
            subtitlesIncluded: hasValidSubtitles,
            metadataIncluded: metadataArgs.length > 0,
            audioCodec: audioCodec
        };
        
        if (isError) {
            console.error('Error code:' + code, logs);
            process.exit(code);
        } else {
            console.log('Successfully encoded:', logs);
        }
    });

    child.on('error', error => {
        console.error('Spawn error:', error);
        process.exit(1);
    });

    process.on('SIGINT', () => child.kill('SIGINT'));
})();

// すべての字幕ストリームを検出する包括的な関数
function detectAllSubtitleStreams() {
    try {
        const options = [
            '-v', 'error',
            '-select_streams', 's',
            '-show_entries', 'stream=index,codec_name,codec_type,tags:stream_tags:stream_tags=language',
            '-of', 'json',
            getEnv('INPUT')
        ];
        
        const result = execFileSync(getEnv('FFPROBE'), options, { encoding: 'utf8' });
        const info = JSON.parse(result);
        const subtitleStreams = [];
        
        if (info.streams && info.streams.length > 0) {
            // すべての字幕ストリームを対象とする(コーデック名に関わらず)
            for (const stream of info.streams) {
                if (stream.codec_type === 'subtitle') {
                    const lang = stream.tags && stream.tags.language ? stream.tags.language : 'unknown';
                    subtitleStreams.push(stream.index);
                    console.log(`Found subtitle stream: index=${stream.index}, codec=${stream.codec_name}, language=${lang}`);
                    
                    // arib_captionの場合は特別にログ輸出
                    if (stream.codec_name === 'arib_caption') {
                        console.log(`  ARIB caption stream detected: index=${stream.index}`);
                    }
                }
            }
        }
        
        console.log('All subtitle streams found:', subtitleStreams);
        return subtitleStreams;
    } catch (error) {
        console.error('Error detecting subtitle streams:', error.message);
        return [];
    }
}

// 字幕の設定を取得
function getSubTitlesArg(hasLibaribb24) {
    const fix = [];
    const map = [];
    const fileName = getEnv('NAME');
    const isSub = /\[字\]/.test(fileName);

    console.log('Subtitle detection:', { fileName, isSub, hasLibaribb24 });

    // [字]がある場合のみ字幕処理を実行
    if (isSub) {
        if (hasLibaribb24) {
            fix.push('-fix_sub_duration');
            console.log('libaribb24 is available, using -fix_sub_duration');
            
            // 複数の方法で字幕ストリームを検出
            const subtitleStreams = detectAllSubtitleStreams();
            console.log('All detected subtitle streams:', subtitleStreams);
            
            if (subtitleStreams.length > 0) {
                // 検出されたすべての字幕ストリームをマップ
                for (let i = 0; i < subtitleStreams.length; i++) {
                    map.push('-map', `0:${subtitleStreams[i]}?`);
                    map.push(`-c:s:${i}`, 'mov_text');
                    map.push(`-metadata:s:${i}`, 'language=jpn');
                }
                console.log('Mapped subtitle streams:', map);
            } else {
                console.log('No subtitle streams found, trying fallback method');
                
                // フォールバック: すべての字幕ストリームをマップ
                map.push('-map', '0:s?');
                map.push('-c:s', 'mov_text');
                map.push('-metadata:s:0', 'language=jpn');
            }
        } else {
            console.log('libaribb24 is not available, skipping subtitle mapping to avoid errors');
            // libaribb24がない場合は字幕ストリームをマップしない
        }
    } else {
        console.log('No [字] tag in filename, skipping subtitle processing');
    }

    return { fix: fix, map: map, isSub: isSub };
}

// 補助関数
function isTs() {
    const reg = new RegExp(epgsConfig.recordedFileExtension + '$');
    return (getEnv('INPUT').match(reg) !== null)
}

function getAnalyze() {
    return ['-analyzeduration', '10M'];
}

function getFFprobe(showOptions) {
    try {
        const options = [].concat(getAnalyze(), '-v', '0', showOptions, '-of', 'json',  getEnv('INPUT'));
        const stdout = execFileSync(getEnv('FFPROBE'), options);
        return JSON.parse(stdout);
    } catch (error) { 
        console.error('getFFprobe error:', error.message);
        return null;
    }
}

function convertTimeToSec(time) {
    const times = time.split(':');
    return parseFloat(times[0]) * 3600 + parseFloat(times[1]) * 60 + parseFloat(times[2]);
}

function convertSecToTime(second) {
    const date = new Date(0);
    date.setSeconds(second);
    return date.toISOString().substring(11, 19);
}

// 実際の音声ストリームインデックスを取得する関数
function getActualAudioStreamIndices() {
    try {
        const options = [
            '-v', 'error',
            '-select_streams', 'a',
            '-show_entries', 'stream=index,codec_name,channels,bit_rate,sample_rate',
            '-of', 'json',
            getEnv('INPUT')
        ];
        
        const result = execFileSync(getEnv('FFPROBE'), options, { encoding: 'utf8' });
        const info = JSON.parse(result);
        const audioIndices = [];
        
        if (info.streams && info.streams.length > 0) {
            console.log('All audio streams found:');
            for (const stream of info.streams) {
                console.log(`  Stream #${stream.index}: ${stream.codec_name}, ${stream.channels} channels, ${stream.bit_rate} bitrate, ${stream.sample_rate} Hz`);
                audioIndices.push(stream.index);
            }
        } else {
            console.log('No audio streams found, using default stream 0');
            audioIndices.push(0);
        }
        
        return audioIndices;
    } catch (error) {
        console.error('Error getting actual audio stream indices:', error.message);
        // エラー時は安全策として[0]を返す
        return [0];
    }
}

// 音声の設定を取得する関数
function getAudioArgs(audioCodec) {
    const fileName = getEnv('NAME');
    const audioComponentType = parseInt(getEnv('AUDIOCOMPONENTTYPE'), 10);
    const isDualMono = audioComponentType == 2;
    const isBilingual = /\[二\]/.test(fileName);
    const isExplanation = /\[解\]/.test(fileName);
    const isMultiAudio = /\[多\]/.test(fileName);
    const args = [];
    
    console.log('Audio component type:', audioComponentType, 'isDualMono:', isDualMono, 'isBilingual:', isBilingual, 'isExplanation:', isExplanation, 'audioCodec:', audioCodec);

    // 実際の音声ストリームインデックスを取得
    let actualAudioIndices = getActualAudioStreamIndices();
    
    // 必要な音声ストリーム数を決定
    let needAudioCount = 1;
    if ((isBilingual || isExplanation || isMultiAudio) && !isDualMono) {
        needAudioCount = 2;
    }
    
    // 実際のストリーム数と必要なストリーム数の小さい方を採用
    const audioCount = Math.min(actualAudioIndices.length, needAudioCount);
    console.log('Using audio streams:', audioCount, 'out of', actualAudioIndices.length, 'available');

    // 音声多重放送(デュアルモノ) - audioComponentTypeが2の場合のみ処理
    if (isDualMono && actualAudioIndices.length >= 1) {
        console.log('Processing as dual mono');
        // 左右チャンネルを分割して2つのモノラルストリームを作成
        args.push('-filter_complex', `[0:${actualAudioIndices[0]}]channelsplit=channel_layout=stereo[left][right]`);
        // 左チャンネル(日本語)
        args.push('-map', '[left]');
        // 右チャンネル(英語)
        args.push('-map', '[right]');
        // メタデータ設定(日本語→英語の順序)
        args.push('-metadata:s:a:0', 'language=jpn');
        args.push('-metadata:s:a:1', 'language=eng');
        // 音声コーデック設定(選択されたコーデックを使用)
        args.push('-c:a', audioCodec);
        args.push('-b:a:0', '192k');
        args.push('-b:a:1', '192k');
        args.push('-ac:0', '1');
        args.push('-ac:1', '1');
        return { args: args };
    }

    // 通常の音声処理
    console.log('Processing as normal audio');
    
    // 音声マッピング - 取得したインデックスを使ってマップ(オプショナルマッピング)
    for (let i = 0; i < audioCount; i++) {
        const streamIndex = actualAudioIndices[i];
        args.push('-map', `0:${streamIndex}?`); // オプショナルマッピング
        console.log(`Mapping audio stream: 0:${streamIndex}?`);
    }
    
    // メタデータ設定 - 実際にマップされたストリームにのみ設定
    for (let index = 0; index < audioCount; index++) {
        let lang = 'jpn';
        if (audioCount > 1) {
            // 二ヶ国語放送の場合のみ2番目の音声を英語に設定
            if (isBilingual && index === 1) {
                lang = 'eng'; // 二ヶ国語放送の副音声は英語
            } else if (isExplanation || isMultiAudio) {
                lang = 'jpn'; // 解説放送や多重放送は日本語
            }
        }
        args.push(`-metadata:s:a:${index}`, `language=${lang}`);
    }

    // 音声エンコード設定(選択されたコーデックを使用)
    args.push('-c:a', audioCodec);
    args.push('-b:a', '192k');
    args.push('-ac', '2');

    return { args: args };
}

// 入力ファイルのすべてのストリーム情報を表示する関数
function debugAllStreams() {
    try {
        const options = [
            '-v', 'error',
            '-show_streams',
            '-of', 'json',
            getEnv('INPUT')
        ];
        
        const result = execFileSync(getEnv('FFPROBE'), options, { encoding: 'utf8' });
        const info = JSON.parse(result);
        
        console.log('=== DEBUG: All streams in input file ===');
        if (info.streams && info.streams.length > 0) {
            for (const stream of info.streams) {
                const lang = stream.tags && stream.tags.language ? stream.tags.language : 'unknown';
                console.log(`  Stream #${stream.index}: type=${stream.codec_type}, codec=${stream.codec_name}, lang=${lang}`);
                
                // 詳細情報
                if (stream.codec_type === 'video') {
                    console.log(`    Resolution: ${stream.width}x${stream.height}, duration: ${stream.duration}`);
                } else if (stream.codec_type === 'audio') {
                    console.log(`    Channels: ${stream.channels}, sample_rate: ${stream.sample_rate}`);
                } else if (stream.codec_type === 'subtitle') {
                    console.log(`    Subtitle details available`);
                }
            }
        } else {
            console.log('  No streams found');
        }
        console.log('=== DEBUG END ===');
    } catch (error) {
        console.error('Error debugging streams:', error.message);
    }
}

// https://note.com/leal_walrus5520/n/nb560315013e3
// Time stamp: 2025/11/05



enc01.sh

本構築の心臓部。初出は第6回。その後、第7回、第9回、第10回、第11回、第21回、第22回とバージョンアップを重ねている。

現在、2つの録画サーバーの3つの作業フォルダ(work, work, work2)で利用している。環境依存の部分を毎回書き換えるのが面倒になってきたので、環境変数定義は env.sh として分けることにした。

#!/usr/bin/env bash
set -euo pipefail
IFS=$'\n\t'

#ffmpeg のオプション
FFMPEG_OPTS=(-c:v libx264 -crf 23 -preset fast -c:a libfdk_aac -b:a 192k)

# --- 環境変数のロード ---
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="$SCRIPT_DIR/env.sh"

if [[ -f "$ENV_FILE" ]]; then
  source "$ENV_FILE"
else
  echo "環境設定ファイルが見つかりません: $ENV_FILE" >&2
  exit 1
fi

# --- デバッグ設定 ---
# VERBOSE=1 ./enc01.sh で詳細ログを出す
if [[ "${VERBOSE:-0}" -eq 1 ]]; then
  set -x
fi

# --- 関数群 ---
bc_calc() {
    echo "scale=6; $1" | bc -l | awk '{printf "%.6f", $0}'
}

watch_iowait() {
    MAX_WAIT=600
    THRESHOLD=10
    INTERVAL=5
    elapsed=0
    echo "Monitoring IOWAIT... (threshold=${THRESHOLD}%, max_wait=${MAX_WAIT}s)"
    while true; do
        read cpu user nice system idle iowait irq softirq steal guest guest_nice < /proc/stat
        sleep 1
        read cpu2 user2 nice2 system2 idle2 iowait2 irq2 softirq2 steal2 guest2 guest_nice2 < /proc/stat
        total_diff=$(( (user2-user) + (nice2-nice) + (system2-system) + (idle2-idle) + (iowait2-iowait) + (irq2-irq) + (softirq2-softirq) + (steal2-steal) ))
        iowait_diff=$(( iowait2 - iowait ))
        if [ $total_diff -gt 0 ]; then
            IOWAIT=$(awk "BEGIN {printf \"%.1f\", ($iowait_diff*100)/$total_diff}")
        else
            IOWAIT=0
        fi

        echo "  elapsed=${elapsed}s, IOWAIT=${IOWAIT}%"

        if (( $(echo "$IOWAIT < $THRESHOLD" | bc -l) )); then
            echo "IOWAIT < ${THRESHOLD}%, resuming..."
            break
        fi

        sleep "$INTERVAL"
        elapsed=$((elapsed + INTERVAL))
        if (( elapsed >= MAX_WAIT )); then
            echo "IOWAIT did not drop below ${THRESHOLD}% after ${MAX_WAIT}s. Exiting."
	    if [ -v NTFY_URL ]; then
		curl -H "X-Priority: 3" -d "ERROR: IOWAIT > ${THRESHOLD}%: $FILENAME" "$NTFY_URL"
	    fi
            exit 1
        fi
    done
}

trim() {
    local INPUT="$1"
    local TRIMFILE="$2"
    local OUTPUT="$3"
    local FPS=29.97

    if [ ! -s "$TRIMFILE" ]; then
        echo "No trim info. Copying input to output..."
        ffmpeg -hide_banner -loglevel error -y -i "$INPUT" -c copy -map 0 "$OUTPUT"
        exit 0
    fi

    TEMPDIR=$(mktemp -d)
    PARTS_LIST="$TEMPDIR/parts.txt"
    INDEX=0
    > "$PARTS_LIST"

    if ffprobe -v error -select_streams s -show_entries stream=index -of csv=p=0 "$INPUT" | grep -q .; then
        SUB_OPT=(-c:s copy -map 0)
    else
        SUB_OPT=()
    fi

    while read -r line; do
        while [[ "$line" =~ Trim\(([0-9]+),([0-9]+)\) ]]; do
            STARTF="${BASH_REMATCH[1]}"
            ENDF="${BASH_REMATCH[2]}"

            STARTSEC=$(bc_calc "$STARTF / $FPS")
            ENDSEC=$(bc_calc "$ENDF / $FPS")
            DURATION=$(bc_calc "$ENDSEC - $STARTSEC")

            PART="$TEMPDIR/part_${INDEX}.mp4"
            watch_iowait

            if (( $(echo "$STARTSEC >= 5" | bc -l) )); then
                START_BEFORE=$(echo "$STARTSEC - 5" | bc -l | sed 's/^\./0./')
                ffmpeg -ss "$START_BEFORE" -i "$INPUT" -ss 5 -t "$DURATION" "${FFMPEG_OPTS[@]}"  "${SUB_OPT[@]}" "$PART"
            else
                ffmpeg -i "$INPUT" -ss "$STARTSEC" -t "$DURATION" "${FFMPEG_OPTS[@]}" "${SUB_OPT[@]}" "$PART"
            fi

            echo "file '$PART'" >> "$PARTS_LIST"
            INDEX=$((INDEX+1))
            line=${line#*"Trim("}
        done
    done < "$TRIMFILE"

    TITLE=$(ffprobe -v error -show_entries format_tags=title -of default=nw=1:nk=1 "$INPUT")
    DATE=$(ffprobe -v error -show_entries format_tags=date -of default=nw=1:nk=1 "$INPUT")
    DESC=$(ffprobe -v error -show_entries format_tags=description -of default=nw=1:nk=1 "$INPUT")
    GENRE=$(ffprobe -v error -show_entries format_tags=genre -of default=nw=1:nk=1 "$INPUT")

    ffmpeg -hide_banner -loglevel error -y \
        -f concat -safe 0 -i "$PARTS_LIST" \
        -c copy -map 0 \
        -map_metadata -1 \
        -metadata title="$TITLE" \
        -metadata date="$DATE" \
        -metadata description="$DESC" \
        -metadata genre="$GENRE" \
        "$OUTPUT"

    rm -rf "$TEMPDIR"
    echo "Done: $OUTPUT"
}

jls() {
    local filename="$1"
    local output_file="$2"

    if [[ -z "$filename" || -z "$output_file" ]]; then
        echo "Usage: jls FILENAME OUTPUT_FILE" >&2
        return 1
    fi

    local avs_file="join.avs"
    cat > "$avs_file" << EOF
TSFilePath="$filename"
LWLibavVideoSource(TSFilePath, repeat=true, dominance=1)
AudioDub(last,LWLibavAudioSource(TSFilePath, av_sync=true))
EOF

    timeout -k 60 1800 chapter_exe -v "$avs_file" -o chap_out.txt || {
        echo "chapter_exe failed" >&2
        return 1
    }

    if [ ! -f "$GRSTRING.lgd" ]; then
        echo "Error: $GRSTRING.lgd is not found" >&2
        return 1
    fi

    logoframe "$avs_file" -logo "$GRSTRING.lgd" -oa lf_out.txt || {
        echo "logoframe failed" >&2
        return 1
    }

    join_logo_scp -inlogo lf_out.txt -inscp chap_out.txt -incmd JL_標準.txt -o "$output_file" || {
        echo "join_logo_scp failed" >&2
        return 1
    }
}

# --- メイン処理 ---
"$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 [ -v CHKDRP ]; then
	    ./chkdrp.sh "$SOURCEDIR/$FILE" 
	fi
        ./epg.sh "$FILE" > epg.json
        ./enc.js "$SOURCEDIR/$FILE" epg.json  || {
            echo "enc.js failed" >&2
		if [ -v NTFY_URL ]; then
                    curl -H "X-Priority: 3" -d "ERROR: enc.js failed: $FILENAME" "$NTFY_URL"
		fi
		exit 1
	}

        if [ -f "$FILENAME.mp4" ] && [ "${CMCUT}" != "false" ] && [ "$GRSTRING" != "$NHK1" ] && [ "$GRSTRING" != "$NHK2" ]; then
            jls "$FILENAME.mp4" jls_out.txt
            trim "$FILENAME.mp4" jls_out.txt temp$$.mp4
            if [ -s temp$$.mp4 ]; then
                rm "$FILENAME.mp4"
                mv temp$$.mp4 "$FILENAME.mp4"
            else
                echo "Error: trim failed" >&2
		if [ -v NTFY_URL ]; then
                    curl -H "X-Priority: 3" -d "ERROR: trim failed: $FILENAME" "$NTFY_URL"
		fi
                ./mvjf.sh "$FILENAME.mp4" "$OUTDIR"
                rm "$FILENAME.mp4.lwi"
                ./processed.py "$FILE"
                exit 1
            fi
            rm "$FILENAME.mp4.lwi"
        fi

        if [ -s "$FILENAME.mp4" ]; then
            ./mvjf.sh "$FILENAME.mp4" "$OUTDIR"
	    if [ -v NTFY_URL ]; then
		curl -H "X-Priority: 2" -d "mp4 created: $FILENAME" "$NTFY_URL"
	    fi
        else
            echo "ERROR: mp4 not created" >&2
	    if [ -v NTFY_URL ]; then
		curl -H "X-Priority: 3" -d "ERROR: mp4 not created: $FILENAME" "$NTFY_URL"
	    fi
            exit 1
        fi

        ./processed.py "$FILE" || true
    fi
done

#Time stamp: 2025/11/02

env.sh

# ディレクトリ
export WORKDIR="$HOME/work/" # 作業ディレクトリ(このファイルのディレクトリ)
export SOURCEDIR="$HOME/docker-mirakurun-epgstation/recorded/" # m2ts (変換前) のソースディレクトリ
export OUTDIR="$HOME/media/" # mp4 (変換後) の出力先ディレクトリ

# オプション
export CMCUT=true   # false: CMカットなし
export CHKDRP=false # false: 動画チェックなし
export WATCHDIR=false # false: サーバー(EPGSTATION_URL)監視、true: ディレクトリ(SOURCEDIR) 監視
export EPGSTATION_URL="http://localhost:8888" #
export NTFY_URL="https://fearow22.taile153a7.ts.net/PX-S1UD" # ntfy.sh 未設定ならコメントアウト
#NHKチャンネルID: CMCUT=falseなら設定不要
export NHK1="GR26"
export NHK2="GR27"

注意

今回取り扱ったMP4タグは、メディアサーバーの自動オンラインスキャナー(TMDB, TVDB, AniDBなど)が番組動画を判別できなかった場合に、役に立ちます。

注意点として、Jellyfinが参照するメタデータには優先順位があり、MP4タグは、オンラインメタデータ(TMDB;第12回参照)よりも、優先度が低くなっています。このため、たとえば、データベース連携がほぼ完ぺきな「映画」(第13回)では、MP4タグは無視されます。

また、Jellyfin の「番組」ライブラリについても、ジャンルは基本的に「シリーズ(番組単位)」に集約され、個々のエピソード(mp4 ファイル)に付けたジャンルは 参照されません。というのも、 TV 系メタデータ API(TMDB / TVDB など)が「シリーズ全体にジャンルを付与」する方式をとっているためです。

以上のメタデータの扱いは、欧米で一般的な流儀で、Jellyfinの設計哲学もこれに沿ったものになっています。

日本の地上波の「ガラパゴス化」問題

日本のデジタル地上波では、番組情報を放送波から直接取得する仕組みをとっています。しかしながら、欧米を中心とする海外では、番組IDを基に外部データベースからメタデータを取得する仕組みが一般的です。

日本の地上波録画番組を、番組単位でデータベースと対応づけるのは、簡単ではありません。放送波が「シリーズ」や「エピソード」の情報をもたず、草の根レベルの公開データベースがないためです。データベースを作ること自体は、技術的には難しくありませんが、有志が善意で構築したとしても、スクレイピング等で収集した情報を不特定多数に公開すれば、著作権侵害や不正競争防止法違反として訴えられるリスクを拭えません。

データベースを参照できるかどうかは、視聴体験の質に大きく影響します。視聴者参加型の「オープンな」方式ならば、評価・評判やポスター・ファンアートなどを番組単位で共有できるのですが、末端の視聴者間の連携を阻止するように設計されている点が、日本流の「閉じた」方式の欠点になります。

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

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

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

  • 26本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
テレビ録画メディアサーバー構築入門(第24回)|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