--- title: tver の現状 tags: - homelab - tver status: 本番配置を準備中 --- # tver の現状 > [!info] このファイルの位置づけ > tver プロジェクトの現状(決定事項・要件・構成・課題)を積み上げていく。運用規約は [[AGENTS]] を参照。 ## 今の状態 - Go の HTTP サーバー、TVer API クライアント、ドラマ・バラエティの新着表示、テスト、web・worker コンテナの定義がある - web・worker・登録番組の定期確認まで実装済み。本番配置に向けて、NAS用PostgreSQL定義と配置手順を整備している - リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git` ## 目的 TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ動画を yt-dlp でダウンロードし、アプリ内で再生する Web アプリを作る。 ## 決定事項 | 項目 | 決定 | |---|---| | TVer 利用規約との関係 | 鑑賞を前提とするダウンロードのため、問題無しと判断(ユーザー判断) | | 利用者・公開範囲 | 自分のみ・**LAN 内だけ**。EC2 nginx では公開しない | | 保存先 | **NAS(ds923p)の `/volume1/video/tver`**。既存の`anime`・`drama`・`misc`・`personal`・`variety`と分け、admin相当の書き込み範囲を限定する。`/volume1/video`をdockageの`/mnt/nas-tver`へNFSマウントし、`.env`は`VIDEO_HOST_DIR=/mnt/nas-tver/tver`とする | | 自動ダウンロード | **作る**(番組を登録し、新しい回を自動でダウンロードする) | | 再生 | **アプリの中で再生する** | | ダウンロード | yt-dlp を使う | | デプロイ | 公開リポジトリをdockage VMの`/opt/stacks/tver`へcloneし、Dockgeでbuild・起動する。更新は`git pull --ff-only`後に再ビルドする | | 開発言語 | **Go**(`web`・`worker` とも)。yt-dlp は外部コマンドとして呼び、進捗・結果は yt-dlp の出力から読み取る | | DB | **NAS上のtver専用Postgres 17コンテナ**(Giteaと同じ方式)。ユーザー・DB名は`tver`、公開ポートは`15434`。ポートが空いているかは未確認 [要確認] | | DB ドライバ | `github.com/jackc/pgx/v5` の `pgxpool` | | DB テスト | `github.com/fergusstrange/embedded-postgres` v1.34.0 の Postgres 17 を起動する。Apple Silicon では Rosetta 2 が必要 | | 定期実行 | **`worker` 内のスケジューラ** | | NAS のマウント | **NFS**。dockageの`/etc/fstab`へ`_netdev,nofail`付きで登録し、DSMでは接続元`192.168.10.155`だけを許可する | | NFS 権限 | 読み書き、Squash「すべてのユーザーをadminに割り当てる」、`sys`、非同期オン。DSMのadminアカウントが無効でもUID `65532`から書き込み確認済み | | DBの通信制限 | DSMファイアウォールでTCP `15434`を`192.168.10.155`からだけ許可する | | DBバックアップ | 稼働後に、コンテナ内の`pg_dump`からNASへ保存する日次処理を用意する | | 保持期間 | **削除は手動のみ**。自動削除はしない | | 待ち行列 | **Postgres のテーブルだけで実装する**。専用のキュー基盤は持たない | | 定期確認の間隔 | 登録番組の新しい回を **24 時間ごと**に確認する | | 定期確認の走査 | worker のスケジューラが起動直後と1時間ごとに、最終確認から24時間以上経過した有効な番組を探す。番組は1件ずつ確認し、間に数秒空ける | | 定期確認の予告 | **180秒以下**の回は自動登録せず、既知としてだけ記録する。新着画面の予告判定と同じしきい値を使う | | 定期確認の失敗 | `last_checked_at` を更新せず、失敗日時と秘匿値を伏せた短いメッセージを保存する。次の1時間走査で再試行し、成功時にエラーを消す | | 登録時の既存回 | **登録の都度**「公開中の回もダウンロードする」かを選ぶ。選ばない場合も公開中の回を既知として記録する | | 番組の一時停止 | 登録は残し、自動ダウンロードだけ止める。再開後は停止中に公開された未確認の回も対象にする | | 動画のマウント先 | web と worker は `VIDEO_DIR`(既定 `/videos`)に同じ NAS 共有をマウントする。`download_jobs.file_path` は `VIDEO_DIR` からの相対パスを保存する | | ファイル名 | `<番組名>/<番組名> <回タイトル> [<エピソードID>].mp4`。禁止文字と制御文字を `_` に置換し、空の要素は `名称未設定`とする。番組名はUTF-8で90バイト、回タイトルは140バイト、サニタイズ後のエピソードIDは64バイトまでとし、ファイル名全体を255バイト以下に収める。64バイトを超えるIDはworkerで`invalid_output`にする。実際のTVer IDは約10文字で、yt-dlpのextractorも英数字として扱うため64バイトを上限とした。既存ファイルは改名せず、新しい保存から適用する | | 動画ファイルの境界 | webは`VIDEO_DIR`を`os.OpenRoot`で開き、DBの相対`file_path`を`Root.Open`・`Root.Remove`へ渡す。`VIDEO_DIR`外へのパストラバーサルと外向きシンボリックリンクをOSのディレクトリ境界で拒否する | | 同名ファイル | 移動先に同名ファイルがあれば上書きする。ファイル名にエピソード ID が入り `episode_id` は一意なので、同名は同じ回の前回処理の残りに限られる。再実行で同じ結果になることを優先する | | 動画の削除 | ファイルを先に削除してからジョブ記録を削除する。ファイルが既に無い場合も記録は削除する。登録番組の `known_episodes` は残す | | 同時ダウンロード数 | 既定は **1**。`WORKER_CONCURRENCY` で **2まで**増やせる | | ダウンロードの再試行 | 自動では再試行しない。失敗を `failed` で残し、画面から手動で再試行する | | 実行中の取り消し | web で `running` を `canceled` にし、worker が検知して yt-dlp のプロセスグループを停止する | | yt-dlp の作業場所 | `VIDEO_DIR/.incoming//` に `%(id)s.%(ext)s` で保存し、検査後に Go が `media.RelativeVideoPath` の場所へ同一ファイルシステム内で移動する。終了状態にかかわらず作業場所を片付ける | ## 要件 ### 機能要件 | # | 要件 | 補足 | |---|---|---| | F1 | ドラマ・バラエティの新着一覧を表示する | 番組名・回タイトル・放送局・長さ・配信終了日時・サムネイル | | F2 | 一覧を絞り込む | 予告(「60秒PR」等)を除外できる。ダウンロード済み・待ち行列中を区別して表示する | | F3 | 選んだ動画をダウンロードの待ち行列に入れる | 複数選択できる | | F4 | ダウンロードの進捗・結果を表示する | 失敗理由(DRM・地域制限・TVer 側の変更による抽出失敗)を区別して表示する | | F5 | 同じ回を二重にダウンロードしない | 手動・自動の両方で共通 | | F6 | 番組(シリーズ)を登録し、新しい回を自動でダウンロードする | 登録・解除・一覧。定期的に新しい回を確認する | | F7 | ダウンロード済みの動画をアプリ内で再生する | シーク(HTTP Range)に対応する | | F8 | ダウンロード済みの動画を一覧・削除する | NAS の容量管理のため | | F9 | 保存ファイルの名前と置き場所を規則で決める | 例: `<番組名>/<番組名> <回タイトル> [<エピソードID>].mp4` | ### 非機能要件 - **yt-dlp を素早く更新できること。** TVer 側の変更で抽出が壊れやすいので、worker のイメージを再ビルドするだけで更新できるようにする - **TVer への負荷を抑える。** 一覧取得はキャッシュし、同時ダウンロード数は 1〜2 に制限する - **ブラウザで再生できる形式で保存する。** 映像 H.264 + 音声を MP4 に結合する(ffmpeg が必要) - 認証は持たない(LAN 内限定のため) - homelab の規約に従う([[homelab/docs/conventions|運用規約]]): Compose v2、秘匿情報は `.env`、`TZ=Asia/Tokyo` ## 構成(案) | コンテナ | 役割 | | -------- | ----------------------------------------------------- | | `web` | UI・API。新着一覧の取得、待ち行列への登録、番組登録、動画の配信(再生) | | `worker` | Go + yt-dlp + ffmpeg。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する | - 現在の `web` は `/healthz`、新着(`/`)、待ち行列(`/queue`)、登録番組(`/subscriptions`)、完了済みライブラリ(`/library`)、再生ページ(`/library/{id}`)、Range 対応の MP4 配信(`/videos/{id}`)を提供する - 動画配信と削除はクライアントからジョブ ID だけを受け取り、DB の相対 `file_path` を `os.Root` の `Open`・`Remove` で扱う。絶対パス・`..`・`VIDEO_DIR` 外を指すシンボリックリンクをディレクトリ境界で拒否する。削除後は新着から手動で再登録できるが、登録番組の既知記録は残るため自動再登録しない - 新着の「番組を登録」から、公開中の回をダウンロードするか選ぶ。web はサーバー側の新着キャッシュから番組 ID と名前を引き、TVer の season ごとの公開中エピソードを取得する。取得失敗時は登録しない - 登録と公開中の回の既知記録・任意の待ち行列投入は1トランザクションで行い、登録時刻を最終確認日時に設定する。`known_episodes` は番組解除時に連動して削除する。一時停止中は投入を拒否する - worker のスケジューラは起動直後と1時間ごとに、`enabled` かつ最終確認から24時間以上の番組を抽出する。TVer から公開中の回を順次取得し、未確認の180秒超の回だけを `source=subscription` で投入する。180秒以下も既知にし、成功時の最終確認更新・エラー消去まで1トランザクションで行う - TVer の番組取得に失敗した場合は最終確認日時を変えず、失敗日時と伏字済みの短いメッセージを `subscriptions` に記録して画面に表示する。番組間には3秒空け、SIGTERM では取得中または待機中の確認を中断する - 新着の登録ボタンはエピソード ID とジャンルだけを送る。web が有効な新着キャッシュから番組情報を引き、`episode_id` の一意制約で重複登録を防ぐ。新着一覧はジョブ状態を一括取得し、失敗・取り消しには再試行を表示する - 待ち行列は実行中・待機中・失敗・取り消し・完了を状態別に直近50件ずつ表示する。段階別の進捗と失敗理由を表示し、待機中の取り消しと失敗・取り消しの再試行ができる。実行中または待機中のジョブがある間だけ5秒ごとに一覧を更新する - 実行中のジョブにも取り消しボタンを表示する。worker は DB の `canceled` を定期確認し、yt-dlp をプロセスグループごと停止する - 新着はジャンルごとにメモリへ 10 分間キャッシュする。キャッシュ中は TVer セッションの再発行と一覧の再取得を行わない。TVer の取得に失敗した場合、一覧の場所にエラーを表示して詳細をログへ記録する - 「予告を隠す」は既定でオンとし、180 秒以下の件を非表示にする。長さは秒から分へ切り上げて表示し、配信終了日時は Asia/Tokyo で表示する。ジャンルと予告表示の状態は URL に残す - 画面は `html/template` と htmx 2.0.11 を使い、htmx は Go の `embed` から `/static/` でローカル配信する - `compose.yaml` は web と worker を定義する。web は dockage VM の LAN アドレス `192.168.10.155:8080` で公開し、待ち受けは `WEB_ADDR` で変更できる。両方へ `DATABASE_URL` と同じ動画ボリュームを渡し、worker の並行数は `WORKER_CONCURRENCY`(既定1、最大2)で指定する - NAS用の`deploy/nas/tver-db/compose.yaml`は`postgres:17`を`15434:5432`で公開し、`/volume1/docker/tver-db/pgdata`へ保存する。パスワードは同ディレクトリのGit管理外`.env`から注入する - 本番配置と更新の手順は[[docs/deploy|tver 本番配置手順]]にまとめる。dockageでは`/volume1/video`を`/mnt/nas-tver`へNFSマウントし、その配下の`tver`だけをコンテナ内の`/videos`へ渡す - DB は NAS 上に tver 専用 Postgres 17 コンテナを置く。`internal/store` の埋め込み SQL を `schema_migrations` で管理し、`pg_advisory_lock` で複数プロセスからの同時適用を直列化する。web は起動時に `pgxpool` で接続してマイグレーションを適用する - `download_jobs` は `episode_id` を一意にして二重登録を防ぎ、ジョブの状態・段階別進捗・結果を持つ。`subscriptions` はシリーズ単位の登録と最終確認日時を持つ。worker からの操作と画面用の一覧取得を `internal/store` にまとめる - worker は `yt-dlp[default]` と ffmpeg を外部コマンドとして使い、映像・音声・結合・移動の段階別進捗を1秒間隔に間引いて保存する。独立した heartbeat を5秒ごとに更新し、正確な全体パーセントは保存しない。`after_move:filepath` で実パスを取り、ffprobe で H.264 と AAC の MP4 を確認して成功とする。失敗は取り消し、DRM、地域制限、ネットワーク、配信不可、抽出失敗、出力不正、その他の順に扱い、自動再試行しない - worker は起動時にマイグレーションと停止中ジョブの `RequeueStale` を行い、登録番組の確認も直ちに1回行う。SIGTERM では yt-dlp と確認処理を止め、担当中ジョブを `queued` に戻してから終了する。`Dockerfile.worker` は yt-dlp 2026.08.19 を ARG で固定し、ffmpeg・ffprobe とともに非 root で実行する - 保存先の NAS 共有フォルダは dockage VM に NFS でマウントし、`worker`(書き込み)と `web`(読み取り・削除)に同じ `VIDEO_DIR` でバインドマウントする ## 外部とのつながり | 相手 | 用途 | 備考 | |---|---|---| | TVer `platform-api.tver.jp` | 新着一覧(`/service/api/v1/callNewerDetail/{drama\|variety}`)・番組のエピソード一覧 | **非公開 API**。セッションは `/v2/api/platform_users/browser/create` で発行する(yt-dlp と同じ方式) | | TVer `service-api.tver.jp` | 番組の season 一覧(`/api/v1/callSeriesSeasons/{series_id}`) | **非公開 API**。[yt-dlp の TVer extractor](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/tver.py) と同じ取得方式 | | yt-dlp | ダウンロード | TVer extractor を使う | | NAS ds923p(NFS) | 動画の保存先 | `/volume1/video`をdockageの`/mnt/nas-tver`へNFSv3でマウントし、アプリは`tver`サブフォルダを使う。DSMのNFS権限は`192.168.10.155`のみ | | NAS ds923p(Postgres 17) | DB | tver専用コンテナを`15434:5432`で公開し、DSMファイアウォールで`192.168.10.155`のみ許可する。`15434`の空きは未確認 [要確認] | ## 確認済みの事実 - 新着 API は `drama` / `variety` それぞれ 100 件を返す。予告動画も含まれる。各件に `id`・`seriesID`・`seriesTitle`・`title`・`broadcasterName`・`duration`・`endAt`・`thumbnailPath` がある [実機] 2026-10-03 確認 - 番組 `sr0lhdrtdb` の `callSeriesSeasons` は `result.contents` に `type=season` の1件(`ss3i6njoq3`)を返し、`callSeasonEpisodes` は `type=episode` の5件を返した。エピソードには新着と同じ `id`・`seriesID`・`seriesTitle`・`title`・`broadcasterName`・`duration`・`endAt`・`thumbnailPath` が含まれる。他の番組での season 数や一覧件数は未確認 [実機] 2026-10-03 確認 - dockage VM から TVer API に到達でき(HTTP 200)、送信元は日本と判定される [実機] 2026-10-03 確認 - yt-dlp 2026.08.19 で TVer のエピソードを抽出できる(`--simulate`)。映像(H.264 / `avc1`)と音声が別ストリームで、結合に ffmpeg が要る。1080p・約 50 分で約 1.2GiB [実機] 2026-10-03 確認 - 5 分の `epq432nee4` を yt-dlp 2026.08.19 で取得した。`--newline` と `--progress-template` の 1 行 1 JSON は Go の外部プロセスから逐次読める。ただし `uvx yt-dlp` のみで `pycryptodomex` が無いと AES-128 HLS を ffmpeg に委譲し、途中のダウンロード進捗が出なかった。`yt-dlp[default]` に含まれる `pycryptodomex` を加えた試行では途中の進捗が出た [実機] 2026-10-03 確認 - 別ストリームの映像と音声は進捗が個別に始まり、`format_id` で区別できる。結合処理は `postprocess:` の出力で検知でき、`--print after_move:filepath` で実ファイルのパスを取得した。完成した MP4 は 24,613,787 バイトで、ffprobe では H.264 640×360 と AAC LC だった。初回取得は約 10 秒だった [実機] 2026-10-03 確認 - SIGTERM で止めると `.part` と `.ytdl` が残り、再実行で 437,066 バイトから 438,090 バイトへ続きから取得できた [実機] 2026-10-03 確認 - 到達不能のプロキシ経由の通信失敗と存在しないエピソードは、ともに yt-dlp の終了コード 1 だった。stderr の文言で分類が必要。DRM・地域制限の失敗文言と終了コードは実測していない(yt-dlp の extractor ソースのみ確認) [実機] 2026-10-03 確認 - yt-dlp の TVer extractor は日本のみ(`_GEO_COUNTRIES = ['JP']`)。DRM 付きの作品は取得できない [公式] https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/tver.py (取得日: 2026-10-03) - dockage VM に NAS のマウントは無い。ルートの空きは約 20GB [実機] 2026-10-03 確認 - DSMのNFS権限を接続元`192.168.10.155`、読み書き、全ユーザーをadminへ割り当て、`sys`、非同期オンにすると、dockageからNFSv3で`/volume1/video`をマウントできた。設定直後の1回目は`access denied`、直後の2回目は成功したため、権限の反映待ちと考えられる [実機] 2026-10-04 確認 - DSMのadminアカウントが無効でも、UID `65532`のコンテナから`tver`サブフォルダの作成・書き込み・読み込み・rename・削除に成功した。NAS上の所有者はUID `1024`・GID `100`。`/mnt/nas-tver/tver`は作成済み [実機] 2026-10-04 確認 - NFSマウントのファイル名上限は`namlen=255` [実機] 2026-10-04 確認 ## 課題 - 新着 API が 100 件で打ち切りか、続きを取得できるか [要確認] - 新着のうち DRM 付きの作品の割合 [要確認] - 非公開 API と yt-dlp の TVer 対応は、TVer 側の変更で予告なく壊れうる。壊れたことを検知する手段が要る - Container ManagerとDSMで、DB公開ポート`15434`が未使用か確認する [要確認] - 稼働後に、コンテナ内の`pg_dump`→gzip→NAS `/volume1/backups/tver/`、既定14日保持のバックアップを用意する - DRM・地域制限の yt-dlp 失敗時の文言と終了コードは未実測 [要確認] ## 確かめ方 - Codex では `go test -count=1 ./...` を環境変数の前置なしで実行する。`go test` を許可する rules に一致させ、Postgres の共有メモリ作成に必要な権限で実行するため。初回のモジュール取得にはネットワーク接続が必要 - web を起動するには `.env.example` を参考に、Git 管理外の `.env` に実際の `DATABASE_URL` と `VIDEO_HOST_DIR` を設定する。`VIDEO_DIR` の既定は `/videos`。DB 接続がある環境では起動時にマイグレーションが適用される - DB テストは `embedded-postgres` v1.34.0 が Postgres 17 を一度起動し、テストごとに独立した DB を作る。バイナリキャッシュ、実行ファイル、データは `/private/tmp` 配下に置く。Apple Silicon での実行には Rosetta 2 の導入が必要 - TVer 応答のテストは `internal/tver/testdata/newer_drama.json`(2026-10-03 の実応答を 3 件に縮小)と `httptest` を使い、テストから本物の TVer にはアクセスしない - 番組一覧の応答は `internal/tver/testdata/series_seasons.json` と `season_episodes.json`(2026-10-03 の実応答を各1件に縮小)に記録した。テストは `httptest` を使い、実 API にアクセスしない - 新着画面のテストは偽物の TVer クライアント・store と差し替え可能な時計を使い、表示項目、絞り込みの境界、10 分キャッシュ、取得失敗、JST 表示、登録・状態表示・待ち行列の操作を確認する - ライブラリのテストは一時ディレクトリの小さな動画を使い、Range の 206・範囲外の 416、`os.Root`による相対パスとシンボリックリンクの拒否、404、ファイル削除後の記録削除を確認する。共有のファイル名規則は`internal/media`で90・140・255バイトの境界とUTF-8の完全性をテストする - worker のテストは `internal/worker/testdata` の偽 yt-dlp・ffprobe を使い、本物の TVer へ接続せずに正常完了、失敗分類、取消、終了時の待機復帰、進捗の間引き、heartbeat、出力検査、移動先、作業場所の片付けを確認する - 登録番組の定期確認は偽の TVer クライアントと差し替え可能な時計・タイマーを使い、24時間の境界、順次確認、1時間走査、失敗記録、予告の既知化、終了時の中断を確認する。store の抽出・既知化・投入・日時更新・エラー消去は embedded Postgres 17 で確認する - NAS用Composeは`deploy/nas/tver-db/compose.yaml`をYAMLパーサーで読み、`services.postgres`、環境変数、ポート、保存先、healthcheckを確認する。Dockerが使える環境では`docker compose --env-file deploy/nas/tver-db/.env.example -f deploy/nas/tver-db/compose.yaml config`でも展開結果を確認する - 本番の確認手順は[[docs/deploy|tver 本番配置手順]]に従い、NFS上の`/mnt/nas-tver/tver`をUID `65532`で読み書き・rename・削除できること、DBがhealthyであること、webの`/healthz`、実動画の保存・再生・シークを確認する - `env GOPATH=/private/tmp/tver-gopath GOCACHE=/private/tmp/tver-go-cache GOMODCACHE=/private/tmp/tver-go-modcache GOPROXY=off go vet ./...` で静的解析する - `DATABASE_URL`、`VIDEO_DIR`、`WEB_ADDR=127.0.0.1:18080` を設定して `go run ./cmd/web` で web を起動できる。worker は同じ `DATABASE_URL` と `VIDEO_DIR` を設定して `go run ./cmd/worker` で起動する。worker の実行は TVer への実アクセスを伴うため手動確認は少数回に留める