Compare commits
7
Commits
80d9606750
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4346c06fe | ||
|
|
a2dd67b8b6 | ||
|
|
3fb3227aa7 | ||
|
|
9b856d63fa | ||
|
|
3ddd2063b6 | ||
|
|
f963dea2d1 | ||
|
|
37d42851ff |
+1
-1
@@ -1,4 +1,4 @@
|
||||
DATABASE_URL=
|
||||
VIDEO_HOST_DIR=
|
||||
VIDEO_HOST_DIR=/mnt/nas-tver/tver
|
||||
VIDEO_DIR=/videos
|
||||
WORKER_CONCURRENCY=1
|
||||
|
||||
+32
-17
@@ -3,7 +3,7 @@ title: tver の現状
|
||||
tags:
|
||||
- homelab
|
||||
- tver
|
||||
status: worker の登録番組確認を実装中
|
||||
status: 本番稼働中
|
||||
---
|
||||
|
||||
# tver の現状
|
||||
@@ -14,7 +14,7 @@ status: worker の登録番組確認を実装中
|
||||
## 今の状態
|
||||
|
||||
- Go の HTTP サーバー、TVer API クライアント、ドラマ・バラエティの新着表示、テスト、web・worker コンテナの定義がある
|
||||
- **worker のダウンロード本体と登録番組の定期確認を実装中。** 待ち行列からの取得に加え、有効な登録番組の新しい回を確認して自動登録する
|
||||
- **本番稼働中。** dockageのweb・worker、NASのPostgreSQL 17、NFS動画保存を構成し、実動画の取得・再生・Range配信まで確認済み
|
||||
- リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git`
|
||||
|
||||
## 目的
|
||||
@@ -27,17 +27,21 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
|
||||
|---|---|
|
||||
| TVer 利用規約との関係 | 鑑賞を前提とするダウンロードのため、問題無しと判断(ユーザー判断) |
|
||||
| 利用者・公開範囲 | 自分のみ・**LAN 内だけ**。EC2 nginx では公開しない |
|
||||
| 保存先 | **NAS(ds923p)** |
|
||||
| 保存先 | **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 を使う |
|
||||
| デプロイ | Dockge で管理する複数コンテナ構成。dockage VM の `/opt/stacks/tver` |
|
||||
| デプロイ | 読み取り専用のGiteaユーザー`tver-deploy`でdockage VMの`/opt/stacks/tver`へcloneし、`docker compose up -d --build`で起動する。Dockgeにも同じスタックが表示される。更新は`git pull --ff-only`後に再ビルドする |
|
||||
| 配置用のGit認証 | `tver-deploy`を`tver`の読み取り共同作業者とし、repository読み取りトークンをdockageの権限`600`の専用credentialファイルに保存する。トークンの実値はGitや文書へ残さない |
|
||||
| 開発言語 | **Go**(`web`・`worker` とも)。yt-dlp は外部コマンドとして呼び、進捗・結果は yt-dlp の出力から読み取る |
|
||||
| DB | **NAS 上の tver 専用 Postgres 17 コンテナ**(Gitea と同じ方式)。バックアップは規約どおり `pg_dump` → NAS。公開ポートは未定(候補 15434、実機で確認する) |
|
||||
| DB | **NAS上のtver専用Postgres 17コンテナ**(Giteaと同じ方式)。ユーザー・DB名は`tver`、公開ポートは`15434`。dockageからの到達を確認済み |
|
||||
| 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**。DSM で `192.168.10.155` のみ許可する |
|
||||
| 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 時間ごと**に確認する |
|
||||
@@ -47,7 +51,8 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
|
||||
| 登録時の既存回 | **登録の都度**「公開中の回もダウンロードする」かを選ぶ。選ばない場合も公開中の回を既知として記録する |
|
||||
| 番組の一時停止 | 登録は残し、自動ダウンロードだけ止める。再開後は停止中に公開された未確認の回も対象にする |
|
||||
| 動画のマウント先 | web と worker は `VIDEO_DIR`(既定 `/videos`)に同じ NAS 共有をマウントする。`download_jobs.file_path` は `VIDEO_DIR` からの相対パスを保存する |
|
||||
| ファイル名 | `<番組名>/<番組名> <回タイトル> [<エピソードID>].mp4`。禁止文字と制御文字を `_` に置換し、空の要素は `名称未設定`、各入力要素は UTF-8 で60バイトまでに切り詰める。エピソード ID で同名衝突を避ける |
|
||||
| ファイル名 | `<番組名>/<番組名> <回タイトル> [<エピソード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まで**増やせる |
|
||||
@@ -87,7 +92,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
|
||||
| `worker` | Go + yt-dlp + ffmpeg。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する |
|
||||
|
||||
- 現在の `web` は `/healthz`、新着(`/`)、待ち行列(`/queue`)、登録番組(`/subscriptions`)、完了済みライブラリ(`/library`)、再生ページ(`/library/{id}`)、Range 対応の MP4 配信(`/videos/{id}`)を提供する
|
||||
- 動画配信と削除はクライアントからジョブ ID だけを受け取り、DB の相対 `file_path` を使う。絶対パス・`..`・`VIDEO_DIR` 外を指すシンボリックリンクを拒否する。削除後は新着から手動で再登録できるが、登録番組の既知記録は残るため自動再登録しない
|
||||
- 動画配信と削除はクライアントからジョブ 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トランザクションで行う
|
||||
@@ -99,6 +104,9 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
|
||||
- 「予告を隠す」は既定でオンとし、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`へ渡す
|
||||
- Giteaは匿名閲覧とSSHを無効にしているため、dockageのclone・pullは読み取り専用`tver-deploy`のrepository読み取りトークンを使う。Gitのcredential helperはGiteaのHTTPS URLに限定する
|
||||
- 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、地域制限、ネットワーク、配信不可、抽出失敗、出力不正、その他の順に扱い、自動再試行しない
|
||||
@@ -112,8 +120,9 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
|
||||
| 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) | 動画の保存先 | DSM で NFS を有効化し、共有フォルダを `192.168.10.155` のみに許可する |
|
||||
| NAS ds923p(Postgres 17) | DB | tver 専用コンテナを使う。DSM 内蔵の Postgres が `5432` を使うため別ポートで公開し、DSM ファイアウォールで `192.168.10.155` のみ許可する。公開ポートは実機で決める |
|
||||
| Gitea `gitea.kazu634.com` | 配置元 | 匿名cloneとSSHは使えない。`tver-deploy`のHTTPSトークンでclone・pullする |
|
||||
| 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`のみ許可する。dockageから接続確認済み |
|
||||
|
||||
## 確認済みの事実
|
||||
|
||||
@@ -126,15 +135,19 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
|
||||
- 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 確認
|
||||
- Giteaは`REQUIRE_SIGNIN_VIEW = true`・`DISABLE_SSH = true`で、匿名HTTPSの`info/refs`はHTTP `401`だった。読み取り専用ユーザー`tver-deploy`とrepository読み取りトークンで`/opt/stacks/tver`へのcloneに成功した [実機] 2026-10-04 確認
|
||||
- dockageで`docker compose up -d --build`を実行してweb・workerを起動でき、Dockgeにも`tver`スタックとして表示された。DSMファイアウォール設定後、dockageからNASのTCP `15434`へ到達できた [実機] 2026-10-04 確認
|
||||
- 本番の`/healthz`はHTTP `200`、新着バラエティは98件だった。4分の本編`ep5hcgopp2`は登録から20秒以内に完了し、92,340,468バイトの1080p H.264/AAC MP4をNASへ保存した。所有者はUID `1024`・GID `100`、`.incoming`は空で、`/library`とHTTP Range `206`も確認した [実機] 2026-10-04 確認
|
||||
- NASは番組フォルダ内に所有者UID `0`の`@eaDir`を作る。ライブラリ一覧はDBから作るため、アプリへの影響はない [実機] 2026-10-04 確認
|
||||
- `callNewerDetail/drama`は毎回100件を返し、応答に続きや総件数を示す項目はなかった。`result.contents`は`type=newer`で、`content`は`id`・`version`・`title`のみ。`page=2`・`offset=100`・`limit=200`を付けても先頭・末尾IDが同じ100件となり、パラメータは無視された。100件を超えて取得する手段は見つからなかったが、非公開APIのため不可能とは断定しない。新着一覧は直近100件までとなる一方、登録番組の自動確認は番組ごとの`callSeriesSeasons`→`callSeasonEpisodes`を使うため、新着から外れた回も取りこぼさない [実機] 2026-10-04 確認
|
||||
|
||||
## 課題
|
||||
|
||||
- 新着 API が 100 件で打ち切りか、続きを取得できるか [要確認]
|
||||
- 新着のうち DRM 付きの作品の割合 [要確認]
|
||||
- 非公開 API と yt-dlp の TVer 対応は、TVer 側の変更で予告なく壊れうる。壊れたことを検知する手段が要る
|
||||
- NAS の準備(NFS 共有フォルダ、tver 専用 Postgres 17 コンテナ、DSM ファイアウォール)は DSM での手作業になる。DB の公開ポート(候補 15434)は本番前に実機で確認する [要確認]
|
||||
- DRM・地域制限の yt-dlp 失敗時の文言と終了コードは未実測 [要確認]
|
||||
- **DBバックアップは未着手。** コンテナ内の`pg_dump`→gzip→NAS `/volume1/backups/tver/`、既定14日保持の日次処理を用意する
|
||||
- DRM・地域制限の実例探しはTVerへの大量アクセスになるため行わず、運用中に観測する。workerは失敗時の`error_message`をDBへ保存し、既知の文言に一致しない失敗は`unknown`にする。実例が出た時点で分類を見直す
|
||||
|
||||
## 確かめ方
|
||||
|
||||
@@ -144,8 +157,10 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
|
||||
- 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、相対パスとシンボリックリンクの検査、404、ファイル削除後の記録削除を確認する。共有のファイル名規則は `internal/media` でテストする
|
||||
- ライブラリのテストは一時ディレクトリの小さな動画を使い、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 への実アクセスを伴うため手動確認は少数回に留める
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
POSTGRES_PASSWORD=
|
||||
@@ -0,0 +1,36 @@
|
||||
# tver 用 PostgreSQL スタック(Synology ds923p / 192.168.10.201)
|
||||
#
|
||||
# 配置先: ds923p の Container Manager「プロジェクト」
|
||||
# /volume1/docker/tver-db/ に本ファイルを compose.yaml として配置する。
|
||||
# パスワードは同じディレクトリの .env(POSTGRES_PASSWORD)から注入し、
|
||||
# このファイルには実値を書かない。
|
||||
#
|
||||
# 注意(初回起動チェックポイント):
|
||||
# 起動前に /volume1/docker/tver-db/pgdata を作成する。
|
||||
# 初回起動後は Container Manager のヘルス状態とログを確認し、
|
||||
# "database system is ready to accept connections" が出てからアプリを起動する。
|
||||
# bind mount の所有権・権限で初期化に失敗した場合は、原因を確認してから
|
||||
# 名前付きボリュームへの切り替えを検討する。
|
||||
|
||||
services:
|
||||
postgres:
|
||||
# Debian ベースの PostgreSQL 17。initdb の UTF-8 ロケールを利用する。
|
||||
image: postgres:17
|
||||
container_name: tver-postgres
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: tver
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: tver
|
||||
# 初回の initdb でのみ有効。既存の pgdata には後から反映されない。
|
||||
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=en_US.UTF-8 --lc-ctype=en_US.UTF-8"
|
||||
ports:
|
||||
# [要確認] Container Manager で 15434 が未使用であることを起動前に確認する。
|
||||
# DSM ファイアウォールでは dockage(192.168.10.155)だけを許可する。
|
||||
- "15434:5432"
|
||||
volumes:
|
||||
- /volume1/docker/tver-db/pgdata:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U tver -d tver"]
|
||||
interval: 10s
|
||||
retries: 5
|
||||
+238
@@ -0,0 +1,238 @@
|
||||
---
|
||||
title: tver 本番配置手順
|
||||
tags:
|
||||
- homelab
|
||||
- tver
|
||||
- deployment
|
||||
---
|
||||
|
||||
# tver 本番配置手順
|
||||
|
||||
> [!info] 対象
|
||||
> Synology NAS `ds923p`(`192.168.10.201`)に PostgreSQL 17 と動画共有を用意し、dockage(`192.168.10.155`)の `/opt/stacks/tver` で web・worker を起動する。
|
||||
|
||||
> [!danger] 秘匿値を記録しない
|
||||
> PostgreSQL のパスワードや完成した `DATABASE_URL` は、この文書や Git 管理下のファイルに書かない。NAS と dockage の `.env` にだけ保存し、権限を `600` にする。
|
||||
|
||||
## 事前に確認する値
|
||||
|
||||
| 項目 | 値 |
|
||||
|---|---|
|
||||
| NAS | `ds923p` / `192.168.10.201` |
|
||||
| dockage | `192.168.10.155` |
|
||||
| NAS のNFS共有 | `/volume1/video` |
|
||||
| dockage のNFSマウント | `/mnt/nas-tver` |
|
||||
| tverの動画保存先 | NAS: `/volume1/video/tver`、dockage: `/mnt/nas-tver/tver` |
|
||||
| PostgreSQL 公開ポート | `15434` |
|
||||
| PostgreSQL ユーザー / DB | `tver` / `tver` |
|
||||
| アプリ配置先 | `/opt/stacks/tver` |
|
||||
| Gitea配置用ユーザー | `tver-deploy`(リポジトリ読み取りのみ) |
|
||||
|
||||
## 1. NASを準備する
|
||||
|
||||
### 1.1 NFSサービスを有効にする
|
||||
|
||||
1. DSMの「コントロール パネル」→「ファイル サービス」→「NFS」でNFSサービスを有効にする。
|
||||
2. 共有フォルダ `video` が `/volume1/video` にあることを確認する。
|
||||
3. `video` の「編集」→「NFS 権限」で次を追加する。
|
||||
|
||||
| 設定 | 値 |
|
||||
|---|---|
|
||||
| ホスト名またはIP | `192.168.10.155` |
|
||||
| 権限 | 読み書き |
|
||||
| Squash | すべてのユーザーをadminに割り当てる |
|
||||
| セキュリティ | `sys` |
|
||||
| 非同期を有効にする | オン |
|
||||
|
||||
> [!success] adminへの割り当てを確認済み
|
||||
> DSMの`admin`アカウントが無効な状態でも、上記設定でUID `65532`からフォルダ作成・書き込み・読み込み・rename・削除に成功した。NAS上の所有者はUID `1024`・GID `100`だった。[実機] 2026-10-04 確認
|
||||
|
||||
### 1.2 PostgreSQLのファイルを用意する
|
||||
|
||||
1. NAS上に次のディレクトリを事前作成する。
|
||||
|
||||
```text
|
||||
/volume1/docker/tver-db/
|
||||
/volume1/docker/tver-db/pgdata/
|
||||
```
|
||||
|
||||
2. [[../deploy/nas/tver-db/compose.yaml|NAS用compose.yaml]] と `.env.example` を `/volume1/docker/tver-db/` に配置する。
|
||||
3. `.env.example`を`.env`へコピーし、`POSTGRES_PASSWORD`に十分長いランダムな値を設定する。値には、URLへの埋め込みで追加のエスケープが不要な16進文字列を使える。
|
||||
|
||||
```bash
|
||||
cd /volume1/docker/tver-db
|
||||
umask 077
|
||||
cp .env.example .env
|
||||
# openssl rand -hex 64 で値を生成し、.env の POSTGRES_PASSWORD に設定する。
|
||||
chmod 600 .env
|
||||
```
|
||||
|
||||
> [!important] DBとアプリで同じパスワードを使う
|
||||
> ここで設定した値を、後述するdockage側の`DATABASE_URL`にも使う。値そのものは文書やGitへ残さない。
|
||||
|
||||
### 1.3 Container ManagerでPostgreSQLを起動する
|
||||
|
||||
1. Container Managerの「プロジェクト」で、`/volume1/docker/tver-db/compose.yaml`を使う`tver-db`プロジェクトを作成する。
|
||||
2. 起動前に、他のプロジェクトとポート一覧を確認し、`15434`が空いていることを確認する。
|
||||
3. プロジェクトをビルド・起動する。
|
||||
4. `tver-postgres`が`healthy`になることを確認する。
|
||||
5. コンテナログに`database system is ready to accept connections`があることを確認する。
|
||||
|
||||
初期化に失敗した場合は、先に`pgdata`の所有権・権限とログを確認する。空のDBを作り直す場合を除き、既存の`pgdata`を削除しない。
|
||||
|
||||
### 1.4 DSMファイアウォールを設定する
|
||||
|
||||
TCP `15434`について、送信元`192.168.10.155`だけを許可するルールを追加する。他の送信元を許可する広いルールより前に配置し、LAN内の別ホストからDBへ接続できない構成にする。
|
||||
|
||||
DSMファイアウォールを設定した状態で、dockageからNASのTCP `15434`へ到達できることを確認済みである。[実機] 2026-10-04 確認
|
||||
|
||||
## 2. dockageを準備する
|
||||
|
||||
### 2.1 NFSクライアントとマウント先を用意する
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install -y nfs-common
|
||||
sudo mkdir -p /mnt/nas-tver
|
||||
```
|
||||
|
||||
`/etc/fstab`へ次の1行を追加する。`_netdev`でネットワーク後に扱い、`nofail`でNAS停止時もdockageの起動を妨げない。
|
||||
|
||||
```fstab
|
||||
192.168.10.201:/volume1/video /mnt/nas-tver nfs defaults,_netdev,nofail 0 0
|
||||
```
|
||||
|
||||
設定を反映し、想定したNFS共有がマウントされたことを確認する。
|
||||
|
||||
```bash
|
||||
sudo mount -a
|
||||
findmnt --target /mnt/nas-tver
|
||||
```
|
||||
|
||||
実機ではNFSv3でマウントできた。NFS権限を追加した直後の1回目は`access denied`となり、直後の2回目で成功した。NFS権限の反映待ちと考えられるため、設定内容が正しい場合は少し待って`sudo mount -a`を再実行する。[実機] 2026-10-04 確認
|
||||
|
||||
### 2.2 workerのUIDで書き込みを確認する
|
||||
|
||||
既存の`anime`・`drama`・`misc`・`personal`・`variety`とアプリのファイルを分け、admin相当の書き込み範囲を限定するため、tver専用サブフォルダを使う。Dockerグループの`kazu634`で、workerコンテナと同じUID・GID `65532:65532`の確認用コンテナを実行し、フォルダ作成、書き込み、読み取り、rename、削除を確認する。`sudo`は不要である。
|
||||
|
||||
```bash
|
||||
docker run --rm --user 65532:65532 -v /mnt/nas-tver/tver:/videos busybox:stable sh -c 'mkdir /videos/.tver-write-test && echo ok > /videos/.tver-write-test/f && cat /videos/.tver-write-test/f && mv /videos/.tver-write-test/f /videos/.tver-write-test/g && ls -ln /videos/.tver-write-test && rm -r /videos/.tver-write-test && echo cleanup-ok'
|
||||
```
|
||||
|
||||
期待する結果は、内容の`ok`、`ls -ln`で表示される`g`の所有者`1024 100`、最後の`cleanup-ok`である。確認後は確認用イメージを削除する。
|
||||
|
||||
```bash
|
||||
docker rmi busybox:stable
|
||||
```
|
||||
|
||||
`/mnt/nas-tver/tver`は作成済みで、上記のDockerコンテナによる操作は実機確認済みである。[実機] 2026-10-04 確認。別環境へ作り直す場合も、書き込み確認が成功するまでworkerを起動しない。
|
||||
|
||||
### 2.3 Giteaに配置用ユーザーを用意する
|
||||
|
||||
Giteaは`REQUIRE_SIGNIN_VIEW = true`かつ`DISABLE_SSH = true`で動作している。匿名HTTPSの`info/refs`は`401`となり、SSHも使えないため、配置専用の読み取り資格情報を使う。[実機] 2026-10-04 確認
|
||||
|
||||
1. Giteaの管理画面で、配置専用ユーザー`tver-deploy`を作成する。
|
||||
2. `kazu634/tver`リポジトリの「設定」→「共同作業者」で`tver-deploy`を追加し、権限を「読み取り」にする。
|
||||
3. `tver-deploy`でGiteaへログインし、ユーザー設定の「アプリケーション」からrepository読み取り権限だけを持つトークンを作成する。
|
||||
4. トークンは作成時に安全な場所へ控え、この文書、シェル履歴、Git管理下のファイルへ実値を書かない。
|
||||
|
||||
### 2.4 dockageにclone用トークンを保存する
|
||||
|
||||
Dockerホストの管理ユーザー`kazu634`で次を実行する。トークンは非表示で読み取り、権限`600`の専用credentialファイルへ保存する。credential helperはGiteaのHTTPS URLにだけ設定する。
|
||||
|
||||
```bash
|
||||
install -d -m 700 ~/.config/git
|
||||
read -rs -p 'Gitea token: ' TOKEN; echo
|
||||
umask 077
|
||||
printf 'https://tver-deploy:%s@gitea.kazu634.com\n' "$TOKEN" > ~/.config/git/credentials-tver
|
||||
unset TOKEN
|
||||
git config --global credential.https://gitea.kazu634.com.helper 'store --file ~/.config/git/credentials-tver'
|
||||
git clone https://tver-deploy@gitea.kazu634.com/kazu634/tver.git /opt/stacks/tver
|
||||
cd /opt/stacks/tver
|
||||
```
|
||||
|
||||
この手順で認証付きcloneに成功した。[実機] 2026-10-04 確認。トークンを失効・更新した場合は、同じ手順で`credentials-tver`を作り直す。
|
||||
|
||||
### 2.5 アプリの環境変数を用意する
|
||||
|
||||
`.env.example`をGit管理外の`.env`へコピーし、`DATABASE_URL`へパスワード用の伏字を入れる。ファイルを`600`にしてから、パスワードを非表示で読み取り、伏字だけを置換する。
|
||||
|
||||
```bash
|
||||
cd /opt/stacks/tver
|
||||
umask 077
|
||||
cp .env.example .env
|
||||
sed -i 's|^DATABASE_URL=.*|DATABASE_URL=postgres://tver:__SET_PASSWORD__@192.168.10.201:15434/tver?sslmode=disable|' .env
|
||||
chmod 600 .env
|
||||
read -rs -p 'PostgreSQL password: ' DB_PASSWORD; echo
|
||||
sed -i "s|__SET_PASSWORD__|${DB_PASSWORD}|" .env
|
||||
unset DB_PASSWORD
|
||||
if grep -q '__SET_PASSWORD__' .env; then echo 'DATABASE_URLのパスワードが未設定です' >&2; exit 1; fi
|
||||
```
|
||||
|
||||
完成した`.env`は次の項目を持つ。`DATABASE_URL`の実値は表示したり、文書へ転記したりしない。
|
||||
|
||||
```dotenv
|
||||
DATABASE_URL=<設定済み・表示しない>
|
||||
VIDEO_HOST_DIR=/mnt/nas-tver/tver
|
||||
VIDEO_DIR=/videos
|
||||
WORKER_CONCURRENCY=1
|
||||
```
|
||||
|
||||
> [!warning] パスワードの文字種
|
||||
> この手順で使うPostgreSQLパスワードには、URLや`sed`の区切りに影響する`@`・`:`・`/`・`?`・`#`・`%`・`|`を含めない。NAS準備時に16進文字列で生成すれば、この制約を満たせる。
|
||||
|
||||
### 2.6 起動する
|
||||
|
||||
`/opt/stacks/tver`でwebとworkerをビルドして起動する。
|
||||
|
||||
```bash
|
||||
cd /opt/stacks/tver
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
この方法で起動すると、Dockgeの画面にも`tver`スタックとして表示された。[実機] 2026-10-04 確認。Dockge上でweb・workerがともにrunningになり、再起動を繰り返していないことを確認する。
|
||||
|
||||
コマンドでも状態とログを確認できる。
|
||||
|
||||
```bash
|
||||
cd /opt/stacks/tver
|
||||
docker compose ps
|
||||
docker compose logs --tail=100 web worker
|
||||
```
|
||||
|
||||
## 3. 動作を確認する
|
||||
|
||||
1. ヘルスチェックが`200`と`ok`を返すことを確認する。
|
||||
|
||||
```bash
|
||||
curl --fail --show-error http://192.168.10.155:8080/healthz
|
||||
```
|
||||
|
||||
2. ブラウザで`http://192.168.10.155:8080/`を開き、新着一覧が表示されることを確認する。
|
||||
3. 番組またはエピソードを1件登録し、`/queue`で待ち行列、進捗、完了を確認する。
|
||||
4. `/library`から動画を再生し、シークできることを確認する。
|
||||
5. NASの`/volume1/video/tver`に規則どおりのMP4が作成されたことを確認する。
|
||||
6. `tver-postgres`のログとweb・workerのログに、接続失敗やマイグレーション失敗がないことを確認する。
|
||||
|
||||
> [!success] 本番の動作確認結果
|
||||
> `/healthz`はHTTP `200`、新着バラエティは98件を表示した。4分の本編`ep5hcgopp2`を登録すると20秒以内に完了し、NASの`video/tver/<番組名>/`へ92,340,468バイトの1080p H.264/AAC MP4が保存された。所有者はUID `1024`・GID `100`、`.incoming`は空だった。`/library`の表示・再生とHTTP Range `206`も確認した。[実機] 2026-10-04 確認
|
||||
|
||||
> [!note] Synologyの`@eaDir`
|
||||
> NASが番組フォルダ内に所有者UID `0`の`@eaDir`を作る。アプリのライブラリ一覧はファイルシステムの走査ではなくDBから作るため、表示や再生には影響しない。[実機] 2026-10-04 確認
|
||||
|
||||
## 4. 更新する
|
||||
|
||||
```bash
|
||||
cd /opt/stacks/tver
|
||||
git status --short
|
||||
git pull --ff-only
|
||||
```
|
||||
|
||||
`git status --short`に追跡対象ファイルの変更がある場合は、内容を確認してから更新する。pull後、Dockgeで`tver`スタックを再ビルドして再配置し、[[#3. 動作を確認する]]を繰り返す。
|
||||
|
||||
yt-dlpを更新するときは`Dockerfile.worker`の`YT_DLP_VERSION`の既定値を変更してリポジトリへ反映し、workerイメージをキャッシュなしで再ビルドする。更新後はworkerログと少数の実動画で抽出・保存を確認する。
|
||||
|
||||
## 5. 稼働後に残る作業
|
||||
|
||||
> [!todo] PostgreSQLバックアップ
|
||||
> tver専用PostgreSQL 17コンテナ内の`pg_dump`で取得し、gzip圧縮してNASの`/volume1/backups/tver/`へ保存する日次処理を用意する。保持日数は既定14日とし、稼働確認後に別作業として実装する。
|
||||
+36
-15
@@ -1,23 +1,41 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
const maxComponentRunes = 80
|
||||
const maxComponentBytes = 60
|
||||
const (
|
||||
maxSeriesBytes = 90
|
||||
maxEpisodeBytes = 140
|
||||
maxEpisodeIDBytes = 64
|
||||
maxFilenameBytes = 255
|
||||
)
|
||||
|
||||
// RelativeVideoPath は worker と web が共有する保存先の相対パスを返す。
|
||||
func RelativeVideoPath(seriesTitle, episodeTitle, episodeID string) string {
|
||||
series := sanitizeComponent(seriesTitle)
|
||||
episode := sanitizeComponent(episodeTitle)
|
||||
id := sanitizeComponent(episodeID)
|
||||
return filepath.Join(series, series+" "+episode+" ["+id+"].mp4")
|
||||
func RelativeVideoPath(seriesTitle, episodeTitle, episodeID string) (string, error) {
|
||||
series := sanitizeComponent(seriesTitle, maxSeriesBytes)
|
||||
episode := sanitizeComponent(episodeTitle, maxEpisodeBytes)
|
||||
id := sanitizeComponent(episodeID, -1)
|
||||
if len(id) > maxEpisodeIDBytes {
|
||||
return "", fmt.Errorf("エピソードIDはサニタイズ後%dバイト以下である必要があります: %dバイト", maxEpisodeIDBytes, len(id))
|
||||
}
|
||||
|
||||
func sanitizeComponent(value string) string {
|
||||
// A directory entry must fit within the NFS namlen limit. Keep the episode
|
||||
// ID intact and use the remaining bytes for the episode title.
|
||||
const separators = " [].mp4"
|
||||
seriesInFilename := series
|
||||
titleBytes := maxFilenameBytes - len(seriesInFilename) - len(id) - len(separators)
|
||||
if titleBytes < 0 {
|
||||
titleBytes = 0
|
||||
}
|
||||
episode = truncateComponent(episode, titleBytes, false)
|
||||
return filepath.Join(series, seriesInFilename+" "+episode+" ["+id+"].mp4"), nil
|
||||
}
|
||||
|
||||
func sanitizeComponent(value string, maxBytes int) string {
|
||||
var result []rune
|
||||
lastReplacement := false
|
||||
for _, r := range value {
|
||||
@@ -39,22 +57,25 @@ func sanitizeComponent(value string) string {
|
||||
if clean == "" || clean == "." || clean == ".." {
|
||||
return "名称未設定"
|
||||
}
|
||||
runes := []rune(clean)
|
||||
if len(runes) > maxComponentRunes {
|
||||
runes = runes[:maxComponentRunes]
|
||||
return truncateComponent(clean, maxBytes, true)
|
||||
}
|
||||
|
||||
func truncateComponent(value string, maxBytes int, fallback bool) string {
|
||||
if maxBytes < 0 {
|
||||
return value
|
||||
}
|
||||
var limited []rune
|
||||
bytes := 0
|
||||
for _, r := range runes {
|
||||
for _, r := range value {
|
||||
size := len(string(r))
|
||||
if bytes+size > maxComponentBytes {
|
||||
if bytes+size > maxBytes {
|
||||
break
|
||||
}
|
||||
limited = append(limited, r)
|
||||
bytes += size
|
||||
}
|
||||
clean = strings.Trim(string(limited), " .")
|
||||
if clean == "" || clean == "." || clean == ".." {
|
||||
clean := strings.Trim(string(limited), " .")
|
||||
if fallback && (clean == "" || clean == "." || clean == "..") {
|
||||
return "名称未設定"
|
||||
}
|
||||
return clean
|
||||
|
||||
+141
-28
@@ -1,6 +1,7 @@
|
||||
package media
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -9,19 +10,28 @@ import (
|
||||
)
|
||||
|
||||
func TestRelativeVideoPath(t *testing.T) {
|
||||
got := RelativeVideoPath("番組名", "第1話", "ep123")
|
||||
got, err := RelativeVideoPath("番組名", "第1話", "ep123")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := filepath.Join("番組名", "番組名 第1話 [ep123].mp4")
|
||||
if got != want {
|
||||
t.Fatalf("path=%q want=%q", got, want)
|
||||
}
|
||||
other := RelativeVideoPath("番組名", "第1話", "ep456")
|
||||
other, err := RelativeVideoPath("番組名", "第1話", "ep456")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got == other || !strings.Contains(other, "[ep456]") {
|
||||
t.Fatalf("ID による衝突回避: %q %q", got, other)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeVideoPathSanitizesComponents(t *testing.T) {
|
||||
got := RelativeVideoPath(" 番組:/\\*?<>|\"\x00\n\t\x7f\u0085. ", "\x00\n", "../")
|
||||
got, err := RelativeVideoPath(" 番組:/\\*?<>|\"\x00\n\t\x7f\u0085. ", "\x00\n", "../")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if filepath.IsAbs(got) || strings.ContainsAny(got, `:*?<>|"`) || strings.Contains(got, "..") {
|
||||
t.Fatalf("禁止文字が残った: %q", got)
|
||||
}
|
||||
@@ -38,27 +48,122 @@ func TestRelativeVideoPathSanitizesComponents(t *testing.T) {
|
||||
|
||||
func TestRelativeVideoPathHandlesEmptyAndLongNames(t *testing.T) {
|
||||
for _, name := range []string{"", " ", ".", "..", "..."} {
|
||||
got := RelativeVideoPath(name, name, "")
|
||||
got, err := RelativeVideoPath(name, name, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(got, "名称未設定") {
|
||||
t.Errorf("空の代替名 %q: %q", name, got)
|
||||
}
|
||||
}
|
||||
long := strings.Repeat("界", 200)
|
||||
got := RelativeVideoPath(long, long, "id")
|
||||
got, err := RelativeVideoPath(long, long, "id")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(got), "/")
|
||||
if len(parts) != 2 || len([]byte(parts[0])) != 60 || !utf8.ValidString(parts[0]) {
|
||||
t.Fatalf("番組名の60バイト境界: %q (%d bytes)", parts[0], len([]byte(parts[0])))
|
||||
if len(parts) != 2 || len([]byte(parts[0])) != maxSeriesBytes || !utf8.ValidString(parts[0]) {
|
||||
t.Fatalf("番組名の%dバイト境界: %q (%d bytes)", maxSeriesBytes, parts[0], len([]byte(parts[0])))
|
||||
}
|
||||
for _, part := range strings.Split(filepath.ToSlash(got), "/") {
|
||||
if len([]byte(part)) > 240 {
|
||||
t.Fatalf("長すぎる要素: %d bytes", len([]byte(part)))
|
||||
if len([]byte(parts[1])) > maxFilenameBytes || !utf8.ValidString(parts[1]) {
|
||||
t.Fatalf("ファイル名の上限: %q (%d bytes)", parts[1], len([]byte(parts[1])))
|
||||
}
|
||||
if !utf8.ValidString(part) {
|
||||
t.Fatalf("不正な UTF-8: %q", part)
|
||||
if got := sanitizeComponent(strings.Repeat("a", maxEpisodeBytes+1), maxEpisodeBytes); len([]byte(got)) != maxEpisodeBytes {
|
||||
t.Fatalf("ASCII の%dバイト境界: %d %q", maxEpisodeBytes, len([]byte(got)), got)
|
||||
}
|
||||
}
|
||||
if got := sanitizeComponent(strings.Repeat("a", 61)); len([]byte(got)) != 60 {
|
||||
t.Fatalf("ASCII の60バイト境界: %d %q", len([]byte(got)), got)
|
||||
|
||||
func TestRelativeVideoPathEpisodeIDLimit(t *testing.T) {
|
||||
id := strings.Repeat("i", maxEpisodeIDBytes)
|
||||
got, err := RelativeVideoPath(strings.Repeat("s", maxSeriesBytes), strings.Repeat("t", maxEpisodeBytes), id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(got), "/")
|
||||
if len(parts) != 2 || len([]byte(parts[1])) > maxFilenameBytes || !strings.Contains(parts[1], "["+id+"]") {
|
||||
t.Fatalf("64バイトIDのファイル名=%q", got)
|
||||
}
|
||||
if _, err := RelativeVideoPath("series", "episode", strings.Repeat("i", maxEpisodeIDBytes+1)); err == nil {
|
||||
t.Fatal("65バイトのエピソードIDを受理した")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeVideoPathUsesFilenameBudgetInRequiredOrder(t *testing.T) {
|
||||
series := strings.Repeat("s", maxSeriesBytes)
|
||||
title := strings.Repeat("t", maxEpisodeBytes)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
idBytes int
|
||||
wantSeriesInFile int
|
||||
wantEpisodeInFile int
|
||||
wantFilenameBytes int
|
||||
}{
|
||||
{"ちょうど255バイト", 17, 90, 140, 255},
|
||||
{"回タイトルから1バイト削る", 18, 90, 139, 255},
|
||||
{"64バイトIDでも番組名を維持", 64, 90, 93, 255},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
id := strings.Repeat("i", tt.idBytes)
|
||||
path, err := RelativeVideoPath(series, title, id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||
if len(parts) != 2 || parts[0] != series {
|
||||
t.Fatalf("ディレクトリ名=%q", parts)
|
||||
}
|
||||
wantFilename := strings.Repeat("s", tt.wantSeriesInFile) + " " + strings.Repeat("t", tt.wantEpisodeInFile) + " [" + id + "].mp4"
|
||||
if parts[1] != wantFilename || len([]byte(parts[1])) != tt.wantFilenameBytes {
|
||||
t.Fatalf("filename=%q (%d bytes) want=%q (%d bytes)", parts[1], len([]byte(parts[1])), wantFilename, tt.wantFilenameBytes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeVideoPathEpisodeIDNearAndBeyondFilenameLimit(t *testing.T) {
|
||||
for _, idBytes := range []int{64, 65, 248, 255, 256} {
|
||||
t.Run(fmt.Sprintf("id_%d_bytes", idBytes), func(t *testing.T) {
|
||||
id := strings.Repeat("i", idBytes)
|
||||
path, err := RelativeVideoPath("series", "episode", id)
|
||||
if idBytes > 64 {
|
||||
if err == nil || path != "" {
|
||||
t.Fatalf("%dバイトID: path=%q err=%v, want error", idBytes, path, err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "64バイト以下") {
|
||||
t.Fatalf("%dバイトIDのエラー理由=%q", idBytes, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("parts=%q", parts)
|
||||
}
|
||||
if !strings.Contains(parts[1], "["+id+"]") {
|
||||
t.Fatalf("episode ID was changed: %q", parts[1])
|
||||
}
|
||||
if got := len([]byte(parts[1])); got > maxFilenameBytes {
|
||||
t.Fatalf("episode ID %d bytes produced %d-byte filename above %d: %q", idBytes, got, maxFilenameBytes, parts[1])
|
||||
}
|
||||
if !utf8.ValidString(parts[1]) {
|
||||
t.Fatalf("filename is not valid UTF-8: %q", parts[1])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeVideoPathHasNoRuneLimit(t *testing.T) {
|
||||
series := strings.Repeat("a", 90)
|
||||
title := strings.Repeat("b", 100)
|
||||
got, err := RelativeVideoPath(series, title, "id")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(got), "/")
|
||||
if parts[0] != series || !strings.Contains(parts[1], " "+title+" [id].mp4") {
|
||||
t.Fatalf("80 rune limit remains: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,11 +174,11 @@ func TestSanitizeComponentTrimsWhitespaceBeforeLengthLimit(t *testing.T) {
|
||||
{"前後の空白", " \t 番組名\n ", "_ 番組名_"},
|
||||
{"空白のみ", " ", "名称未設定"},
|
||||
{"制御文字のみ", "\t\n", "_"},
|
||||
{"多数の先頭空白", strings.Repeat(" ", maxComponentRunes+10) + "番組名", "番組名"},
|
||||
{"先頭空白と長い名前", strings.Repeat(" ", 70) + strings.Repeat("a", 70), strings.Repeat("a", 60)},
|
||||
{"多数の先頭空白", strings.Repeat(" ", 100) + "番組名", "番組名"},
|
||||
{"先頭空白と長い名前", strings.Repeat(" ", 70) + strings.Repeat("a", 100), strings.Repeat("a", maxSeriesBytes)},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := sanitizeComponent(tt.input); got != tt.want {
|
||||
if got := sanitizeComponent(tt.input, maxSeriesBytes); got != tt.want {
|
||||
t.Errorf("sanitizeComponent()=%q want=%q", got, tt.want)
|
||||
}
|
||||
})
|
||||
@@ -84,14 +189,14 @@ func TestSanitizeComponentTrimsAgainAfterLengthLimit(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name, input, want string
|
||||
}{
|
||||
{"60バイト目が空白", strings.Repeat("a", 59) + " " + "b", strings.Repeat("a", 59)},
|
||||
{"60バイト目がドット", strings.Repeat("a", 59) + "." + "b", strings.Repeat("a", 59)},
|
||||
{"切り詰め部分が空白のみ", "a" + strings.Repeat(" ", 59) + "b", "a"},
|
||||
{"切り詰め部分がドットのみ", "a" + strings.Repeat(".", 59) + "b", "a"},
|
||||
{"複数バイト文字の直後がドット", strings.Repeat("界", 19) + ".続", strings.Repeat("界", 19)},
|
||||
{"90バイト目が空白", strings.Repeat("a", 89) + " " + "b", strings.Repeat("a", 89)},
|
||||
{"90バイト目がドット", strings.Repeat("a", 89) + "." + "b", strings.Repeat("a", 89)},
|
||||
{"切り詰め部分が空白のみ", "a" + strings.Repeat(" ", 89) + "b", "a"},
|
||||
{"切り詰め部分がドットのみ", "a" + strings.Repeat(".", 89) + "b", "a"},
|
||||
{"複数バイト文字の直後がドット", strings.Repeat("界", 29) + ".続", strings.Repeat("界", 29)},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := sanitizeComponent(tt.input)
|
||||
got := sanitizeComponent(tt.input, maxSeriesBytes)
|
||||
if got != tt.want {
|
||||
t.Errorf("sanitizeComponent()=%q want=%q", got, tt.want)
|
||||
}
|
||||
@@ -117,15 +222,15 @@ func TestSanitizeComponentSafetyProperties(t *testing.T) {
|
||||
string([]byte{0xff, 0xfe, 'a', 0xc0, 0xaf}),
|
||||
}
|
||||
for i, input := range inputs {
|
||||
got := sanitizeComponent(input)
|
||||
got := sanitizeComponent(input, maxEpisodeBytes)
|
||||
if got == "" || got == "." || got == ".." {
|
||||
t.Errorf("case %d: 空またはdot segment: %q", i, got)
|
||||
}
|
||||
if strings.Trim(got, " .") != got {
|
||||
t.Errorf("case %d: 前後に空白またはdot: %q", i, got)
|
||||
}
|
||||
if len([]byte(got)) > maxComponentBytes || utf8.RuneCountInString(got) > maxComponentRunes {
|
||||
t.Errorf("case %d: 上限超過: bytes=%d runes=%d %q", i, len([]byte(got)), utf8.RuneCountInString(got), got)
|
||||
if len([]byte(got)) > 140 {
|
||||
t.Errorf("case %d: 上限超過: bytes=%d %q", i, len([]byte(got)), got)
|
||||
}
|
||||
if !utf8.ValidString(got) {
|
||||
t.Errorf("case %d: 不正なUTF-8: %q", i, got)
|
||||
@@ -138,14 +243,22 @@ func TestSanitizeComponentSafetyProperties(t *testing.T) {
|
||||
t.Errorf("case %d: 制御文字が残った: %U in %q", i, r, got)
|
||||
}
|
||||
}
|
||||
if again := sanitizeComponent(got); again != got {
|
||||
if again := sanitizeComponent(got, maxEpisodeBytes); again != got {
|
||||
t.Errorf("case %d: 再適用で変化: %q -> %q", i, got, again)
|
||||
}
|
||||
|
||||
path := RelativeVideoPath(input, input, input)
|
||||
path, err := RelativeVideoPath(input, input, "id")
|
||||
if err != nil {
|
||||
t.Errorf("case %d: パス生成エラー: %v", i, err)
|
||||
continue
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||
if filepath.IsAbs(path) || len(parts) != 2 || parts[0] == "" || parts[0] == "." || parts[0] == ".." || !strings.HasSuffix(parts[1], ".mp4") {
|
||||
t.Errorf("case %d: 安全でない相対パス: %q parts=%q", i, path, parts)
|
||||
continue
|
||||
}
|
||||
if len([]byte(parts[0])) > 90 || len([]byte(parts[1])) > 255 {
|
||||
t.Errorf("case %d: パス要素の上限超過: dir=%d filename=%d path=%q", i, len([]byte(parts[0])), len([]byte(parts[1])), path)
|
||||
}
|
||||
if !utf8.ValidString(path) {
|
||||
t.Errorf("case %d: パスが不正なUTF-8: %q", i, path)
|
||||
|
||||
+18
-60
@@ -7,7 +7,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -76,12 +75,13 @@ func (s *server) serveVideo(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
return
|
||||
}
|
||||
path, err := secureVideoPath(s.videoDir, *job.FilePath, true)
|
||||
root, err := os.OpenRoot(s.videoDir)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
file, err := os.Open(path)
|
||||
defer root.Close()
|
||||
file, err := root.Open(*job.FilePath)
|
||||
if err != nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
@@ -93,7 +93,7 @@ func (s *server) serveVideo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "video/mp4")
|
||||
http.ServeContent(w, r, filepath.Base(path), info.ModTime(), file)
|
||||
http.ServeContent(w, r, filepath.Base(*job.FilePath), info.ModTime(), file)
|
||||
}
|
||||
|
||||
func (s *server) deleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -102,12 +102,24 @@ func (s *server) deleteVideo(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if job.FilePath != nil {
|
||||
path, err := secureVideoPath(s.videoDir, *job.FilePath, false)
|
||||
if !filepath.IsLocal(*job.FilePath) {
|
||||
http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
root, err := os.OpenRoot(s.videoDir)
|
||||
if err != nil {
|
||||
http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
defer root.Close()
|
||||
file, err := root.Open(*job.FilePath)
|
||||
if err == nil {
|
||||
_ = file.Close()
|
||||
} else if !errors.Is(err, os.ErrNotExist) {
|
||||
http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict)
|
||||
return
|
||||
}
|
||||
if err := root.Remove(*job.FilePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
s.logger.Printf("動画ファイルの削除に失敗: %v", err)
|
||||
http.Error(w, "動画ファイルを削除できませんでした", http.StatusInternalServerError)
|
||||
return
|
||||
@@ -139,57 +151,3 @@ func (s *server) succeededJob(w http.ResponseWriter, r *http.Request) (store.Job
|
||||
}
|
||||
return job, true
|
||||
}
|
||||
|
||||
func secureVideoPath(root, relative string, mustExist bool) (string, error) {
|
||||
if relative == "" || filepath.IsAbs(relative) {
|
||||
return "", errors.New("video path must be relative")
|
||||
}
|
||||
clean := filepath.Clean(relative)
|
||||
if clean == "." || clean == ".." || strings.HasPrefix(clean, ".."+string(filepath.Separator)) {
|
||||
return "", errors.New("video path leaves root")
|
||||
}
|
||||
rootAbs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
rootReal, err := filepath.EvalSymlinks(rootAbs)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
candidate := filepath.Join(rootAbs, clean)
|
||||
if err := insideRoot(rootAbs, candidate); err != nil {
|
||||
return "", err
|
||||
}
|
||||
resolved, err := filepath.EvalSymlinks(candidate)
|
||||
if err == nil {
|
||||
if err := insideRoot(rootReal, resolved); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
if mustExist || !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
ancestor := filepath.Dir(candidate)
|
||||
for {
|
||||
resolvedAncestor, evalErr := filepath.EvalSymlinks(ancestor)
|
||||
if evalErr == nil {
|
||||
if err := insideRoot(rootReal, resolvedAncestor); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return candidate, nil
|
||||
}
|
||||
if !errors.Is(evalErr, os.ErrNotExist) || ancestor == rootAbs {
|
||||
return "", evalErr
|
||||
}
|
||||
ancestor = filepath.Dir(ancestor)
|
||||
}
|
||||
}
|
||||
|
||||
func insideRoot(root, path string) error {
|
||||
rel, err := filepath.Rel(root, path)
|
||||
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
|
||||
return errors.New("path leaves video root")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -135,7 +135,23 @@ func TestVideoPathTraversalAndSymlinkAreRejected(t *testing.T) {
|
||||
if err := os.Symlink(outside, filepath.Join(dir, "linked-directory")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, relative := range []string{"../outside.mp4", "show/../../outside.mp4", outsideFile, "link.mp4", "linked-directory/outside.mp4", "linked-directory/missing.mp4"} {
|
||||
relativeOutsideFile, err := filepath.Rel(dir, outsideFile)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
relativeOutsideDir, err := filepath.Rel(dir, outside)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(relativeOutsideFile, filepath.Join(dir, "relative-link.mp4")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(relativeOutsideDir, filepath.Join(dir, "relative-linked-directory")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, relative := range []string{"../outside.mp4", "show/../../outside.mp4", outsideFile,
|
||||
"link.mp4", "linked-directory/outside.mp4", "linked-directory/missing.mp4",
|
||||
"relative-link.mp4", "relative-linked-directory/outside.mp4", "relative-linked-directory/missing.mp4"} {
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, relative)}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
|
||||
@@ -146,6 +162,89 @@ func TestVideoPathTraversalAndSymlinkAreRejected(t *testing.T) {
|
||||
t.Errorf("delete path %q status=%d jobs=%d", relative, deleted.Code, len(jobs.jobs))
|
||||
}
|
||||
}
|
||||
if got, err := os.ReadFile(outsideFile); err != nil || string(got) != "secret" {
|
||||
t.Fatalf("outside target was changed: content=%q err=%v", got, err)
|
||||
}
|
||||
for _, link := range []string{"link.mp4", "linked-directory", "relative-link.mp4", "relative-linked-directory"} {
|
||||
if _, err := os.Lstat(filepath.Join(dir, link)); err != nil {
|
||||
t.Errorf("rejected link %q was changed: %v", link, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoSymlinkWithinRootCanBeServedAndDeleted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "target.mp4")
|
||||
if err := os.WriteFile(target, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("target.mp4", filepath.Join(dir, "link.mp4")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "link.mp4")}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
if response := getPage(handler, "/videos/1"); response.Code != http.StatusOK || response.Body.String() != "video" {
|
||||
t.Fatalf("serve: status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
if response := postForm(handler, "/library/1/delete", nil); response.Code != http.StatusSeeOther || len(jobs.jobs) != 0 {
|
||||
t.Fatalf("delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
||||
}
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Fatalf("symlink target was removed: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(dir, "link.mp4")); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("symlink itself remains: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoSymlinkedDirectoryWithinRootCanBeServedAndDeleted(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(dir, "target"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
target := filepath.Join(dir, "target", "video.mp4")
|
||||
if err := os.WriteFile(target, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("target", filepath.Join(dir, "linked-directory")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "linked-directory/video.mp4")}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
if response := getPage(handler, "/videos/1"); response.Code != http.StatusOK || response.Body.String() != "video" {
|
||||
t.Fatalf("serve: status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
if response := postForm(handler, "/library/1/delete", nil); response.Code != http.StatusSeeOther || len(jobs.jobs) != 0 {
|
||||
t.Fatalf("delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
||||
}
|
||||
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("target file remains: %v", err)
|
||||
}
|
||||
if _, err := os.Lstat(filepath.Join(dir, "linked-directory")); err != nil {
|
||||
t.Fatalf("directory symlink was removed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVideoAbsoluteSymlinkWithinRootIsRejectedByOSRoot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
target := filepath.Join(dir, "target.mp4")
|
||||
if err := os.WriteFile(target, []byte("video"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(target, filepath.Join(dir, "absolute-link.mp4")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "absolute-link.mp4")}}
|
||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
|
||||
t.Fatalf("absolute symlink serve status=%d", got)
|
||||
}
|
||||
if response := postForm(handler, "/library/1/delete", nil); response.Code != http.StatusConflict || len(jobs.jobs) != 1 {
|
||||
t.Fatalf("absolute symlink delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
||||
}
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
t.Fatalf("target was changed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteVideoFileThenRecord(t *testing.T) {
|
||||
|
||||
@@ -172,6 +172,11 @@ func (r *Runner) ProcessJob(ctx context.Context, job store.Job) {
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(workDir)
|
||||
relative, err := media.RelativeVideoPath(job.SeriesTitle, job.Title, job.EpisodeID)
|
||||
if err != nil {
|
||||
r.failIfRunning(job.ID, "invalid_output", redact(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
downloaded, stderr, err := r.download(ctx, job, workDir)
|
||||
if errors.Is(err, errShutdown) {
|
||||
@@ -214,7 +219,6 @@ func (r *Runner) ProcessJob(ctx context.Context, job store.Job) {
|
||||
r.failIfRunning(job.ID, "unknown", "移動状態を記録できません: "+err.Error())
|
||||
return
|
||||
}
|
||||
relative := media.RelativeVideoPath(job.SeriesTitle, job.Title, job.EpisodeID)
|
||||
destination := filepath.Join(r.config.VideoDir, relative)
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||
r.failIfRunning(job.ID, "unknown", "保存先を作成できません: "+err.Error())
|
||||
|
||||
@@ -228,6 +228,15 @@ func runningJob() store.Job {
|
||||
return store.Job{ID: 7, EpisodeID: "ep123", SeriesTitle: "番組名", Title: "第1話", Status: "running"}
|
||||
}
|
||||
|
||||
func relativeVideoPath(t *testing.T, seriesTitle, episodeTitle, episodeID string) string {
|
||||
t.Helper()
|
||||
path, err := media.RelativeVideoPath(seriesTitle, episodeTitle, episodeID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestProcessJobDownloadsProbesMovesAndCleans(t *testing.T) {
|
||||
t.Setenv("FAKE_YTDLP_MODE", "success")
|
||||
t.Setenv("FAKE_FFPROBE_MODE", "valid")
|
||||
@@ -236,7 +245,7 @@ func TestProcessJobDownloadsProbesMovesAndCleans(t *testing.T) {
|
||||
t.Setenv("FAKE_ARGS_FILE", argsFile)
|
||||
jobs := &fakeStore{job: runningJob()}
|
||||
testRunner(t, jobs, dir).ProcessJob(context.Background(), jobs.job)
|
||||
want := filepath.ToSlash(media.RelativeVideoPath("番組名", "第1話", "ep123"))
|
||||
want := filepath.ToSlash(relativeVideoPath(t, "番組名", "第1話", "ep123"))
|
||||
if !jobs.completed || jobs.completePath != want || jobs.completeSize == 0 {
|
||||
t.Fatalf("complete: done=%v path=%q size=%d", jobs.completed, jobs.completePath, jobs.completeSize)
|
||||
}
|
||||
@@ -273,6 +282,32 @@ func TestProcessJobDownloadsProbesMovesAndCleans(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessJobRejectsLongEpisodeIDAndCleans(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
argsFile := filepath.Join(dir, "yt-dlp-args")
|
||||
t.Setenv("FAKE_ARGS_FILE", argsFile)
|
||||
workDir := filepath.Join(dir, ".incoming", "7")
|
||||
if err := os.MkdirAll(workDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workDir, "stale"), []byte("stale"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
job := runningJob()
|
||||
job.EpisodeID = strings.Repeat("i", 65)
|
||||
jobs := &fakeStore{job: job}
|
||||
testRunner(t, jobs, dir).ProcessJob(context.Background(), job)
|
||||
if jobs.completed || jobs.failedKind != "invalid_output" || !strings.Contains(jobs.failedMessage, "64バイト以下") {
|
||||
t.Fatalf("長いIDの失敗: kind=%q message=%q completed=%v", jobs.failedKind, jobs.failedMessage, jobs.completed)
|
||||
}
|
||||
if _, err := os.Stat(argsFile); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("yt-dlp が起動された: %v", err)
|
||||
}
|
||||
if _, err := os.Stat(workDir); !errors.Is(err, os.ErrNotExist) {
|
||||
t.Fatalf("作業ディレクトリが残った: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessJobKeepsPercentNamesOutOfYTDLPTemplate(t *testing.T) {
|
||||
t.Setenv("FAKE_YTDLP_MODE", "success")
|
||||
t.Setenv("FAKE_FFPROBE_MODE", "valid")
|
||||
@@ -294,7 +329,7 @@ func TestProcessJobKeepsPercentNamesOutOfYTDLPTemplate(t *testing.T) {
|
||||
if strings.Contains(string(args), job.SeriesTitle) || strings.Contains(string(args), job.Title) {
|
||||
t.Fatalf("番組情報が yt-dlp 引数に入った: %s", args)
|
||||
}
|
||||
want := filepath.ToSlash(media.RelativeVideoPath(job.SeriesTitle, job.Title, job.EpisodeID))
|
||||
want := filepath.ToSlash(relativeVideoPath(t, job.SeriesTitle, job.Title, job.EpisodeID))
|
||||
if jobs.completePath != want {
|
||||
t.Fatalf("保存先=%q want=%q", jobs.completePath, want)
|
||||
}
|
||||
@@ -305,7 +340,7 @@ func TestProcessJobReplacesExistingDestination(t *testing.T) {
|
||||
t.Setenv("FAKE_FFPROBE_MODE", "valid")
|
||||
dir := t.TempDir()
|
||||
job := runningJob()
|
||||
relative := media.RelativeVideoPath(job.SeriesTitle, job.Title, job.EpisodeID)
|
||||
relative := relativeVideoPath(t, job.SeriesTitle, job.Title, job.EpisodeID)
|
||||
destination := filepath.Join(dir, relative)
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -409,7 +444,7 @@ func TestProcessJobDestinationDirectoryFailureCleansIncoming(t *testing.T) {
|
||||
t.Setenv("FAKE_FFPROBE_MODE", "valid")
|
||||
dir := t.TempDir()
|
||||
job := runningJob()
|
||||
seriesDir := filepath.Join(dir, filepath.Dir(media.RelativeVideoPath(job.SeriesTitle, job.Title, job.EpisodeID)))
|
||||
seriesDir := filepath.Join(dir, filepath.Dir(relativeVideoPath(t, job.SeriesTitle, job.Title, job.EpisodeID)))
|
||||
if err := os.WriteFile(seriesDir, []byte("blocks directory"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user