Compare commits

5 Commits
18 changed files with 1415 additions and 19 deletions
+13 -5
View File
@@ -41,7 +41,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
| NAS のマウント | **NFS**。dockageの`/etc/fstab`へ`_netdev,nofail`付きで登録し、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`から書き込み確認済み | | NFS 権限 | 読み書き、Squash「すべてのユーザーをadminに割り当てる」、`sys`、非同期オン。DSMのadminアカウントが無効でもUID `65532`から書き込み確認済み |
| DBの通信制限 | DSMファイアウォールでTCP `15434`を`192.168.10.155`からだけ許可する | | DBの通信制限 | DSMファイアウォールでTCP `15434`を`192.168.10.155`からだけ許可する |
| DBバックアップ | 稼働後に、コンテナ内の`pg_dump`からNASへ保存する日次処理を用意する | | DBバックアップ | NASのDSMタスクスケジューラからrootで毎日実行する。`tver-postgres`内の`pg_dump`をgzip圧縮して`/volume1/backups/tver/tver_<YYYYmmdd_HHMMSS>.sql.gz`へ保存し、14日より古い世代を削除する。同名の完成ファイルは上書きせず失敗させる。初回は一時DBへの復元と主要4表の行数一致まで確認する |
| 保持期間 | **削除は手動のみ**。自動削除はしない | | 保持期間 | **削除は手動のみ**。自動削除はしない |
| 待ち行列 | **Postgres のテーブルだけで実装する**。専用のキュー基盤は持たない | | 待ち行列 | **Postgres のテーブルだけで実装する**。専用のキュー基盤は持たない |
| 定期確認の間隔 | 登録番組の新しい回を **24 時間ごと**に確認する | | 定期確認の間隔 | 登録番組の新しい回を **24 時間ごと**に確認する |
@@ -71,7 +71,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
| F3 | 選んだ動画をダウンロードの待ち行列に入れる | 複数選択できる | | F3 | 選んだ動画をダウンロードの待ち行列に入れる | 複数選択できる |
| F4 | ダウンロードの進捗・結果を表示する | 失敗理由(DRM・地域制限・TVer 側の変更による抽出失敗)を区別して表示する | | F4 | ダウンロードの進捗・結果を表示する | 失敗理由(DRM・地域制限・TVer 側の変更による抽出失敗)を区別して表示する |
| F5 | 同じ回を二重にダウンロードしない | 手動・自動の両方で共通 | | F5 | 同じ回を二重にダウンロードしない | 手動・自動の両方で共通 |
| F6 | 番組(シリーズ)を登録し、新しい回を自動でダウンロードする | 登録・解除・一覧。定期的に新しい回を確認する | | F6 | 番組(シリーズ)を登録し、新しい回を自動でダウンロードする | 登録・解除・一覧。定期的に新しい回を確認する。番組URL(`tver.jp/series/…`)から登録でき、登録前に確認画面を表示する |
| F7 | ダウンロード済みの動画をアプリ内で再生する | シーク(HTTP Range)に対応する | | F7 | ダウンロード済みの動画をアプリ内で再生する | シーク(HTTP Range)に対応する |
| F8 | ダウンロード済みの動画を一覧・削除する | NAS の容量管理のため | | F8 | ダウンロード済みの動画を一覧・削除する | NAS の容量管理のため |
| F9 | 保存ファイルの名前と置き場所を規則で決める | 例: `<番組名>/<番組名> <回タイトル> [<エピソードID>].mp4` | | F9 | 保存ファイルの名前と置き場所を規則で決める | 例: `<番組名>/<番組名> <回タイトル> [<エピソードID>].mp4` |
@@ -88,12 +88,13 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
| コンテナ | 役割 | | コンテナ | 役割 |
| -------- | ----------------------------------------------------- | | -------- | ----------------------------------------------------- |
| `web` | UI・API。新着一覧の取得、待ち行列への登録、番組登録、動画の配信(再生) | | `web` | UI・API。新着一覧の取得、待ち行列への登録、番組URLを含む番組登録、動画の配信(再生) |
| `worker` | Go + yt-dlp + ffmpeg。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する | | `worker` | Go + yt-dlp + ffmpeg。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する |
- 現在の `web` は `/healthz`、新着(`/`)、待ち行列(`/queue`)、登録番組(`/subscriptions`)、完了済みライブラリ(`/library`)、再生ページ(`/library/{id}`)、Range 対応の MP4 配信(`/videos/{id}`)を提供する - 現在の `web` は `/healthz`、新着(`/`)、待ち行列(`/queue`)、登録番組(`/subscriptions`)、完了済みライブラリ(`/library`)、再生ページ(`/library/{id}`)、Range 対応の MP4 配信(`/videos/{id}`)を提供する
- 動画配信と削除はクライアントからジョブ ID だけを受け取り、DB の相対 `file_path` を `os.Root` の `Open`・`Remove` で扱う。絶対パス・`..`・`VIDEO_DIR` 外を指すシンボリックリンクをディレクトリ境界で拒否する。削除後は新着から手動で再登録できるが、登録番組の既知記録は残るため自動再登録しない - 動画配信と削除はクライアントからジョブ ID だけを受け取り、DB の相対 `file_path` を `os.Root` の `Open`・`Remove` で扱う。絶対パス・`..`・`VIDEO_DIR` 外を指すシンボリックリンクをディレクトリ境界で拒否する。削除後は新着から手動で再登録できるが、登録番組の既知記録は残るため自動再登録しない
- 新着の「番組を登録」から、公開中の回をダウンロードするか選ぶ。web はサーバー側の新着キャッシュから番組 ID と名前を引き、TVer の season ごとの公開中エピソードを取得する。取得失敗時は登録しない - 新着の「番組を登録」から、公開中の回をダウンロードするか選ぶ。web はサーバー側の新着キャッシュから番組 ID と名前を引き、TVer の season ごとの公開中エピソードを取得する。取得失敗時は登録しない
- `/subscriptions`では`https://tver.jp/series/<ID>`形式の番組URLを受け付け、検証した番組IDからTVer APIの接続先を組み立てる。登録前に番組名・番組ID・公開中の回数を確認し、登録時にもサーバー側で番組名と公開中の回を取り直す。クライアントから番組名は受け取らない
- 登録と公開中の回の既知記録・任意の待ち行列投入は1トランザクションで行い、登録時刻を最終確認日時に設定する。`known_episodes` は番組解除時に連動して削除する。一時停止中は投入を拒否する - 登録と公開中の回の既知記録・任意の待ち行列投入は1トランザクションで行い、登録時刻を最終確認日時に設定する。`known_episodes` は番組解除時に連動して削除する。一時停止中は投入を拒否する
- worker のスケジューラは起動直後と1時間ごとに、`enabled` かつ最終確認から24時間以上の番組を抽出する。TVer から公開中の回を順次取得し、未確認の180秒超の回だけを `source=subscription` で投入する。180秒以下も既知にし、成功時の最終確認更新・エラー消去まで1トランザクションで行う - worker のスケジューラは起動直後と1時間ごとに、`enabled` かつ最終確認から24時間以上の番組を抽出する。TVer から公開中の回を順次取得し、未確認の180秒超の回だけを `source=subscription` で投入する。180秒以下も既知にし、成功時の最終確認更新・エラー消去まで1トランザクションで行う
- TVer の番組取得に失敗した場合は最終確認日時を変えず、失敗日時と伏字済みの短いメッセージを `subscriptions` に記録して画面に表示する。番組間には3秒空け、SIGTERM では取得中または待機中の確認を中断する - TVer の番組取得に失敗した場合は最終確認日時を変えず、失敗日時と伏字済みの短いメッセージを `subscriptions` に記録して画面に表示する。番組間には3秒空け、SIGTERM では取得中または待機中の確認を中断する
@@ -105,6 +106,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
- 画面は `html/template` と htmx 2.0.11 を使い、htmx は Go の `embed` から `/static/` でローカル配信する - 画面は `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)で指定する - `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`から注入する - NAS用の`deploy/nas/tver-db/compose.yaml`は`postgres:17`を`15434:5432`で公開し、`/volume1/docker/tver-db/pgdata`へ保存する。パスワードは同ディレクトリのGit管理外`.env`から注入する
- NAS用の`backup-db.sh`はコンテナ内の`pg_dump`を検査後にgzip圧縮し、同一ディレクトリ内のハードリンクで完成ファイルを上書きせず公開する。`restore-test.sh`は最新ダンプを一時DBへ復元して`download_jobs`・`subscriptions`・`known_episodes`・`schema_migrations`の行数を本番DBと比較する。一時DBはEXIT trapで必ず削除し、復元SQLの通常出力はログへ記録しない
- 本番配置と更新の手順は[[docs/deploy|tver 本番配置手順]]にまとめる。dockageでは`/volume1/video`を`/mnt/nas-tver`へNFSマウントし、その配下の`tver`だけをコンテナ内の`/videos`へ渡す - 本番配置と更新の手順は[[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に限定する - 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` で接続してマイグレーションを適用する - DB は NAS 上に tver 専用 Postgres 17 コンテナを置く。`internal/store` の埋め込み SQL を `schema_migrations` で管理し、`pg_advisory_lock` で複数プロセスからの同時適用を直列化する。web は起動時に `pgxpool` で接続してマイグレーションを適用する
@@ -117,7 +119,7 @@ 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 `platform-api.tver.jp` | 新着一覧(`/service/api/v1/callNewerDetail/{drama\|variety}`)・番組名(`/service/api/v2/callSeries/{series_id}`)・番組のエピソード一覧 | **非公開 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) と同じ取得方式 | | 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 を使う | | yt-dlp | ダウンロード | TVer extractor を使う |
| Gitea `gitea.kazu634.com` | 配置元 | 匿名cloneとSSHは使えない。`tver-deploy`のHTTPSトークンでclone・pullする | | Gitea `gitea.kazu634.com` | 配置元 | 匿名cloneとSSHは使えない。`tver-deploy`のHTTPSトークンでclone・pullする |
@@ -143,10 +145,14 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
- 本番の`/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 確認 - 本番の`/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 確認 - 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 確認 - `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 確認
- dockage上の使い捨て`postgres:17`コンテナへマイグレーション001〜003を適用してテスト用の行を投入し、`backup-db.sh`と`restore-test.sh`を実行した。gzip圧縮・権限600のダンプ作成、同じ`STAMP`での2回目が`ln`の失敗で非0終了して既存ダンプを保持すること、主要4表の行数一致、一時DBの削除、ログに`psql`の標準出力が残らないことを確認した [実機] 2026-10-04 確認
- NASの`/volume1/docker/tver-db/`へスクリプトを配置し、DSMタスクスケジューラのrootタスクから実行して、`/volume1/backups/tver/`へ`tver_20261004_182838.sql.gz`(2,286バイト)と`tver_20261004_183256.sql.gz`(2,289バイト)を作成した。ダンプはroot所有・権限600で、バックアップディレクトリはroot所有・`drwxr-xr-x`だった。`restore-test.sh`を`sudo`で実行し、`download_jobs` 1件・`subscriptions` 1件・`known_episodes` 1件・`schema_migrations` 3件の一致、ログへの結果記録、一時DBの削除を確認した。一般ユーザーではroot所有のログへ書き込めず失敗する [実機] 2026-10-04 確認
- DSMのACLで全員書き込み可だった`/volume1/docker`配下について、`/volume1/docker/tver-db`を`chmod 755`、`backup-db.sh`と`restore-test.sh`を`chmod 700`にしてACLが外れたことを確認した。変更後も本番アプリの`/queue`と`/subscriptions`はHTTP `200`でDBを読み取れた。PostgreSQLコンテナ再起動後の動作は未確認 [実機] 2026-10-04 確認
- platform-apiの`v2/callSeries/{series_id}`は`result.content.content.title`に番組名を返し、存在しないIDにはHTTP `404`を返した。`sr8a864g0w`では番組名がエピソード側の`seriesTitle`と一致した。`v1/callEpisode/{episode_id}`は`seriesID`・`seriesTitle`を返した(今回は使わない) [実機] 2026-10-07 確認
## 課題 ## 課題
- **DBバックアップは未着手。** コンテナ内の`pg_dump`→gzip→NAS `/volume1/backups/tver/`、既定14日保持の日次処理を用意する - 登録済みのDSM日次バックアップタスクが翌日以降に自動実行され、新しいダンプを作ることを確認する [要確認]
- DRM・地域制限の実例探しはTVerへの大量アクセスになるため行わず、運用中に観測する。workerは失敗時の`error_message`をDBへ保存し、既知の文言に一致しない失敗は`unknown`にする。実例が出た時点で分類を見直す - DRM・地域制限の実例探しはTVerへの大量アクセスになるため行わず、運用中に観測する。workerは失敗時の`error_message`をDBへ保存し、既知の文言に一致しない失敗は`unknown`にする。実例が出た時点で分類を見直す
## 確かめ方 ## 確かめ方
@@ -156,11 +162,13 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
- DB テストは `embedded-postgres` v1.34.0 が Postgres 17 を一度起動し、テストごとに独立した DB を作る。バイナリキャッシュ、実行ファイル、データは `/private/tmp` 配下に置く。Apple Silicon での実行には Rosetta 2 の導入が必要 - 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 にはアクセスしない - 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 にアクセスしない - 番組一覧の応答は `internal/tver/testdata/series_seasons.json` と `season_episodes.json`(2026-10-03 の実応答を各1件に縮小)に記録した。テストは `httptest` を使い、実 API にアクセスしない
- 番組情報の応答は`internal/tver/testdata/series.json`(2026-10-07の実応答を番組情報だけに縮小)に記録した。番組URLは純関数でscheme・host・path・IDを検証し、TVerクライアントのテストは`httptest`だけへ接続する
- 新着画面のテストは偽物の TVer クライアント・store と差し替え可能な時計を使い、表示項目、絞り込みの境界、10 分キャッシュ、取得失敗、JST 表示、登録・状態表示・待ち行列の操作を確認する - 新着画面のテストは偽物の TVer クライアント・store と差し替え可能な時計を使い、表示項目、絞り込みの境界、10 分キャッシュ、取得失敗、JST 表示、登録・状態表示・待ち行列の操作を確認する
- ライブラリのテストは一時ディレクトリの小さな動画を使い、Range の 206・範囲外の 416、`os.Root`による相対パスとシンボリックリンクの拒否、404、ファイル削除後の記録削除を確認する。共有のファイル名規則は`internal/media`で90・140・255バイトの境界とUTF-8の完全性をテストする - ライブラリのテストは一時ディレクトリの小さな動画を使い、Range の 206・範囲外の 416、`os.Root`による相対パスとシンボリックリンクの拒否、404、ファイル削除後の記録削除を確認する。共有のファイル名規則は`internal/media`で90・140・255バイトの境界とUTF-8の完全性をテストする
- worker のテストは `internal/worker/testdata` の偽 yt-dlp・ffprobe を使い、本物の TVer へ接続せずに正常完了、失敗分類、取消、終了時の待機復帰、進捗の間引き、heartbeat、出力検査、移動先、作業場所の片付けを確認する - worker のテストは `internal/worker/testdata` の偽 yt-dlp・ffprobe を使い、本物の TVer へ接続せずに正常完了、失敗分類、取消、終了時の待機復帰、進捗の間引き、heartbeat、出力検査、移動先、作業場所の片付けを確認する
- 登録番組の定期確認は偽の TVer クライアントと差し替え可能な時計・タイマーを使い、24時間の境界、順次確認、1時間走査、失敗記録、予告の既知化、終了時の中断を確認する。store の抽出・既知化・投入・日時更新・エラー消去は embedded Postgres 17 で確認する - 登録番組の定期確認は偽の 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`でも展開結果を確認する - 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`でも展開結果を確認する
- DBバックアップスクリプトは`deploy/nas/tver-db/scripts_test.go`からPATH上の偽dockerを使って実行し、正常な圧縮、同名・同時実行時の上書き防止、空ダンプと`pg_dump`失敗、14日の世代境界、復元失敗時の一時DB削除、主要表の行数不一致を確認する。NAS実機では[[docs/deploy#5. PostgreSQLをバックアップする|配置手順]]に従って手動バックアップと復元確認を行う
- 本番の確認手順は[[docs/deploy|tver 本番配置手順]]に従い、NFS上の`/mnt/nas-tver/tver`をUID `65532`で読み書き・rename・削除できること、DBがhealthyであること、webの`/healthz`、実動画の保存・再生・シークを確認する - 本番の確認手順は[[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 ./...` で静的解析する - `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 への実アクセスを伴うため手動確認は少数回に留める - `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 への実アクセスを伴うため手動確認は少数回に留める
+66
View File
@@ -0,0 +1,66 @@
#!/bin/bash
# tver DBバックアップ(Synology ds923p / DSMタスクスケジューラで日次実行)
#
# 配置先: /volume1/docker/tver-db/backup-db.sh
# DSM設定: コントロールパネル → タスクスケジューラ → 作成
# → スケジュールされたタスク → ユーザー定義のスクリプト
# ユーザーはroot、実行周期は毎日とする。
# 実行コマンド: bash /volume1/docker/tver-db/backup-db.sh
#
# コンテナ内のpg_dumpを使い、サーバーとクライアントのバージョンを合わせる。
# PostgreSQLのローカル接続を使うため、DBパスワードはスクリプトへ書かない。
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/volume1/backups/tver}"
KEEP_DAYS="${KEEP_DAYS:-14}"
CONTAINER="${CONTAINER:-tver-postgres}"
DB_USER="${DB_USER:-tver}"
DB_NAME="${DB_NAME:-tver}"
DOCKER="${DOCKER:-$(command -v docker || printf '/usr/local/bin/docker')}"
STAMP="${STAMP:-$(date +%Y%m%d_%H%M%S)}"
OUT="${BACKUP_DIR}/tver_${STAMP}.sql.gz"
RAW_TMP="${BACKUP_DIR}/.tver_${STAMP}.$$.sql"
GZ_TMP="${OUT}.tmp.$$"
case "$KEEP_DAYS" in
''|*[!0-9]*)
echo "ERROR: KEEP_DAYS must be a non-negative integer: $KEEP_DAYS" >&2
exit 1
;;
esac
mkdir -p "$BACKUP_DIR"
umask 077
cleanup() {
rm -f "$RAW_TMP" "$GZ_TMP"
}
trap cleanup EXIT
# 先に非圧縮の一時ファイルへ出し、pg_dump成功と空でないことを確認する。
"$DOCKER" exec "$CONTAINER" pg_dump -U "$DB_USER" -d "$DB_NAME" > "$RAW_TMP"
if [[ ! -s "$RAW_TMP" ]]; then
echo "ERROR: dump is empty" >&2
exit 1
fi
gzip -9 -c "$RAW_TMP" > "$GZ_TMP"
if [[ ! -s "$GZ_TMP" ]]; then
echo "ERROR: compressed dump is empty" >&2
exit 1
fi
# 完成ファイルは上書きしない。同名が同時に作られても、ハードリンクの
# 作成に成功した一方だけを公開する。一時ファイルは同じディレクトリに
# あるため同一ファイルシステム内でリンクできる。
if ! ln "$GZ_TMP" "$OUT"; then
echo "ERROR: backup already exists or could not be published: $OUT" >&2
exit 1
fi
rm -f "$GZ_TMP" "$RAW_TMP"
# KEEP_DAYS日より古い完了済みバックアップだけを削除する。
find "$BACKUP_DIR" -name 'tver_*.sql.gz' -type f -mtime "+$KEEP_DAYS" -delete
SIZE_BYTES="$(wc -c < "$OUT" | tr -d '[:space:]')"
echo "OK: $OUT ($SIZE_BYTES bytes)"
+73
View File
@@ -0,0 +1,73 @@
#!/bin/bash
# tver DB復元確認(Synology ds923pで手動実行)
#
# 配置先: /volume1/docker/tver-db/restore-test.sh
# 実行コマンド: bash /volume1/docker/tver-db/restore-test.sh
# 最新のバックアップを同じPostgreSQLコンテナ内の一時DBへ復元し、主要4表の
# 行数を本番DBと比較する。本番DBにはSELECTだけを実行する。
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/volume1/backups/tver}"
LOG_FILE="${LOG_FILE:-${BACKUP_DIR}/restore-test.log}"
CONTAINER="${CONTAINER:-tver-postgres}"
DB_USER="${DB_USER:-tver}"
DB_NAME="${DB_NAME:-tver}"
RESTORE_DB="${RESTORE_DB:-tver_restore_test}"
DOCKER="${DOCKER:-$(command -v docker || printf '/usr/local/bin/docker')}"
TABLES=(download_jobs subscriptions known_episodes schema_migrations)
mkdir -p "$BACKUP_DIR"
touch "$LOG_FILE"
chmod 600 "$LOG_FILE"
exec > >(tee -a "$LOG_FILE") 2>&1
if [[ "$RESTORE_DB" == "$DB_NAME" ]]; then
echo "ERROR: RESTORE_DB must differ from DB_NAME" >&2
exit 1
fi
# 終了状態にかかわらず、一時DBの削除を最後に試みる。
cleanup() {
status=$?
trap - EXIT
set +e
"$DOCKER" exec "$CONTAINER" dropdb --force --if-exists -U "$DB_USER" "$RESTORE_DB"
cleanup_status=$?
if ((status != 0)); then
echo "復元確認に失敗"
exit "$status"
fi
exit "$cleanup_status"
}
trap cleanup EXIT
LATEST="$(find "$BACKUP_DIR" -name 'tver_*.sql.gz' -type f -print | sort | tail -n 1)"
if [[ -z "$LATEST" ]]; then
echo "ERROR: backup dump was not found in $BACKUP_DIR" >&2
exit 1
fi
echo "復元確認を開始: $LATEST"
"$DOCKER" exec "$CONTAINER" dropdb --force --if-exists -U "$DB_USER" "$RESTORE_DB"
"$DOCKER" exec "$CONTAINER" createdb -U "$DB_USER" "$RESTORE_DB"
gzip -dc "$LATEST" \
| "$DOCKER" exec -i "$CONTAINER" psql -q -o /dev/null -v ON_ERROR_STOP=1 -U "$DB_USER" -d "$RESTORE_DB"
for table in "${TABLES[@]}"; do
query="SELECT count(*) FROM public.${table};"
production_count="$("$DOCKER" exec "$CONTAINER" psql -A -t -q -U "$DB_USER" -d "$DB_NAME" -c "$query" | tr -d '[:space:]')"
restored_count="$("$DOCKER" exec "$CONTAINER" psql -A -t -q -U "$DB_USER" -d "$RESTORE_DB" -c "$query" | tr -d '[:space:]')"
if [[ ! "$production_count" =~ ^[0-9]+$ || ! "$restored_count" =~ ^[0-9]+$ ]]; then
echo "ERROR: $table の行数を取得できませんでした: production=$production_count restore=$restored_count" >&2
exit 1
fi
if [[ "$production_count" != "$restored_count" ]]; then
echo "ERROR: $table の行数が一致しません: production=$production_count restore=$restored_count" >&2
exit 1
fi
echo "OK: $table production=$production_count restore=$restored_count"
done
echo "復元確認に成功: $LATEST"
+401
View File
@@ -0,0 +1,401 @@
package tverdb_test
import (
"bytes"
"compress/gzip"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func TestBackupDB(t *testing.T) {
t.Run("success and retention boundary", func(t *testing.T) {
env := newScriptEnv(t, "success")
old := filepath.Join(env.backupDir, "tver_20260901_000000.sql.gz")
boundary := filepath.Join(env.backupDir, "tver_20260920_000000.sql.gz")
unrelated := []string{
filepath.Join(env.backupDir, "restore-test.log"),
filepath.Join(env.backupDir, "other_20260901_000000.sql.gz"),
filepath.Join(env.backupDir, "tver_20260901_000000.sql"),
}
writeFile(t, old, "old")
writeFile(t, boundary, "boundary")
for _, path := range unrelated {
writeFile(t, path, "keep")
}
now := time.Now()
oldTime := now.Add(-(15*24*time.Hour + time.Hour))
boundaryTime := now.Add(-(15*24*time.Hour - time.Hour))
if err := os.Chtimes(old, oldTime, oldTime); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(boundary, boundaryTime, boundaryTime); err != nil {
t.Fatal(err)
}
for _, path := range unrelated {
if err := os.Chtimes(path, oldTime, oldTime); err != nil {
t.Fatal(err)
}
}
output, err := env.run(t, "backup-db.sh", "STAMP=20261004_010203", "KEEP_DAYS=14", "CONTAINER=custom-postgres", "DB_USER=custom-user", "DB_NAME=custom-db")
if err != nil {
t.Fatalf("backup failed: %v\n%s", err, output)
}
created := filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz")
if got := readGzip(t, created); !strings.Contains(got, "CREATE TABLE example") {
t.Fatalf("dump=%q", got)
}
if _, err := os.Stat(old); !os.IsNotExist(err) {
t.Fatalf("old backup remains: %v", err)
}
if _, err := os.Stat(boundary); err != nil {
t.Fatalf("14-day backup was deleted: %v", err)
}
for _, path := range unrelated {
if got := readFile(t, path); got != "keep" {
t.Fatalf("unrelated file was changed: %s content=%q", path, got)
}
}
if !strings.Contains(output, "OK: "+created) || !strings.Contains(output, "bytes") {
t.Fatalf("success output=%q", output)
}
if log := readFile(t, env.dockerLog); !strings.Contains(log, "exec custom-postgres pg_dump -U custom-user -d custom-db") {
t.Fatalf("docker log=%q", log)
}
})
t.Run("creates missing backup directory", func(t *testing.T) {
env := newScriptEnv(t, "success")
if err := os.RemoveAll(env.backupDir); err != nil {
t.Fatal(err)
}
output, err := env.run(t, "backup-db.sh", "STAMP=20261004_010203")
if err != nil {
t.Fatalf("backup failed: %v\n%s", err, output)
}
if got := readGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz")); !strings.Contains(got, "CREATE TABLE example") {
t.Fatalf("dump=%q", got)
}
})
t.Run("does not replace an existing completed backup", func(t *testing.T) {
env := newScriptEnv(t, "success")
completed := filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz")
writeGzip(t, completed, "existing")
output, err := env.run(t, "backup-db.sh", "STAMP=20261004_010203")
if err == nil {
t.Fatalf("backup replaced an existing file: %s", output)
}
if got := readGzip(t, completed); got != "existing" {
t.Fatalf("existing backup was changed: %q", got)
}
assertNoBackupTemps(t, env.backupDir)
})
t.Run("same stamp concurrently allows exactly one backup", func(t *testing.T) {
env := newScriptEnv(t, "concurrent")
barrier := filepath.Join(env.dir, "barrier")
extra := []string{"STAMP=20261004_010203", "FAKE_DOCKER_BARRIER=" + barrier}
commands := make([]*exec.Cmd, 2)
outputs := make([]bytes.Buffer, 2)
for i := range commands {
commands[i] = exec.Command("bash", "backup-db.sh")
commands[i].Env = append(append(append([]string{}, os.Environ()...), env.baseEnv...), extra...)
commands[i].Stdout = &outputs[i]
commands[i].Stderr = &outputs[i]
if err := commands[i].Start(); err != nil {
t.Fatal(err)
}
}
succeeded := 0
for i, command := range commands {
if err := command.Wait(); err == nil {
succeeded++
} else if _, ok := err.(*exec.ExitError); !ok {
t.Fatalf("backup %d wait: %v output=%q", i, err, outputs[i].String())
}
}
if succeeded != 1 {
t.Fatalf("concurrent successes=%d want=1 outputs=%q / %q", succeeded, outputs[0].String(), outputs[1].String())
}
if got := readGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz")); !strings.Contains(got, "CREATE TABLE example") {
t.Fatalf("completed dump=%q", got)
}
assertNoBackupTemps(t, env.backupDir)
})
for _, mode := range []string{"dump-fail", "dump-partial-fail", "empty"} {
t.Run(mode, func(t *testing.T) {
env := newScriptEnv(t, mode)
output, err := env.run(t, "backup-db.sh", "STAMP=20261004_010203")
if err == nil {
t.Fatalf("backup succeeded unexpectedly: %s", output)
}
matches, globErr := filepath.Glob(filepath.Join(env.backupDir, "tver_*.sql.gz"))
if globErr != nil || len(matches) != 0 {
t.Fatalf("partial backups=%v err=%v", matches, globErr)
}
assertNoBackupTemps(t, env.backupDir)
if mode == "empty" && !strings.Contains(output, "empty") {
t.Fatalf("empty dump error missing: %q", output)
}
})
}
for _, keepDays := range []string{"-1", "invalid"} {
t.Run("invalid retention "+keepDays, func(t *testing.T) {
env := newScriptEnv(t, "success")
output, err := env.run(t, "backup-db.sh", "STAMP=20261004_010203", "KEEP_DAYS="+keepDays)
if err == nil || !strings.Contains(output, "KEEP_DAYS") {
t.Fatalf("invalid KEEP_DAYS: err=%v output=%q", err, output)
}
if _, err := os.Stat(env.dockerLog); !os.IsNotExist(err) {
t.Fatalf("pg_dump was invoked: %v", err)
}
})
}
}
func TestRestoreTest(t *testing.T) {
t.Run("restores latest dump and compares rows", func(t *testing.T) {
env := newScriptEnv(t, "success")
writeGzip(t, filepath.Join(env.backupDir, "tver_20261003_010203.sql.gz"), "older")
writeGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz"), "newest")
output, err := env.run(t, "restore-test.sh")
if err != nil {
t.Fatalf("restore test failed: %v\n%s", err, output)
}
for _, table := range []string{"download_jobs", "subscriptions", "known_episodes", "schema_migrations"} {
if !strings.Contains(output, "OK: "+table) {
t.Errorf("missing result for %s: %s", table, output)
}
}
if got := readFile(t, env.restoreInput); got != "newest" {
t.Fatalf("restored dump=%q", got)
}
log := readFile(t, env.dockerLog)
if !strings.Contains(log, "psql -q -o /dev/null -v ON_ERROR_STOP=1") {
t.Fatalf("psql output suppression is missing: %q", log)
}
cleanupCommand := "dropdb --force --if-exists -U tver tver_restore_test"
if strings.Count(log, cleanupCommand) != 2 || !strings.HasSuffix(strings.TrimSpace(log), cleanupCommand) {
t.Fatalf("temporary DB cleanup log=%q", log)
}
for _, line := range strings.Split(strings.TrimSpace(log), "\n") {
if (strings.Contains(line, " createdb ") || strings.Contains(line, " dropdb ")) && !strings.HasSuffix(line, "tver_restore_test") {
t.Fatalf("production DB mutation was attempted: %q", line)
}
if strings.Contains(line, " psql ") && strings.Contains(line, " -d tver ") && !strings.Contains(line, "SELECT count(*)") {
t.Fatalf("production DB received a non-count command: %q", line)
}
if strings.Contains(line, " ON_ERROR_STOP=1 ") && !strings.Contains(line, " -d tver_restore_test") {
t.Fatalf("dump was restored into a non-temporary DB: %q", line)
}
}
if restoreLog := readFile(t, filepath.Join(env.backupDir, "restore-test.log")); !strings.Contains(restoreLog, "復元確認に成功") || strings.Contains(restoreLog, "SET\n") || strings.Contains(restoreLog, "CREATE TABLE\n") {
t.Fatalf("restore log=%q", restoreLog)
}
})
for _, mode := range []string{"restore-fail", "count-mismatch"} {
t.Run(mode+" cleans temporary database", func(t *testing.T) {
env := newScriptEnv(t, mode)
writeGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz"), "dump")
output, err := env.run(t, "restore-test.sh")
if err == nil {
t.Fatalf("restore test succeeded unexpectedly: %s", output)
}
log := strings.TrimSpace(readFile(t, env.dockerLog))
if !strings.HasSuffix(log, "dropdb --force --if-exists -U tver tver_restore_test") {
t.Fatalf("cleanup was not last command: %q", log)
}
if mode == "count-mismatch" && !strings.Contains(output, "ERROR: known_episodes") {
t.Fatalf("mismatch not reported: %q", output)
}
if mode == "restore-fail" {
if !strings.Contains(output, "restore failed") {
t.Fatalf("restore error missing from output: %q", output)
}
if restoreLog := readFile(t, filepath.Join(env.backupDir, "restore-test.log")); !strings.Contains(restoreLog, "restore failed") {
t.Fatalf("restore error missing from log: %q", restoreLog)
}
}
})
}
t.Run("rejects production database as restore target", func(t *testing.T) {
env := newScriptEnv(t, "success")
writeGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz"), "dump")
output, err := env.run(t, "restore-test.sh", "RESTORE_DB=tver")
if err == nil || !strings.Contains(output, "RESTORE_DB must differ from DB_NAME") {
t.Fatalf("production restore target was accepted: err=%v output=%q", err, output)
}
if _, err := os.Stat(env.dockerLog); !os.IsNotExist(err) {
t.Fatalf("docker was invoked: %v", err)
}
if log := readFile(t, filepath.Join(env.backupDir, "restore-test.log")); !strings.Contains(log, "RESTORE_DB must differ from DB_NAME") {
t.Fatalf("rejection was not logged: %q", log)
}
})
t.Run("missing dump fails and cleans temporary database", func(t *testing.T) {
env := newScriptEnv(t, "success")
output, err := env.run(t, "restore-test.sh")
if err == nil || !strings.Contains(output, "backup dump was not found") {
t.Fatalf("missing dump: err=%v output=%q", err, output)
}
log := strings.TrimSpace(readFile(t, env.dockerLog))
if log != "exec tver-postgres dropdb --force --if-exists -U tver tver_restore_test" {
t.Fatalf("unexpected cleanup: %q", log)
}
if restoreLog := readFile(t, filepath.Join(env.backupDir, "restore-test.log")); !strings.Contains(restoreLog, "backup dump was not found") {
t.Fatalf("failure was not logged: %q", restoreLog)
}
})
t.Run("honors database and container overrides", func(t *testing.T) {
env := newScriptEnv(t, "success")
writeGzip(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz"), "dump")
output, err := env.run(t, "restore-test.sh", "CONTAINER=custom-postgres", "DB_USER=custom-user", "DB_NAME=production", "RESTORE_DB=temporary")
if err != nil {
t.Fatalf("restore failed: %v\n%s", err, output)
}
for _, line := range strings.Split(strings.TrimSpace(readFile(t, env.dockerLog)), "\n") {
if !strings.Contains(line, " custom-postgres ") || strings.Contains(line, " tver-postgres ") || !strings.Contains(line, " -U custom-user ") {
t.Fatalf("override missing: %q", line)
}
if (strings.Contains(line, " createdb ") || strings.Contains(line, " dropdb ")) && !strings.HasSuffix(line, "temporary") {
t.Fatalf("wrong temporary DB: %q", line)
}
}
})
t.Run("invalid gzip fails and cleans temporary database", func(t *testing.T) {
env := newScriptEnv(t, "success")
writeFile(t, filepath.Join(env.backupDir, "tver_20261004_010203.sql.gz"), "not gzip")
output, err := env.run(t, "restore-test.sh")
if err == nil {
t.Fatalf("invalid gzip succeeded: %s", output)
}
log := strings.TrimSpace(readFile(t, env.dockerLog))
if !strings.HasSuffix(log, "dropdb --force --if-exists -U tver tver_restore_test") {
t.Fatalf("cleanup was not last command: %q", log)
}
})
}
type scriptEnv struct {
dir, backupDir, dockerLog, restoreInput string
baseEnv []string
}
func newScriptEnv(t *testing.T, mode string) scriptEnv {
t.Helper()
dir := t.TempDir()
backupDir := filepath.Join(dir, "backups")
if err := os.MkdirAll(backupDir, 0o755); err != nil {
t.Fatal(err)
}
scriptDir, err := filepath.Abs(".")
if err != nil {
t.Fatal(err)
}
dockerDir := filepath.Join(scriptDir, "testdata")
return scriptEnv{
dir: dir,
backupDir: backupDir,
dockerLog: filepath.Join(dir, "docker.log"),
restoreInput: filepath.Join(dir, "restore.sql"),
baseEnv: []string{
"PATH=" + dockerDir + string(os.PathListSeparator) + os.Getenv("PATH"),
"BACKUP_DIR=" + backupDir,
"LOG_FILE=" + filepath.Join(backupDir, "restore-test.log"),
"FAKE_DOCKER_LOG=" + filepath.Join(dir, "docker.log"),
"FAKE_RESTORE_INPUT=" + filepath.Join(dir, "restore.sql"),
"FAKE_DOCKER_MODE=" + mode,
},
}
}
func (e scriptEnv) run(t *testing.T, script string, extraEnv ...string) (string, error) {
t.Helper()
cmd := exec.Command("bash", script)
cmd.Env = append(append(os.Environ(), e.baseEnv...), extraEnv...)
var output bytes.Buffer
cmd.Stdout = &output
cmd.Stderr = &output
err := cmd.Run()
return output.String(), err
}
func writeFile(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
t.Fatal(err)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return string(data)
}
func writeGzip(t *testing.T, path, content string) {
t.Helper()
file, err := os.Create(path)
if err != nil {
t.Fatal(err)
}
writer := gzip.NewWriter(file)
if _, err := io.WriteString(writer, content); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
if err := file.Close(); err != nil {
t.Fatal(err)
}
}
func readGzip(t *testing.T, path string) string {
t.Helper()
file, err := os.Open(path)
if err != nil {
t.Fatal(err)
}
defer file.Close()
reader, err := gzip.NewReader(file)
if err != nil {
t.Fatal(err)
}
defer reader.Close()
data, err := io.ReadAll(reader)
if err != nil {
t.Fatal(err)
}
return string(data)
}
func assertNoBackupTemps(t *testing.T, backupDir string) {
t.Helper()
entries, err := os.ReadDir(backupDir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
name := entry.Name()
if strings.HasPrefix(name, ".tver_") || strings.Contains(name, ".sql.gz.tmp.") {
t.Errorf("temporary backup remains: %s", name)
}
}
}
+97
View File
@@ -0,0 +1,97 @@
#!/bin/bash
set -euo pipefail
printf '%s\n' "$*" >> "${FAKE_DOCKER_LOG:?}"
command_line=" $* "
if [[ "$command_line" == *" pg_dump "* ]]; then
case "${FAKE_DOCKER_MODE:-success}" in
dump-fail)
printf 'pg_dump failed\n' >&2
exit 42
;;
dump-partial-fail)
printf '%s\n' '-- incomplete dump --'
printf 'pg_dump failed after output\n' >&2
exit 42
;;
empty)
exit 0
;;
concurrent)
barrier="${FAKE_DOCKER_BARRIER:?}"
mkdir -p "$barrier"
touch "$barrier/$$"
for _ in {1..500}; do
count="$(find "$barrier" -type f | wc -l | tr -d '[:space:]')"
if ((count >= 2)); then
break
fi
sleep 0.01
done
if ((count < 2)); then
printf 'concurrent backup barrier timed out\n' >&2
exit 46
fi
;;
esac
printf '%s\n' '-- fake PostgreSQL dump --' 'CREATE TABLE example(id integer);'
exit 0
fi
if [[ "$command_line" == *" createdb "* ]]; then
exit 0
fi
if [[ "$command_line" == *" dropdb "* ]]; then
exit 0
fi
if [[ "$command_line" == *" psql "* && "$command_line" == *" ON_ERROR_STOP=1 "* ]]; then
cat > "${FAKE_RESTORE_INPUT:?}"
if [[ "${FAKE_DOCKER_MODE:-success}" == "restore-fail" ]]; then
printf 'restore failed\n' >&2
exit 43
fi
if [[ "$command_line" != *" -o /dev/null "* ]]; then
printf '%s\n' 'SET' 'CREATE TABLE'
fi
exit 0
fi
if [[ "$command_line" == *" psql "* && "$command_line" == *" SELECT count(*) FROM public."* ]]; then
database=""
query=""
while (($#)); do
case "$1" in
-d)
database="$2"
shift 2
;;
-c)
query="$2"
shift 2
;;
*)
shift
;;
esac
done
table="${query#*public.}"
table="${table%%;*}"
case "$table" in
download_jobs) count=3 ;;
subscriptions) count=2 ;;
known_episodes) count=5 ;;
schema_migrations) count=3 ;;
*) printf 'unexpected table: %s\n' "$table" >&2; exit 44 ;;
esac
if [[ "${FAKE_DOCKER_MODE:-success}" == "count-mismatch" && "$database" == "${RESTORE_DB:-tver_restore_test}" && "$table" == "known_episodes" ]]; then
count=4
fi
printf '%s\n' "$count"
exit 0
fi
printf 'unexpected docker invocation: %s\n' "$*" >&2
exit 45
+105 -4
View File
@@ -56,7 +56,18 @@ tags:
/volume1/docker/tver-db/pgdata/ /volume1/docker/tver-db/pgdata/
``` ```
2. [[../deploy/nas/tver-db/compose.yaml|NAS用compose.yaml]] と `.env.example` を `/volume1/docker/tver-db/` に配置する。 2. [[../deploy/nas/tver-db/compose.yaml|NAS用compose.yaml]]、`.env.example`、`backup-db.sh`、`restore-test.sh`を`/volume1/docker/tver-db/`に配置する。ディレクトリはroot以外がファイルを削除・置換できないようにし、スクリプトはrootだけが実行できるようにする。
```bash
chmod 755 /volume1/docker/tver-db
chmod 700 /volume1/docker/tver-db/backup-db.sh
chmod 700 /volume1/docker/tver-db/restore-test.sh
ls -ld /volume1/docker/tver-db
ls -l /volume1/docker/tver-db/backup-db.sh /volume1/docker/tver-db/restore-test.sh
```
DSMのACLが付いている場合、`chmod`後の`ls`でパーミッション表示の末尾から`+`が外れ、ディレクトリが`drwxr-xr-x`、スクリプトが`-rwx------`になったことを確認する。`/volume1/docker`では全員書き込み可のACLが確認されたため、スクリプトだけでなく配置ディレクトリにも`chmod 755`を適用する。
3. `.env.example`を`.env`へコピーし、`POSTGRES_PASSWORD`に十分長いランダムな値を設定する。値には、URLへの埋め込みで追加のエスケープが不要な16進文字列を使える。 3. `.env.example`を`.env`へコピーし、`POSTGRES_PASSWORD`に十分長いランダムな値を設定する。値には、URLへの埋め込みで追加のエスケープが不要な16進文字列を使える。
```bash ```bash
@@ -232,7 +243,97 @@ git pull --ff-only
yt-dlpを更新するときは`Dockerfile.worker`の`YT_DLP_VERSION`の既定値を変更してリポジトリへ反映し、workerイメージをキャッシュなしで再ビルドする。更新後はworkerログと少数の実動画で抽出・保存を確認する。 yt-dlpを更新するときは`Dockerfile.worker`の`YT_DLP_VERSION`の既定値を変更してリポジトリへ反映し、workerイメージをキャッシュなしで再ビルドする。更新後はworkerログと少数の実動画で抽出・保存を確認する。
## 5. 稼働後に残る作業 ## 5. PostgreSQLをバックアップする
> [!todo] PostgreSQLバックアップ バックアップはNAS上のDSMタスクスケジューラから毎日実行する。`tver-postgres`コンテナ内の`pg_dump`を使って`/volume1/backups/tver/tver_<YYYYmmdd_HHMMSS>.sql.gz`へ保存し、既定では14日より古い世代を削除する。
> tver専用PostgreSQL 17コンテナ内の`pg_dump`で取得し、gzip圧縮してNASの`/volume1/backups/tver/`へ保存する日次処理を用意する。保持日数は既定14日とし、稼働確認後に別作業として実装する。
> [!warning] バックアップ先も同じNASにある
> このバックアップはDBの誤操作や破損からの復元用であり、NAS本体の故障には備えられない。`/volume1/backups/tver/`はSynology Hyper Backupの対象にも含める。
### 5.1 DSMの日次タスクを登録する
DSMの「コントロール パネル」→「タスク スケジューラ」で「スケジュールされたタスク」→「ユーザー定義のスクリプト」を作成する。
| 設定 | 値 |
|---|---|
| ユーザー | `root` |
| 実行周期 | 毎日 |
| ユーザー定義のスクリプト | `bash /volume1/docker/tver-db/backup-db.sh` |
`DOCKER`、`CONTAINER`、`BACKUP_DIR`、`KEEP_DAYS`、`DB_USER`、`DB_NAME`は環境変数で上書きできる。通常運用では既定値を使う。
> [!success] NASで手動実行を確認済み
> DSMタスクスケジューラへrootの日次タスクを登録し、手動実行で2つのgzipダンプを作成した。復元確認も成功している。翌日以降の日次自動実行は未確認である。[実機] 2026-10-04 確認
> [!todo] 日次自動実行を確認する
> 翌日以降にDSMタスクの実行履歴と新しいダンプを確認する。[要確認]
### 5.2 手動でバックアップを確認する
NASへSSH接続し、初回は手動で実行する。
```bash
sudo bash /volume1/docker/tver-db/backup-db.sh
LATEST=$(sudo find /volume1/backups/tver -name 'tver_*.sql.gz' -type f -print | sort | tail -n 1)
test -n "$LATEST"
sudo test -s "$LATEST"
sudo gzip -t "$LATEST"
sudo ls -lh "$LATEST"
```
スクリプトが`OK:`、保存先、バイト数を表示し、`gzip -t`が成功することを確認する。`pg_dump`の失敗や空のダンプでは非0で終了し、完成ファイルを残さない。同じ秒の完成ファイルが既にある場合も非0で終了し、既存ファイルを上書きしない。同時実行時は同一ディレクトリ内のハードリンク作成に成功した一方だけが完成ファイルを公開する。
> [!success] NASでのバックアップ結果
> DSMタスクスケジューラから実行し、`tver_20261004_182838.sql.gz`(2,286バイト)と`tver_20261004_183256.sql.gz`(2,289バイト)を`/volume1/backups/tver/`へ作成した。ダンプはroot所有・権限600で、バックアップディレクトリはroot所有・`drwxr-xr-x`だった。[実機] 2026-10-04 確認
### 5.3 別DBへの復元を確認する
最新ダンプを本番DBへ戻す前に、同じコンテナ内の一時DB`tver_restore_test`へ復元する。
```bash
sudo bash /volume1/docker/tver-db/restore-test.sh
sudo tail -n 100 /volume1/backups/tver/restore-test.log
```
`restore-test.sh`はrootで実行する。バックアップディレクトリとログがroot所有のため、一般ユーザーでは`restore-test.log`への書き込みに失敗する。`download_jobs`、`subscriptions`、`known_episodes`、`schema_migrations`の行数が本番DBと一致し、最後に「復元確認に成功」と表示されることを確認する。復元SQLの通常出力は抑制され、ログには開始、各表の結果、成功または失敗が残る。SQLエラーは標準エラーとしてログに残る。一時DBは成功・失敗のどちらでもEXIT trapで削除される。本番DBに対しては行数取得の`SELECT`だけを実行する。
> [!success] NASでの復元確認結果
> 一般ユーザー`kazu634`での実行は`restore-test.log`へ書き込めず、`touch: Permission denied`で終了した。`sudo`で実行すると、`download_jobs` 1件、`subscriptions` 1件、`known_episodes` 1件、`schema_migrations` 3件が本番DBと一時DBで一致し、「復元確認に成功」と表示された。同じ結果がログに残り、一時DBも削除された。[実機] 2026-10-04 確認
> [!note] 配置ディレクトリの権限変更後の状態
> `/volume1/docker/tver-db`を`chmod 755`にした後も、本番アプリの`/queue`と`/subscriptions`はHTTP `200`を返し、DBを読み取れた。PostgreSQLコンテナを再起動した後の動作は未確認である。[要確認]
### 5.4 障害時に本番DBへ復元する
> [!danger] 本番DBを置き換える操作
> 先に[[#5.3 別DBへの復元を確認する]]を成功させ、復元対象のダンプを明示してから実行する。`tver`以外のDB名へ読み替えない。
1. dockageの`/opt/stacks/tver`でwebとworkerを停止し、DBへの書き込みを止める。
```bash
cd /opt/stacks/tver
docker compose stop web worker
```
2. NASで復元対象を指定し、存在・サイズ・gzip形式を確認する。
```bash
DUMP=/volume1/backups/tver/tver_<YYYYmmdd_HHMMSS>.sql.gz
test -s "$DUMP"
gzip -t "$DUMP"
```
3. NASで本番DBを作り直し、ダンプを復元する。
```bash
docker exec tver-postgres dropdb --if-exists -U tver tver
docker exec tver-postgres createdb -U tver tver
gzip -dc "$DUMP" | docker exec -i tver-postgres psql -v ON_ERROR_STOP=1 -U tver -d tver
```
4. dockageでwebとworkerを再開し、[[#3. 動作を確認する]]を実施する。
```bash
cd /opt/stacks/tver
docker compose start web worker
```
+9 -1
View File
@@ -40,6 +40,14 @@ type Client struct {
httpClient *http.Client httpClient *http.Client
} }
type httpStatusError struct {
statusCode int
}
func (e *httpStatusError) Error() string {
return fmt.Sprintf("HTTP %d", e.statusCode)
}
// NewClient はテスト用に API の接続先と HTTP クライアントを差し替えられる。 // NewClient はテスト用に API の接続先と HTTP クライアントを差し替えられる。
// 接続先が空、またはクライアントが nil の場合は本番用の既定値を使う。 // 接続先が空、またはクライアントが nil の場合は本番用の既定値を使う。
func NewClient(baseURL string, httpClient *http.Client) (*Client, error) { func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
@@ -154,7 +162,7 @@ func (c *Client) getJSON(req *http.Request, dst any) error {
} }
defer response.Body.Close() defer response.Body.Close()
if response.StatusCode < 200 || response.StatusCode >= 300 { if response.StatusCode < 200 || response.StatusCode >= 300 {
return fmt.Errorf("HTTP %d", response.StatusCode) return &httpStatusError{statusCode: response.StatusCode}
} }
if err := json.NewDecoder(io.LimitReader(response.Body, 8<<20)).Decode(dst); err != nil { if err := json.NewDecoder(io.LimitReader(response.Body, 8<<20)).Decode(dst); err != nil {
return fmt.Errorf("decode JSON: %w", err) return fmt.Errorf("decode JSON: %w", err)
+44
View File
@@ -11,6 +11,9 @@ import (
var tverSeriesIDPattern = regexp.MustCompile(`^[A-Za-z0-9]+$`) var tverSeriesIDPattern = regexp.MustCompile(`^[A-Za-z0-9]+$`)
// ErrSeriesNotFound は callSeries が HTTP 404 を返したことを表す。
var ErrSeriesNotFound = errors.New("series not found")
type seriesContents struct { type seriesContents struct {
Code int `json:"code"` Code int `json:"code"`
Result struct { Result struct {
@@ -21,6 +24,47 @@ type seriesContents struct {
} `json:"result"` } `json:"result"`
} }
func (c *Client) FetchSeries(ctx context.Context, seriesID string) (string, error) {
if !tverSeriesIDPattern.MatchString(seriesID) {
return "", errors.New("invalid series id")
}
session, err := c.CreateSession(ctx)
if err != nil {
return "", err
}
endpoint := c.baseURL.ResolveReference(&url.URL{Path: "/service/api/v2/callSeries/" + seriesID})
query := endpoint.Query()
query.Set("platform_uid", session.PlatformUID)
query.Set("platform_token", session.PlatformToken)
endpoint.RawQuery = query.Encode()
request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil)
if err != nil {
return "", fmt.Errorf("series request: %w", err)
}
setTVerHeaders(request)
var response struct {
Code int `json:"code"`
Result struct {
Content struct {
Content struct {
Title string `json:"title"`
} `json:"content"`
} `json:"content"`
} `json:"result"`
}
if err := c.getJSON(request, &response); err != nil {
var statusErr *httpStatusError
if errors.As(err, &statusErr) && statusErr.statusCode == http.StatusNotFound {
return "", ErrSeriesNotFound
}
return "", fmt.Errorf("series: %w", err)
}
if response.Code != 0 || response.Result.Content.Content.Title == "" {
return "", fmt.Errorf("series: invalid API result (code %d)", response.Code)
}
return response.Result.Content.Content.Title, nil
}
func (c *Client) FetchSeriesEpisodes(ctx context.Context, seriesID string) ([]Episode, error) { func (c *Client) FetchSeriesEpisodes(ctx context.Context, seriesID string) ([]Episode, error) {
if !tverSeriesIDPattern.MatchString(seriesID) { if !tverSeriesIDPattern.MatchString(seriesID) {
return nil, errors.New("invalid series id") return nil, errors.New("invalid series id")
+123
View File
@@ -2,6 +2,7 @@ package tver
import ( import (
"context" "context"
"errors"
"net/http" "net/http"
"os" "os"
"strings" "strings"
@@ -180,3 +181,125 @@ func TestFetchSeriesEpisodesRejectsInvalidSeriesIDBeforeRequest(t *testing.T) {
} }
} }
} }
func TestFetchSeriesFromRecordedResponse(t *testing.T) {
fixture, err := os.ReadFile("testdata/series.json")
if err != nil {
t.Fatal(err)
}
var sessionCalls, seriesCalls int
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/api/platform_users/browser/create":
sessionCalls++
_, _ = w.Write([]byte(`{"code":0,"result":{"platform_uid":"uid-test","platform_token":"token-test"}}`))
case "/service/api/v2/callSeries/sr0lhdrtdb":
seriesCalls++
if r.Method != http.MethodGet || r.URL.Query().Get("platform_uid") != "uid-test" || r.URL.Query().Get("platform_token") != "token-test" {
t.Errorf("series request method/query: %s %v", r.Method, r.URL.Query())
}
for name, want := range map[string]string{"x-tver-platform-type": "web", "Origin": "https://tver.jp", "Referer": "https://tver.jp/"} {
if got := r.Header.Get(name); got != want {
t.Errorf("series header %s = %q; want %q", name, got, want)
}
}
_, _ = w.Write(fixture)
default:
t.Errorf("unexpected request: %s", r.URL.Path)
http.NotFound(w, r)
}
})
title, err := client.FetchSeries(context.Background(), "sr0lhdrtdb")
if err != nil || title != "韓国ドラマ「良いが悪い、ドンジェ」" {
t.Fatalf("series: title=%q error=%v", title, err)
}
if sessionCalls != 1 || seriesCalls != 1 {
t.Fatalf("calls: session=%d series=%d", sessionCalls, seriesCalls)
}
}
func TestFetchSeriesNotFound(t *testing.T) {
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/api/platform_users/browser/create" {
_, _ = w.Write([]byte(`{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`))
return
}
http.NotFound(w, r)
})
_, err := client.FetchSeries(context.Background(), "missing")
if !errors.Is(err, ErrSeriesNotFound) {
t.Fatalf("error=%v; want ErrSeriesNotFound", err)
}
}
func TestFetchSeriesTransportAndDecodeErrors(t *testing.T) {
validSession := `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`
for _, tt := range []struct {
name string
sessionStatus, seriesStatus int
sessionBody, seriesBody string
want string
wantSeriesCalls int
}{
{"session HTTP is not series not found", http.StatusNotFound, http.StatusOK, "", "", "create session: HTTP 404", 0},
{"series HTTP", http.StatusOK, http.StatusBadGateway, validSession, "", "series: HTTP 502", 1},
{"series JSON", http.StatusOK, http.StatusOK, validSession, "{", "series: decode JSON", 1},
} {
t.Run(tt.name, func(t *testing.T) {
var seriesCalls int
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/v2/api/platform_users/browser/create":
w.WriteHeader(tt.sessionStatus)
_, _ = w.Write([]byte(tt.sessionBody))
case "/service/api/v2/callSeries/sr1":
seriesCalls++
w.WriteHeader(tt.seriesStatus)
_, _ = w.Write([]byte(tt.seriesBody))
default:
t.Errorf("unexpected request: %s", r.URL.Path)
}
})
_, err := client.FetchSeries(context.Background(), "sr1")
if err == nil || !strings.Contains(err.Error(), tt.want) || errors.Is(err, ErrSeriesNotFound) {
t.Fatalf("error=%v; want %q and not ErrSeriesNotFound", err, tt.want)
}
if seriesCalls != tt.wantSeriesCalls {
t.Fatalf("series calls=%d; want %d", seriesCalls, tt.wantSeriesCalls)
}
})
}
}
func TestFetchSeriesRejectsBadResponses(t *testing.T) {
for _, tt := range []struct {
name, body string
}{
{"empty title", `{"code":0,"result":{"content":{"content":{"title":""}}}}`},
{"API code", `{"code":7,"result":{"content":{"content":{"title":"番組"}}}}`},
} {
t.Run(tt.name, func(t *testing.T) {
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/api/platform_users/browser/create" {
_, _ = w.Write([]byte(`{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`))
return
}
_, _ = w.Write([]byte(tt.body))
})
if _, err := client.FetchSeries(context.Background(), "sr1"); err == nil {
t.Fatal("invalid response accepted")
}
})
}
}
func TestFetchSeriesRejectsInvalidSeriesIDBeforeRequest(t *testing.T) {
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
t.Errorf("unexpected request: %s", r.URL.Path)
})
for _, id := range []string{"", "../other", "sr1?x=1", "sr_1"} {
if _, err := client.FetchSeries(context.Background(), id); err == nil {
t.Errorf("invalid series id accepted: %q", id)
}
}
}
+1
View File
@@ -0,0 +1 @@
{"code":0,"result":{"content":{"type":"series","content":{"id":"sr0lhdrtdb","title":"韓国ドラマ「良いが悪い、ドンジェ」"}}}}
+3
View File
@@ -85,6 +85,9 @@ func NewHandler(fetcher NewerFetcher, jobs JobStore, videoDir string, now func()
mux.HandleFunc("POST /jobs/{id}/cancel", s.cancelJob) mux.HandleFunc("POST /jobs/{id}/cancel", s.cancelJob)
mux.HandleFunc("GET /subscriptions", s.subscriptionsPage) mux.HandleFunc("GET /subscriptions", s.subscriptionsPage)
mux.HandleFunc("GET /subscriptions/list", s.subscriptionsFragment) mux.HandleFunc("GET /subscriptions/list", s.subscriptionsFragment)
mux.HandleFunc("POST /subscriptions/from-url/confirm", s.confirmSubscriptionURL)
mux.HandleFunc("POST /subscriptions/from-url/register", s.registerSubscriptionURL)
mux.HandleFunc("GET /subscriptions/from-url/cancel", s.cancelSubscriptionURL)
mux.HandleFunc("GET /subscriptions/choice", s.subscriptionChoice) mux.HandleFunc("GET /subscriptions/choice", s.subscriptionChoice)
mux.HandleFunc("POST /subscriptions/register", s.registerSubscription) mux.HandleFunc("POST /subscriptions/register", s.registerSubscription)
mux.HandleFunc("POST /subscriptions/{id}/pause", s.pauseSubscription) mux.HandleFunc("POST /subscriptions/{id}/pause", s.pauseSubscription)
+1
View File
@@ -21,6 +21,7 @@ var platformTokenPattern = regexp.MustCompile(`(?i)(platform_token=)[^&\s]+`)
type NewerFetcher interface { type NewerFetcher interface {
FetchNewer(context.Context, string) ([]tver.Episode, error) FetchNewer(context.Context, string) ([]tver.Episode, error)
FetchSeries(context.Context, string) (string, error)
FetchSeriesEpisodes(context.Context, string) ([]tver.Episode, error) FetchSeriesEpisodes(context.Context, string) ([]tver.Episode, error)
} }
+8
View File
@@ -23,6 +23,14 @@ type fakeFetcher struct {
seriesEpisodes []tver.Episode seriesEpisodes []tver.Episode
seriesErr error seriesErr error
seriesCalls []string seriesCalls []string
seriesTitle string
seriesTitleErr error
seriesTitleCalls []string
}
func (f *fakeFetcher) FetchSeries(_ context.Context, seriesID string) (string, error) {
f.seriesTitleCalls = append(f.seriesTitleCalls, seriesID)
return f.seriesTitle, f.seriesTitleErr
} }
func (f *fakeFetcher) FetchNewer(_ context.Context, genre string) ([]tver.Episode, error) { func (f *fakeFetcher) FetchNewer(_ context.Context, genre string) ([]tver.Episode, error) {
+3
View File
@@ -18,6 +18,9 @@ main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; }
.subscription-action button, .subscription-item button { font: inherit; padding: .35rem .7rem; } .subscription-action button, .subscription-item button { font: inherit; padding: .35rem .7rem; }
.subscription-item { border: 1px solid #d6dee4; border-radius: .5rem; margin: .75rem 0; padding: .8rem 1rem; } .subscription-item { border: 1px solid #d6dee4; border-radius: .5rem; margin: .75rem 0; padding: .8rem 1rem; }
.subscription-item form { display: inline-block; margin-right: .5rem; } .subscription-item form { display: inline-block; margin-right: .5rem; }
.subscription-url-entry { border: 1px solid #d6dee4; border-radius: .5rem; margin-bottom: 1.5rem; padding: .8rem 1rem; }
.subscription-url-entry input[type="url"] { font: inherit; min-width: min(32rem, 70vw); padding: .35rem .5rem; }
.subscription-url-entry button { font: inherit; padding: .35rem .7rem; }
.episode-action form { display: inline; } .episode-action form { display: inline; }
.episode-action button, .queue-job button { font: inherit; padding: .35rem .7rem; } .episode-action button, .queue-job button { font: inherit; padding: .35rem .7rem; }
.queue-job { border: 1px solid #d6dee4; border-radius: .5rem; margin: .75rem 0; padding: .8rem 1rem; } .queue-job { border: 1px solid #d6dee4; border-radius: .5rem; margin: .75rem 0; padding: .8rem 1rem; }
+161
View File
@@ -0,0 +1,161 @@
package web
import (
"errors"
"net/http"
"net/url"
"regexp"
"strings"
"gitea.kazu634.com/kazu634/tver/internal/store"
"gitea.kazu634.com/kazu634/tver/internal/tver"
)
const seriesURLInputError = "番組ページ(https://tver.jp/series/…)の URL を入力してください"
var seriesURLPathPattern = regexp.MustCompile(`^/series/([A-Za-z0-9]+)/?$`)
var seriesIDPattern = regexp.MustCompile(`^[A-Za-z0-9]+$`)
type subscriptionURLResultView struct {
SeriesID, Title, ErrorText string
EpisodeCount int
Subscribed bool
}
type subscriptionURLRegisteredView struct {
Subscriptions subscriptionsView
}
func parseSeriesURL(raw string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(raw))
if err != nil || parsed.Scheme != "https" || parsed.User != nil || (!strings.EqualFold(parsed.Host, "tver.jp") && !strings.EqualFold(parsed.Host, "www.tver.jp")) {
return "", errors.New(seriesURLInputError)
}
match := seriesURLPathPattern.FindStringSubmatch(parsed.Path)
if len(match) != 2 {
return "", errors.New(seriesURLInputError)
}
return match[1], nil
}
func (s *server) confirmSubscriptionURL(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("HX-Request") != "true" {
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
return
}
if err := r.ParseForm(); err != nil {
http.Error(w, "入力が不正です", http.StatusBadRequest)
return
}
seriesID, err := parseSeriesURL(r.Form.Get("series_url"))
if err != nil {
s.render(w, "subscription_url_result", subscriptionURLResultView{ErrorText: seriesURLInputError})
return
}
title, episodes, ok := s.fetchSubscriptionSeries(w, r, seriesID)
if !ok {
return
}
subscribed, err := s.isSubscribed(r.Context(), seriesID)
if err != nil {
s.logger.Printf("番組の登録状態取得に失敗: %v", err)
http.Error(w, "登録状態を取得できませんでした", http.StatusInternalServerError)
return
}
s.render(w, "subscription_url_result", subscriptionURLResultView{
SeriesID: seriesID, Title: title, EpisodeCount: len(episodes), Subscribed: subscribed,
})
}
func (s *server) registerSubscriptionURL(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
http.Error(w, "入力が不正です", http.StatusBadRequest)
return
}
seriesID := r.Form.Get("series_id")
if !seriesIDPattern.MatchString(seriesID) {
http.Error(w, "番組 ID が不正です", http.StatusBadRequest)
return
}
choice := r.Form.Get("download_existing")
if choice != "" && choice != "1" {
http.Error(w, "公開中の回の選択が不正です", http.StatusBadRequest)
return
}
subscribed, err := s.isSubscribed(r.Context(), seriesID)
if err != nil {
s.logger.Printf("番組の登録状態取得に失敗: %v", err)
http.Error(w, "登録状態を取得できませんでした", http.StatusInternalServerError)
return
}
if subscribed {
s.subscriptionURLRegisteredResponse(w, r)
return
}
title, episodes, ok := s.fetchSubscriptionSeries(w, r, seriesID)
if !ok {
return
}
items := make([]store.Episode, 0, len(episodes))
for _, episode := range episodes {
items = append(items, storeEpisode(episode))
}
_, err = s.jobs.RegisterSubscription(r.Context(), seriesID, title, items, choice == "1")
if err != nil {
s.logger.Printf("番組URLからの登録に失敗: %v", err)
s.subscriptionURLFailure(w, r, "番組を登録できませんでした。再試行してください")
return
}
s.subscriptionURLRegisteredResponse(w, r)
}
func (s *server) cancelSubscriptionURL(w http.ResponseWriter, _ *http.Request) {
s.render(w, "subscription_url_result", subscriptionURLResultView{})
}
func (s *server) fetchSubscriptionSeries(w http.ResponseWriter, r *http.Request, seriesID string) (string, []tver.Episode, bool) {
title, err := s.fetcher.FetchSeries(r.Context(), seriesID)
if err != nil {
if errors.Is(err, tver.ErrSeriesNotFound) {
s.subscriptionURLFailure(w, r, "番組が見つかりません")
return "", nil, false
}
s.logSubscriptionFetchError(err)
s.subscriptionURLFailure(w, r, "番組情報を取得できませんでした。再試行してください")
return "", nil, false
}
episodes, err := s.fetcher.FetchSeriesEpisodes(r.Context(), seriesID)
if err != nil {
s.logSubscriptionFetchError(err)
s.subscriptionURLFailure(w, r, "番組情報を取得できませんでした。再試行してください")
return "", nil, false
}
return title, episodes, true
}
func (s *server) logSubscriptionFetchError(err error) {
s.logger.Printf("番組URLの情報取得に失敗: %s", platformTokenPattern.ReplaceAllString(err.Error(), "${1}[REDACTED]"))
}
func (s *server) subscriptionURLFailure(w http.ResponseWriter, r *http.Request, message string) {
if r.Header.Get("HX-Request") != "true" {
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
return
}
s.render(w, "subscription_url_result", subscriptionURLResultView{ErrorText: message})
}
func (s *server) subscriptionURLRegisteredResponse(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("HX-Request") != "true" {
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
return
}
view, err := s.loadSubscriptions(r.Context())
if err != nil {
s.logger.Printf("登録番組の取得に失敗: %v", err)
http.Error(w, "登録番組を取得できませんでした", http.StatusInternalServerError)
return
}
view.OutOfBand = true
s.render(w, "subscription_url_registered", subscriptionURLRegisteredView{Subscriptions: view})
}
+260
View File
@@ -0,0 +1,260 @@
package web
import (
"bytes"
"errors"
"log"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"time"
"gitea.kazu634.com/kazu634/tver/internal/store"
"gitea.kazu634.com/kazu634/tver/internal/tver"
)
func TestParseSeriesURL(t *testing.T) {
tests := []struct {
name, raw, want string
valid bool
}{
{"without www", "https://tver.jp/series/sr123", "sr123", true},
{"with www", "https://www.tver.jp/series/ABC123", "ABC123", true},
{"uppercase host", "https://TVER.JP/series/sr123", "sr123", true},
{"trailing slash", "https://tver.jp/series/sr123/", "sr123", true},
{"query and fragment", "https://tver.jp/series/sr123/?foo=bar#section", "sr123", true},
{"surrounding whitespace", " \thttps://tver.jp/series/sr123\n", "sr123", true},
{"http", "http://tver.jp/series/sr123", "", false},
{"subdomain suffix", "https://tver.jp.example.com/series/sr123", "", false},
{"evil prefix", "https://eviltver.jp/series/sr123", "", false},
{"userinfo on allowed host", "https://evil@tver.jp/series/sr123", "", false},
{"allowed host as userinfo", "https://tver.jp@evil.example/series/sr123", "", false},
{"episode", "https://tver.jp/episodes/ep123", "", false},
{"lp", "https://tver.jp/lp/example", "", false},
{"corner", "https://tver.jp/corner/example", "", false},
{"feature", "https://tver.jp/feature/example", "", false},
{"missing id", "https://tver.jp/series/", "", false},
{"invalid id", "https://tver.jp/series/sr_123", "", false},
{"extra path", "https://tver.jp/series/sr123/episodes", "", false},
{"encoded slash in id", "https://tver.jp/series/sr123%2Fevil", "", false},
{"encoded dot segments", "https://tver.jp/series/%2E%2E/episodes", "", false},
{"duplicate slash", "https://tver.jp//series/sr123", "", false},
{"port", "https://tver.jp:443/series/sr123", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := parseSeriesURL(tt.raw)
if tt.valid {
if err != nil || got != tt.want {
t.Fatalf("parseSeriesURL(%q)=%q, %v; want %q", tt.raw, got, err, tt.want)
}
return
}
if err == nil || err.Error() != seriesURLInputError {
t.Fatalf("parseSeriesURL(%q)=%q, %v; want input error", tt.raw, got, err)
}
})
}
}
func TestSubscriptionsPageShowsSeriesURLForm(t *testing.T) {
page := getPage(newTestHandlerWithStore(&fakeFetcher{}, &fakeJobStore{}, nil), "/subscriptions")
body := page.Body.String()
if page.Code != http.StatusOK || !strings.Contains(body, `name="series_url"`) || !strings.Contains(body, `hx-post="/subscriptions/from-url/confirm"`) || !strings.Contains(body, `id="subscriptions-list"`) {
t.Fatalf("URL入力フォーム: status=%d body=%s", page.Code, body)
}
}
func TestConfirmSubscriptionURL(t *testing.T) {
fetcher := &fakeFetcher{
seriesTitle: `<script>alert("title")</script>`,
seriesEpisodes: []tver.Episode{
{ID: "ep1", SeriesID: "sr1"},
{ID: "ep2", SeriesID: "sr1"},
},
}
handler := newTestHandlerWithStore(fetcher, &fakeJobStore{}, nil)
response := postForm(handler, "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/sr1?x=1#fragment"}})
body := response.Body.String()
for _, want := range []string{"&lt;script&gt;", "番組ID: sr1", "公開中の回数: 2件", `name="series_id" value="sr1"`, "公開中の回もダウンロードする", ">登録</button>", ">キャンセル</button>"} {
if !strings.Contains(body, want) {
t.Errorf("確認画面に %q がない: %s", want, body)
}
}
if response.Code != http.StatusOK || strings.Contains(body, `<script>`) || strings.Contains(body, `name="series_title"`) || strings.Contains(body, `checked`) {
t.Fatalf("確認画面: status=%d body=%s", response.Code, body)
}
if len(fetcher.seriesTitleCalls) != 1 || fetcher.seriesTitleCalls[0] != "sr1" || len(fetcher.seriesCalls) != 1 || fetcher.seriesCalls[0] != "sr1" {
t.Fatalf("取得したID: title=%v episodes=%v", fetcher.seriesTitleCalls, fetcher.seriesCalls)
}
}
func TestConfirmSubscriptionURLAlreadyRegisteredIncludingPaused(t *testing.T) {
fetcher := &fakeFetcher{seriesTitle: "番組", seriesEpisodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1"}}}
jobs := &fakeJobStore{subscriptions: map[string]store.Subscription{"sr1": {SeriesID: "sr1", SeriesTitle: "番組", Enabled: false}}}
response := postForm(newTestHandlerWithStore(fetcher, jobs, nil), "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/sr1"}})
body := response.Body.String()
if response.Code != http.StatusOK || !strings.Contains(body, "番組") || !strings.Contains(body, "公開中の回数: 1件") || !strings.Contains(body, "登録済み") || strings.Contains(body, ">登録</button>") || len(fetcher.seriesTitleCalls) != 1 || len(fetcher.seriesCalls) != 1 {
t.Fatalf("登録済み確認: status=%d body=%s title=%v episodes=%v", response.Code, body, fetcher.seriesTitleCalls, fetcher.seriesCalls)
}
}
func TestSubscriptionURLNotFoundAndInvalidURL(t *testing.T) {
fetcher := &fakeFetcher{seriesTitleErr: tver.ErrSeriesNotFound}
jobs := &fakeJobStore{}
handler := newTestHandlerWithStore(fetcher, jobs, nil)
notFound := postForm(handler, "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/missing"}})
if notFound.Code != http.StatusOK || !strings.Contains(notFound.Body.String(), "番組が見つかりません") || len(fetcher.seriesCalls) != 0 {
t.Fatalf("404: status=%d body=%s episodeCalls=%v", notFound.Code, notFound.Body.String(), fetcher.seriesCalls)
}
registerNotFound := postForm(handler, "/subscriptions/from-url/register", url.Values{"series_id": {"missing"}})
if registerNotFound.Code != http.StatusOK || !strings.Contains(registerNotFound.Body.String(), "番組が見つかりません") || jobs.registerCalls != 0 {
t.Fatalf("登録時404: status=%d body=%s registerCalls=%d", registerNotFound.Code, registerNotFound.Body.String(), jobs.registerCalls)
}
invalid := postForm(handler, "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://evil.example/series/sr1"}})
if invalid.Code != http.StatusOK || !strings.Contains(invalid.Body.String(), seriesURLInputError) || len(fetcher.seriesTitleCalls) != 2 {
t.Fatalf("不正URL: status=%d body=%s titleCalls=%v", invalid.Code, invalid.Body.String(), fetcher.seriesTitleCalls)
}
}
func TestRegisterSubscriptionURLWithAndWithoutExistingEpisodes(t *testing.T) {
for _, download := range []bool{false, true} {
t.Run(map[bool]string{false: "without existing", true: "with existing"}[download], func(t *testing.T) {
fetcher := &fakeFetcher{seriesTitle: "番組", seriesEpisodes: []tver.Episode{}}
jobs := &fakeJobStore{}
handler := newTestHandlerWithStore(fetcher, jobs, nil)
confirmation := postForm(handler, "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/sr1"}})
if confirmation.Code != http.StatusOK || !strings.Contains(confirmation.Body.String(), "公開中の回はありません") {
t.Fatalf("0件の確認: %d %s", confirmation.Code, confirmation.Body.String())
}
values := url.Values{"series_id": {"sr1"}, "series_title": {"偽番組"}}
if download {
values.Set("download_existing", "1")
}
registered := postForm(handler, "/subscriptions/from-url/register", values)
body := registered.Body.String()
if registered.Code != http.StatusOK || !strings.Contains(body, "登録済み") || !strings.Contains(body, `id="subscriptions-list"`) || !strings.Contains(body, `hx-swap-oob="outerHTML"`) || !strings.Contains(body, `id="subscription-url-input"`) {
t.Fatalf("登録応答: %d %s", registered.Code, body)
}
if jobs.registerCalls != 1 || jobs.registerDownload != download || len(jobs.registerEpisodes) != 0 || jobs.subscriptions["sr1"].SeriesTitle != "番組" {
t.Fatalf("登録内容: calls=%d download=%t episodes=%v subscriptions=%v", jobs.registerCalls, jobs.registerDownload, jobs.registerEpisodes, jobs.subscriptions)
}
if len(fetcher.seriesTitleCalls) != 2 || len(fetcher.seriesCalls) != 2 {
t.Fatalf("確認と登録で再取得していない: title=%v episodes=%v", fetcher.seriesTitleCalls, fetcher.seriesCalls)
}
})
}
}
func TestRegisterSubscriptionURLUsesServerFetchedData(t *testing.T) {
fetcher := &fakeFetcher{seriesTitle: "本当の番組", seriesEpisodes: []tver.Episode{{
ID: "ep1", SeriesID: "sr1", SeriesTitle: "本当の番組", Title: "公開中の回", Duration: 200,
}}}
jobs := &fakeJobStore{}
handler := newTestHandlerWithStore(fetcher, jobs, nil)
response := postForm(handler, "/subscriptions/from-url/register", url.Values{
"series_id": {"sr1"}, "series_title": {"偽番組"}, "download_existing": {"1"},
})
if response.Code != http.StatusOK || jobs.registerCalls != 1 || !jobs.registerDownload || len(jobs.registerEpisodes) != 1 || jobs.registerEpisodes[0].ID != "ep1" || jobs.subscriptions["sr1"].SeriesTitle != "本当の番組" {
t.Fatalf("サーバー取得値での登録: status=%d calls=%d download=%t episodes=%+v subscriptions=%+v", response.Code, jobs.registerCalls, jobs.registerDownload, jobs.registerEpisodes, jobs.subscriptions)
}
}
func TestRegisterSubscriptionURLAlreadyRegisteredDoesNothing(t *testing.T) {
fetcher := &fakeFetcher{seriesTitleErr: errors.New("must not fetch"), seriesErr: errors.New("must not fetch")}
jobs := &fakeJobStore{subscriptions: map[string]store.Subscription{"sr1": {SeriesID: "sr1", SeriesTitle: "番組", Enabled: false}}}
response := postForm(newTestHandlerWithStore(fetcher, jobs, nil), "/subscriptions/from-url/register", url.Values{"series_id": {"sr1"}})
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "登録済み") || jobs.registerCalls != 0 || len(fetcher.seriesTitleCalls) != 0 || len(fetcher.seriesCalls) != 0 {
t.Fatalf("登録済みの再登録: status=%d body=%s register=%d title=%v episodes=%v", response.Code, response.Body.String(), jobs.registerCalls, fetcher.seriesTitleCalls, fetcher.seriesCalls)
}
}
func TestRegisterSubscriptionURLFetchFailuresDoNotRegisterAndRedactToken(t *testing.T) {
for _, tt := range []struct {
name string
seriesErr error
episodesErr error
wantEpisodeCall int
}{
{"series", errors.New("platform_token=secret-series& upstream"), nil, 0},
{"episodes", nil, errors.New("platform_token=secret-episodes& upstream"), 1},
} {
t.Run(tt.name, func(t *testing.T) {
fetcher := &fakeFetcher{seriesTitle: "番組", seriesTitleErr: tt.seriesErr, seriesErr: tt.episodesErr}
jobs := &fakeJobStore{}
var logs bytes.Buffer
handler := NewHandler(fetcher, jobs, "", time.Now, log.New(&logs, "", 0))
response := postForm(handler, "/subscriptions/from-url/register", url.Values{"series_id": {"sr1"}})
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "番組情報を取得できませんでした。再試行してください") || jobs.registerCalls != 0 || len(jobs.subscriptions) != 0 {
t.Fatalf("取得失敗: status=%d body=%s calls=%d subscriptions=%v", response.Code, response.Body.String(), jobs.registerCalls, jobs.subscriptions)
}
if strings.Contains(logs.String(), "secret-") || !strings.Contains(logs.String(), "platform_token=[REDACTED]") || len(fetcher.seriesCalls) != tt.wantEpisodeCall {
t.Fatalf("ログまたは呼出回数: logs=%s episodeCalls=%v", logs.String(), fetcher.seriesCalls)
}
})
}
}
func TestRegisterSubscriptionURLRejectsInvalidChoice(t *testing.T) {
fetcher := &fakeFetcher{}
jobs := &fakeJobStore{}
handler := newTestHandlerWithStore(fetcher, jobs, nil)
for _, values := range []url.Values{
{},
{"series_id": {"bad_id"}},
{"series_id": {"../sr1"}},
{"series_id": {"sr1"}, "download_existing": {"yes"}},
} {
response := postForm(handler, "/subscriptions/from-url/register", values)
if response.Code != http.StatusBadRequest {
t.Errorf("不正値 %v: status=%d body=%s", values, response.Code, response.Body.String())
}
}
if len(fetcher.seriesTitleCalls) != 0 || len(fetcher.seriesCalls) != 0 || jobs.subscribedCalls != 0 || jobs.registerCalls != 0 {
t.Fatalf("不正な hidden 値で外部取得または DB 操作を実行: title=%v episodes=%v subscribed=%d register=%d", fetcher.seriesTitleCalls, fetcher.seriesCalls, jobs.subscribedCalls, jobs.registerCalls)
}
}
func TestCancelSubscriptionURLClearsConfirmation(t *testing.T) {
response := getPage(newTestHandlerWithStore(&fakeFetcher{}, &fakeJobStore{}, nil), "/subscriptions/from-url/cancel")
body := response.Body.String()
if response.Code != http.StatusOK || !strings.Contains(body, `id="subscription-url-result"`) || strings.Contains(body, `name="series_id"`) || strings.Contains(body, `role="alert"`) || strings.Contains(body, `role="status"`) {
t.Fatalf("キャンセル応答: status=%d body=%q", response.Code, response.Body.String())
}
}
func TestSubscriptionURLPostsWithoutHtmxRedirect(t *testing.T) {
fetcher := &fakeFetcher{seriesTitle: "番組", seriesEpisodes: []tver.Episode{}}
jobs := &fakeJobStore{}
handler := newTestHandlerWithStore(fetcher, jobs, nil)
for _, tt := range []struct {
path string
form url.Values
}{
{"/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/sr1"}}},
{"/subscriptions/from-url/register", url.Values{"series_id": {"sr1"}}},
} {
request := httptest.NewRequest(http.MethodPost, tt.path, strings.NewReader(tt.form.Encode()))
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/subscriptions" {
t.Errorf("通常POST %s: status=%d Location=%q", tt.path, response.Code, response.Header().Get("Location"))
}
}
if len(fetcher.seriesTitleCalls) != 1 || jobs.registerCalls != 1 {
t.Fatalf("登録POSTが処理されていない: fetch=%v register=%d", fetcher.seriesTitleCalls, jobs.registerCalls)
}
}
func TestSubscriptionURLPostRoutesRejectGet(t *testing.T) {
handler := newTestHandlerWithStore(&fakeFetcher{}, &fakeJobStore{}, nil)
for _, path := range []string{"/subscriptions/from-url/confirm", "/subscriptions/from-url/register"} {
response := getPage(handler, path)
if response.Code != http.StatusMethodNotAllowed || !strings.Contains(response.Header().Get("Allow"), "POST") {
t.Errorf("GET %s: status=%d Allow=%q", path, response.Code, response.Header().Get("Allow"))
}
}
}
+1
View File
@@ -22,6 +22,7 @@ type subscriptionView struct {
type subscriptionsView struct { type subscriptionsView struct {
Items []subscriptionView Items []subscriptionView
OutOfBand bool
} }
func (s *server) subscriptionChoice(w http.ResponseWriter, r *http.Request) { func (s *server) subscriptionChoice(w http.ResponseWriter, r *http.Request) {
+38 -1
View File
@@ -18,11 +18,48 @@
{{define "subscriptions_page"}} {{define "subscriptions_page"}}
<h1>登録番組</h1> <h1>登録番組</h1>
<section class="subscription-url-entry">
<h2>番組URLから登録</h2>
<form action="/subscriptions/from-url/confirm" method="post" hx-post="/subscriptions/from-url/confirm" hx-target="#subscription-url-result" hx-swap="outerHTML">
<label>番組URL <input id="subscription-url-input" type="url" name="series_url" placeholder="https://tver.jp/series/…" required></label>
<button type="submit">確認</button>
</form>
<div id="subscription-url-result"></div>
</section>
{{template "subscriptions_list" .}} {{template "subscriptions_list" .}}
{{end}} {{end}}
{{define "subscription_url_result"}}
<div id="subscription-url-result">
{{if .ErrorText}}
<p role="alert">{{.ErrorText}}</p>
{{else if .SeriesID}}
<h3>{{.Title}}</h3>
<p>番組ID: {{.SeriesID}}</p>
{{if eq .EpisodeCount 0}}<p>公開中の回はありません</p>{{else}}<p>公開中の回数: {{.EpisodeCount}}件</p>{{end}}
{{if .Subscribed}}
<p role="status">登録済み</p>
<button type="button" hx-get="/subscriptions/from-url/cancel" hx-target="#subscription-url-result" hx-swap="outerHTML">キャンセル</button>
{{else}}
<form action="/subscriptions/from-url/register" method="post" hx-post="/subscriptions/from-url/register" hx-target="#subscription-url-result" hx-swap="outerHTML">
<input type="hidden" name="series_id" value="{{.SeriesID}}">
<label><input type="checkbox" name="download_existing" value="1">公開中の回もダウンロードする</label>
<button type="submit">登録</button>
<button type="button" hx-get="/subscriptions/from-url/cancel" hx-target="#subscription-url-result" hx-swap="outerHTML">キャンセル</button>
</form>
{{end}}
{{end}}
</div>
{{end}}
{{define "subscription_url_registered"}}
<div id="subscription-url-result"><p role="status">登録済み</p></div>
<input id="subscription-url-input" type="url" name="series_url" placeholder="https://tver.jp/series/…" required hx-swap-oob="outerHTML">
{{template "subscriptions_list" .Subscriptions}}
{{end}}
{{define "subscriptions_list"}} {{define "subscriptions_list"}}
<div id="subscriptions-list"> <div id="subscriptions-list" {{if .OutOfBand}}hx-swap-oob="outerHTML"{{end}}>
{{range .Items}} {{range .Items}}
<article class="subscription-item"> <article class="subscription-item">
<h2>{{.Title}}</h2> <h2>{{.Title}}</h2>