至高のガーリックライス

調理道具

  • 深鍋フライパン大
  • アルミホイルとタオル(牛肉を包む)

材料(3人分)

  • ごはん     600g(3膳分)
  • 牛サーロイン肉 400g~450g ※ステーキ用(他の牛肉で代用も可)
  • 牛脂      2個
  • 塩       ひとつまみ
  • 黒こしょう   少々(9捻りくらい)
  • ニンニク    6片 ※3個はガーリックチップ、3個はご飯と炒める
  • 醤油      大さじ1.5
  • コンソメ顆粒  5g(1個分)
  • 有塩バター   30g
  • 小ネギ     3本

下準備

  • 牛肉を常温に戻す
  • 牛肉の筋を切っておく
  • 牛肉に塩・こしょうを塗しておく
  • ご飯を温めておく
  • にんにく3片を輪切りにする
  • にんにく3片をみじん切りにする
  • 小ネギを輪切りにする

調理

【ガーリックチップを作る】

  1. フライパンに牛脂を入れて温めて溶かす
  2. ガーリックスライスを入れて、弱火で狐色になる寸前まで炒めて火を止める
    ※焦がさないように注意すること
  3. 箸で小皿に移す ※剥がれ落ちた芯の部分はきちんと取り切ること

【牛肉を焼く】

  1. フライパンに牛肉を入れて強火にする
  2. 焦げ目がつく寸前で、ひっくり返して両面を焼く。
  3. 牛肉をアルミホイルで包み、布で包んでおく

【ライスを炒める】

  1. 弱火にして、ニンニクのみじん切りを入れて香りが立つ(色が少し変わる程度)までじっくり炒める
  2. 中火にしてフライパンにご飯を入れて炒める
  3. フライパンに塩ひとつまみ、醤油大さじ1.5、バター30g、コンソメ顆粒1個を入れて炒める
  4. 黒胡椒を入れて炒める
  5. 火を止める

【盛り付ける】

  1. ガーリックライス皿に盛り付ける
  2. 牛肉をスライスして盛り付ける
  3. ガーリックチップと小ネギを散らす

メモ

  • ニンニクは焦げやすいので、炒まっている寸前くらいで次の工程に進むようにするといいです。
  • 牛肉とガーリックチップを抜くと、ガーリックライスだけになります
  • 小ネギの代わりに乾燥パセリでもいいです

参考レシピ

至高のさつまいもサラダ

調理道具

  • ボウル(さつまいもを蒸す)
  • フライパン小(ベーコンと卵を焼く)

材料(3人分)

【さつまいも】

  • さつまいも   280g ※店頭で売っているさつまいも一本分程度
  • 水       大さじ1
  • ベーコン    40g
  • 卵       2個
  • マヨネーズ   40g
  • 創味シャンタン 小さじ1
  • カレー粉    小さじ1/3
  • 塩       適量
  • 黒コショウ   適量

下準備

  • さつまいもを1cm角の角切りにする
  • ベーコンを5mm程度の細さの短冊で切る

調理

【さつまいもを蒸す】

  1. さつまいもをボウル小に入れて、600Wで6分蒸す
  2. 粗熱をとる

【卵とベーコンを焼く】※さつまいもの粗熱をとっている間に行う

  1. フライパンを中火で温めて、ベーコンを焼く
  2. 卵2個をフライパンに割り入れて白身が固まるまで焼く
  3. 目玉焼きをひっくり返して、黄身を8割程度固まるまで焼く

【混ぜる】

  1. さつまいもをフォークで軽く崩す
  2. ボウルにベーコン・卵を入れて混ぜる
  3. 粗熱をとる
  4. マヨネーズ40g、創味シャンタン小さじ1、カレー粉小さじ1/3、黒胡椒を入れて混ぜる
  5. 塩で味を整えて盛り付ける

メモ

  • ベーコンの塩味や創味シャンタンの味が結構濃いので、塩は最後に味調整で入れる方がいいです。

参考レシピ

Amatsukazeを利用してCM抜きMP4を自動生成する

録画してできたM2TSは1時間番組でもファイルサイズが7GBを超えるため保存やコピーに手間がかかります。そこで、MP4へ変換してファイルサイズを落として扱いやすくする必要があります。

スポーツ番組や旅番組など画像の変化の激しいものや古い映画などのようにノイズの多いものはファイルサイズは半分程度までしか落ちないこともありますが、アニメなどの変化の少ないものは10分の1程度まで落とせます。概ね2-3割程度まで落とせると考えればいいでしょう。

また、変換の際にCMを抜くとさらにファイルサイズを落とせます。通常2割弱程度がCMですので、圧縮後のファイルからさらに2割サイズを落とすことができます。ただし、手動でCMカットはかなりの手間ですので、WindowsアプリのAmatsukazeを利用します。

Amatsukazeはコマンドラインからも実行できるのでバッチファイルによる自動処理が可能です。ただし、バッチファイルは制限が多く複雑な自動処理には向きません。また、変換だけでなく、変換したファイルをNASへコピーしたり、途中で停止したり処理の確認をしたりと、様々な追加要望が出てきます。

そこで、バッチファイルからPowershellを起動する形で処理を自動化します。Powershellでは起動オプションによって動作を変えるように実装し、バッチファイル側で機能を選択する形で様々なパターンに対応できるようにしました。PowershellはWindowsのデフォルトシェルなので、Pythonのように別途ソフトをインストールすることなく自動化処理が可能になります。

Amatsukazeを利用してmp4への変換を行うPowershellスクリプトとスクリプト起動用のバッチファイルを以下に示します。具体的には以下の処理を順番に実行します。

  1. M2TSファイルをWindowsのローカルストレージにコピーする
  2. Amatsukazeを使ってCM抜き処理とmp4変換
  3. mp4とass(字幕ファイル)のNASへのバックアップ
  4. リモート視聴用ファイルの作成と再生用サーバーへのコピー
  5. PCシャットダウン

ファイルの保存位置など環境に依存するパラメータについては、スクリプトの先頭にまとめていますので、こちらを編集してください。

Amatsukazeはアプリ単体で動作するように事前に設定してください。変換パラメータについてはスクリプト側で指定するので、特に調整する必要はありません。

なお、このスクリプトはNvidiaのGPUの使用する前提で組んでいます。CPUのみでのエンコードには対応していないので、対応させたい場合はAmatsukazeのオプションパラメータを調整してください。NVEncC64.exeについては各環境に合わせてパスを変更してください。

このスクリプトは下記を考慮しています。

  • ファイルの最終書き込み日時と作成日時を比較して、変換・コピー処理が不要な場合はスキップする
  • 録画中のM2TSはバックアップしない
  • 番宣などの再生時間が極端に短いファイルはNASへのコピーはしない
  • ファイルコピー途中に強制終了した場合に次回実行時に再度コピーを行う
  • 変換途中に強制終了させた場合は次回実行時に再度変換処理を行う
  • ファイル名の先頭に’_’をつけることで、変換・コピーの対象から外す
  • 撮り貯め対象外フォルダの一部フォルダからのコピー処理をサポートする
  • ローカルディスクとNAS上のファイルをミラーリングする
  • mp4ファイルそのものに字幕データを保持する

ファイルは必ずBOM付きUTF-8で保存してください。BOMなしの場合、PowerShell内の日本語がShift JISとして認識されてしまい、PowerShellの処理文字コードであるUTF-8と食い違いが発生して正しく処理されません。

参考までに、4070Tiを使用している場合でCM抜きとmp4へのエンコードにかかる時間は、CM付きの2時間映画で12分~13分程度です。

@echo off

@REM Initialize batch
SET CURRENT_DIR_COPY_ENCODE_BACKUP_SHUTDOWN_BAT=%~dp0
cd /d %CURRENT_DIR_COPY_ENCODE_BACKUP_SHUTDOWN_BAT%
SET BATCH_NAME_COPY_ENCODE_BACKUP_SHUTDOWN_BAT=%~n0%~x0

@REM Get date/time string
for /f %%a in ('wmic os get LocalDateTime ^| findstr \.') DO SET LDT=%%a
SET CUR_DATE=%LDT:~0,8%
SET CUR_TIME=%LDT:~8,6%

@REM Constant
SET POWERSHELL_SCRIPT_PATH=%CURRENT_DIR_COPY_ENCODE_BACKUP_SHUTDOWN_BAT%\Copy_Encode_Backup_Shutdown.ps1
SET LOG_FILE_PATH_PATH=%CURRENT_DIR_COPY_ENCODE_BACKUP_SHUTDOWN_BAT%\%~n0_%CUR_DATE%_%CUR_TIME%.log

SET POWERSHELL_SCRIPT_ARGUMENT_LIST=^
-backup_m2ts ^
-backup_m2ts_within_day 0 ^
-convert_to_mp4 ^
-copy_to_nas ^
-generate_remoteplay ^
-remoteplay_synology ^
-log_file "%LOG_FILE_PATH_PATH%"

if "%1"=="" (
    @echo [%BATCH_NAME_COPY_ENCODE_BACKUP_SHUTDOWN_BAT%] powershell -NoProfile -ExecutionPolicy Bypass -file %POWERSHELL_SCRIPT_PATH% -ArgumentList %POWERSHELL_SCRIPT_ARGUMENT_LIST%
    call powershell -NoProfile -ExecutionPolicy Bypass -file %POWERSHELL_SCRIPT_PATH% -ArgumentList %POWERSHELL_SCRIPT_ARGUMENT_LIST%
) else (
    @echo [%BATCH_NAME_COPY_ENCODE_BACKUP_SHUTDOWN_BAT%] powershell -NoProfile -ExecutionPolicy Bypass -file %POWERSHELL_SCRIPT_PATH% -ArgumentList %POWERSHELL_SCRIPT_ARGUMENT_LIST%
    call powershell -NoProfile -ExecutionPolicy Bypass -file %POWERSHELL_SCRIPT_PATH% -ArgumentList %POWERSHELL_SCRIPT_ARGUMENT_LIST%
)

pause
Param(
    [switch]$backup_m2ts,
    [int]$backup_m2ts_within_day,
    [switch]$convert_to_mp4,
    [switch]$copy_to_nas,
    [switch]$generate_remoteplay,
    [switch]$remoteplay_rename,
    [string]$amatsukaze_directory,
    [string]$work_directory,    
    [string]$log_file,
    [switch]$quiet,
    [switch]$delete,
    [switch]$shutdown,
    [switch]$debug,
    [switch]$help
)

#########################
# Parameters
#########################
[string]$script:script_path = Split-Path -Parent $PSCommandPath
[string]$script:script_name = Split-Path -Leaf $PSCommandPath

[string]$script:log_file_path=$log_file
if([string]::IsNullOrEmpty($script:log_file_path)){
    $script:log_file_path = $PSCommandPath.Replace(".ps1",((Get-Date).ToString("_yyyyMMdd_HHmmss"))+".log")
}

[string[]]$script:source_directory_root        = @()
          $script:source_directory_root       += "\\192.168.1.7\recorded_files\keep"
          $script:source_directory_root       += "\\192.168.1.7\recorded_files\on_hold"
        #   $script:source_directory_root       += "\\192.168.1.6\tv-recorder\keep"
        #   $script:source_directory_root       += "\\192.168.1.6\tv-recorder\on_hold"

[string[]]$script:tentative_directory_root     = @()
          $script:tentative_directory_root    += "\\192.168.1.7\recorded_files\delete\7_映画(午後のロードショー)"
          $script:tentative_directory_root    += "\\192.168.1.6\tv-recorder\delete\7_映画(午後のロードショー)"

[string]$script:backup_directory_root          = "Z:\tv-recorder\m2ts"
[string]$script:convert_directory_root         = "Z:\tv-recorder\mp4"
[string]$script:nas_directory_root             = "\\192.168.1.7\converted_files\mp4"
[string]$script:remote_play_directory_root     = "Z:\tv-recorder\remote_play"
[string]$script:remote_play_directory_nas_root = "\\192.168.1.7\converted_files\remote_play"

[bool]$script:quiet_flag=$quiet

if($debug){
    [string[]]$script:source_directory_root          = @( "Z:\tv-recorder_test\recorded" )
    [string]  $script:backup_directory_root          = "Z:\tv-recorder_test\m2ts"
    [string]  $script:convert_directory_root         = "Z:\tv-recorder_test\mp4"
    [string]  $script:nas_directory_root             = "Z:\tv-recorder_test\nas"
    [string]  $script:remote_play_directory_root     = "Z:\tv-recorder_test\remote_play"
    [string]  $script:remote_play_directory_nas_root = "\\bluesky-nas\video\TV"

    if( $backup_m2ts )
    {
        foreach( $source_directory in ${script:source_directory_root} )
        {
            if( -Not(Test-Path "${source_directory}") ){
                New-Item -Type Directory ${source_directory} > $null
            }
        }
    }

    if( $backup_m2ts -and -Not(Test-Path "${script:backup_directory_root}") ){
        New-Item -Type Directory ${script:backup_directory_root} > $null
    }

    if( $convert_to_mp4 -and -Not(Test-Path "${script:convert_directory_root}") ){
        New-Item -Type Directory ${script:convert_directory_root} > $null
    }

    if( $copy_to_nas -and -Not(Test-Path "${script:nas_directory_root}") ){
        New-Item -Type Directory ${script:nas_directory_root} > $null
    }

    if( $generate_remoteplay -and -Not(Test-Path "${script:remote_play_directory_root}") ){
        New-Item -Type Directory ${script:remote_play_directory_root} > $null
    }

    if( $generate_remoteplay -and -Not(Test-Path "${script:remote_play_directory_nas_root}") ){
        New-Item -Type Directory ${script:remote_play_directory_nas_root} > $null
    }
}

[string]$script:amatsukaze_directory = $amatsukaze_directory
if( [string]::IsNullOrEmpty($script:amatsukaze_directory) ){
    $script:amatsukaze_directory = "$script:script_path"
}
[string]$script:amatsukaze_cli_exe = "$script:amatsukaze_directory\exe_files\AmatsukazeCLI.exe"
[string]$script:nvencc64_exe = "$script:amatsukaze_directory\exe_files\NVEncC\NVEncC64.exe"
[string]$script:work_directory = $work_directory
if( [string]::IsNullOrEmpty($script:work_directory) ){
    $script:work_directory = "$script:amatsukaze_directory\temp"
}
[string]$script:convert_to_mp4_commandline  = " -s 1024" `
                                            + " --drcs `"$script:amatsukaze_directory\drcs\drcs_map.txt`" "`
                                            + " -w `"$script:work_directory`" "`
                                            + " --chapter-exe `"$script:amatsukaze_directory\exe_files\chapter_exe.exe`" "`
                                            + " --jls `"$script:amatsukaze_directory\exe_files\join_logo_scp.exe`" "`
                                            + " --cmoutmask 2 "`
                                            + " -et NVEnc "`
                                            + " -e `"$script:amatsukaze_directory\exe_files\NVEncC\NVEncC64.exe`" "`
                                            + " -j `"$script:work_directory\2-enc.json`" "`
                                            + " --mp4box `"$script:amatsukaze_directory\exe_files\mp4box.exe`" "`
                                            + " -t `"$script:amatsukaze_directory\exe_files\timelineeditor.exe`" "`
                                            + " -eo `"-c hevc --profile main10 --vbrhq 0 --vbr-quality 25 --gop-len 90 --cqp 20:23:25`" "`
                                            + " -fmt mp4 "`
                                            + " -m `"$script:amatsukaze_directory\exe_files\muxer.exe`" "`
                                            + " -bcm 0.5 "`
                                            + " --chapter "`
                                            + " -f `"$script:amatsukaze_directory\avscache\17E37FEB.avs`" "`
                                            + " --subtitles "`
                                            + " --jls-cmd `"$script:amatsukaze_directory\JL\JL_Standard.txt`" "`
                                            + " --mpeg2decoder CUVID "`
                                            + " --h264decoder CUVID "`
                                            + " --ignore-no-drcsmap "`
                                            + " --no-delogo "`
                                            + " --logo `"$script:amatsukaze_directory\logo\SID171-1.lgd`" "`
                                            + " --logo `"$script:amatsukaze_directory\logo\SID1032-1.lgd`" "`
                                            + " --logo `"$script:amatsukaze_directory\logo\SID1040-1.lgd`" "`
                                            + " --logo `"$script:amatsukaze_directory\logo\SID1048-1.lgd`" "`
                                            + " --logo `"$script:amatsukaze_directory\logo\SID1056-1.lgd`" "`
                                            + " --logo `"$script:amatsukaze_directory\logo\SID1064-1.lgd`" "`
                                            + " --logo `"$script:amatsukaze_directory\logo\SID1072-1.lgd`" "`
                                            + " --logo `"$script:amatsukaze_directory\logo\SID1072-2.lgd`" "`

[string]$script:remoteplay_commandline_nvenc= " -c h264 "`
                                            + " --avhw "`
                                            + " --profile main10 "`
                                            + " --vbrhq 0 "`
                                            + " --vbr-quality 25 "`
                                            + " --gop-len 90 "`
                                            + " --cqp 28:30:32 "`
                                            + " --max-bitrate 1000 "`
                                            + " --output-res 1280x720 "`
                                            + " --chapter-copy "`
                                            + " --audio-copy 1 "`
                                            + " --sub-copy "`

[int]$delay_shutdown = 180
[int]$recording_detection_duration = 1*60         #Unit:sec
[int]$copy_allow_size              = 30*1000*1000 #Unit:byte
[int]$copy_allow_size_remote_play  = 5*1000*1000  #Unit:byte

#########################
# Help
#########################
if( $help ) {
    Write-Host "NAME"
    Write-Host ""
    Write-Host "   $script:script_name - M2TSファイルをCMを抜いたMP4形式に変換しNASへコピーする"
    Write-Host ""
    Write-Host "SYPNOSYS"
    Write-Host ""
    Write-Host "   $script:script_name [-amatsukaze_directory <directory path>]"
    Write-Host "                       [-backup_m2ts] [-convert_to_mp4] [-copy_to_nas] [-generate_remoteplay] [-shutdown] [-strict]"
    Write-Host "                       [-work_directory <directory path>] [-log_file <file path>] "
    Write-Host ""
    Write-Host "DESCRIPTION"
    Write-Host ""
    Write-Host "   -amatsukaze_directory"
    Write-Host "        Amatsukazeがインストールされているディレクトリを指定する。指定がない場合はデフォルトパスが設定される "
    Write-Host "        デフォルトパス:${script:script_path}"
    Write-Host ""
    Write-Host "   -work_directory"
    Write-Host "        Amatsukazeの一時ファイルの保存ディレクトリを指定する(高速なSSDを推奨)。指定がない場合はデフォルトパスが設定される "
    Write-Host "        デフォルトパス:${script:script_path}\temp"
    Write-Host ""
    Write-Host "   -backup_m2ts"
    Write-Host "        M2TSファイルのバックアップコピーを実行する。"
    Write-Host "        コピー元:${script:source_directory_root}"
    Write-Host "        コピー先:${script:backup_directory_root}"
    Write-Host ""
    Write-Host "   -backup_m2ts_within_day <days>"
    Write-Host "        M2TSファイルをコピーする際に、ここで指定された日数を経過したものはバックアップから除外する。0の場合はすべてコピーする"
    Write-Host ""
    Write-Host "   -convert_to_mp4"
    Write-Host "        M2TSファイルのバックアップコピー元にあるファイルからCMを抜いてMP4へ変換する "
    Write-Host "        コピー元:${script:backup_directory_root}"
    Write-Host "        コピー先:${script:convert_directory_root}"
    Write-Host ""
    Write-Host "   -copy_to_nas"
    Write-Host "        変換したMP4ファイルをNASへコピーする"
    Write-Host "        コピー元:${script:convert_directory_root}"
    Write-Host "        コピー先:${script:nas_directory_root}"
    Write-Host ""
    Write-Host "   -generate_remoteplay"
    Write-Host "        変換したMP4ファイルをRemotePlay用にサイズを縮小して変換しNASへコピーする "
    Write-Host "        コピー元:${script:convert_directory_root}"
    Write-Host "        変換先 :${script:remote_play_directory_root}"
    Write-Host "        コピー先:${script:remote_play_directory_nas_root}"
    Write-Host ""
    Write-Host "   -remoteplay_rename"
    Write-Host "        RemotePlay用に録画日をファイル名の先頭から後ろに移動酢する[<ファイル名>(YYYYMMDD)] "
    Write-Host "        ※m2tsのファイル名は'YYYYMMDD <ファイル名>'である必要がある"
    Write-Host ""
    Write-Host "   -quiet"
    Write-Host "        処理開始前の確認をスキップします"
    Write-Host ""
    Write-Host "   -delete"
    Write-Host "        PC上にないファイルをNASから削除します。quietオプションは無視されます"
    Write-Host ""
    Write-Host "   -shutdown"
    Write-Host "        変換終了後、${delay_shutdown}秒後にPCをシャットダウンする"
    Write-Host ""
    exit
}

###################
# Enumuration
###################
enum OutputLogType {
    Info
    Warning
    Error
    Debug
    NoType
}

###################
# Class
###################

###################
# Function
###################
function OutputLog {
    Param(
        [OutputLogType]$type = [OutputLogType]::NoType,
        [string]$function = $null,
        [string]$message        
    )

    [string]$output = ""

    if([string]::IsNullOrEmpty($function)){
        $output += [string]::Format("[${script:script_name}] " )
    } else {
        $output += [string]::Format("[${script:script_name}:${function}] " )
    }

    $output += [string]::Format("{0} : ", ((Get-Date).ToString("yyyy/MM/dd HH:mm:ss")) )

    if( $type -ne [OutputLogType]::NoType ){
        $output += [string]::Format("{0} : ", $type )
    }

    $output += [string]::Format("{0} ", $message )

    if([string]::IsNullOrEmpty($script:log_file_path)){
        Write-Output $output
    } else {
        Write-Output $output | Tee-Object -Append -FilePath $script:log_file_path
    }
}

function ConvertPath([string]$filepath){
    
    [string]$directory = ( Split-Path -Parent $filepath ) 
    [string]$filename = ( Split-Path -Leaf $filepath ) 

    [string]$modified_filename = $filename.Replace('[','`[').Replace(']','`]')

    return $directory+"\" + $modified_filename + $filename_extention
}

function GetDurationString([TimeSpan]$duration){
    [string]$duration_string=""

    if($duration.TotalSeconds -lt 60){
        $duration_string=($duration.TotalMilliseconds/1000).ToString("0.00")+" sec."
    } else {
        $duration_string+=($duration.TotalSeconds.ToString("0")/60).ToString("0")+" min. "
        $duration_string+=($duration.TotalSeconds.ToString("0")%60).ToString("0")+" sec. "
    }

    return $duration_string
}

function BackupM2ts {
    Param(
        [string[]]$sources,
        [string[]]$tentatives,
        [string]$destination
    )

    [string]$output_message = "Start backup"
    foreach( $source in $sources )
    {
        $output_message += "`n  - ${source}"
    }

    OutputLog -type Info -function "BackupM2ts" -message $output_message
        
    [string[]]$copy_files = @()
    [string[]]$destination_files = @()
    foreach( $source in $sources )
    {
        $m2ts_files = Get-ChildItem "$source" -Recurse -Filter *.m2ts -Exclude "lost+found"

        foreach( $m2ts_file in $m2ts_files ){

            if( !$m2ts_file.Name.StartsWith("_") ){

                [bool]$copy_flag = $false

                $destination_file_path = ConvertPath($m2ts_file.FullName.Replace($source,$destination))
        
                if(-Not(Test-Path $destination_file_path) ) {
                    $copy_flag = $true
                } elseif( ( (Get-Item $destination_file_path).Length -gt $m2ts_file.Length ) ){
                    $copy_flag = $true
                }

                if($copy_flag)
                {
                    [string]$temp_file_path = (Split-Path -Parent ${destination_file_path}) + "\TEMP_" +(Split-Path -Leaf ${destination_file_path})
                    if( Test-Path( $temp_file_path) ){
                        Remove-Item -Path ${temp_file_path}
                    }

                    [timespan]$time_diff = [DateTime]::Now - $m2ts_file.LastWriteTime
                    if( $time_diff.TotalSeconds -lt $recording_detection_duration ){
                        $copy_flag = $false
                    } elseif ( (${backup_m2ts_within_day} -gt 0 ) -and ( $time_diff.TotalDays -ge $backup_m2ts_within_day ) ){
                        $copy_flag = $false
                    }
                    else 
                    {
                        $find_flag = $false
                        foreach( $destination_file in $destination_files )
                        {
                            if( ( Split-Path -Leaf $m2ts_file ).Equals( ( Split-Path -Leaf $destination_file ) ) )
                            {
                                $find_flag = $true
                                break
                            }
                        }

                        if( !$find_flag )
                        {
                            $copy_files += $m2ts_file.FullName
                            $destination_files += $m2ts_file.FullName.Replace($source,$destination)
                        }
                    }
                } 
            }
        }
    }

    foreach( $tentative in $tentatives )
    {
        $m2ts_files = Get-ChildItem "$tentative" -Recurse -Filter *.m2ts -Exclude "lost+found"

        $tentative_parent = (Split-Path -Parent $tentative)

        foreach( $m2ts_file in $m2ts_files ){

            if( !$m2ts_file.Name.StartsWith("_") ){

                [bool]$copy_flag = $false

                $destination_file_path = ConvertPath($m2ts_file.FullName.Replace($tentative_parent,$destination))
        
                if(-Not(Test-Path $destination_file_path) )
                {
                    $copy_flag = $true
                } elseif( (Get-Item $destination_file_path).Length -ne $m2ts_file.Length ){
                    $copy_flag = $true
                }

                if($copy_flag)
                {
                    [timespan]$time_diff = [DateTime]::Now - $m2ts_file.LastWriteTime
                    if( $time_diff.TotalSeconds -lt $recording_detection_duration ){
                        $copy_flag = $false
                    } elseif ( (${backup_m2ts_within_day} -gt 0 ) -and ( $time_diff.TotalDays -ge $backup_m2ts_within_day ) ){
                        $copy_flag = $false
                    }
                    else
                    {
                        $find_flag = $false
                        foreach( $destination_file in $destination_files )
                        {
                            if( ( Split-Path -Leaf $m2ts_file ).Equals( ( Split-Path -Leaf $destination_file ) ) )
                            {
                                $find_flag = $true
                                break
                            }
                        }

                        if( !$find_flag )
                        {
                            $copy_files += $m2ts_file.FullName
                            $destination_files += $m2ts_file.FullName.Replace($tentative_parent,$destination)
                        }
                    }
                } 
            }
        }
    }

    if( $copy_files.Count -gt 0 )
    {
        [string]$console_output = "以下のファイルをバックアップします。"
        foreach( $copy_file in $copy_files ){
            $console_output += ( "`n  - ${copy_file}" )
        }
        OutputLog -type Info -function "BackupM2ts" -message $console_output

        if( !$script:quiet_flag )
        {
            $response = ( Read-Host "よろしいですか?([Y]es/[n]o/[q]uiet)" )
            switch($response)
            {
                "" {}
                "y" {}
                "q" { 
                    $script:quiet_flag = $true
                }
                default { return; }
            }
        }
    }

    for( [int]$i=0; $i -lt $copy_files.Count; $i++ )
    {
        BackupM2ts_OneFile -source $copy_files[$i] -destination $destination_files[$i]
    }
    
    OutputLog -type Info -function "BackupM2ts" -message "Finish backup"
}

function BackupM2ts_OneFile {
    Param(
        [string]$source,
        [string]$destination
    )

    [string]$source_file_name = ( Split-Path -Leaf $source ) 
    [string]$source_file_path = ConvertPath($source)

    [string]$destination_directory = Split-Path -Parent $destination
    if( -Not(Test-Path(${destination_directory}))){
        New-Item -Type Directory ($destination_directory) > $null
    }

    if( Test-Path(ConvertPath(${destination})) ){
        Remove-Item -Path (ConvertPath(${destination}))
    }

    [UInt64]$source_size = (Get-Item $source_file_path).Length
    OutputLog -type Info -function "BackupM2ts_OneFile" -message ( "Copy `""+$source_file_name+"`" ("+($source_size/1000/1000/1000).ToString("0.00")+"GB) ..." )

    [DateTime]$start_time = [DateTime]::Now
    if( $debug ){ OutputLog -type Debug -function "BackupM2ts_OneFile" -message "Copy-Item -Path $source_file_path -Destination $destination_directory -Force" }
    [string]$temp_destination_path = ("$destination_directory"+"\TEMP_"+$source_file_name)
    if( Test-Path(ConvertPath(${temp_destination_path})) ){
        Remove-Item -Path (${temp_destination_path})
    }
    Copy-Item -Path "$source_file_path" -Destination ${temp_destination_path} -Force
    if( Test-Path(ConvertPath(${destination})) ){
        Remove-Item -Path (ConvertPath(${destination}))
    }
    Move-Item -Path  (ConvertPath(${temp_destination_path})) -Destination ${destination} -Force
    [TimeSpan]$duration=[DateTime]::Now-$start_time
    OutputLog -type Info -function "BackupM2ts_OneFile" -message ( "Copy duration : " + (GetDurationString($duration)) )

    [string]$destination_file_path = ConvertPath($destination)
    if(-Not(Test-Path($destination_file_path))){
        OutputLog -type Error -function "BackupM2ts_OneFile" -message ( "Fail to backup : " + $source_file_name  )
    }
}

function ConvertToMp4 {
    Param(
        [string]$source,
        [string]$destination
    )

    OutputLog -type Info -function "ConvertToMp4" -message "Start converting"

    $m2ts_files = Get-ChildItem "$source" -Recurse -Filter *.m2ts

    $convert_files = @()
    foreach( $m2ts_file in $m2ts_files ){

        [bool]$convert_flag = $false

        if( !$m2ts_file.Name.StartsWith("_") -and !$m2ts_file.Name.StartsWith("TEMP_") )
        {
            [string]$destination_file_path = ConvertPath($m2ts_file.FullName.Replace($source,$destination)).Replace(".m2ts",".mp4")

            if( -Not(Test-Path $destination_file_path) ){
                $convert_flag = $true
            } elseif( (Get-Item $destination_file_path).LastWriteTime -lt $m2ts_file.CreationTime ){
                $convert_flag = $true
            } elseif( Test-Path( ConvertPath((Split-Path -Parent ${destination_file_path}) + "\TEMP_" +(Split-Path -Leaf ${destination_file_path}) ) ) ){
                $convert_flag = $true
            }
        }

        if($convert_flag){
            [string]$temp_file_path = (Split-Path -Parent ${destination_file_path}) + "\TEMP_" +(Split-Path -Leaf ${destination_file_path})
            if( Test-Path( $temp_file_path) ){
                Remove-Item -Path ${temp_file_path}
            }

            $convert_files += $m2ts_file
        }
    }

    if( $convert_files.Count -gt 0 )
    {
        [string]$console_output = "以下のファイルをMP4へ変換します。"
        foreach( $convert_file in $convert_files ){
            $console_output += ( "`n  - " + (Split-Path -Leaf $convert_file) )
        }
        OutputLog -type Info -function "ConvertToMp4" -message $console_output

        if( !$script:quiet_flag )
        {
            $response = ( Read-Host "よろしいですか?([Y]es/[n]o/[q]uiet)" )
            switch($response)
            {
                "" {}
                "y" {}
                "q" { 
                    $script:quiet_flag = $true
                }
                default { return; }
            }
        }
    }

    foreach( $convert_file in $convert_files )
    {
        ConvertToMp4_OneFile -source $convert_file.FullName -destination $convert_file.FullName.Replace($source,$destination).Replace(".m2ts",".mp4")
    }

    OutputLog -type Info -function "ConvertToMp4" -message "Finish converting"

    Remove-Item "${script:work_directory}/*" -Exclude .gitkeep -Recurse -Force > $null
}

function ConvertToMp4_OneFile {
    Param(
        [string]$source,
        [string]$destination
    )

    [string]$source_file_name = ( Split-Path -Leaf $source ) 

    [string]$destination_directory = (Split-Path -Parent $destination)
    if( -Not(Test-Path(${destination_directory}))){
        New-Item -Type Directory ${destination_directory} > $null
    }

    OutputLog -type Info -function "ConvertToMp4_OneFile" -message "Convert `"$source_file_name`" ..."

    [DateTime]$start_time_convert = [DateTime]::Now

    if( Test-Path(ConvertPath(${destination})) ){
        Remove-Item -Path (ConvertPath(${destination}))
    }
    if( Test-Path(ConvertPath(${destination}).Replace(".mp4",".ass")) ){
        Remove-Item -Path (ConvertPath(${destination}).Replace(".mp4",".ass"))
    }
    [string]$temp_file = (Split-Path -Parent ${destination}) + "\TEMP_" +(Split-Path -Leaf ${destination})

    [string]$argument_list = $script:convert_to_mp4_commandline
    $argument_list += " --input `"${source}`" "
    $argument_list += " --output `"${temp_file}`" "
    OutputLog -type Info -function "ConvertToMp4_OneFile" -message "Start-Process $script:amatsukaze_cli_exe -ArgumentList `"$argument_list`" -Wait"
    Start-Process $script:amatsukaze_cli_exe -ArgumentList "$argument_list" -Wait

    Move-Item -Path (ConvertPath(${temp_file})) -Destination (ConvertPath(${destination}))
    if( Test-Path(ConvertPath(${temp_file}).Replace(".mp4",".ass")) ){
        Move-Item -Path (ConvertPath(${temp_file}).Replace(".mp4",".ass")) -Destination (ConvertPath(${destination}).Replace(".mp4",".ass"))
    }
        
    [TimeSpan]$duration_convert=[DateTime]::Now-$start_time_convert
    OutputLog -type Info -function "ConvertToMp4_OneFile" -message ( "Convert duration : " + (GetDurationString($duration_convert) ) )
    
    [string]$destination_file_path = ConvertPath($destination)
    if(-Not(Test-Path($destination_file_path))){
        OutputLog -type Error -function "ConvertToMp4_OneFile" -message ( "Fail to convert : " + $source_file_name )
    }
}

function CopyToNas {
    Param(
        [string]$source,
        [string]$destination,
        [int]$minimum_copy_size
    )

    OutputLog -type Info -function "CopyToNas" -message "Start copying to NAS"

    $mp4_files = Get-ChildItem "$source" -Recurse -Filter *.mp4

    $copy_files = @()
    foreach( $mp4_file in $mp4_files ){

        [bool]$copy_flag = $false

        if( !$mp4_file.Name.StartsWith("_")-and !$mp4_file.Name.StartsWith("TEMP_") )
        {
            [string]$destination_file_path = ConvertPath($mp4_file.FullName.Replace($source,$destination))

            if(-Not(Test-Path $destination_file_path) ){
                $copy_flag = $true
            } elseif( (Get-Item $destination_file_path).Length -ne $mp4_file.Length ){
                $copy_flag = $true
            }

            if($copy_flag)
            {
                if( $mp4_file.Length -gt $minimum_copy_size )
                {
                    $copy_files += $mp4_file
                }
            }
        }
    }

    if( $copy_files.Count -gt 0 )
    {
        [string]$console_output = "以下のファイルをNASへコピーします。"
        foreach( $copy_file in $copy_files ){
            $console_output += ( "`n  - ${copy_file}" )
        }
        OutputLog -type Info -function "CopyToNas" -message $console_output

        if( !$script:quiet_flag )
        {
            $response = ( Read-Host "よろしいですか?([Y]es/[n]o/[q]uiet)" )

            $response = ( Read-Host $console_output ).Trim()
            switch($response)
            {
                "" {}
                "y" {}
                "q" { 
                    $script:quiet_flag = $true
                }
                default { return; }
            }
        }
    }

    foreach( $copy_file in $copy_files )
    {
        CopyToNas_OneFile -source $copy_file.FullName -destination $copy_file.FullName.Replace($source,$destination)
    }

    OutputLog -type Info -function "CopyToNas" -message "Finish copying to NAS"
}

function CopyToNas_OneFile {
    Param(
        [string]$source,
        [string]$destination
    )

    [string]$source_file_path = ConvertPath($source)
    [string]$source_file_name = ( Split-Path -Leaf $source ) 

    [string]$destination_directory = Split-Path -Parent $destination
    if( -Not(Test-Path(${destination_directory}))){
        New-Item -Type Directory ($destination_directory) > $null
    }

    [UInt64]$source_size = (Get-Item $source_file_path).Length
    OutputLog -type Info -function "CopyToNas_OneFile" -message ( "Copy `""+$source_file_name+"`" ("+($source_size/1000/1000/1000).ToString("0.00")+"GB) ..." )

    [DateTime]$start_time = [DateTime]::Now
    if( $debug ){ OutputLog -type Debug -function "CopyToNas_OneFile" -message "Copy-Item -Path $source_file_path -Destination $destination_directory -Force" }

    [string]$temp_destination_path = ("$destination_directory"+"\TEMP_"+$source_file_name)
    if( Test-Path(ConvertPath(${temp_destination_path})) ){
        Remove-Item -Path (${temp_destination_path})
    }
    Copy-Item -Path "$source_file_path" -Destination ${temp_destination_path} -Force
    if( Test-Path(ConvertPath(${destination})) ){
        Remove-Item -Path (ConvertPath(${destination}))
    }
    Move-Item -Path  (ConvertPath(${temp_destination_path})) -Destination ${destination} -Force

    if( Test-Path($source_file_path.Replace(".mp4",".ass")) ){
        Copy-Item -Path $source_file_path.Replace(".mp4",".ass") -Destination $destination_directory -Force
    }
    [TimeSpan]$duration=[DateTime]::Now-$start_time
    OutputLog -type Info -function "CopyToNas_OneFile" -message ( "Copy duration : " + (GetDurationString($duration))  )

    [string]$destination_file_path = ConvertPath($destination)
    if(-Not(Test-Path($destination_file_path))){
        OutputLog -type Error -function "CopyToNas_OneFile" -message ( "Fail to convert : " + $source_file_name  )
    }
}

function GenerateRemotePlay_ConvertFileName {
    Param(
        [string]$filepath
    )

    if(!$remoteplay_rename)
    {
        return $filepath
    }

    [string]$directory = ( Split-Path -Parent $filepath ) 
    [string]$filename = ( Split-Path -Leaf $filepath ) 
    [string]$filename_body = [System.IO.Path]::GetFileNameWithoutExtension($filename) 
    [string]$filename_extention = [System.IO.Path]::GetExtension($filename) 

    [string]$date = $filename_body.Substring(0,8)

    [string]$modified_filename_body = ( $filename_body.Substring(9) -Replace "\[.\]", "" )+ "(" + $date + ")"

    return $directory+"\" + $modified_filename_body + $filename_extention
}

function GenerateRemotePlay {
    Param(
        [string]$source,
        [string]$destination
    )

    OutputLog -type Info -function "GenerateRemotePlay" -message "Start generating remote play file"

    $mp4_files = Get-ChildItem "$source" -Recurse -Filter *.mp4

    [string[]]$convert_files = @()
    [string[]]$destination_files = @()
    foreach( $mp4_file in $mp4_files )
    {
        [bool]$convert_flag = $false
        
        if( !$mp4_file.Name.StartsWith("_") -and !$mp4_file.Name.StartsWith("TEMP_") ){

            [string]$destination_file_path = GenerateRemotePlay_ConvertFileName($mp4_file.FullName.Replace($source,$destination))

            if( -Not(Test-Path (ConvertPath($destination_file_path)) )){
                $convert_flag = $true
            } elseif( (Get-Item (ConvertPath($destination_file_path)) ).LastWriteTime -lt $mp4_file.LastWriteTime ){
                $convert_flag = $true
            } elseif( Test-Path( ConvertPath((Split-Path -Parent (ConvertPath(${destination_file_path}))) + "\TEMP_" +(Split-Path -Leaf ${destination_file_path}) ) ) ){
                $convert_flag = $true
            }
        
            if($convert_flag)
            {
                if( $mp4_file.Length -gt $copy_allow_size )
                {
                    [string]$temp_file_path = (Split-Path -Parent ${destination_file_path}) + "\TEMP_" +(Split-Path -Leaf (GenerateRemotePlay_ConvertFileName($mp4_file.FullName)))
                    if( Test-Path( ConvertPath($temp_file_path)) ){
                        Remove-Item -Path (ConvertPath(${temp_file_path}))
                    }
        
                    $convert_files += $mp4_file.FullName
                    $destination_files += $mp4_file.FullName.Replace($source,$destination)
                }
            }
        }
    }

    if( $convert_files.Count -gt 0 )
    {
        [string]$console_output = "以下のファイルをリモート視聴用ファイルに変換します。"
        foreach( $convert_file in $convert_files )
        {
            $console_output += ( "`n  - " + (Split-Path -Leaf $convert_file) + "`n      -> " + (Split-Path -Leaf (GenerateRemotePlay_ConvertFileName($convert_file))) )
        }
        OutputLog -type Info -function "GenerateRemotePlay" -message $console_output

        if( !$script:quiet_flag )
        {
            $response = ( Read-Host "よろしいですか?([Y]es/[n]o/[q]uiet)" )
            switch($response)
            {
                "" {}
                "y" {}
                "q" { 
                    $script:quiet_flag = $true
                }
                default { return; }
            }
        }
    }

    for( [int]$i=0; $i -lt $convert_files.Count; $i++ )
    {
        GenerateRemotePlay_OneFile -source $convert_files[$i] -destination $destination_files[$i]
    }
    
    Remove-Item "${script:work_directory}/*" -Exclude .gitkeep -Recurse -Force > $null

    OutputLog -type Info -function "GenerateRemotePlay" -message "Finish generating remote play file"
}

function GenerateRemotePlay_OneFile {
    Param(
        [string]$source,
        [string]$destination
    )

    [string]$source_file_name = ( Split-Path -Leaf $source ) 
    [string]$source_file_path = ConvertPath($source)

    [string]$destination_directory = (Split-Path -Parent $destination)
    if( -Not(Test-Path(${destination_directory}))){
        New-Item -Type Directory ${destination_directory} > $null
    }

    OutputLog -type Info -function "GenerateRemotePlay_OneFile" -message "Convert `"$source_file_name`" ..."
    [DateTime]$start_time_convert = [DateTime]::Now

    if( Test-Path(ConvertPath(${destination})) ){
        Remove-Item -Path (ConvertPath(${destination}))
    }

    if( Test-Path(ConvertPath(${destination}).Replace(".mp4",".ass")) ){
        Remove-Item -Path (ConvertPath(${destination}).Replace(".mp4",".ass"))
    }

    [string]$temp_file = (Split-Path -Parent ${destination}) + "\TEMP_" +(Split-Path -Leaf ${destination})

    [string]$argument_list = $script:remoteplay_commandline_nvenc
    $argument_list += " --input `"${source}`" "
    $argument_list += " --output `"${temp_file}`" "
    OutputLog -type Info -function "GenerateRemotePlay_OneFile" -message "Start-Process $script:nvencc64_exe -ArgumentList `"$argument_list`" -Wait"
    Start-Process $script:nvencc64_exe -ArgumentList "$argument_list" -Wait

    Move-Item -Path (ConvertPath(${temp_file})) -Destination (ConvertPath(${destination}))
    if( Test-Path($source_file_path.Replace(".mp4",".ass")) ){
        Copy-Item -Path $source_file_path.Replace(".mp4",".ass") -Destination $destination.Replace(".mp4",".ass") -Force
    }

    [TimeSpan]$duration_convert=[DateTime]::Now-$start_time_convert
    OutputLog -type Info -function "GenerateRemotePlay_OneFile" -message ( "Convert duration : " + (GetDurationString($duration_convert) ) )
    
    [string]$destination_file_path = ConvertPath($destination)
    if(-Not(Test-Path($destination_file_path))){
        OutputLog -type Error -function "GenerateRemotePlay_OneFile" -message ( "Fail to convert : " + $source_file_name )
    }
}

function DeleteFiles {
    Param(
        [string]$source,
        [string]$destination
    )

    OutputLog -type Info -function "DeleteFiles" -message "Start deleting files from share folder"

    $mp4_files = Get-ChildItem "$destination" -Recurse -Filter *.mp4

    [string[]]$delete_files = @()
    foreach( $mp4_file in $mp4_files ){

        if( !$mp4_file.Name.StartsWith("_") ){

            $source_file_path = ConvertPath($mp4_file.FullName.Replace($destination,$source))
    
            if(-Not(Test-Path $source_file_path) ) {
                $delete_files += $mp4_file.FullName
            }
        }
    }

    if( $delete_files.Count -gt 0 )
    {
        [string]$console_output = "以下のファイルを共有フォルダから削除します。`n"
        foreach( $delete_file in $delete_files )
        {
            $console_output += ( "  - " + (Split-Path -Leaf $delete_file) + "`n" )
        }
        $console_output += "よろしいですか?([y]es/[N]o)"

        $response = ( Read-Host $console_output ).Trim()
        switch($response)
        {
            "y" {}
            default { return; }
        }
    }

    foreach( $delete_file in $delete_files )
    {
        Remove-Item -Path (ConvertPath($delete_file))
        if( Test-Path(ConvertPath($delete_file).Replace(".mp4",".ass")) ){
            Remove-Item -Path (ConvertPath($delete_file).Replace(".mp4",".ass"))
        }

        [string]$parent_folder_path = (Split-Path -Parent $delete_file)
        [int]$number_of_mp4_file = (Get-ChildItem $parent_folder_path -Recurse -Filter *.mp4 -ErrorAction Stop | Measure-Object).Count
        if ( $number_of_mp4_file -eq 0){  
            Remove-Item -Path (ConvertPath($parent_folder_path)) -Force -Recurse
        } 
    }
}

#########################
# Parameter check
#########################
if( -Not(Test-Path "${script:amatsukaze_directory}") ){
    OutputLog -type Error -function "ParameterCheck" -message ( "No Amatsukaze folder '"+${script:amatsukaze_director}+"'")
    exit
}

if( $backup_m2ts )
{
    foreach( $source_directory in ${script:source_directory_root} )
    {
        if( -Not(Test-Path "${source_directory}") ){
            OutputLog -type Error -function "ParameterCheck" -message ( "No m2ts folder '"+${source_directory}+"'")
            exit
        }
    }

    foreach( $tentative_directory in ${script:tentative_directory_root} )
    {
        if( -Not(Test-Path "${tentative_directory}") ){
            OutputLog -type Error -function "ParameterCheck" -message ( "No m2ts folder '"+${tentative_directory_root}+"'")
            exit
        }
    }
}

if( $copy_to_nas -and -Not(Test-Path "${script:nas_directory_root}") ){
    OutputLog -type Error -function "ParameterCheck" -message ( "No NAS folder '"+${script:nas_directory_root}+"'")
    exit
}

if( $generate_remoteplay -and -Not(Test-Path "${script:remote_play_directory_nas_root}") ){
    OutputLog -type Error -function "ParameterCheck" -message ( "No NAS folder '"+${script:remote_play_directory_nas_root}+"'")
    exit
}

if( -Not(Test-Path "${script:work_directory}") ){
    New-Item -Type Directory ${script:work_directory} > $null
    if( -Not(Test-Path "${script:work_directory}") ){
        OutputLog -type Error -function "ParameterCheck" -message ( "Cannot create work directory '"+${script:work_directory}+"'")
        exit
    }
}

$log_file_dir = ( Split-Path -Parent ${script:log_file_path} )
if( -Not(Test-Path (${log_file_dir}) ) ){
    New-Item -Type Directory ${log_file_dir} > $null
}

#########################
# Main
#########################
OutputLog -type Info -message "Start processing"

Remove-Item "${script:work_directory}/*" -Exclude .gitkeep -Recurse -Force > $null

# Backup m2ts
if( $backup_m2ts ){
    if( -Not(Test-Path "${script:backup_directory_root}") ){
        New-Item -Type Directory ${script:backup_directory_root} > $null
    }

    BackupM2ts -sources ${script:source_directory_root} -tentatives ${script:tentative_directory_root} -destination ${script:backup_directory_root}
}

# convert
if( $convert_to_mp4 ){
    if( -Not(Test-Path "${script:convert_directory_root}") ){
        New-Item -Type Directory ${script:convert_directory_root} > $null
    }
    ConvertToMp4 -source "$script:backup_directory_root" -destination "$script:convert_directory_root"
}

# Copy to NAS
if( $copy_to_nas ){
    CopyToNas -source "$script:convert_directory_root" -destination "$script:nas_directory_root" -minimum_copy_size $copy_allow_size
}

# convert
if( $generate_remoteplay ){
    if( -Not(Test-Path "${script:remote_play_directory_root}") ){
        New-Item -Type Directory ${script:remote_play_directory_root} > $null
    }
    GenerateRemotePlay -source "$script:convert_directory_root" -destination "$script:remote_play_directory_root"
    CopyToNas -source "$script:remote_play_directory_root" -destination "$script:remote_play_directory_nas_root" -minimum_copy_size $copy_allow_size_remote_play
}

# delete
if( $delete ){
    DeleteFiles -source "$script:convert_directory_root" -destination "$script:nas_directory_root"
    DeleteFiles -source "$script:remote_play_directory_root" -destination "$script:remote_play_directory_nas_root"
}

#shutdown
if( $shutdown ){
    for( [int]$i=$delay_shutdown;$i -gt 0;$i-=10){
        Write-Output ([string]::Format("[$script:script_name] {0} : Info : Shutdown in ${i} sec.", ((Get-Date).ToString("yyyy/MM/dd HH:mm:ss")) ) ) | Tee-Object -Append -FilePath $script:log_file_path
        Start-Sleep -s 10
    }
    Write-Output ([string]::Format("[$script:script_name] {0} : Info : Shutdown", ((Get-Date).ToString("yyyy/MM/dd HH:mm:ss")) ) ) | Tee-Object -Append -FilePath $script:log_file_path
    shutdown.exe /s -t 5
}

OutputLog -type Info -message "Finish processing"

RasberryPi 4でTV録画サーバーを作る

※内容は2023/10/28時点で実行している手順です。記載している情報は随時アップデートし、過去に行っていた不必要な設定情報は全て削除していますので、あらかじめご了承ください。

RasberryPi 4で地デジ4ch同時TV録画サーバーを仕立て上げました。

過日HDDレコーダーの故障によりバックアップ不能な5年分の録画データをロストした際に結構なショックを受けたので、二度とこのような思いをしないように録画データをバックアップできる環境が欲しくなったというのが発端です。

そこで、ネットにある情報を頼りに機材を購入し、セットアップしてみたところ、市販のHDDレコーダーよりもはるかに便利であることがわかりました。具体的には以下の点でメリットがあります。

  • 録画データを他のストレージにバックアップできるようになる
  • 録画データからCMを削除でき、MP4ファイルへ変換することで最終保存ファイルの再生時間とファイルサイズを自在に縮小できる
  • 録画データの再生方法に縛りがない
  • 4chの地デジ受信ができ、録画ファイルを再生する際にはチューナーを利用しないので、同時視聴・同時録画による視聴制限を受けることが事実上発生しない
  • 録画予約やファイル整理に専用アプリを必要とせず、ブラウザ上の広い画面で操作でき、文字の入力にPC・スマホ・タブレットのキーボードを使えるので操作性がいい
  • スマホやタブレットに録画番組をローカルに保持して再生できる
  • 一度セットアップしてしまえばメンテナンスフリーでHDDレコーダー並に安定稼働する
  • ハード故障時の復旧も故障箇所のハードウェアの入れ替えでほぼ完了できる
  • 待機電力は4W、録画時も8W弱程度で消費電力が低い
  • 設置面積が市販HDDレコーダーの1/4以下で済む

NHKニュース・全映画・全ドラマを自動録画し、リアルタイム視聴をして負荷をかけて1週間動作させてみたところ動作は非常に安定しているので、今後市販のHDDレコーダーに戻ることはなさそうです。

しかし、これらの設定と手順はネット上に散逸しており、検索をしても古い情報が出てきたりするので、後日同じ設定をしようとしても時間がかかります。そこで、検証を重ねて確定させたセットアップの最小手順、及び設定内容と設定の意味を忘れないようにメモしておきます。

目指すTV録画サーバー仕様について

RasberryPiで録画サーバーを組み上げるときに選択できる機能は数多くあるので野放図に設定をすると仕様が一向に固まらず、設定に終わりが見えなくなります。そこで、ここでは下記の条件に留意しつつ設定手順を記載しています。

  • 完全ヘッドレスで初期セットアップからメンテナンスまで行えるようにする
  • OSはmicroSDで運用し、外付けストレージには録画データのみを保存する
  • OSが書かれているmicroSDへの書き込みを極力抑えて、microSDの故障率を下げるようにする
  • 設定値はできるだけデフォルトのものを使用し、一から復旧する際の手順を単純化する
  • RasberryPi上での処理はm2tvでのTV録画と録画データの外部転送のみとする。エンコードなどCPUに負荷がかかる機能は使わない
  • DBの不整合などを避けるため、録画ファイルの管理はEPG stationでのみ行い、コンソールやSamba経由では行わない
  • 設定については安定稼働と管理コストの最小化を最優先事項とする

準備するもの

RasberryPiで録画サーバーを製作するにあたり、準備したものは以下のとおりです。

  • Rasberry Pi 4(8GB)
  • RasberryPi用ケース
  • 12V 20W充電器 & USB-Cケーブル(最大電流3A対応)
  • SSD/HDD(USB接続外付けストレージ)
  • 32GB 高耐久microSD
  • 4ch地デジチューナー
  • カードリーダー & B-CASカード

Rasberry Pi 4

RasberryPi 4はメモリサイズが1GB、2GB、4GB、8GBのものがあります。ネットの動作情報を見ると4GBのもので問題なく動作しているようなので、4GBのものでも動作すると思います。ただし、仮想メモリをOFFにした場合に安定動作するかはわからず、今後ソフトのアップデートなどでメモリを必要とすることもあるかもしれませんので、8GBのものを購入するほうが安心です。

RasberryPi 4本体の購入はMouserが一番安かったです。

RasberryPi用ケース

ケースとしては「Argon ONE Raspberry Pi 4 Case V2」と「DeskPi Pro」が候補になりました。

Rasberry Pi用として安価で売られている小型プラケースはファンの電源をGPIOから取っていて、冷却ファンが壊れて停止した時に急な電流値の上昇によりRasberryPiが過電流で故障することがあります。このことから安い小型プラケースでは、年単位で常時稼働させるには心許ないです。

また、Rasberry Piで録画中はそこそこ熱くなりますので、放熱性能と頑丈さを鑑みて金属ケースの「Argon ONE Raspberry Pi 4 Case V2」と「DeskPi Pro」を候補としました。

これらのケースはHDMIコネクタをフルサイズのものに変換するので、トラブル対応時にモバイルモニタに付属しているHDMIケーブルを使用してモバイルモニタに接続できる利点があります。

これらのケースは電源を常時通電状態にできるので、停電時復帰時に自動で再起動がかかります。もし電源ボタン付きのケースを録画がサーバーにする場合は、電源接続時に通電状態になるかを確認しましょう。

なお、発熱に関しては下記YouTube動画で検証してくれていて、冷却ファンでRasberryPiを冷やしていれば問題は発生しない、すなわち冷却ファンのあるケースであれば問題ないと判断できます。

Argon ONE Raspberry Pi 4 Case V2

録画用HDDにUSB接続の外付けHDDを利用する場合は、Argon ONE Raspberry Pi 4 Case V2がおすすめです。

https://argon40.com/products/argon-one-v2-case-for-raspberry-pi-4

Argon ONE Raspberry Pi 4 Case V2は安価なわりに頑丈な金属ケースで、放熱はこの金属ケースが担います。また、映像出力が標準サイズのHDMIなので、セットアップやトラブル時などにディスプレイを接続する場合、micro HDMIのケーブルを用意せずに済む特徴もあります。

稼働中にファンが動作することはほどんどなく、録画中に寝室に置いても動作しているかどうかわからないくらいでした。

私はこのケースを利用したので、以降の記載はこのケースの利用を前提としています。

こちらはMouserが一番安かったです。

DeskPi Pro

録画用HDDをRasberryPiと同じケースに入れて利用したい場合は、DeskPi Proがおすすめです。https://www.seeedstudio.com/DeskPi-Pro-Set-top-Box-For-Raspberry-Pi-4-p-4704.html

DeskPi ProはHDD/SSDを内蔵できることが特徴で、映像出力がArgon ONE Raspberry Pi 4 Case V2と同様に標準HDMIになります。ただし、値段は1万円以上でちょっと高いです。

DeskPi Proはケース内空間に余裕があり、RasberryPiには大きな放熱フィンが付けられ、放熱用のファンの電源をRasberryPiからとっていないので、連続運転環境用のケースとしては安心感があります。

また、DeskPi ProはRasberryPiにスタックする位置ではなく、横に並べる形でHDD/SSDを内蔵するのでRasberryPi本体からSSDの熱から離せる特徴もあります。SSDを内蔵できるケースは他にもありますが、SSDの発熱を考えると内部空間に余裕があるDeskPi Proが安心です。

録画中の動作音もArgon ONE Raspberry Pi 4 Case V2と同様に、寝室に置いても動作しているかどうかわからないくらいでした。

こちらもMouserが一番安かったです。

microSD

環境セットアップ後のmicro SDの使用ファイルサイズは8GB程度なので、microSDの容量は16GBあれば十分です。ここでは32GBのものを使用しており、容量は過剰にはなりますが、入手性が良く価格も手頃で偽物を掴まされる危険が少ない点で、32GBのものを選択しています。

なお、TV録画サーバーは長期間連続運用するので、熱と書き込み回数に対して耐久性のある高耐久のものが必須です。購入時はバックアップや故障に備えて2枚購入しておくといいです。

12V 20W充電器 & USB-Cケーブル

充電器はUSB-AタイプのものがDeskPi Proに付属しています。ただし、電源投入時の突入電流がわずかに足りないせいか、TVチューナーを接続した状態で再起動するとTVチューナーの動作が不安定になることを確認しています。

USB-Aタイプの充電器は規格上限が18WのためUSB-Aタイプのものは選択せず、USB-C出力の20WのUSB-C接続充電器を別途購入する必要があります。ダイソーでも700円程度で売っていますが、常時通電するものなので、Anker製など少しでも信頼性の高いものを選択する方が安心です。

充電ケーブルは3A対応のものであれば100均のもので問題はないです。

USB接続外付けストレージ

録画用ストレージは常に書き込み処理が走り故障のリスクが高いため、録画用ストレージは消耗品と考え、録画データの一時置き場扱いと考えたほうがいいです。このため、録画データはこまめにバックアップしておくようにします。

いつ壊れてもいいという前提で運用するので、きちんと動作すれば古いPCから抜き出したものを再利用するといいです。

ストレージとRasberry PiはUSBで接続するので、Rasberry Piからの電力を必要とする2.5インチHDDではなく、外部電源で動作する3.5インチHDDか消費電力の少ないSSDを使った方が録画サーバーの安定性の点で安心です。

録画データのファイルサイズは1時間あたり7.5GBになるため、100時間以上の録画データを保存できる様に1GB以上のHDD/SSDが使いやすいです。

4ch地デジチューナー

地デジチューナーは導入のしやすさの点でPLEX製のものを選択することになります。

我が家ではBS/CSは見ないので地デジ4chチューナーで十分です。ですのでPX-Q1UDを購入しました。

BSを録画する場合はPX-Q3U4を購入すればいいですし、動作に必要なドライバは同じようですので、後で追加購入しても差し替えるだけで動作すると思います。

カードリーダー & B-CASカード

カードリーダーはEPG stationにドライバが同梱されているSCR3310を利用するのが安心です。そのほかでもネット上で動作確認されているものであればなんでもいいと思います。

B-CASカードについてはは古いテレビやHDDレコーダーのものを使います。2160円でB-CASカードを入手する方法もあるようです。

実際に購入したもの

参考までに購入時の注文画面のキャプチャを貼っておきます。

ハードウェア組み立て

Rasberry Pi 4をArgon ONE Raspberry Pi 4 Case V2に取り付ける

説明書に従って接続し、ネジ止めすれば大丈夫です。ネジが多くあるので、無くさないように小皿を準備しておくと良いです。

ネットワーク接続方法

無線LAN接続では録画データをコピーするときの転送速度が遅すぎるため、ネットワーク接続は有線LAN接続一択です。

RasberryPi OSをセットアップする

セットアップはディスプレイがなくてもできますが、できるだけディスプレイは接続しておいた方がいいです。ディスプレイに起動メッセージが表示されるので、エラーがある場合にすぐに対処ができ、手戻りの発生を防げます。

動作確認ができたあとにディスプレイを外して安定動作確認をする方が、トラブルシュート時の効率がいいです。

SDカードにRasberryPi OSをインストールする

まずmicroSDをPCに挿して認識させます。

Rasberry Pi imagerでSDカードにOSイメージを書く前に、ウィンドウ右下の歯車アイコンを選択して表示されるAdvanced Optionを以下のように指定しておきます。

username(ユーザーアカウント名)はセキュリティの観点からデフォルトの’pi’は使用せず、’tv-recorder’としています。以降、ユーザーアカウント名は’tv-recorder’として記載します。

OSは「RASBERRY PI OS LITE(64-BIT)」を選択し、Storageには差し込んだmicroSDを選択して「WRITE」ボタンを押します。すると書き込みが始まり、2分程度で書き込みとVerifyが完了します。

書き込みが完了したら、microSDをDeskPi Proに差し込み電源を入れます。すると数回の再起動の後にログインプロンプトが画面に表示されます。

IPアドレスを固定する

電源を投入してLANに繋がったら、FingなどでネットスキャンをしてRasberry PiのIPアドレスとMacアドレスを特定します。

我が家ではクライアント側のIPアドレスはすべてDHCPで割り振るようにしており、IPアドレスを固定する必要がある機器(NASやWebサーバーなど)はルーターが固定のIPアドレスを渡すようにしています。Rasberry PiのMACアドレスはFingなどのLAN scannerを利用すれば特定できるので、このMACアドレスに対して固定IPを渡すように設定することで、OS再インストール時も常にRasberry Piに固定IPが割り当てられるようになります。

Kernel起動時にエラーが出ていないかを確認する

RasberryPiが起動したら、dmesgコマンドで起動時にKernelが致命的なエラーを吐いていないかを確認しましょう。

もしmicroSDカードに異常がある場合、dmesgの表示は以下のようになります。このmicroSDカードをOSディスクにした時は再起動時にカーネルパニックが頻発しました。このような場合はmicroSDを交換して対応します。

ロケールを日本語に変更する

RasberryPiのデフォルトロケールは’en-GB.UTF-8’です。録画ファイルのファイル名は日本語で表記されるため、このままではファイルリストを表示したときなどにファイル名が文字化けしてしまい、各種作業の確認が困難になります。日本語表記のファイル名が正しく表示できるようにするため、ロケールを’ja-JP.UTF8’に変更します。

変更には下記コマンドを実行して、ロケールを変更します。

$ sudo raspi-configCode language: Bash (bash)

RasPi configが起動したら、’5. Localisation Option > L1. Locale’を選択します。

Package configuration画面が表示されたら、’en_GB.UTF-8’のチェックを外し、’jp_JP.UTF-8’にチェックを入れ、OKを選択します。

RasPi configをクローズする際にデフォルトロケールの選択画面が表示されるので、’ja_JP.UTF-8’を選択してOKを選択します。

ロケール変更が終わったら、.bashrcの最終行にロケール宣言を追記します。

$ vi ~/.bashrc
---
...
export LC_CTYPE=ja_JP.UTF-8

必須ソフトをインストール・アップデートする

下記コマンドを実行してRasberry Pi OSをアップデートします。

$ sudo apt update
$ sudo apt upgrade -yCode language: Bash (bash)

下記コマンドを実行してgitをインストールします。

$ sudo apt install -y gitCode language: Bash (bash)

下記コマンドを実行してvimをアップデートします。

$ sudo apt --purge remove vim-common vim-tiny
$ sudo apt install vim
$ vi --version
VIM - Vi IMproved 8.2 (2019 Dec 12, compiled Oct 01 2021 01:51:08)
...Code language: Bash (bash)

録画データのフォルダ名・ファイル名は日本語になるため、コマンドラインでの録画ファイルの整理はかなりの手間になります。この問題を解決するため、FDもインストールしておきます。

$ sudo apt-get install fdcloneCode language: JavaScript (javascript)

また、意図せずにディスクがいっぱいになったときにディスクを占有しているファイルやフォルダを特定できるようにするため、ncduもインストールしておきます。

$ sudo apt-get install ncdu Code language: JavaScript (javascript)

ケース用のドライバをインストールする

Argon ONE PI4 V2

下記コマンドを実行して’Argon ONE PI4 V2 Power button & Fan control’をインストールします。実行後、常に動作していたファンの音がしなくなることが確認できます。

$ curl https://download.argon40.com/argon1.sh | bashCode language: JavaScript (javascript)

argonone-configでCPUの温度ごとにファンの速度を変えることができますが、これはデフォルトのままで良さそうです。

室温28度の条件下でCPU温度を計測したところ、何も動作させていない状態で42-45度、4ch同時録画中で52度くらいでした。ArgonOneの設定値は55度で10%、60度で55%、65度で100%でファンが駆動する設定で、CPUクロック低下は80度を超えた時ですので、デフォルト値で十分と判断しています。

Desk Pi Proの場合

下記コマンドを実行してファンのコントロール用ドライバをインストールします。インストーラーを実行すると再起動がかかり、ファンが停止していることがわかります。

$ cd ~
$ git clone https://github.com/DeskPi-Team/deskpi.git
$ cd ~/deskpi/
$ chmod +x install.sh
$ sudo ./install.shCode language: JavaScript (javascript)

ファンの動作温度についてはArgonよりも効果のあるCPUクーラーを搭載しファンで空冷もしているので、デフォルトの設定値で良いと思います。

Swapを無効化する

microSDを保護するため、下記コマンドを実行してSwapファイルによる書き込みを抑止します。

$ sudo swapoff --all
$ sudo systemctl stop dphys-swapfile
$ sudo systemctl disable dphys-swapfileCode language: Bash (bash)

設定完了後、下記コマンドを実行してSwapが作成されなくなったことを確認しましょう。

$ free -h
               total        used        free      shared  buff/cache   available
Mem:           7.6Gi       141Mi       7.3Gi       1.0Mi       216Mi       7.4Gi
Swap:             0B          0B          0BCode language: Bash (bash)

ログ出力先をRAMに変更する

microSDへの書き込みを減らすために/var/logへの書き込み先をRAMに変更するlog2ramをインストールします。

$ wget https://github.com/azlux/log2ram/archive/master.tar.gz -O log2ram.tar.gz
$ tar xf log2ram.tar.gz
$ cd log2ram-master
$ sudo ./install.shCode language: JavaScript (javascript)

インストールが完了したらResberry Piを再起動し、dfコマンドでlog2ramが動作していることを確認しましょう。

$ df
Filesystem      1K-blocks     Used  Available Use% Mounted on
...
log2ram            131072    14632     116440  12% /var/log
...Code language: Bash (bash)

※’sudo apt install log2ram’でインストールする手順が各種サイトで紹介されていますが、Rasberry Pi Lite(64bit)では起動時にエラーが出て動作しないため、上記手順でインストールする必要があります。

HDD/SSDをセットアップする

ここではSSD/HDDをフォーマットして読み書きができるようにします。バックアップ・入れ替え時の手間・運用時の柔軟性の観点から、セットアップは1パーティション構成としています。

HDD/SSDを接続してOSが認識できることを確認する

まず外付けストレージをUSBで接続します。次に下記コマンドを実行して、RasberryPi OS上からHDD/SSDが見えていることを確認します。

$ sudo fdisk -l
...
Disk /dev/sda: 931.51 GiB, 1000204886016 bytes, 1953525168 sectors
Disk model: MQ01ABD100      
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 4096 bytes
I/O size (minimum/optimal): 4096 bytes / 4096 bytes
... 

もし接続したHDD/SSDが表示されていない場合は、接続に問題がない、故障していないかなどを確認し、正しく表示されるようにする必要があります。

HDD/SSDの既存のパーティションを削除する

すでに作成されているパーティションがある場合は'fdisk -l'コマンド実行時に下記のように/dev/sda1~のように追加情報が表示されます。

$ sudo fdisk -l
...
Disk /dev/sda: 931.51 GiB, 1000204886016 bytes, 1953525168 sectors
Disk model: TOSHIBA MQ01ABD1
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0xd940675f

Device     Boot      Start        End    Sectors   Size Id Type
/dev/sda1             2048 1048578047 1048576000   500G 83 Linux
/dev/sda2       1048578048 1953525167  904947120 431.5G 83 Linux
...

この場合は下記のようにfdiskでdコマンドを実行して既存のパーティションを削除します。

$ sudo fdisk /dev/sda

Welcome to fdisk (util-linux 2.36.1).
Changes will remain in memory only, until you decide to write them.
Be careful before using the write command.

Command (m for help): d
Partition number (1,2, default 2): 1

Partition 1 has been deleted.

Command (m for help): d
Selected partition 2
Partition 2 has been deleted.

Command (m for help): w

The partition table has been altered.
Calling ioctl() to re-read partition table.
Syncing disks.Code language: JavaScript (javascript)

HDD/SSDのパーティションを作成する

下記コマンドを実行してHDD/SSDをフォーマットします。一旦運用が始まるとパーティションのサイズの調整はできないのでパーティションは切らずHDD/SSD全体を1パーティションとします。

$ sudo fdisk /dev/sda

Welcome to fdisk (util-linux 2.36.1).
Changes will remain in memory only, until you decide to write them.
Be careful before using the write command.

Command (m for help): n
Partition type
   p   primary (0 primary, 0 extended, 4 free)
   e   extended (container for logical partitions)
Select (default p): p
Partition number (1-4, default 1): 
First sector (2048-1953525167, default 2048): 2048
Last sector, +/-sectors or +/-size{K,M,G,T,P} (2048-1953523120, default 1953523120): 1953523120       

Created a new partition 1 of type 'Linux' and of size 931.5 GiB.
Partition #1 contains a ext4 signature.

Command (m for help): w
The partition table has been altered.Code language: PHP (php)

wコマンド実行時に実際にパーティションが作成されます。実行後にfdiskコマンドでパーティションができたかを確認します。

$ sudo fdisk -l /dev/sda
Disk /dev/sda: 931.51 GiB, 1000204886016 bytes, 1953525168 sectors
Disk model: TOSHIBA MQ01ABD1
Units: sectors of 1 * 512 = 512 bytes
Sector size (logical/physical): 512 bytes / 512 bytes
I/O size (minimum/optimal): 512 bytes / 512 bytes
Disklabel type: dos
Disk identifier: 0xa615109d

Device     Boot     Start        End    Sectors   Size Id Type
/dev/sda1            2048 1953523120 1953523120 931.5G 83 Linux

作成したパーティションをフォーマットする

下記コマンドでHDD/SDDをext4でフォーマットします。/dev/sda1の部分はフォーマット対象のHDD/SSDのデバイス名に置き換えて実行します。コマンド実行時に確認メッセージは表示されないため、デバイス名を間違えないように気をつけましょう。フォーマットは10秒程度で完了します。

$ sudo mkfs.ext4 /dev/sda1
mke2fs 1.46.2 (28-Feb-2021)
Creating filesystem with 13107200 4k blocks and 3276800 inodes
Filesystem UUID: 7e24b26f-0e1e-41fb-8812-b5a7522c592d
Superblock backups stored on blocks: 
	32768, 98304, 163840, 229376, 294912, 819200, 884736, 1605632, 2654208, 
	4096000, 7962624, 11239424

Allocating group tables: done                            
Writing inode tables: done                            
Creating journal (65536 blocks): done
Writing superblocks and filesystem accounting information: doneCode language: JavaScript (javascript)

HDD/SSDを自動マウントする

/etc/fstabを編集し、再起動時に’/mnt/tv-recorderにHDD/SSDをマウントするように設定します。

まず、下記コマンドでマウントポイントを作成します。

$ sudo mkdir -p /mnt/tv-recorder
$ sudo chmod 777 /mnt/tv-recorder
$ sudo chown -R tv-recorder:tv-recorder /mnt/tv-recorder

/etc/fstab で起動時にマウントさせるため、HDD/SSDのパーティションのID(PARTUUID)を調べます。

$ sudo blkid
...
/dev/sda1: UUID="61cc9c24-b882-9949-88c8-88eab12b1ce5" BLOCK_SIZE="4096" TYPE="ext4" PARTUUID="432802cd-a1c7-ab4c-aeee-10bdddde3212"
......Code language: JavaScript (javascript)

/etc/fstabにエントリを追加します。
※パーティションを作成せず、HDD/SSD全体を1パーティションとした場合はPARTUUIDがありません。この場合はUUID=…でデバイスを指定すること。

$ sudo vi /etc/fstab
---
proc            /proc           proc    defaults          0       0
PARTUUID=47e8465b-01  /boot           vfat    defaults          0       2
PARTUUID=47e8465b-02  /               ext4    defaults,noatime  0       1
# a swapfile is not a swap partition, no line here
#   use  dphys-swapfile swap[on|off]  for that
PARTUUID=61cc9c24-b882-9949-88c8-88eab12b1ce5 /mnt/tv-recorder  ext4 defaults,nofail 0 0Code language: PHP (php)

再起動して自動マウントされることを確認します。

$ df
Filesystem     1K-blocks      Used Available Use% Mounted on
...
/dev/sda1      960302824 297730032 613718332   1% /mnt/tv-recorder/backup
...Code language: JavaScript (javascript)

自動マウントされていることが確認できたら'touch /recorded/testfile'コマンドでファイルが書けるかを確認しましょう。

Sambaサーバーをインストールする

Windows PCから録画ファイルを読めるように、sambaをインストールします。操作ミスで録画ファイルを消さないようにread onlyをyesにしておき、ファイル操作は全てEPG stationからでしかできないようにします。

$ sudo apt install samba samba-common-bin

/etc/samba/smb.confに以下を追加します。

$ sudo vi /etc/samba/smb.conf
---
...
[recorded]
path = /mnt/tv-recorder/
public = yes
guest ok = yes
read only = yes
force user = tv-recorder
comment=EPG stationCode language: JavaScript (javascript)

smb.confを書き換えた場合は下記コマンドでsambaサーバーを再起動する必要があります。

$ sudo systemctl restart smbd

再起動したら他のPCからRasberry Piのrecordedフォルダにアクセスできることを確認します。

カードリーダー/TVチューナーをセットアップする

カードリーダーを接続する

RasberryPi上のUSB2.0ポートに接続します。カードリーダーはUSB1.0として認識されるため、USB 3.0ポートへの接続は無意味です。

ドライバのインストールと動作確認はEPG Stationのインストール時に行うため、ここでは接続のみで作業完了です。

TVチューナーを接続する

RasberryPi上のUSB2.0ポートに接続します。TVチューナーはUSB 2.0として認識されるため、USB3.0ポートへの接続は無意味です。

なお、PX-U1DXはRasberryPiに20W電源を接続している場合、バスパワーで問題なく動作することを確認しているため、TVチューナーに付属している電源を接続する必要はありません。

また、電源を接続しているとチューナー側からUSBポートを通して電力が供給されるため、再起動時に接続されたハードウェアが完全にリセットされる保証がなく、トラブル時の原因の切り分けが難しくなりますので、電源は外しておいた方が無難です。

※電源無しで動作することは公式に保証しておらず、動作確認で問題がないことを確認しているだけですので、動作不安定な場合には電源を接続して、問題が改善するかどうか確認しましょう。

PX-Q1UDのドライバはRasberryOSにデフォルトでインストールされており、接続した直後からPX-S1UDが4つとして認識されていますが、このドライバでは起動時にファイル不足のエラーが吐かれていることがdmesgで確認できEPGデータも取得できないことを確認しているので、下記コマンドを実行して公式サイトにあるドライバをインストールします。

$ wget http://plex-net.co.jp/plex/px-s1ud/PX-S1UD_driver_Ver.1.0.1.zip
$ unzip PX-S1UD_driver_Ver.1.0.1.zip
$ sudo cp PX-S1UD_driver_Ver.1.0.1/x64/amd64/isdbt_rio.inp /lib/firmware/Code language: JavaScript (javascript)

ドライバインストール後にPX-S1UDを接続したままRasberry Piを再起動し、下記コマンドを実行してPX-Q1UDが認識されていることを確認しましょう。

$ dmesg | grep PX-
[ 2.792753] usb 1-1.2.1: Product: PX-S1UD Digital TV Tuner
[ 2.980706] usb 1-1.2.2: Product: PX-S1UD Digital TV Tuner
[ 3.164841] usb 1-1.2.3: Product: PX-S1UD Digital TV Tuner
[ 3.348650] usb 1-1.2.4: Product: PX-S1UD Digital TV Tuner
$ ls -al /dev/dvb*
total 0
drwxr-xr-x  6 root root  120 Sep 30 01:47 .
drwxr-xr-x 18 root root 4220 Sep 30 01:47 ..
drwxr-xr-x  2 root root  100 Sep 30 01:47 adapter0
drwxr-xr-x  2 root root  100 Sep 30 01:47 adapter1
drwxr-xr-x  2 root root  100 Sep 30 01:47 adapter2
drwxr-xr-x  2 root root  100 Sep 30 01:47 adapter3

TVチューナーが認識されていない場合は、ディスプレイを繋いでTVチューナー接続時にエラーが出ていないか確認しましょう。エラーが出ている場合はリブートすると治ることがあります。

Docker版mirakurun-epgstationをインストールする

現時点でメンテナンスが継続されていて、かつI/Fが最も洗練されているのはEPG stationです。現在Docker版でメンテナンスが進んでいることと、インストールが簡単であることからDocker版のEPG stationをインストールします。

Docker, Docker-composeをインストールする

まず下記コマンドを実行して、docker,docker-composeをインストールします

$ sudo apt install -y docker docker-compose

Docker版EPG Stationをインストールする

次に下記コマンドでEPG stationをインストールします。ダウンロード速度にもよりますが、コマンド実行完了まで3分程度かかるのでのんびり待ちましょう。

$ cd ~
$ curl -sf https://raw.githubusercontent.com/l3tnun/docker-mirakurun-epgstation/v2/setup.sh | sudo sh -s
$ sudo chown -R tv-recorder:tv-recorder docker-mirakurun-epgstation
$ cd docker-mirakurun-epgstationCode language: JavaScript (javascript)

※この時点で編集済みのdocker-compose.yml,epgstation/config/config.ymlがある場合はコピーをしておくことで復旧の手間を省くことができます

下記コマンドを実行してMirakurun/EPG Stataionを起動します。初回起動時はビルドが走り、起動完了まで20分程度かかるのでのんびり待ちましょう。

$ sudo docker-compose up -d

インストール完了後、ブラウザから'http://<IP Address>:40772/'にアクセスしてMirakrunのHPが表示され、'http://<IP Address>:8888/'にアクセスしてEPG Stationが表示されることを確認します。

B-CASカードが認識できることを確認する

この過程でカードリーダーのドライバもインストールされるため、EPG stationが起動したあとにカードリーダーが正しく認識されているかどうかをlsusbコマンドで確認します。

$ lsusb
...
Bus 001 Device 003: ID 04e6:5116 SCM Microsystems, Inc. SCR331-LC1 / SCR3310 SmartCard Reader
...

次にB-CASカードをカードリーダーに差し込み、LEDが点灯することを確認します。

LEDが点灯しない場合は、カードリーダが正しく動作しておらず、LEDが点滅している場合はカードが正しく認識されていないことになるので、カードの裏表を逆にして挿入して動作するかどうかと、カードリーダー/B-CASカードに問題がないかを確認します。

チャンネルスキャンをする

下記コマンドを実行してチャンネルスキャンを実行します。スキャンは10分程度で完了します。

$ curl -X PUT "http://localhost:40772/api/config/channels/scan"Code language: JavaScript (javascript)

実行しなくてもEPG stationで番組が表示されますが、一部チャンネル(TOKYO MXなど)が表示されないことがあること、再取得処理が延々と続きプロセスを圧迫することがあり、録画が突然停止することもあるため、未実施の場合は必ず実行しましょう。

Docker版mirakurun-epgstationを設定する

ここではdocker-compose.ymlとepgstation/config/config.ymlを編集して、ディスク構成や録画ファイルを設定します。この2つのymlは一度設定後にバックアップをとっておくことで次回の設定の手間を省くことができます。

録画データを分類するためのフォルダを作成する

録画データの分類用フォルダは以下のように設定しました。

  • ‘delete’ -> m2tsの保存もmp4へ変換も行わない(ニュース・バラエティ番組など)
  • ‘on_hold’ -> mp4ファイルのみ残す。m2tsファイルは残さない(連続ドラマ・映画など)
  • ‘keep’ -> m2ts/mp4ともに残す(ずっと残しておきたい番組)

さらにSDデータのbackup用のフォルダも用意します。

$ sudo mkdir -p /mnt/tv-recorder/backup /mnt/tv-recorder/delete /mnt/tv-recorder/on_hold /mnt/tv-recorder/keep 
$ sudo chmod -R 777 /mnt/tv-recorder
$ sudo chown -R tv-recorder:tv-recorder /mnt/tv-recorderCode language: JavaScript (javascript)

Dockerから’/mnt/tv-recorder/backup’と’/mnt/tv-recorder/tentative’にアクセスできるように設定する

録画ファイルの保存先として’/mnt/tv-recorder/recorded’と’/mnt/tv-recorder/tentative’を指定できるようにdocker-conpose.ymlを修正します。

$ cd ~
$ vi docker-mirakurun-epgstation/docker-compose.yml
---
services:
    ...
    epgstation:
        ...
        volumes:
            ...
(修正前)    
            - ./recorded:/app/recorded
(修正後)    
           - /mnt/tv-recorder/backup:/app/backup
           - /mnt/tv-recorder/tentative:/app/delete
           - /mnt/tv-recorder/tentative:/app/on_hold
           - /mnt/tv-recorder/recorded:/app/keepCode language: JavaScript (javascript)

設定が完了したらdockerを再起動し、dockerコンテナから/mnt/tv-recorder以下にマウントしたbackup,recorded,tentativeディレクトリにアクセスできるようになったことを確認します。docker inspectコマンドで指定するContainer IDは起動ごとに動的に変化するため、psコマンドで確認する必要があります。
※再起動時はrestartではなく donw&upで行わないと設定が反映されないため注意すること

$ cd ~/docker-mirakurun-epgstation
$ sudo docker-compose down 
$ sudo docker-compose up -d
$ sudo docker ps -a
CONTAINER ID   IMAGE                                    COMMAND                  CREATED       STATUS        PORTS                                              NAMES
562d4973e9d5   docker-mirakurun-epgstation_epgstation   "npm start"              10 days ago   Up 35 hours   0.0.0.0:8888-8889->8888-8889/tcp                   docker-mirakurun-epgstation_epgstation_1
dd8371eb9ce9   chinachu/mirakurun                       "docker-entrypoint.s…"   10 days ago   Up 36 hours   0.0.0.0:9229->9229/tcp, 0.0.0.0:40772->40772/tcp   docker-mirakurun-epgstation_mirakurun_1
d6b9da403421   mariadb:10.5                             "docker-entrypoint.s…"   10 days ago   Up 36 hours   3306/tcp                                           docker-mirakurun-epgstation_mysql_1
$ sudo docker inspect 562d4973e9d5
...
           {
                "Type": "bind",
                "Source": "/mnt/tv-recorder/delete",
                "Destination": "/app/delete",
                "Mode": "rw",
                "RW": true,
                "Propagation": "rprivate"
            },
            {
                "Type": "bind",
                "Source": "/mnt/tv-recorder/keep",
                "Destination": "/app/keep",
                "Mode": "rw",
                "RW": true,
                "Propagation": "rprivate"
            },
            {
                "Type": "bind",
                "Source": "/mnt/tv-recorder/on_hold",
                "Destination": "/app/on_hold",
                "Mode": "rw",
                "RW": true,
                "Propagation": "rprivate"
            },
            {
                "Type": "bind",
                "Source": "/mnt/tv-recorder/backup",
                "Destination": "/app/backup",
                "Mode": "rw",
                "RW": true,
                "Propagation": "rprivate"
            },
  ...Code language: PHP (php)

録画ファイルの保存パスとデフォルト録画ファイル名を変更する

まず、ファイル名を'YYYYMMDD_HHMM <番組名>'に変更します。

$ cd ~
$ vi docker-mirakurun-epgstation/epgstation/config/config.yml
---
...
(修正前)
recordedFormat: '%YEAR%年%MONTH%月%DAY%日%HOUR%時%MIN%分%SEC%秒-%TITLE%'
(修正後)
recordedFormat: '%YEAR%%MONTH%%DAY%_%HALF_WIDTH_TITLE%'
...Code language: JavaScript (javascript)

さらに録画データの保存先を’自動削除する'(delete)、’とりあえず録っておく'(on_hold)’、’保存する'(keep)で指定できるように、下記のように変更します。

設定ではHDDの容量が50GBを切った場合に、自動で古いものからファイルを削除するようにしています。EPG stationは保存先によらず単純に古い録画データから消していく仕様のようなので、削除開始の閾値は全て同じにする方が動作がわかりやすくなります。

$ cd ~
$ vi docker-mirakurun-epgstation/epgstation/config/config.yml
---
...
recorded:
(修正前)
    - name: recorded
      path: '%ROOT%/recorded'
(修正後)
    - name: '自動削除する'
      path: '%ROOT%/delete'
      limitThreshold: 50000
      action : remove
    - name: 'とりあえず録っておく'
      path: '%ROOT%/on_hold'
      limitThreshold: 50000
      action : remove
    - name: '保存する'
      path: '%ROOT%/keep'
      limitThreshold: 50000
      action : removeCode language: JavaScript (javascript)

容量が足りなくなる前に常に手動で消す方法でもいいですが、削除する量が足りない、削除忘れなどのヒューマンエラーで容量不足になるリスクがあることから、自動削除で対応する方が良いかと思います。保存期間を長くしたい場合はHDD/SSDの容量を大きくすればいいだけですので対応も簡単です。

修正後にEPGStationを再起動し、EPGStationのメニューの’ストレージ’を選択して保存先が正しく認識され、録画可能な容量がHDDサイズになっていることを確認します。

$ cd ~/docker-mirakurun-epgstation
$ sudo docker-compose restart

録画データの所有者IDをtv-recorderに変更する

デフォルトの録画ファイルの所有者はrootになっており、sudoで実行しないと録画ファイルの移動や削除ができません。この手間を解消するため、ファイルの所有者がtv-recorderになるようにconfig.ymlに’uid: 1000′(1000はtv-redcorderのUID)を追加します。

$ cd ~
$ vi docker-mirakurun-epgstation/epgstation/config/config.yml
---
...
uid: 1000
gid: 1000
...Code language: Bash (bash)

再起動時にEPG stationが起動しない問題への対応

上記機材と手順でセットアップを完了させるとReboot後にEPG stationが表示されない問題があります。起動直後のプロセスを確認するとMirakrunが起動していないことがわかります。

 $ ps -ax | grep docker-proxy
   1410 ?        Sl     0:00 /usr/sbin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 9229 -container-ip 172.18.0.2 -container-port 9229
   1916 ?        Sl     0:00 /usr/sbin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8889 -container-ip 172.18.0.3 -container-port 8889
   1931 ?        Sl     0:00 /usr/sbin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8888 -container-ip 172.18.0.3 -container-port 8888
   2283 pts/0    S+     0:00 grep --color=auto docker-proxyCode language: Bash (bash)

Mirakurunが動作している場合は下記のプロセスが表示されますが、これがありません。

   1397 ?        Sl     0:00 /usr/sbin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 40772 -container-ip 172.18.0.2 -container-port 40772Code language: Bash (bash)

原因として電源ON時の突入電力不足を疑いましたが、RasberryPiを100Wの充電器に接続してもTVチューナーに電源を直接接続してもこの現象が発生することから電力不足ではないことまでは確認済みです。原因は現時点では不明です。

OS起動後にsshログインして手動で再起動はできるので、systemdを利用してOS起動後に遅延処理を実行してdockerを再スタートするように設定を追加して対応します。

再起動用のスクリプトを書く

まず下記スクリプトを作成して/home/tv-recorderに置きます。chmod 777で実行権限を付与し、再起動処理が正しく動作することを確認します。

#!/bin/bash
pushd /home/tv-recorder/docker-mirakurun-epgstation
sudo docker-compose restart
popd

OS起動後に再起動処理を実行するように設定する

遅延実行にはsystemdを利用します。まずepgstation.serviceとepgstation.timerファイルを下記手順で作成します。

$ sudo vi /etc/systemd/system/epgstation.service
---
[Unit]
Description=Restart EPG station
[Service]
ExecStart=/home/tv-recorder/restart_epgstation.sh
[Install]
WantedBy=multi-user.target
---Code language: Bash (bash)
$ sudo vi /etc/systemd/system/epgstation.timer
---
[Unit]
Description=EPG station restart timer
[Timer]
OnBootSec=30sec
Unit=epgstation.service
[Install]
WantedBy=multi-user.target
---Code language: Bash (bash)

待ち時間は20秒ではdockerのリスタートされず、30秒で安定してリスタートがかかったことから30秒に設定しています。

次に下記コマンドを実行してepgstation.timerをsystemdに登録します。

$ sudo systemctl enable epgstation.timer

上記設定が完了したらRasberryPiを再起動して、EPG stationが正しく起動することを確認してください。

再生環境セットアップ

ここではオフィシャルサイトで設定例として紹介されているVLCを使います。必要に応じて再生方法を自分で変更してください。

Windows

録画データの一覧と再生には、Sambaで表示されるm2tsを直接ダブルクリックしてVLCで再生されるようにデフォルトアプリを設定する方法が一番簡単で確実です。

リアルタイム視聴は視聴時にダウンロードされる.m3u8ファイルをVLCに割り当てて、ファイルをダブルクリックして視聴を開始する方法が簡単です。

個人的にはインターフェースがシンプルなMPC-BE(Media Player Classic-Black Edition)がおすすめです。

Mac

録画データの一覧と再生には、Sambaで表示されるm2tsを直接ダブルクリックしてVLCで再生されるようにデフォルトアプリを設定する方法が一番簡単で確実です。

リアルタイム視聴は視聴時にダウンロードされる.m3u8ファイルをVLCに割り当てて、ファイルをダブルクリックして視聴を開始する方法が簡単です。

iOS

録画データの一覧と再生には、infuseを使うのが一番手間がかからないです。

リアルタイム視聴はVLCをインストールした後に”設定-放映中-視聴 URL Scheme”に以下を入力すればいいです。
vlc://PROTOCOL://ADDRESS

Android

VLCをインストールするだけで、EPG Stationから番組視聴ができます。

TV/プロジェクター

Google Chromecastを繋げてPlexやKodiをインストールし、smb経由でm2tsを参照することでTV視聴することができます。

動作確認

まず再起動したあとにdmesgコマンドで表示されるカーネルログで致命的なエラーが出力されていないかを確認しましょう。私の環境ではエラーは出力されませんでした。

次に、30分程度電源を入れっぱなしにして放置したあとにEPG stationの番組表を表示し、利用するすべてのチャンネルの番組表が表示されていることを確認しましょう。表示されない場合はアンテナ接続やMirakurunの動作(http://<IP address>:40772)などを確認しましょう。

次にEPG stationから4番組同時録画を1時間程度実行し、録画データをVLCで再生してみてノイズがないことを確認しましょう。

あとは継続使用をしてトラブルに見舞われたら随時対応と対策を打つようにします。

トラブルシュートの欄に記載した電波強度とエラーブロックの発生状況を知るツールをあらかじめ用意しておくと、ブロックノイズ発生時に原因の特定がスムースに行えます。

リモート視聴

録画サーバに過度な負荷を与えないように視聴用のサーバーを別に立てて、そちらにファイルをコピーしPlexを介してリモート視聴するようにしています。(詳細は後日別コンテンツとしてアップ予定)

録画データのバックアップとmp4への変換

下記にまとめましたので、こちらを参照してください。

https://simplelife.pgw.jp/etc/cut_tv_commercial/

microSDカードとEPG stationのデータのバックアップとリストア

microSDカード

microSDカードをRasberry PiのOSディスクにしていると、microSDのイメージファイルを作ることで全く同じOSディスクを複製できます。OSのイメージファイルさえ持っていれば、設定の変更によりレコーダーの動作が安定しない状態に陥っても、イメージファイルからmicroSDカードを複製して入れ替えれば完全に元通りにすることができます。

イメージ作成はddコマンドなどでできますが、使用している容量にかかわらずmicroSDのサイズ分のイメージデータができます。すなわち32GBのmicroSDの場合は使用している容量にかかわらず32GBのイメージが出来上がります。

このサイズを縮小するために以下のサイトにあるスクリプトを利用させてもらっています。
https://dev.classmethod.jp/articles/raspberrypi-compress-backup/

このスクリプトはRasberry Pi OS DesktopでユーザーIDがpiに設定されている環境で正常に動作するので、ログインユーザー名が違う場合は下記のハイライト部分を修正する必要があります。

吸い出したSDカードイメージはRasberry Piの/mnt/tv-recorder/backup領域に保存しておき、SDカードが壊れた時にここからイメージファイルを取り出して、SDカードをすぐに復旧できるようにしておきましょう。

イメージファイルをmicroSDへ書き出す際には下記のツールを利用しています。

https://github.com/dnobori/DN-Win32DiskImagerRenewal

EPG stationのデータ(録画データのメタ情報、サムネイル、録画予約など)

/mnt/tv-recorder/backupフォルダに下記のスクリプトを作成し、’chmod 777 backup.sh’で実行権限をつけて、バックアップできることを確認します。下記スクリプトはEPG stationのデータの他に、homeディレクトリと/etc以下の編集したファイルをバックアップする処理を追してあります。

#!/bin/bash

function set_attribute()
{
	sudo mkdir -p     $1
	sudo chmod -R a+r $1
	sudo chmod -R a+w $1
	sudo chown tv-recorder:tv-recorder $1
}

record_dir=/mnt/tv-recorder
backup_dir=${record_dir}/backup

#create record directory
set_attribute ${record_dir}/keep
sudo touch ${record_dir}/keep/.keep
set_attribute ${record_dir}/on_hold
sudo touch ${record_dir}/on_hold/.keep
set_attribute ${record_dir}/delete
sudo touch ${record_dir}/delete/.keep

#create backup directory
set_attribute ${record_dir}/backup
set_attribute ${record_dir}/backup/epgstation
set_attribute ${record_dir}/backup/etc
set_attribute ${record_dir}/backup/home

# Delete empty directory
find ${record_dir}/keep/    -type d -empty -exec sudo rm -rf {} \;
find ${record_dir}/on_hold/ -type d -empty -exec sudo rm -rf {} \;
find ${record_dir}/delete/  -type d -empty -exec sudo rm -rf {} \;

#backup epgstation
pushd ${backup_dir}/epgstation
	sudo cp -rf database_backup database_backup_old
	sudo docker exec docker-mirakurun-epgstation_epgstation_1 npm run backup /app/backup/epgstation/database_backup
	set_attribute ./docker-mirakurun-epgstation/epgstation/thumbnail
	cp ~/docker-mirakurun-epgstation/epgstation/thumbnail/* ./docker-mirakurun-epgstation/epgstation/thumbnail/
	set_attribute ./docker-mirakurun-epgstation/epgstation/config
	cp ~/docker-mirakurun-epgstation/epgstation/config/config.yml ./docker-mirakurun-epgstation/epgstation/config/
	cp ~/docker-mirakurun-epgstation/docker-compose.yml ./docker-mirakurun-epgstation/
popd

#backup etc
pushd ${backup_dir}/etc
	cp /etc/fstab .
	mkdir -p samba
	cp /etc/samba/smb.conf samba/
	mkdir -p systemd/system
	cp /etc/systemd/system/epgstation.* systemd/system/
popd

#backup home
sudo rsync -aP --delete /home/tv-recorder/ ${backup_dir}/home/

次に録画処理が発生することがほぼないであろう夜中の2時にバックアップ処理が走るように、crontab -eで下記エントリを追加します。

0 2 * * * cd /mnt/tv-recorder/backup/ && ./backup.sh -e

OS再インストール時などでEPG stationを再構築した時は、下記コマンドを実行してbackupファイルからリストアできます。

$ sudo docker exec der-mirakurun-epgstation_epgstation_1 npm run restore /app/backup/epgstation/database_backup
$ cp /mnt/tv-recorder/backup/docker-mirakurun-epgstation/epgstation/thumbnail/* ~/docker-mirakurun-epgstation/epgstation/thumbnail/ 

録画データ

録画データは前述の変換スクリプトでWindows PCにバックアップされます。

Tips & Troubleshooting

ここでは、初めてRasberryPiで録画サーバーをセットアップしたときに躓いたところや、動作確認の際に調べたことをメモしておきます。

停電復帰時に自動で再起動しない

DeskPiとArgon ONEは停電などで一度電源が落ちると電源が復旧しても電源ボタンを押すまでRasberryPiには電源が供給されません。このため、一旦停電が発生すると手動で電源をONするまで、録画が止まってしまいます。

DeskPiとArgon ONEは常時通電設定もできますので、停電対策には常時通電状態に設定を変更します。

ただし、電源ボタンによるRasberryPiの電源ON/OFFができなくなるので、強制リセットなどで電源を一旦切る場合は電源を物理的に切断する必要があります。

同時録画ができない

Mirakurunのページを開いて、下記のPidの数値が上がっている場合は録画開始時に録画プロセスが死んでおり、RasberryPiへの電源供給が足りていません。20W以上の供給能力を持つ充電アダプタをつけましょう。

CrontabでEPG stationのデータだけがバックアップできない

バックアップスクリプトを手動で実行した場合はバックアップできるにもかかわらず、Crontabで動作させるとdocker内のEPG stationのデータだけがバックアップされない場合があります。

この場合はdocker execコマンドに-itオプションがついているので、-itオプションを削除して対応します。

(正) $ sudo docker exec -it docker-mirakurun-epgstation_epgstation_1 npm run backup /app/recorded/_backup/epgstation_database_backup
(誤) $ sudo docker exec     docker-mirakurun-epgstation_epgstation_1 npm run backup /app/recorded/_backup/epgstation_database_backup

EPGは取得できるがTV放送が表示されない

B-CASカードがきちんと挿さっていない場合に発生します。再生状態でカードリーダーのLEDが動作中を示しているか確認し、点灯していない場合は挿入し直しましょう。

トラブル解析時にEPG stationのLogを見たい

下記コマンドでログのリアルタイム出力を確認できます。

$ sudo docker-compose logs -f mirakurun
$ sudo docker-compose logs -f mysql
$ sudo docker-compose logs -f epgstation

プロセスは起動しているがEPG stationが表示されない

現時点で原因・対処方法ともに不明です。この現象発生時にセットアップ時の確認事項を確認したところ、カードリーダーが正しく認識しているにもかかわらず、B-CASカードリーダーのLEDが点灯しません。このことからUSB関連の何かでトラブルがあったものと思われます。

時間をおいて再起動すると直ります。

録画ファイルのWindowsへの転送速度が極端に遅くなる

共有フォルダからWindowsへファイルをダウンロードしたときに10Mb/s程度しか出なくなることがあります。通常は50〜80MB/s程度ですので、GB級のダウンロード時はかなりの差がでます。

HDDの転送速度の低下、CPUのオーバーロード、Sambaサーバーのバグなどを疑ってみましたが、これらに問題はありませんでした。

そこで、SpeedTest CLIで外部とのネットワークの転送速度をしたところ、起動直後は400MB/sでていたものが時間が経過すると50MB/s以下に低下することを確認しています。他のRasberry Piではこのような現象は発生していないことから、Rasberry Piとハブとの間に問題があると推測し、Etherケーブルを太いものに取り替えたところ現象が発生しなくなりました。

直接の原因は不明ですが、Rasberry Piをサーバーとして使う場合はノイズ耐性の高い太いEtherケーブルで接続するのが無難なようです。

電波強度が安定しているにも関わらずブロックノイズが乗る場合がある

しばらく運用を続けていると、NHK Eテレを録画したときに番組を通して盛大なノイズが乗ることがありました。ただし、常にノイズが乗るわけではなく、ノイズが乗る時は最初から最後まで乗り続けます。また、ノイズが乗っている最中に再起動するとノイズがなり、同時に視聴していてもノイズが乗らないという状態でしばらく原因がわかりませんでした。

ただ、同じチャンネルを続けて録画すると後番組でノイズが突然発生し、そのまま最後までノイズが乗り続けることがわかり、このことからチューナーによってノイズが乗る場合があると推測が立ちました。

そこで、チューナーごとにエラーブロックの発生量を調べるとデバイス0にあたるチューナーのみがエラーブロックを出力することがわかりました。Mirakurunのconfigで、デバイス0のチューナーをOFFにして使用しないようにして対処しました。

3チャンネルまでしか同時録画ができなくなりますが、ノイズありの番組を録画をしないためには致し方なく、実運用上3チャンネル同時録画で十分用を成すのでOFFとしました。ノイズの乗るチャンネルがほとんど視聴することがないようであれば、Aでチューナーのノイズが乗るチューナーの優先順位を下げて対処して、4チャンネル同時録画を維持するのもいいと思います。

なお、ブロックノイズが乗る原因には電波強度が弱すぎる・強すぎるというものもあります。そこで原因を切り分けするためにこちらのサイトを参考にしました。具体的には以下の手順で確認します。

まず、チューナーとチャンネルを指定してエラーブロックの数と電波強度を出力するバイナリをコンパイルします。

$ git clone https://github.com/jsasky/recdvb.git
...
$ sudo apt-get update
$ sudo apt-get install -y --no-install-recommends ca-certificates gcc g++ build-essential libtool autoconf automake cmake make
...
$ cd recdvb
$ chmod +x autogen.sh
$ ./autogen.sh
...
$ ./configure
...
$ make
...

次に下記コマンドとパラメータでノイズの乗っているチャンネルとチューナーを指定してエラーブロックの有無と信号強度を確認します。

$ ./chkdvbsignal --lnb 15 26 --dev 0
LNB = 15V
device = /dev/dvb/adapter0/frontend0
Using DVB device "Siano Mobile Digital MDTV Receiver"
tuning to 551.143 MHz
..ok(0x1F)
CNR: 16.00000[dB]  Error:  10621[counts]  Total:  26560[counts]  SIG: -28.00000[dBm]

チャンネルは以下の通りです。–devで指定する数値がチューナー番号になります。–lnbは15固定です。これで電波強度を調整したあと、エラーブロックの発生するチャンネルとチューナーを特定します。

20ch: Tokyo MX TV
21ch: フジテレビ
22ch: TBS
23ch: テレビ東
24ch: テレビ朝日
25ch: 日テレ
26ch: NHK Eテレ
27ch: NHK総合
28ch: 放送大学Code language: HTTP (http)

アッテネータを駆使して調べたところ、信号強度は-40〜-35dBm程度であればエラーブロックが発生しないようです。信号強度が範囲外の場合はアッテネーターやブースターを使用して適切な電波強度になるようにします。

以上、参考になれば幸いです。

食パンでつくるカレーパン

調理道具

  • フライパン小(カレーパンを揚げる)
  • ボウル中(カレーパンを卵に浸す)
  • ボウル大(パン粉をつける)

材料(8枚切りパン8枚分)

  • カレー   200g ※一人前より少し多いくらい。具材が細かいキーマカレーがいい
  • 食パン   1斤(8枚切)
  • ピザチーズ 1枚あたり一つまみ ※カレーのトッピング(福神漬けなど)ならなんでもいい
  • 溶き卵   1個
  • パン粉   300cc
  • サラダ油  フライパンに対して、深さ1cm程度の量

下準備

【食パンを下ごしらえする】

  • 食パンは耳の部分を切り落とす
  • 食パンの中央部分を麺棒の端で厚さが戻らない程度まで伸ばし、端は厚さを維持する程度に伸ばす
  • 食パンを三角形に折り目をつけておく

【その他】

  • 揚げた直後のカレーパンの油を抜くため、キッチンペーパーを広げて敷いておく
  • ボウル中に卵を割り入れて溶いておく
  • ボウル大にパン粉を入れる

調理

【パンにカレーを挟みパン粉を塗す】

  1. パンの縁から2cmほどを空け、下半分にカレーとチーズ(または福神漬け)を三角形にのせる
  2. パンを半分に折りたたみ、端をフォークで押さえる。揚げているときに油が入らないようにできるだけきっちり閉じること。
  3. パンをボウル中に入れて溶き卵を馴染ませる。閉じている部分が空いているようならここで閉じ込む
  4. パンをボウル大に入れてパン粉をまぶしたあと、軽く握ってパン粉が落ちないようにする

【揚げる】

  1. フライパンにサラダ油をひいて中火で熱する
  2. パンを入れる。合わせ目が空いている場合は、合わせ目を押さえて閉じてパンを入れる。
  3. 両面を2分ずつ焼き、こんがりと焼き色がついたら火から下ろしてクッキングペーパーで余計な油を吸わせる
  4. お皿に盛り付けて出来上がり

メモ

  • 具材を詰め込みすぎるとパンが開くので、具材は気持ち少なめにしましょう

参考レシピ

Kurashiru:簡単に食パンで作れる!三角カレーパン

カレーリメイク版カレーピラフ

調理道具

  • 深鍋フライパン

材料(3人分)

  • ご飯      茶碗3杯強
  • キーマカレー  200g
  • チキンコンソメ 1個
  • カレー粉    小さじ1
  • サラダ油    大さじ1.5

下準備

  • カレーとご飯をあらかじめ温めておく

調理

  1. フライパンに油を引いて温める
  2. ご飯とチキンコンソメ1個を入れて、パラパラになるまで炒める
  3. カレーパウダー小さじ1とカレーを入れて、ご飯にカレーが絡まるまで炒める
  4. 皿に盛り付けて食べる

メモ

  • レシピにある分量は1人分としては少な目なので、1.3倍量くらいに増やしています

参考レシピ

カレーリメイク版カレーパスタ

調理道具

  • 深鍋フライパン(パスタを茹でる、具材を炒める、カレーを温める)

材料(3人分)

  • スパゲッティ(1.7mm) 300g
  • 残ったカレー      600g ※通常のカレー3人分
  • マッシュルーム(生)  6個
  • ミニトマト       9個
  • バター         25g
  • 粉チーズ         大さじ2

下準備

  • マッシュルームを薄切りにする
  • ミニトマトを4等分に切る。

調理

【パスタを茹でる】

  1. たっぷりの湯に1%の塩(小さじ2程度)を入れて、説明書に従ってパスタを茹でる
  2. 茹で終わったらザルにあけて水気を取る。茹で汁は100ccくらいとっておくこと。

【具材を炒め、カレーを温める】

  1. フライパンを熱したあと、バターを入れて溶かす
  2. マッシュルーム、ミニトマトをフライパンに入れ、マッシュルームがしんなりするまで炒める
  3. 軽く具材をつぶした残ったカレーとスパゲッティのゆで汁75ml程度をフライパンに入れて加えて、溶きのばしながら温める
  4. 皿にスパゲッティとカレーソースをかけて、仕上げに粉チーズを振りかける

メモ

  • オリーブオイルをかけるとイタリアンな味になります。

参考レシピ

ハウス食品:https://housefoods.jp/recipe/rcp_00025227.html

野菜たっぷりキーマカレー

調理道具

  • 深鍋フライパン
  • ボウル中(切った野菜を置いておく)
  • 小皿(トマト半分を置いておく)

材料(5人分)

  • ジャワカレー(中辛)1/2箱
  • 牛豚ひき肉     250g
  • 玉ねぎ       1個
  • トマト       1個
  • なす        2本
  • ピーマン      2個
  • パブリカ(黄)   1/2個
  • サラダ油      大さじ1
  • 水         200ml

下準備

  • なす・ピーマン・パプリカを2cm角で切り、ボウルに入れる
  • トマトの皮を剥ぎ、2cm角の大きさに切って、半分はボウル、半分は小皿に置いておく
  • 玉ねぎをみじん切りにする

調理

  1. フライパンにサラダ油大さじ1を入れて温める。
  2. 玉ねぎを入れて、透き通るまで炒める
  3. ひき肉を入れて焼き色が着くまで炒める
  4. トマト半分、ナス、ピーマン、パプリカをフライパンに入れてさっと炒める
  5. 水を加えて沸騰させる
  6. 灰汁をとり、蓋をして5分中火で煮込む
  7. 火を止めてルウを入れて溶かす
  8. 弱火で10分煮込む
  9. 火を止めて、野菜の旨みを染み込ませるためにラップをして半日置いておく
  10. 食べる時にトマト半分を入れ、トマトとカレーを温めてできあがり

メモ

  • にんじんのみじん切りを入れてもいいです
  • 合い挽き肉ではなく、豚ももひき肉でも美味しく食べられます
  • とろけるチーズをカレーに挟んで溶かすと美味しくなります
  • アボガド、卵焼きを添えると彩りがいいです

参考レシピ

ハウス食品:https://housefoods.jp/recipe/special/natsuyasaicurry/rcp_00015396.html

最高の鶏釜飯

調理道具

  • 炊飯器

材料(2~3人分)

  • 米          2合
  • 味噌と出汁      二人前分
  • かつお粉       4g
  • 鶏モモ肉       180g
  • ニンジン       50g ※半本分程度
  • ゴボウ        100g ※20cm程度
  • 万能ネギ(小口切り) 適量
  • 七味唐辛子      適量

下準備

  • ごぼうを2つに割って斜め切りでささがきする
  • にんじんを細切りにして切る
  • 鰹節を細かく粉状にしておく
  • 鶏もも肉を一口サイズに切る

調理

  1. 米を洗って水を抜いておく
  2. 味噌、出汁を2合分の水に溶かして炊飯器に入れる
  3. ごぼう、にんじん、鶏肉を炊飯器に入れる
  4. 炊飯器で炊く
  5. 炊き上がったら5分蒸らす
  6. 小ネギ、七味をかけて食べる

メモ

  • 5合炊きの炊飯器の場合は3合程度にとどめること

参考レシピ

至高のチキンカレー

調理道具

  • 深鍋フライパン(チキンカレーを作る)
  • ボウル大(アチャールを作る)

材料(3人分)

【カレー】

  • 鶏もも肉    350g
  • 玉ねぎ     1個
  • ホールトマト缶 200g
  • サラダ油    大さじ4
  • にんにく    4片
  • 生姜      25g
  • クミン     小さじ1
  • ターメリック  小さじ1
  • コリアンダー  小さじ1
  • チリペッパー  小さじ1
  • 水       400cc
  • 創味シャンタン 小さじ1と1/2
  • 塩       小さじ1と1/3
  • はちみつ    小さじ2

【煮込み後の追加香辛料】

  • クミン     小さじ1
  • ターメリック  小さじ1/2
  • コリアンダー  小さじ1/2
  • チリペッパー  小さじ1/2

【アチャール(玉ねぎの浅漬け)】

  • 玉ねぎ     1個
  • 塩       小さじ1
  • チリペッパー  小さじ2/3
  • レモン     1/6個(もしくはレモン汁小さじ2)

下準備

  • ニンニクをみじん切りにする
  • 生姜をみじん切りにする
  • 玉ねぎをみじん切りにする
  • 鶏もも肉の筋を取り除き、一口大の大きさに削ぎ切りする
  • クミン小さじ1、ターメリック小さじ1、コリアンダー小さじ1、チリペッパー小さじ1を計量して小皿に入れておく
  • ホールトマト缶200gを計量して皿に移しておく

調理

【チキンカレー】

  1. 油大さじ4を深鍋フライパンに入れて、よく温める
  2. にんにくをフライパンに入れ、中火で1分程度狐色になるまで炒める
  3. 玉ねぎをフライパンに入れ、中火で茶色に色づくまで炒める
  4. 生姜をフライパンに入れて、中火で1分程度香りが立つまで炒める
  5. ホールトマト200gを入れて、中火で温める
  6. フライパンにクミン小さじ1、ターメリック小さじ1、コリアンダー小さじ1、チリペッパー小さじ1を入れて炒める
  7. フライパンに鶏もも肉を入れて、中火で炒める
  8. 水400ccと塩小さじ1/3を入れて沸騰するまで温める
  9. フライパンに、はちみつ小さじ2・創味シャンタン小さじ1と1/2、塩小さじ1と1.3を入れて、強火で沸騰させて8分煮込む
  10. 火を止めてクミン小さじ1、ターメリック小さじ1/2、コリアンダー小さじ1/2、チリペッパー小さじ1/2を入れて混ぜる
  11. 1日置いておき、味をなじませて出来上がり

【アチャール】

  1. 玉ねぎをスライスして、ボウルに入れる
  2. 塩小さじ1をボウルに入れて玉ねぎに揉み込む
  3. 10分置く
  4. 玉ねぎをキツく絞って水分を抜く
  5. チリペッパー小さじ2/3とレモン汁小さじ2を入れて箸で混ぜる

メモ

  • 鶏もも肉の代わりに、鶏のささみでも大丈夫でした
  • 少し辛めなので、煮込み前のチリペッパーを小さじ1/2に減らして、煮込み後に投入するチリペッパーで辛さを調節するといいです

参考レシピ