Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cb6fc1df4c | ||
|
|
fd63a401f7 | ||
|
|
6586d7edd2 | ||
|
|
511365f0d4 | ||
|
|
8e0b03483b |
+19
-8
@@ -13,7 +13,7 @@ status: 本番稼働中
|
|||||||
|
|
||||||
## 今の状態
|
## 今の状態
|
||||||
|
|
||||||
- Go の HTTP サーバー、TVer API クライアント、ドラマ・バラエティの新着表示、テスト、web・worker コンテナの定義がある
|
- Go の HTTP サーバー、TVer API クライアント、ドラマ・バラエティの新着表示、確認画面つきの動画複数削除、テスト、web・worker コンテナの定義がある
|
||||||
- **本番稼働中。** dockageのweb・worker、NASのPostgreSQL 17、NFS動画保存を構成し、実動画の取得・再生・Range配信まで確認済み
|
- **本番稼働中。** dockageのweb・worker、NASのPostgreSQL 17、NFS動画保存を構成し、実動画の取得・再生・Range配信まで確認済み
|
||||||
- リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git`
|
- リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git`
|
||||||
|
|
||||||
@@ -54,7 +54,9 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
|
|||||||
| ファイル名 | `<番組名>/<番組名> <回タイトル> [<エピソードID>].mp4`。禁止文字と制御文字を `_` に置換し、空の要素は `名称未設定`とする。番組名はUTF-8で90バイト、回タイトルは140バイト、サニタイズ後のエピソードIDは64バイトまでとし、ファイル名全体を255バイト以下に収める。64バイトを超えるIDはworkerで`invalid_output`にする。実際のTVer IDは約10文字で、yt-dlpのextractorも英数字として扱うため64バイトを上限とした。既存ファイルは改名せず、新しい保存から適用する |
|
| ファイル名 | `<番組名>/<番組名> <回タイトル> [<エピソード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のディレクトリ境界で拒否する |
|
| 動画ファイルの境界 | webは`VIDEO_DIR`を`os.OpenRoot`で開き、DBの相対`file_path`を`Root.Open`・`Root.Remove`へ渡す。`VIDEO_DIR`外へのパストラバーサルと外向きシンボリックリンクをOSのディレクトリ境界で拒否する |
|
||||||
| 同名ファイル | 移動先に同名ファイルがあれば上書きする。ファイル名にエピソード ID が入り `episode_id` は一意なので、同名は同じ回の前回処理の残りに限られる。再実行で同じ結果になることを優先する |
|
| 同名ファイル | 移動先に同名ファイルがあれば上書きする。ファイル名にエピソード ID が入り `episode_id` は一意なので、同名は同じ回の前回処理の残りに限られる。再実行で同じ結果になることを優先する |
|
||||||
| 動画の削除 | ファイルを先に削除してからジョブ記録を削除する。ファイルが既に無い場合も記録は削除する。登録番組の `known_episodes` は残す |
|
| ライブラリ一覧 | DBの完了済みジョブから作り、NASのフォルダ走査やファイル存在確認は行わない。番組一覧、番組ごとの回一覧、再生の3段構成にする |
|
||||||
|
| 回の自然順 | 回タイトル中の半角・全角数字の並びを数値として比較し、同順位は完了日時の古い順、さらにジョブIDの小さい順にする |
|
||||||
|
| 動画の削除 | アプリからだけ行い、削除前にサーバーが描画する確認画面を必ず挟む。ライブラリでは番組単位、番組ページでは選択した回または番組全体を複数削除できる。確認画面で確定したジョブ ID だけを実行するため、確認後に増えた回は削除しない。各回はファイルを先に削除してからジョブ記録を削除し、親の番組フォルダが完全に空なら削除する。ファイルが既に無い場合も記録は削除し、1件の失敗で残りを止めない。登録番組の `known_episodes` は残す |
|
||||||
| 同時ダウンロード数 | 既定は **1**。`WORKER_CONCURRENCY` で **2まで**増やせる |
|
| 同時ダウンロード数 | 既定は **1**。`WORKER_CONCURRENCY` で **2まで**増やせる |
|
||||||
| ダウンロードの再試行 | 自動では再試行しない。失敗を `failed` で残し、画面から手動で再試行する |
|
| ダウンロードの再試行 | 自動では再試行しない。失敗を `failed` で残し、画面から手動で再試行する |
|
||||||
| 実行中の取り消し | web で `running` を `canceled` にし、worker が検知して yt-dlp のプロセスグループを停止する |
|
| 実行中の取り消し | web で `running` を `canceled` にし、worker が検知して yt-dlp のプロセスグループを停止する |
|
||||||
@@ -71,7 +73,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 +90,14 @@ 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/series/{series_id}`)、再生ページ(`/library/{id}`)、Range 対応の MP4 配信(`/videos/{id}`)を提供する
|
||||||
- 動画配信と削除はクライアントからジョブ ID だけを受け取り、DB の相対 `file_path` を `os.Root` の `Open`・`Remove` で扱う。絶対パス・`..`・`VIDEO_DIR` 外を指すシンボリックリンクをディレクトリ境界で拒否する。削除後は新着から手動で再登録できるが、登録番組の既知記録は残るため自動再登録しない
|
- ライブラリはDBの完了済みジョブだけを基に、番組一覧から番組ページ、再生ページへ進む。番組ページは完了が新しい順と、回タイトルの半角・全角数字を数値比較する話の順を切り替えられる。再生ページは話の順で前後の回へ移動できる
|
||||||
|
- 動画配信はクライアントからジョブ ID だけを受け取り、DB の相対 `file_path` を `os.Root` の `Open`・`Remove` で扱う。絶対パス・`..`・`VIDEO_DIR` 外を指すシンボリックリンクをディレクトリ境界で拒否する。削除はライブラリまたは番組ページの選択、もしくは再生ページの1件削除からサーバー描画の確認画面へ進み、解決済みのジョブ ID を実行画面へ引き継ぐ。実行時は1リクエストにつき動画ルートを1回だけ開き、各回を独立して処理して削除・失敗・対象外の件数を結果画面へ表示する。動画削除後に実ディレクトリの親が空なら削除する。削除後は新着から手動で再登録できるが、登録番組の既知記録は残るため自動再登録しない
|
||||||
- 新着の「番組を登録」から、公開中の回をダウンロードするか選ぶ。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 では取得中または待機中の確認を中断する
|
||||||
@@ -118,7 +122,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する |
|
||||||
@@ -147,12 +151,18 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
|
|||||||
- dockage上の使い捨て`postgres:17`コンテナへマイグレーション001〜003を適用してテスト用の行を投入し、`backup-db.sh`と`restore-test.sh`を実行した。gzip圧縮・権限600のダンプ作成、同じ`STAMP`での2回目が`ln`の失敗で非0終了して既存ダンプを保持すること、主要4表の行数一致、一時DBの削除、ログに`psql`の標準出力が残らないことを確認した [実機] 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 確認
|
- 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 確認
|
- 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 確認
|
||||||
|
|
||||||
## 課題
|
## 課題
|
||||||
|
|
||||||
- 登録済みのDSM日次バックアップタスクが翌日以降に自動実行され、新しいダンプを作ることを確認する [要確認]
|
- 登録済みのDSM日次バックアップタスクが翌日以降に自動実行され、新しいダンプを作ることを確認する [要確認]
|
||||||
- DRM・地域制限の実例探しはTVerへの大量アクセスになるため行わず、運用中に観測する。workerは失敗時の`error_message`をDBへ保存し、既知の文言に一致しない失敗は`unknown`にする。実例が出た時点で分類を見直す
|
- DRM・地域制限の実例探しはTVerへの大量アクセスになるため行わず、運用中に観測する。workerは失敗時の`error_message`をDBへ保存し、既知の文言に一致しない失敗は`unknown`にする。実例が出た時点で分類を見直す
|
||||||
|
|
||||||
|
## 経緯
|
||||||
|
|
||||||
|
- ステップ13で、ライブラリの番組単位削除、番組ページの回選択・番組全体削除、再生ページの1件削除を、すべてサーバー描画の確認画面を経由する方式に統一した。確認時に解決した最大1,000件のジョブ ID だけを実行し、途中の失敗後も残りを処理して結果を表示する
|
||||||
|
- ステップ12でライブラリを番組一覧、番組ページ、再生ページの3段構成にした。番組単位の集計と登録状態はDBから取得し、番組ページの話の順と再生ページの前後移動には同じ自然順を使う。動画削除後は空になった番組フォルダだけを`os.Root`経由で削除する
|
||||||
|
|
||||||
## 確かめ方
|
## 確かめ方
|
||||||
|
|
||||||
- Codex では `go test -count=1 ./...` を環境変数の前置なしで実行する。`go test` を許可する rules に一致させ、Postgres の共有メモリ作成に必要な権限で実行するため。初回のモジュール取得にはネットワーク接続が必要
|
- Codex では `go test -count=1 ./...` を環境変数の前置なしで実行する。`go test` を許可する rules に一致させ、Postgres の共有メモリ作成に必要な権限で実行するため。初回のモジュール取得にはネットワーク接続が必要
|
||||||
@@ -160,8 +170,9 @@ 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の完全性をテストする
|
- ライブラリのテストは番組単位の集計、番組別・ID指定の完了済み回取得、3画面、半角・全角数字の自然順、前後移動を確認する。一時ディレクトリの小さな動画を使い、Rangeの206・範囲外の416、`os.Root`による相対パスとシンボリックリンクの拒否、404、サーバー確認画面の入力検証・重複除去・HTMLエスケープ、複数削除の部分失敗・対象外・確認後の新着保持、ファイル削除後の記録削除、空の番組フォルダだけを削除することも確認する。共有のファイル名規則は`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`でも展開結果を確認する
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
@@ -29,6 +30,70 @@ func completeTestJob(t *testing.T, s *Store, id string) Job {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLibrarySeriesAggregationAndJobs(t *testing.T) {
|
||||||
|
s := New(testPool(t, true))
|
||||||
|
ctx := context.Background()
|
||||||
|
complete := func(ep Episode, size int64, finished time.Time) Job {
|
||||||
|
t.Helper()
|
||||||
|
job, _, err := s.Enqueue(ctx, ep, "manual")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
claimed, err := s.ClaimNext(ctx)
|
||||||
|
if err != nil || claimed.ID != job.ID {
|
||||||
|
t.Fatalf("claim: %+v %v", claimed, err)
|
||||||
|
}
|
||||||
|
if err := s.Complete(ctx, job.ID, ep.SeriesTitle+"/video.mp4", size); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.pool.Exec(ctx, `UPDATE download_jobs SET finished_at=$2 WHERE id=$1`, job.ID, finished); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := s.GetSucceededJob(ctx, job.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
older := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
newer := older.Add(time.Hour)
|
||||||
|
newest := newer.Add(time.Hour)
|
||||||
|
first := complete(Episode{ID: "aggregate-a1", SeriesID: "seriesA", SeriesTitle: "旧番組名", Title: "第1話", BroadcasterName: "旧局", ThumbnailPath: "/old.jpg"}, 100, older)
|
||||||
|
second := complete(Episode{ID: "aggregate-a2", SeriesID: "seriesA", SeriesTitle: "新番組名", Title: "第2話", BroadcasterName: "新局", ThumbnailPath: "/new.jpg"}, 250, newer)
|
||||||
|
complete(Episode{ID: "aggregate-b1", SeriesID: "seriesB", SeriesTitle: "別番組", Title: "第1話", BroadcasterName: "別局", ThumbnailPath: "/other.jpg"}, 500, newest)
|
||||||
|
if _, err := s.pool.Exec(ctx, `UPDATE download_jobs SET file_size_bytes=NULL WHERE id=$1`, first.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.pool.Exec(ctx, `INSERT INTO subscriptions (series_id, series_title) VALUES ('seriesA', '登録名')`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
series, err := s.ListLibrarySeries(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(series) != 2 || series[0].SeriesID != "seriesB" || series[1].SeriesID != "seriesA" {
|
||||||
|
t.Fatalf("番組の並び: %+v", series)
|
||||||
|
}
|
||||||
|
got := series[1]
|
||||||
|
if got.SeriesTitle != "新番組名" || got.LatestTitle != "第2話" || got.BroadcasterName != "新局" || got.ThumbnailPath != "/new.jpg" || got.EpisodeCount != 2 || got.TotalSizeBytes != 250 || got.LatestFinishedAt == nil || !got.LatestFinishedAt.Equal(newer) || !got.Subscribed {
|
||||||
|
t.Fatalf("番組集計: %+v", got)
|
||||||
|
}
|
||||||
|
if series[0].Subscribed || series[0].TotalSizeBytes != 500 {
|
||||||
|
t.Fatalf("未登録番組: %+v", series[0])
|
||||||
|
}
|
||||||
|
jobs, err := s.ListSucceededJobsBySeries(ctx, "seriesA")
|
||||||
|
if err != nil || len(jobs) != 2 || jobs[0].ID != second.ID || jobs[1].ID != first.ID || jobs[1].FileSizeBytes != nil {
|
||||||
|
t.Fatalf("番組の回: %+v %v", jobs, err)
|
||||||
|
}
|
||||||
|
if jobs, err := s.ListSucceededJobsBySeries(ctx, "missing"); err != nil || len(jobs) != 0 {
|
||||||
|
t.Fatalf("存在しない番組: %+v %v", jobs, err)
|
||||||
|
}
|
||||||
|
if _, err := s.ListSucceededJobsBySeries(ctx, ""); err == nil {
|
||||||
|
t.Fatal("空の番組IDを許した")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLibraryStoreOperations(t *testing.T) {
|
func TestLibraryStoreOperations(t *testing.T) {
|
||||||
s := New(testPool(t, true))
|
s := New(testPool(t, true))
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -96,3 +161,45 @@ func TestDeleteSucceededJobKeepsKnownEpisodeAndAllowsManualRequeue(t *testing.T)
|
|||||||
t.Fatalf("削除後の手動再投入: %+v %t %v", requeued, created, err)
|
t.Fatalf("削除後の手動再投入: %+v %t %v", requeued, created, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestListSucceededJobsByIDsAndSeriesIDs(t *testing.T) {
|
||||||
|
s := New(testPool(t, true))
|
||||||
|
ctx := context.Background()
|
||||||
|
first := completeTestJob(t, s, "bulk-list-1")
|
||||||
|
|
||||||
|
ep := seriesEpisode("bulk-list-2", "series-2")
|
||||||
|
second, _, err := s.Enqueue(ctx, ep, "manual")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
claimed, err := s.ClaimNext(ctx)
|
||||||
|
if err != nil || claimed.ID != second.ID {
|
||||||
|
t.Fatalf("claim: %+v %v", claimed, err)
|
||||||
|
}
|
||||||
|
if err := s.Complete(ctx, second.ID, "別番組/video.mp4", 5678); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err = s.GetSucceededJob(ctx, second.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
queued, _, err := s.Enqueue(ctx, seriesEpisode("bulk-list-queued", "series-2"), "manual")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
byIDs, err := s.ListSucceededJobsByIDs(ctx, []int64{second.ID, queued.ID, first.ID})
|
||||||
|
if err != nil || len(byIDs) != 2 || byIDs[0].ID != first.ID || byIDs[1].ID != second.ID {
|
||||||
|
t.Fatalf("ID指定の完了済み取得: %+v %v", byIDs, err)
|
||||||
|
}
|
||||||
|
bySeries, err := s.ListSucceededJobsBySeriesIDs(ctx, []string{"series-2", "series-1"})
|
||||||
|
if err != nil || len(bySeries) != 2 || bySeries[0].ID != first.ID || bySeries[1].ID != second.ID {
|
||||||
|
t.Fatalf("番組指定の完了済み取得: %+v %v", bySeries, err)
|
||||||
|
}
|
||||||
|
if empty, err := s.ListSucceededJobsByIDs(ctx, nil); err != nil || len(empty) != 0 {
|
||||||
|
t.Fatalf("空のID指定: %+v %v", empty, err)
|
||||||
|
}
|
||||||
|
if empty, err := s.ListSucceededJobsBySeriesIDs(ctx, []string{}); err != nil || len(empty) != 0 {
|
||||||
|
t.Fatalf("空の番組指定: %+v %v", empty, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,14 @@ type Job struct {
|
|||||||
CreatedAt, UpdatedAt time.Time
|
CreatedAt, UpdatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LibrarySeries struct {
|
||||||
|
SeriesID, SeriesTitle, LatestTitle string
|
||||||
|
BroadcasterName, ThumbnailPath string
|
||||||
|
EpisodeCount, TotalSizeBytes int64
|
||||||
|
LatestFinishedAt *time.Time
|
||||||
|
Subscribed bool
|
||||||
|
}
|
||||||
|
|
||||||
type Progress struct {
|
type Progress struct {
|
||||||
Phase string
|
Phase string
|
||||||
DownloadedBytes *int64
|
DownloadedBytes *int64
|
||||||
@@ -138,6 +146,94 @@ func (s *Store) ListSucceededJobs(ctx context.Context, limit int) ([]Job, error)
|
|||||||
return jobs, rows.Err()
|
return jobs, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListLibrarySeries(ctx context.Context) ([]LibrarySeries, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `WITH totals AS (
|
||||||
|
SELECT series_id, count(*) AS episode_count,
|
||||||
|
COALESCE(sum(file_size_bytes), 0) AS total_size_bytes
|
||||||
|
FROM download_jobs WHERE status='succeeded' GROUP BY series_id
|
||||||
|
), latest AS (
|
||||||
|
SELECT DISTINCT ON (series_id) series_id, series_title, title,
|
||||||
|
broadcaster_name, thumbnail_path, finished_at, id
|
||||||
|
FROM download_jobs WHERE status='succeeded'
|
||||||
|
ORDER BY series_id, finished_at DESC NULLS LAST, id DESC
|
||||||
|
)
|
||||||
|
SELECT l.series_id, l.series_title, l.title, l.broadcaster_name,
|
||||||
|
l.thumbnail_path, t.episode_count, t.total_size_bytes, l.finished_at,
|
||||||
|
EXISTS (SELECT 1 FROM subscriptions s WHERE s.series_id=l.series_id)
|
||||||
|
FROM latest l JOIN totals t USING (series_id)
|
||||||
|
ORDER BY l.finished_at DESC NULLS LAST, l.id DESC`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var result []LibrarySeries
|
||||||
|
for rows.Next() {
|
||||||
|
var item LibrarySeries
|
||||||
|
if err := rows.Scan(&item.SeriesID, &item.SeriesTitle, &item.LatestTitle,
|
||||||
|
&item.BroadcasterName, &item.ThumbnailPath, &item.EpisodeCount,
|
||||||
|
&item.TotalSizeBytes, &item.LatestFinishedAt, &item.Subscribed); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result = append(result, item)
|
||||||
|
}
|
||||||
|
return result, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListSucceededJobsBySeries(ctx context.Context, seriesID string) ([]Job, error) {
|
||||||
|
if seriesID == "" {
|
||||||
|
return nil, errors.New("series ID must not be empty")
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT `+jobColumns+` FROM download_jobs
|
||||||
|
WHERE status='succeeded' AND series_id=$1
|
||||||
|
ORDER BY finished_at DESC NULLS LAST, id DESC`, seriesID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var jobs []Job
|
||||||
|
for rows.Next() {
|
||||||
|
job, err := scanJob(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
jobs = append(jobs, job)
|
||||||
|
}
|
||||||
|
return jobs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListSucceededJobsByIDs(ctx context.Context, ids []int64) ([]Job, error) {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []Job{}, nil
|
||||||
|
}
|
||||||
|
return s.listSucceededJobs(ctx, `id = ANY($1)`, ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) ListSucceededJobsBySeriesIDs(ctx context.Context, seriesIDs []string) ([]Job, error) {
|
||||||
|
if len(seriesIDs) == 0 {
|
||||||
|
return []Job{}, nil
|
||||||
|
}
|
||||||
|
return s.listSucceededJobs(ctx, `series_id = ANY($1)`, seriesIDs)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Store) listSucceededJobs(ctx context.Context, condition string, value any) ([]Job, error) {
|
||||||
|
rows, err := s.pool.Query(ctx, `SELECT `+jobColumns+` FROM download_jobs
|
||||||
|
WHERE status='succeeded' AND `+condition+`
|
||||||
|
ORDER BY id`, value)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var jobs []Job
|
||||||
|
for rows.Next() {
|
||||||
|
job, err := scanJob(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
jobs = append(jobs, job)
|
||||||
|
}
|
||||||
|
return jobs, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) DeleteSucceededJob(ctx context.Context, id int64) error {
|
func (s *Store) DeleteSucceededJob(ctx context.Context, id int64) error {
|
||||||
tag, err := s.pool.Exec(ctx, `DELETE FROM download_jobs WHERE id=$1 AND status='succeeded'`, id)
|
tag, err := s.pool.Exec(ctx, `DELETE FROM download_jobs WHERE id=$1 AND status='succeeded'`, id)
|
||||||
return transitionResult(tag.RowsAffected(), err)
|
return transitionResult(tag.RowsAffected(), err)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"code":0,"result":{"content":{"type":"series","content":{"id":"sr0lhdrtdb","title":"韓国ドラマ「良いが悪い、ドンジェ」"}}}}
|
||||||
+13
-1
@@ -22,7 +22,10 @@ type page struct {
|
|||||||
Queue *queueView
|
Queue *queueView
|
||||||
Subscriptions *subscriptionsView
|
Subscriptions *subscriptionsView
|
||||||
Library *libraryView
|
Library *libraryView
|
||||||
|
Series *seriesView
|
||||||
Video *videoView
|
Video *videoView
|
||||||
|
DeleteConfirm *deleteConfirmView
|
||||||
|
DeleteResult *deleteResultView
|
||||||
}
|
}
|
||||||
|
|
||||||
type JobStore interface {
|
type JobStore interface {
|
||||||
@@ -39,6 +42,10 @@ type JobStore interface {
|
|||||||
SetSubscriptionEnabled(context.Context, string, bool) error
|
SetSubscriptionEnabled(context.Context, string, bool) error
|
||||||
DeleteSubscription(context.Context, string) (bool, error)
|
DeleteSubscription(context.Context, string) (bool, error)
|
||||||
ListSucceededJobs(context.Context, int) ([]store.Job, error)
|
ListSucceededJobs(context.Context, int) ([]store.Job, error)
|
||||||
|
ListLibrarySeries(context.Context) ([]store.LibrarySeries, error)
|
||||||
|
ListSucceededJobsBySeries(context.Context, string) ([]store.Job, error)
|
||||||
|
ListSucceededJobsByIDs(context.Context, []int64) ([]store.Job, error)
|
||||||
|
ListSucceededJobsBySeriesIDs(context.Context, []string) ([]store.Job, error)
|
||||||
GetSucceededJob(context.Context, int64) (store.Job, error)
|
GetSucceededJob(context.Context, int64) (store.Job, error)
|
||||||
DeleteSucceededJob(context.Context, int64) error
|
DeleteSucceededJob(context.Context, int64) error
|
||||||
}
|
}
|
||||||
@@ -85,14 +92,19 @@ 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)
|
||||||
mux.HandleFunc("POST /subscriptions/{id}/resume", s.resumeSubscription)
|
mux.HandleFunc("POST /subscriptions/{id}/resume", s.resumeSubscription)
|
||||||
mux.HandleFunc("POST /subscriptions/{id}/delete", s.deleteSubscription)
|
mux.HandleFunc("POST /subscriptions/{id}/delete", s.deleteSubscription)
|
||||||
mux.HandleFunc("GET /library", s.libraryPage)
|
mux.HandleFunc("GET /library", s.libraryPage)
|
||||||
|
mux.HandleFunc("GET /library/series/{series_id}", s.librarySeriesPage)
|
||||||
mux.HandleFunc("GET /library/{id}", s.videoPage)
|
mux.HandleFunc("GET /library/{id}", s.videoPage)
|
||||||
mux.HandleFunc("GET /videos/{id}", s.serveVideo)
|
mux.HandleFunc("GET /videos/{id}", s.serveVideo)
|
||||||
mux.HandleFunc("POST /library/{id}/delete", s.deleteVideo)
|
mux.HandleFunc("POST /library/delete/confirm", s.confirmDeleteVideos)
|
||||||
|
mux.HandleFunc("POST /library/delete", s.deleteVideos)
|
||||||
return mux
|
return mux
|
||||||
}
|
}
|
||||||
|
|||||||
+366
-27
@@ -1,19 +1,19 @@
|
|||||||
package web
|
package web
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"gitea.kazu634.com/kazu634/tver/internal/store"
|
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
const libraryLimit = 100
|
|
||||||
|
|
||||||
type libraryItemView struct {
|
type libraryItemView struct {
|
||||||
ID int64
|
ID int64
|
||||||
ThumbnailURL, SeriesTitle, Title string
|
ThumbnailURL, SeriesTitle, Title string
|
||||||
@@ -21,27 +21,141 @@ type libraryItemView struct {
|
|||||||
FileSizeText, FinishedAtText string
|
FileSizeText, FinishedAtText string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type librarySeriesView struct {
|
||||||
|
SeriesID, SeriesTitle, LatestTitle string
|
||||||
|
ThumbnailURL, BroadcasterName string
|
||||||
|
EpisodeCount int64
|
||||||
|
TotalSizeText, LatestFinishedText string
|
||||||
|
Subscribed bool
|
||||||
|
}
|
||||||
|
|
||||||
type libraryView struct {
|
type libraryView struct {
|
||||||
|
Series []librarySeriesView
|
||||||
|
SeriesCount, EpisodeCount int
|
||||||
|
TotalSizeText string
|
||||||
|
}
|
||||||
|
|
||||||
|
type seriesView struct {
|
||||||
|
SeriesID, SeriesTitle, BroadcasterName string
|
||||||
|
EpisodeCount int
|
||||||
|
TotalSizeText, Sort string
|
||||||
|
Subscribed bool
|
||||||
Items []libraryItemView
|
Items []libraryItemView
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type episodeLinkView struct {
|
||||||
|
ID int64
|
||||||
|
Title string
|
||||||
|
}
|
||||||
|
|
||||||
type videoView struct {
|
type videoView struct {
|
||||||
libraryItemView
|
libraryItemView
|
||||||
VideoURL string
|
VideoURL, SeriesURL string
|
||||||
|
Previous, Next *episodeLinkView
|
||||||
|
}
|
||||||
|
|
||||||
|
type deleteGroupView struct {
|
||||||
|
SeriesID, SeriesTitle, TotalSizeText string
|
||||||
|
Items []libraryItemView
|
||||||
|
}
|
||||||
|
|
||||||
|
type deleteConfirmView struct {
|
||||||
|
Groups []deleteGroupView
|
||||||
|
IDs []int64
|
||||||
|
Count int
|
||||||
|
TotalSizeText string
|
||||||
|
CancelURL string
|
||||||
|
}
|
||||||
|
|
||||||
|
type deleteFailureView struct {
|
||||||
|
Title, Reason string
|
||||||
|
}
|
||||||
|
|
||||||
|
type deleteSeriesLinkView struct {
|
||||||
|
SeriesID, SeriesTitle string
|
||||||
|
}
|
||||||
|
|
||||||
|
type deleteResultView struct {
|
||||||
|
DeletedCount, ExcludedCount int
|
||||||
|
Failures []deleteFailureView
|
||||||
|
SeriesLinks []deleteSeriesLinkView
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) libraryPage(w http.ResponseWriter, r *http.Request) {
|
func (s *server) libraryPage(w http.ResponseWriter, r *http.Request) {
|
||||||
jobs, err := s.jobs.ListSucceededJobs(r.Context(), libraryLimit)
|
series, err := s.jobs.ListLibrarySeries(r.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.logger.Printf("ライブラリの取得に失敗: %v", err)
|
s.logger.Printf("ライブラリの取得に失敗: %v", err)
|
||||||
http.Error(w, "ライブラリを取得できませんでした", http.StatusInternalServerError)
|
http.Error(w, "ライブラリを取得できませんでした", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
view := libraryView{Items: make([]libraryItemView, 0, len(jobs))}
|
view := libraryView{Series: make([]librarySeriesView, 0, len(series)), SeriesCount: len(series)}
|
||||||
|
var totalBytes int64
|
||||||
|
for _, item := range series {
|
||||||
|
view.EpisodeCount += int(item.EpisodeCount)
|
||||||
|
totalBytes += item.TotalSizeBytes
|
||||||
|
viewItem := librarySeriesView{
|
||||||
|
SeriesID: item.SeriesID, SeriesTitle: item.SeriesTitle, LatestTitle: item.LatestTitle,
|
||||||
|
ThumbnailURL: thumbnailURL(item.ThumbnailPath), BroadcasterName: item.BroadcasterName,
|
||||||
|
EpisodeCount: item.EpisodeCount, TotalSizeText: formatBytes(float64(item.TotalSizeBytes)),
|
||||||
|
Subscribed: item.Subscribed,
|
||||||
|
}
|
||||||
|
if item.LatestFinishedAt != nil {
|
||||||
|
viewItem.LatestFinishedText = item.LatestFinishedAt.In(s.zone).Format("2006-01-02 15:04 MST")
|
||||||
|
}
|
||||||
|
view.Series = append(view.Series, viewItem)
|
||||||
|
}
|
||||||
|
view.TotalSizeText = fmt.Sprintf("%.2f GB", float64(totalBytes)/1_000_000_000)
|
||||||
|
s.render(w, "layout", page{Title: "ライブラリ", Library: &view})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) librarySeriesPage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
seriesID := r.PathValue("series_id")
|
||||||
|
if !seriesIDPattern.MatchString(seriesID) {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sortOrder := r.URL.Query().Get("sort")
|
||||||
|
if sortOrder == "" {
|
||||||
|
sortOrder = "latest"
|
||||||
|
}
|
||||||
|
if sortOrder != "latest" && sortOrder != "natural" {
|
||||||
|
http.Error(w, "並び順が不正です", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jobs, err := s.jobs.ListSucceededJobsBySeries(r.Context(), seriesID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("番組のライブラリ取得に失敗: %v", err)
|
||||||
|
http.Error(w, "番組を取得できませんでした", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(jobs) == 0 {
|
||||||
|
http.NotFound(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
latest := latestFinishedJob(jobs)
|
||||||
|
if sortOrder == "natural" {
|
||||||
|
sortJobsNaturally(jobs)
|
||||||
|
} else {
|
||||||
|
sortJobsLatest(jobs)
|
||||||
|
}
|
||||||
|
subscribed, err := s.jobs.SubscribedSeries(r.Context(), []string{seriesID})
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("番組登録状態の取得に失敗: %v", err)
|
||||||
|
http.Error(w, "番組を取得できませんでした", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view := seriesView{SeriesID: seriesID, SeriesTitle: latest.SeriesTitle,
|
||||||
|
BroadcasterName: latest.BroadcasterName, EpisodeCount: len(jobs),
|
||||||
|
Subscribed: subscribed[seriesID], Sort: sortOrder, Items: make([]libraryItemView, 0, len(jobs))}
|
||||||
|
var totalBytes int64
|
||||||
for _, job := range jobs {
|
for _, job := range jobs {
|
||||||
|
if job.FileSizeBytes != nil {
|
||||||
|
totalBytes += *job.FileSizeBytes
|
||||||
|
}
|
||||||
view.Items = append(view.Items, s.libraryItem(job))
|
view.Items = append(view.Items, s.libraryItem(job))
|
||||||
}
|
}
|
||||||
s.render(w, "layout", page{Title: "ライブラリ", Library: &view})
|
view.TotalSizeText = formatBytes(float64(totalBytes))
|
||||||
|
s.render(w, "layout", page{Title: latest.SeriesTitle, Series: &view})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) videoPage(w http.ResponseWriter, r *http.Request) {
|
func (s *server) videoPage(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -49,13 +163,32 @@ func (s *server) videoPage(w http.ResponseWriter, r *http.Request) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
item := s.libraryItem(job)
|
jobs, err := s.jobs.ListSucceededJobsBySeries(r.Context(), job.SeriesID)
|
||||||
s.render(w, "layout", page{Title: job.SeriesTitle + " " + job.Title,
|
if err != nil {
|
||||||
Video: &videoView{libraryItemView: item, VideoURL: "/videos/" + strconv.FormatInt(job.ID, 10)}})
|
s.logger.Printf("前後の回の取得に失敗: %v", err)
|
||||||
|
http.Error(w, "動画を取得できませんでした", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sortJobsNaturally(jobs)
|
||||||
|
view := videoView{libraryItemView: s.libraryItem(job), VideoURL: "/videos/" + strconv.FormatInt(job.ID, 10),
|
||||||
|
SeriesURL: "/library/series/" + job.SeriesID}
|
||||||
|
for i := range jobs {
|
||||||
|
if jobs[i].ID != job.ID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if i > 0 {
|
||||||
|
view.Previous = &episodeLinkView{ID: jobs[i-1].ID, Title: jobs[i-1].Title}
|
||||||
|
}
|
||||||
|
if i+1 < len(jobs) {
|
||||||
|
view.Next = &episodeLinkView{ID: jobs[i+1].ID, Title: jobs[i+1].Title}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
s.render(w, "layout", page{Title: job.SeriesTitle + " " + job.Title, Video: &view})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) libraryItem(job store.Job) libraryItemView {
|
func (s *server) libraryItem(job store.Job) libraryItemView {
|
||||||
item := libraryItemView{ID: job.ID, ThumbnailURL: "https://statics.tver.jp" + job.ThumbnailPath,
|
item := libraryItemView{ID: job.ID, ThumbnailURL: thumbnailURL(job.ThumbnailPath),
|
||||||
SeriesTitle: job.SeriesTitle, Title: job.Title, BroadcasterName: job.BroadcasterName,
|
SeriesTitle: job.SeriesTitle, Title: job.Title, BroadcasterName: job.BroadcasterName,
|
||||||
DurationText: fmt.Sprintf("%d分", durationMinutes(int64(job.DurationSeconds)))}
|
DurationText: fmt.Sprintf("%d分", durationMinutes(int64(job.DurationSeconds)))}
|
||||||
if job.FileSizeBytes != nil {
|
if job.FileSizeBytes != nil {
|
||||||
@@ -67,6 +200,40 @@ func (s *server) libraryItem(job store.Job) libraryItemView {
|
|||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func thumbnailURL(path string) string {
|
||||||
|
if path == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "https://statics.tver.jp" + path
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestFinishedJob(jobs []store.Job) store.Job {
|
||||||
|
latest := jobs[0]
|
||||||
|
for _, job := range jobs[1:] {
|
||||||
|
if jobFinishedLater(job, latest) {
|
||||||
|
latest = job
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return latest
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortJobsLatest(jobs []store.Job) {
|
||||||
|
sort.SliceStable(jobs, func(i, j int) bool { return jobFinishedLater(jobs[i], jobs[j]) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func jobFinishedLater(left, right store.Job) bool {
|
||||||
|
if left.FinishedAt == nil || right.FinishedAt == nil {
|
||||||
|
if left.FinishedAt != right.FinishedAt {
|
||||||
|
return left.FinishedAt != nil
|
||||||
|
}
|
||||||
|
return left.ID > right.ID
|
||||||
|
}
|
||||||
|
if !left.FinishedAt.Equal(*right.FinishedAt) {
|
||||||
|
return left.FinishedAt.After(*right.FinishedAt)
|
||||||
|
}
|
||||||
|
return left.ID > right.ID
|
||||||
|
}
|
||||||
|
|
||||||
func (s *server) serveVideo(w http.ResponseWriter, r *http.Request) {
|
func (s *server) serveVideo(w http.ResponseWriter, r *http.Request) {
|
||||||
job, ok := s.succeededJob(w, r)
|
job, ok := s.succeededJob(w, r)
|
||||||
if !ok || job.FilePath == nil {
|
if !ok || job.FilePath == nil {
|
||||||
@@ -96,41 +263,213 @@ func (s *server) serveVideo(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.ServeContent(w, r, filepath.Base(*job.FilePath), info.ModTime(), file)
|
http.ServeContent(w, r, filepath.Base(*job.FilePath), info.ModTime(), file)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) deleteVideo(w http.ResponseWriter, r *http.Request) {
|
const maxDeleteSelections = 1000
|
||||||
job, ok := s.succeededJob(w, r)
|
|
||||||
|
func (s *server) confirmDeleteVideos(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ids, seriesIDs, ok := parseDeleteSelection(w, r, true)
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
jobsByID, err := s.jobs.ListSucceededJobsByIDs(r.Context(), ids)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("削除対象IDの取得に失敗: %v", err)
|
||||||
|
http.Error(w, "削除対象を取得できませんでした", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jobsBySeries, err := s.jobs.ListSucceededJobsBySeriesIDs(r.Context(), seriesIDs)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("削除対象番組の取得に失敗: %v", err)
|
||||||
|
http.Error(w, "削除対象を取得できませんでした", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
byID := make(map[int64]store.Job, len(jobsByID)+len(jobsBySeries))
|
||||||
|
for _, job := range append(jobsByID, jobsBySeries...) {
|
||||||
|
byID[job.ID] = job
|
||||||
|
}
|
||||||
|
if len(byID) == 0 {
|
||||||
|
http.Error(w, "削除対象がありません", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(byID) > maxDeleteSelections {
|
||||||
|
http.Error(w, fmt.Sprintf("削除対象が多すぎます(%d本)。番組を分けて選んでください", len(byID)), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jobs := make([]store.Job, 0, len(byID))
|
||||||
|
for _, job := range byID {
|
||||||
|
jobs = append(jobs, job)
|
||||||
|
}
|
||||||
|
sort.Slice(jobs, func(i, j int) bool {
|
||||||
|
if jobs[i].SeriesTitle != jobs[j].SeriesTitle {
|
||||||
|
return jobs[i].SeriesTitle < jobs[j].SeriesTitle
|
||||||
|
}
|
||||||
|
return jobs[i].ID < jobs[j].ID
|
||||||
|
})
|
||||||
|
view := deleteConfirmView{IDs: make([]int64, 0, len(jobs)), Count: len(jobs), CancelURL: "/library"}
|
||||||
|
groupIndexes := make(map[string]int)
|
||||||
|
groupBytes := make(map[string]int64)
|
||||||
|
var totalBytes int64
|
||||||
|
for _, job := range jobs {
|
||||||
|
view.IDs = append(view.IDs, job.ID)
|
||||||
|
index, exists := groupIndexes[job.SeriesID]
|
||||||
|
if !exists {
|
||||||
|
index = len(view.Groups)
|
||||||
|
groupIndexes[job.SeriesID] = index
|
||||||
|
view.Groups = append(view.Groups, deleteGroupView{SeriesID: job.SeriesID, SeriesTitle: job.SeriesTitle})
|
||||||
|
}
|
||||||
|
view.Groups[index].Items = append(view.Groups[index].Items, s.libraryItem(job))
|
||||||
|
if job.FileSizeBytes != nil {
|
||||||
|
totalBytes += *job.FileSizeBytes
|
||||||
|
groupBytes[job.SeriesID] += *job.FileSizeBytes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range view.Groups {
|
||||||
|
view.Groups[i].TotalSizeText = formatBytes(float64(groupBytes[view.Groups[i].SeriesID]))
|
||||||
|
}
|
||||||
|
view.TotalSizeText = formatBytes(float64(totalBytes))
|
||||||
|
if len(view.Groups) == 1 {
|
||||||
|
view.CancelURL = "/library/series/" + view.Groups[0].SeriesID
|
||||||
|
}
|
||||||
|
s.render(w, "layout", page{Title: "動画の削除確認", DeleteConfirm: &view})
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseDeleteSelection(w http.ResponseWriter, r *http.Request, includeSeries bool) ([]int64, []string, bool) {
|
||||||
|
if err := r.ParseForm(); err != nil {
|
||||||
|
http.Error(w, "入力が不正です", http.StatusBadRequest)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
idSet := make(map[int64]struct{})
|
||||||
|
for _, raw := range r.Form["id"] {
|
||||||
|
id, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
http.Error(w, "動画IDが不正です", http.StatusBadRequest)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
idSet[id] = struct{}{}
|
||||||
|
}
|
||||||
|
seriesSet := make(map[string]struct{})
|
||||||
|
if includeSeries {
|
||||||
|
for _, seriesID := range r.Form["series_id"] {
|
||||||
|
if !seriesIDPattern.MatchString(seriesID) {
|
||||||
|
http.Error(w, "番組IDが不正です", http.StatusBadRequest)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
seriesSet[seriesID] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(idSet)+len(seriesSet) == 0 {
|
||||||
|
http.Error(w, "削除対象がありません", http.StatusBadRequest)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
if len(idSet)+len(seriesSet) > maxDeleteSelections {
|
||||||
|
http.Error(w, "削除対象が多すぎます", http.StatusBadRequest)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
ids := make([]int64, 0, len(idSet))
|
||||||
|
for id := range idSet {
|
||||||
|
ids = append(ids, id)
|
||||||
|
}
|
||||||
|
seriesIDs := make([]string, 0, len(seriesSet))
|
||||||
|
for seriesID := range seriesSet {
|
||||||
|
seriesIDs = append(seriesIDs, seriesID)
|
||||||
|
}
|
||||||
|
return ids, seriesIDs, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) deleteVideos(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ids, _, ok := parseDeleteSelection(w, r, false)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
jobs, err := s.jobs.ListSucceededJobsByIDs(r.Context(), ids)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("削除対象の取得に失敗: %v", err)
|
||||||
|
http.Error(w, "削除対象を取得できませんでした", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
view := deleteResultView{ExcludedCount: len(ids) - len(jobs)}
|
||||||
|
var root *os.Root
|
||||||
|
needsRoot := false
|
||||||
|
for _, job := range jobs {
|
||||||
|
needsRoot = needsRoot || job.FilePath != nil
|
||||||
|
}
|
||||||
|
if needsRoot {
|
||||||
|
root, err = os.OpenRoot(s.videoDir)
|
||||||
|
if err == nil {
|
||||||
|
defer root.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
affectedSeries := make(map[string]string)
|
||||||
|
for _, job := range jobs {
|
||||||
|
affectedSeries[job.SeriesID] = job.SeriesTitle
|
||||||
|
if err != nil && job.FilePath != nil {
|
||||||
|
s.logger.Printf("動画ルートを開けないため削除に失敗 id=%d: %v", job.ID, err)
|
||||||
|
view.Failures = append(view.Failures, deleteFailureView{Title: job.Title, Reason: "動画ファイルを削除できませんでした"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if deleteErr := s.deleteJob(r.Context(), root, job); deleteErr != nil {
|
||||||
|
s.logger.Printf("動画の削除に失敗 id=%d: %v", job.ID, deleteErr)
|
||||||
|
view.Failures = append(view.Failures, deleteFailureView{Title: job.Title, Reason: "削除できませんでした"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
view.DeletedCount++
|
||||||
|
}
|
||||||
|
for seriesID, title := range affectedSeries {
|
||||||
|
remaining, listErr := s.jobs.ListSucceededJobsBySeries(r.Context(), seriesID)
|
||||||
|
if listErr != nil {
|
||||||
|
s.logger.Printf("削除後の番組取得に失敗 series_id=%s: %v", seriesID, listErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(remaining) > 0 {
|
||||||
|
view.SeriesLinks = append(view.SeriesLinks, deleteSeriesLinkView{SeriesID: seriesID, SeriesTitle: title})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(view.SeriesLinks, func(i, j int) bool { return view.SeriesLinks[i].SeriesTitle < view.SeriesLinks[j].SeriesTitle })
|
||||||
|
s.render(w, "layout", page{Title: "動画の削除結果", DeleteResult: &view})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) deleteJob(ctx context.Context, root *os.Root, job store.Job) error {
|
||||||
if job.FilePath != nil {
|
if job.FilePath != nil {
|
||||||
if !filepath.IsLocal(*job.FilePath) {
|
if !filepath.IsLocal(*job.FilePath) {
|
||||||
http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict)
|
return errors.New("動画ファイルの場所が不正")
|
||||||
return
|
|
||||||
}
|
}
|
||||||
root, err := os.OpenRoot(s.videoDir)
|
if root == nil {
|
||||||
if err != nil {
|
return errors.New("動画ルートを開けない")
|
||||||
http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
defer root.Close()
|
|
||||||
file, err := root.Open(*job.FilePath)
|
file, err := root.Open(*job.FilePath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_ = file.Close()
|
_ = file.Close()
|
||||||
} else if !errors.Is(err, os.ErrNotExist) {
|
} else if !errors.Is(err, os.ErrNotExist) {
|
||||||
http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict)
|
return fmt.Errorf("動画ファイルの検査: %w", err)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
if err := root.Remove(*job.FilePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
if err := root.Remove(*job.FilePath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
s.logger.Printf("動画ファイルの削除に失敗: %v", err)
|
return fmt.Errorf("動画ファイルの削除: %w", err)
|
||||||
http.Error(w, "動画ファイルを削除できませんでした", http.StatusInternalServerError)
|
}
|
||||||
|
s.removeEmptyVideoParent(root, *job.FilePath)
|
||||||
|
}
|
||||||
|
if err := s.jobs.DeleteSucceededJob(ctx, job.ID); err != nil {
|
||||||
|
return fmt.Errorf("動画の記録削除: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) removeEmptyVideoParent(root *os.Root, filePath string) {
|
||||||
|
parent := filepath.Dir(filePath)
|
||||||
|
if parent == "." || !filepath.IsLocal(parent) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
info, err := root.Lstat(parent)
|
||||||
|
if err != nil {
|
||||||
|
if !errors.Is(err, os.ErrNotExist) {
|
||||||
|
s.logger.Printf("動画フォルダの確認に失敗: %v", err)
|
||||||
}
|
}
|
||||||
if err := s.jobs.DeleteSucceededJob(r.Context(), job.ID); err != nil {
|
|
||||||
s.logger.Printf("動画の記録削除に失敗: %v", err)
|
|
||||||
http.Error(w, "動画の記録を削除できませんでした", http.StatusInternalServerError)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, "/library", http.StatusSeeOther)
|
if !info.IsDir() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := root.Remove(parent); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
s.logger.Printf("空の動画フォルダを削除できませんでした: %v", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *server) succeededJob(w http.ResponseWriter, r *http.Request) (store.Job, bool) {
|
func (s *server) succeededJob(w http.ResponseWriter, r *http.Request) (store.Job, bool) {
|
||||||
|
|||||||
+364
-19
@@ -4,8 +4,10 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -16,7 +18,7 @@ import (
|
|||||||
func libraryJob(id int64, relative string) store.Job {
|
func libraryJob(id int64, relative string) store.Job {
|
||||||
size := int64(10)
|
size := int64(10)
|
||||||
finished := time.Date(2026, 10, 3, 1, 2, 3, 0, time.UTC)
|
finished := time.Date(2026, 10, 3, 1, 2, 3, 0, time.UTC)
|
||||||
return store.Job{ID: id, EpisodeID: "ep1", SeriesTitle: `<script>番組</script>`, Title: `<b>第1話</b>`,
|
return store.Job{ID: id, EpisodeID: "ep1", SeriesID: "series1", SeriesTitle: `<script>番組</script>`, Title: `<b>第1話</b>`,
|
||||||
BroadcasterName: "放送局", DurationSeconds: 61, ThumbnailPath: "/thumb.jpg", Status: "succeeded",
|
BroadcasterName: "放送局", DurationSeconds: 61, ThumbnailPath: "/thumb.jpg", Status: "succeeded",
|
||||||
FilePath: &relative, FileSizeBytes: &size, FinishedAt: &finished}
|
FilePath: &relative, FileSizeBytes: &size, FinishedAt: &finished}
|
||||||
}
|
}
|
||||||
@@ -30,7 +32,7 @@ func TestLibraryAndPlaybackPage(t *testing.T) {
|
|||||||
t.Fatalf("library status=%d", page.Code)
|
t.Fatalf("library status=%d", page.Code)
|
||||||
}
|
}
|
||||||
body := page.Body.String()
|
body := page.Body.String()
|
||||||
for _, want := range []string{"<script>番組</script>", "<b>第1話</b>", "放送局", "長さ: 2分", "10.0 B", "2026-10-03 10:02 JST", "https://statics.tver.jp/thumb.jpg", `/library/1`} {
|
for _, want := range []string{"<script>番組</script>", "<b>第1話</b>", "放送局", "1番組・1本・0.00 GB", "10.0 B", "2026-10-03 10:02 JST", "https://statics.tver.jp/thumb.jpg", `/library/series/series1`, "1本"} {
|
||||||
if !strings.Contains(body, want) {
|
if !strings.Contains(body, want) {
|
||||||
t.Errorf("library missing %q", want)
|
t.Errorf("library missing %q", want)
|
||||||
}
|
}
|
||||||
@@ -39,22 +41,121 @@ func TestLibraryAndPlaybackPage(t *testing.T) {
|
|||||||
t.Fatal("library fields were not escaped")
|
t.Fatal("library fields were not escaped")
|
||||||
}
|
}
|
||||||
video := getPage(handler, "/library/1")
|
video := getPage(handler, "/library/1")
|
||||||
if video.Code != http.StatusOK || !strings.Contains(video.Body.String(), `<video controls`) || !strings.Contains(video.Body.String(), `src="/videos/1"`) || !strings.Contains(video.Body.String(), `hx-confirm="動画ファイルと記録を削除しますか?"`) {
|
if video.Code != http.StatusOK || !strings.Contains(video.Body.String(), `<video controls`) || !strings.Contains(video.Body.String(), `src="/videos/1"`) || !strings.Contains(video.Body.String(), `action="/library/delete/confirm"`) || strings.Contains(video.Body.String(), `hx-confirm`) || !strings.Contains(video.Body.String(), `/library/series/series1`) || !strings.Contains(video.Body.String(), `前の回はありません`) || !strings.Contains(video.Body.String(), `次の回はありません`) {
|
||||||
t.Fatalf("video page: %d %s", video.Code, video.Body.String())
|
t.Fatalf("video page: %d %s", video.Code, video.Body.String())
|
||||||
}
|
}
|
||||||
for _, path := range []string{"/library/bad", "/library/99", "/library/1/delete"} {
|
for _, path := range []string{"/library/bad", "/library/99", "/library/1/delete"} {
|
||||||
response := getPage(handler, path)
|
response := getPage(handler, path)
|
||||||
want := http.StatusNotFound
|
want := http.StatusNotFound
|
||||||
if strings.HasSuffix(path, "/delete") {
|
|
||||||
want = http.StatusMethodNotAllowed
|
|
||||||
}
|
|
||||||
if response.Code != want {
|
if response.Code != want {
|
||||||
t.Errorf("%s status=%d want=%d", path, response.Code, want)
|
t.Errorf("%s status=%d want=%d", path, response.Code, want)
|
||||||
}
|
}
|
||||||
if want == http.StatusMethodNotAllowed && !strings.Contains(response.Header().Get("Allow"), "POST") {
|
|
||||||
t.Errorf("%s Allow=%q", path, response.Header().Get("Allow"))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLibrarySeriesPageSortingSubscriptionAndNotFound(t *testing.T) {
|
||||||
|
old := libraryJob(2, "show/old.mp4")
|
||||||
|
old.EpisodeID = "ep2"
|
||||||
|
old.Title = "第2話"
|
||||||
|
old.SeriesTitle = "旧番組名"
|
||||||
|
old.BroadcasterName = "旧局"
|
||||||
|
oldFinished := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
old.FinishedAt = &oldFinished
|
||||||
|
latest := libraryJob(10, "show/latest.mp4")
|
||||||
|
latest.EpisodeID = "ep10"
|
||||||
|
latest.Title = "第10話"
|
||||||
|
latest.SeriesTitle = `<em>新番組名</em>`
|
||||||
|
latest.BroadcasterName = "新局"
|
||||||
|
latestFinished := oldFinished.Add(time.Hour)
|
||||||
|
latest.FinishedAt = &latestFinished
|
||||||
|
jobs := &fakeJobStore{
|
||||||
|
jobs: map[string]store.Job{"ep2": old, "ep10": latest},
|
||||||
|
subscriptions: map[string]store.Subscription{"series1": {SeriesID: "series1"}},
|
||||||
|
}
|
||||||
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil)
|
||||||
|
library := getPage(handler, "/library")
|
||||||
|
if library.Code != http.StatusOK || !strings.Contains(library.Body.String(), "登録中") || !strings.Contains(library.Body.String(), "2本") {
|
||||||
|
t.Fatalf("library series card: status=%d body=%s", library.Code, library.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
page := getPage(handler, "/library/series/series1")
|
||||||
|
if page.Code != http.StatusOK {
|
||||||
|
t.Fatalf("series status=%d body=%s", page.Code, page.Body.String())
|
||||||
|
}
|
||||||
|
body := page.Body.String()
|
||||||
|
for _, want := range []string{"ライブラリ", "<em>新番組名</em>", "登録中", "新局・2本・20.0 B", `/library/10`, `/library/2`, `?sort=natural`} {
|
||||||
|
if !strings.Contains(body, want) {
|
||||||
|
t.Errorf("series page missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Index(body, `/library/10`) > strings.Index(body, `/library/2`) {
|
||||||
|
t.Fatal("default order is not latest first")
|
||||||
|
}
|
||||||
|
natural := getPage(handler, "/library/series/series1?sort=natural")
|
||||||
|
if natural.Code != http.StatusOK || strings.Index(natural.Body.String(), `/library/2`) > strings.Index(natural.Body.String(), `/library/10`) {
|
||||||
|
t.Fatalf("natural order: status=%d body=%s", natural.Code, natural.Body.String())
|
||||||
|
}
|
||||||
|
if got := getPage(handler, "/library/series/missing").Code; got != http.StatusNotFound {
|
||||||
|
t.Fatalf("missing series status=%d", got)
|
||||||
|
}
|
||||||
|
if got := getPage(handler, "/library/series/bad-id").Code; got != http.StatusNotFound {
|
||||||
|
t.Fatalf("invalid series status=%d", got)
|
||||||
|
}
|
||||||
|
if got := getPage(handler, "/library/series/series1?sort=bad").Code; got != http.StatusBadRequest {
|
||||||
|
t.Fatalf("invalid sort status=%d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryOmitsSubscriptionMarkerForUnregisteredSeries(t *testing.T) {
|
||||||
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
|
||||||
|
page := getPage(newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil), "/library")
|
||||||
|
if page.Code != http.StatusOK || strings.Contains(page.Body.String(), "登録中") {
|
||||||
|
t.Fatalf("unregistered series: status=%d body=%s", page.Code, page.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryPagesDoNotInspectVideoDirectory(t *testing.T) {
|
||||||
|
missingVideoDir := filepath.Join(t.TempDir(), "missing-video-root")
|
||||||
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "missing/video.mp4")}}
|
||||||
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, missingVideoDir, nil)
|
||||||
|
for _, path := range []string{"/library", "/library/series/series1"} {
|
||||||
|
response := getPage(handler, path)
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Errorf("DB由来の一覧 %s: status=%d body=%s", path, response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(missingVideoDir); !errors.Is(err, os.ErrNotExist) {
|
||||||
|
t.Fatalf("一覧表示が動画ルートを作成または走査した: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPlaybackPreviousAndNextNaturalOrder(t *testing.T) {
|
||||||
|
jobs := make(map[string]store.Job)
|
||||||
|
for _, data := range []struct {
|
||||||
|
id int64
|
||||||
|
epid string
|
||||||
|
title string
|
||||||
|
}{{2, "ep2", "第2話"}, {10, "ep10", "第10話"}, {9, "ep9", "第9話"}} {
|
||||||
|
job := libraryJob(data.id, "show/video.mp4")
|
||||||
|
job.EpisodeID = data.epid
|
||||||
|
job.Title = data.title
|
||||||
|
jobs[data.epid] = job
|
||||||
|
}
|
||||||
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, &fakeJobStore{jobs: jobs}, t.TempDir(), nil)
|
||||||
|
middle := getPage(handler, "/library/9")
|
||||||
|
for _, want := range []string{`href="/library/2"`, `前の回: 第2話`, `href="/library/10"`, `次の回: 第10話`} {
|
||||||
|
if !strings.Contains(middle.Body.String(), want) {
|
||||||
|
t.Errorf("middle playback missing %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
first := getPage(handler, "/library/2")
|
||||||
|
if !strings.Contains(first.Body.String(), "前の回はありません") || !strings.Contains(first.Body.String(), `href="/library/9"`) {
|
||||||
|
t.Fatalf("first playback navigation: %s", first.Body.String())
|
||||||
|
}
|
||||||
|
last := getPage(handler, "/library/10")
|
||||||
|
if !strings.Contains(last.Body.String(), "次の回はありません") || !strings.Contains(last.Body.String(), `href="/library/9"`) {
|
||||||
|
t.Fatalf("last playback navigation: %s", last.Body.String())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestLibraryRejectsInvalidJobIDs(t *testing.T) {
|
func TestLibraryRejectsInvalidJobIDs(t *testing.T) {
|
||||||
@@ -66,7 +167,7 @@ func TestLibraryRejectsInvalidJobIDs(t *testing.T) {
|
|||||||
t.Errorf("GET %s: %d", path, got)
|
t.Errorf("GET %s: %d", path, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got := postForm(handler, "/library/"+id+"/delete", nil).Code; got != http.StatusNotFound {
|
if got := postForm(handler, "/library/delete", url.Values{"id": {id}}).Code; got != http.StatusBadRequest {
|
||||||
t.Errorf("POST delete id=%q: %d", id, got)
|
t.Errorf("POST delete id=%q: %d", id, got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,8 +258,8 @@ func TestVideoPathTraversalAndSymlinkAreRejected(t *testing.T) {
|
|||||||
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
|
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
|
||||||
t.Errorf("path %q status=%d", relative, got)
|
t.Errorf("path %q status=%d", relative, got)
|
||||||
}
|
}
|
||||||
deleted := postForm(handler, "/library/1/delete", nil)
|
deleted := postForm(handler, "/library/delete", url.Values{"id": {"1"}})
|
||||||
if deleted.Code != http.StatusConflict || len(jobs.jobs) != 1 {
|
if deleted.Code != http.StatusOK || len(jobs.jobs) != 1 || !strings.Contains(deleted.Body.String(), "失敗: 1件") {
|
||||||
t.Errorf("delete path %q status=%d jobs=%d", relative, deleted.Code, len(jobs.jobs))
|
t.Errorf("delete path %q status=%d jobs=%d", relative, deleted.Code, len(jobs.jobs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -186,7 +287,7 @@ func TestVideoSymlinkWithinRootCanBeServedAndDeleted(t *testing.T) {
|
|||||||
if response := getPage(handler, "/videos/1"); response.Code != http.StatusOK || response.Body.String() != "video" {
|
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())
|
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 {
|
if response := postForm(handler, "/library/delete", url.Values{"id": {"1"}}); response.Code != http.StatusOK || len(jobs.jobs) != 0 {
|
||||||
t.Fatalf("delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
t.Fatalf("delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(target); err != nil {
|
if _, err := os.Stat(target); err != nil {
|
||||||
@@ -214,7 +315,7 @@ func TestVideoSymlinkedDirectoryWithinRootCanBeServedAndDeleted(t *testing.T) {
|
|||||||
if response := getPage(handler, "/videos/1"); response.Code != http.StatusOK || response.Body.String() != "video" {
|
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())
|
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 {
|
if response := postForm(handler, "/library/delete", url.Values{"id": {"1"}}); response.Code != http.StatusOK || len(jobs.jobs) != 0 {
|
||||||
t.Fatalf("delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
t.Fatalf("delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
|
||||||
@@ -239,7 +340,7 @@ func TestVideoAbsoluteSymlinkWithinRootIsRejectedByOSRoot(t *testing.T) {
|
|||||||
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
|
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
|
||||||
t.Fatalf("absolute symlink serve status=%d", got)
|
t.Fatalf("absolute symlink serve status=%d", got)
|
||||||
}
|
}
|
||||||
if response := postForm(handler, "/library/1/delete", nil); response.Code != http.StatusConflict || len(jobs.jobs) != 1 {
|
if response := postForm(handler, "/library/delete", url.Values{"id": {"1"}}); response.Code != http.StatusOK || len(jobs.jobs) != 1 || !strings.Contains(response.Body.String(), "失敗: 1件") {
|
||||||
t.Fatalf("absolute symlink delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
t.Fatalf("absolute symlink delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(target); err != nil {
|
if _, err := os.Stat(target); err != nil {
|
||||||
@@ -263,19 +364,65 @@ func TestDeleteVideoFileThenRecord(t *testing.T) {
|
|||||||
fileWasGoneBeforeRecordDelete = errors.Is(err, os.ErrNotExist)
|
fileWasGoneBeforeRecordDelete = errors.Is(err, os.ErrNotExist)
|
||||||
}
|
}
|
||||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||||
response := postForm(handler, "/library/1/delete", nil)
|
response := postForm(handler, "/library/delete", url.Values{"id": {"1"}})
|
||||||
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/library" || len(jobs.jobs) != 0 || !fileWasGoneBeforeRecordDelete {
|
if response.Code != http.StatusOK || len(jobs.jobs) != 0 || !fileWasGoneBeforeRecordDelete || !strings.Contains(response.Body.String(), "削除: 1件") {
|
||||||
t.Fatalf("delete: status=%d location=%q jobs=%d", response.Code, response.Header().Get("Location"), len(jobs.jobs))
|
t.Fatalf("delete: status=%d location=%q jobs=%d", response.Code, response.Header().Get("Location"), len(jobs.jobs))
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||||
t.Fatalf("file remains: %v", err)
|
t.Fatalf("file remains: %v", err)
|
||||||
}
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "show")); !os.IsNotExist(err) {
|
||||||
|
t.Fatalf("empty series folder remains: %v", err)
|
||||||
|
}
|
||||||
jobs.jobs["ep1"] = libraryJob(1, "show/missing.mp4")
|
jobs.jobs["ep1"] = libraryJob(1, "show/missing.mp4")
|
||||||
if got := postForm(handler, "/library/1/delete", nil).Code; got != http.StatusSeeOther || len(jobs.jobs) != 0 {
|
if got := postForm(handler, "/library/delete", url.Values{"id": {"1"}}).Code; got != http.StatusOK || len(jobs.jobs) != 0 {
|
||||||
t.Fatalf("missing file delete: status=%d jobs=%d", got, len(jobs.jobs))
|
t.Fatalf("missing file delete: status=%d jobs=%d", got, len(jobs.jobs))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteRedirectsToSeriesWhenEpisodesRemainAndKeepsNonemptyFolder(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "show"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "show", "one.mp4"), []byte("one"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "show", "two.mp4"), []byte("two"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := libraryJob(1, "show/one.mp4")
|
||||||
|
second := libraryJob(2, "show/two.mp4")
|
||||||
|
second.EpisodeID = "ep2"
|
||||||
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": first, "ep2": second}}
|
||||||
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||||
|
response := postForm(handler, "/library/delete", url.Values{"id": {"1"}})
|
||||||
|
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "/library/series/series1") || len(jobs.jobs) != 1 {
|
||||||
|
t.Fatalf("delete: status=%d location=%q jobs=%d", response.Code, response.Header().Get("Location"), len(jobs.jobs))
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "show")); err != nil {
|
||||||
|
t.Fatalf("nonempty series folder removed: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(dir, "show", "two.mp4")); err != nil {
|
||||||
|
t.Fatalf("remaining video removed: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteVideoAtRootNeverRemovesVideoRoot(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "video.mp4"), []byte("video"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "video.mp4")}}
|
||||||
|
response := postForm(newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil), "/library/delete", url.Values{"id": {"1"}})
|
||||||
|
if response.Code != http.StatusOK {
|
||||||
|
t.Fatalf("delete status=%d", response.Code)
|
||||||
|
}
|
||||||
|
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
||||||
|
t.Fatalf("video root was removed: info=%v err=%v", info, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDeleteFailureKeepsRecord(t *testing.T) {
|
func TestDeleteFailureKeepsRecord(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
blocked := filepath.Join(dir, "blocked")
|
blocked := filepath.Join(dir, "blocked")
|
||||||
@@ -287,8 +434,206 @@ func TestDeleteFailureKeepsRecord(t *testing.T) {
|
|||||||
}
|
}
|
||||||
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "blocked")}}
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "blocked")}}
|
||||||
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||||
response := postForm(handler, "/library/1/delete", nil)
|
response := postForm(handler, "/library/delete", url.Values{"id": {"1"}})
|
||||||
if response.Code != http.StatusInternalServerError || len(jobs.jobs) != 1 || !strings.Contains(response.Body.String(), "動画ファイルを削除できませんでした") {
|
if response.Code != http.StatusOK || len(jobs.jobs) != 1 || !strings.Contains(response.Body.String(), "失敗: 1件") {
|
||||||
t.Fatalf("failure: status=%d jobs=%d body=%s", response.Code, len(jobs.jobs), response.Body.String())
|
t.Fatalf("failure: status=%d jobs=%d body=%s", response.Code, len(jobs.jobs), response.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDeleteConfirmationSelectionValidationAndDeduplication(t *testing.T) {
|
||||||
|
first := libraryJob(1, "show/one.mp4")
|
||||||
|
second := libraryJob(2, "show/two.mp4")
|
||||||
|
second.EpisodeID = "ep2"
|
||||||
|
second.Title = `<img src=x onerror=alert(1)>`
|
||||||
|
third := libraryJob(3, "other/three.mp4")
|
||||||
|
third.EpisodeID = "ep3"
|
||||||
|
third.SeriesID = "series2"
|
||||||
|
third.SeriesTitle = "別番組"
|
||||||
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": first, "ep2": second, "ep3": third}}
|
||||||
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil)
|
||||||
|
|
||||||
|
for name, values := range map[string]url.Values{
|
||||||
|
"IDのみ": {"id": {"1"}},
|
||||||
|
"番組のみ": {"series_id": {"series2"}},
|
||||||
|
"両方と重複除去": {"id": {"1"}, "series_id": {"series1"}},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
response := postForm(handler, "/library/delete/confirm", values)
|
||||||
|
if response.Code != http.StatusOK || len(jobs.jobs) != 3 {
|
||||||
|
t.Fatalf("status=%d jobs=%d body=%s", response.Code, len(jobs.jobs), response.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(response.Body.String(), `<img src=x`) || strings.Contains(response.Body.String(), `<script>番組</script>`) {
|
||||||
|
t.Fatal("確認画面でHTMLがエスケープされていない")
|
||||||
|
}
|
||||||
|
if name == "両方と重複除去" && (!strings.Contains(response.Body.String(), `<script>番組</script>`) || !strings.Contains(response.Body.String(), `<img src=x onerror=alert(1)>`)) {
|
||||||
|
t.Fatalf("番組名または回タイトルのエスケープ結果がない: %s", response.Body.String())
|
||||||
|
}
|
||||||
|
if name == "両方と重複除去" && strings.Count(response.Body.String(), `name="id"`) != 2 {
|
||||||
|
t.Fatalf("重複除去後のhidden ID: %s", response.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for name, values := range map[string]url.Values{
|
||||||
|
"対象なし": {},
|
||||||
|
"該当なし": {"id": {"99"}},
|
||||||
|
"不正ID": {"id": {"bad"}},
|
||||||
|
"ゼロID": {"id": {"0"}},
|
||||||
|
"不正番組ID": {"series_id": {"bad-id"}},
|
||||||
|
} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
if got := postForm(handler, "/library/delete/confirm", values).Code; got != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status=%d", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
tooMany := url.Values{}
|
||||||
|
for i := 1; i <= maxDeleteSelections+1; i++ {
|
||||||
|
tooMany.Add("id", strconv.Itoa(i))
|
||||||
|
}
|
||||||
|
if got := postForm(handler, "/library/delete/confirm", tooMany).Code; got != http.StatusBadRequest {
|
||||||
|
t.Fatalf("上限超過 status=%d", got)
|
||||||
|
}
|
||||||
|
atLimit := url.Values{}
|
||||||
|
for i := 1; i <= maxDeleteSelections; i++ {
|
||||||
|
atLimit.Add("id", strconv.Itoa(i))
|
||||||
|
}
|
||||||
|
if response := postForm(handler, "/library/delete/confirm", atLimit); response.Code != http.StatusOK || len(jobs.jobs) != 3 {
|
||||||
|
t.Fatalf("入力上限ちょうど: status=%d jobs=%d", response.Code, len(jobs.jobs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteConfirmationRejectsExpandedSelectionOverLimit(t *testing.T) {
|
||||||
|
makeJobs := func(count int) map[string]store.Job {
|
||||||
|
jobs := make(map[string]store.Job, count)
|
||||||
|
for i := 1; i <= count; i++ {
|
||||||
|
job := libraryJob(int64(i), "show/video.mp4")
|
||||||
|
job.EpisodeID = "ep" + strconv.Itoa(i)
|
||||||
|
jobs[job.EpisodeID] = job
|
||||||
|
}
|
||||||
|
return jobs
|
||||||
|
}
|
||||||
|
|
||||||
|
atLimit := &fakeJobStore{jobs: makeJobs(maxDeleteSelections)}
|
||||||
|
response := postForm(newTestHandlerWithVideoDir(&fakeFetcher{}, atLimit, t.TempDir(), nil),
|
||||||
|
"/library/delete/confirm", url.Values{"series_id": {"series1"}})
|
||||||
|
if response.Code != http.StatusOK || len(atLimit.jobs) != maxDeleteSelections {
|
||||||
|
t.Fatalf("上限ちょうど: status=%d jobs=%d", response.Code, len(atLimit.jobs))
|
||||||
|
}
|
||||||
|
|
||||||
|
overLimit := &fakeJobStore{jobs: makeJobs(maxDeleteSelections + 1)}
|
||||||
|
response = postForm(newTestHandlerWithVideoDir(&fakeFetcher{}, overLimit, t.TempDir(), nil),
|
||||||
|
"/library/delete/confirm", url.Values{"series_id": {"series1"}})
|
||||||
|
if response.Code != http.StatusBadRequest || len(overLimit.jobs) != maxDeleteSelections+1 ||
|
||||||
|
!strings.Contains(response.Body.String(), strconv.Itoa(maxDeleteSelections+1)+"本") ||
|
||||||
|
!strings.Contains(response.Body.String(), "番組を分けて選んでください") {
|
||||||
|
t.Fatalf("上限超過: status=%d jobs=%d body=%s", response.Code, len(overLimit.jobs), response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkDeleteContinuesAfterFailureAndDoesNotExpandSeries(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "show"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "show", "one.mp4"), []byte("one"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(dir, "show", "blocked"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "show", "blocked", "child"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
first := libraryJob(1, "show/one.mp4")
|
||||||
|
blocked := libraryJob(2, "show/blocked")
|
||||||
|
blocked.EpisodeID = "ep2"
|
||||||
|
blocked.Title = "削除失敗回"
|
||||||
|
newAfterConfirmation := libraryJob(3, "show/new.mp4")
|
||||||
|
newAfterConfirmation.EpisodeID = "ep3"
|
||||||
|
newAfterConfirmation.Title = "確認後の新着"
|
||||||
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": first, "ep2": blocked, "ep3": newAfterConfirmation}}
|
||||||
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
|
||||||
|
|
||||||
|
response := postForm(handler, "/library/delete", url.Values{
|
||||||
|
"id": {"1", "2", "99"},
|
||||||
|
"series_id": {"series1"},
|
||||||
|
})
|
||||||
|
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "削除: 1件") || !strings.Contains(response.Body.String(), "失敗: 1件") || !strings.Contains(response.Body.String(), "対象外: 1件") {
|
||||||
|
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := jobs.jobs["ep1"]; ok {
|
||||||
|
t.Fatal("正常な回の記録が残った")
|
||||||
|
}
|
||||||
|
if _, ok := jobs.jobs["ep2"]; !ok {
|
||||||
|
t.Fatal("失敗した回の記録が消えた")
|
||||||
|
}
|
||||||
|
if _, ok := jobs.jobs["ep3"]; !ok {
|
||||||
|
t.Fatal("確認後に増えた回が消えた")
|
||||||
|
}
|
||||||
|
if strings.Contains(response.Body.String(), filepath.Join(dir, "show", "blocked")) {
|
||||||
|
t.Fatal("内部パスが結果画面に出た")
|
||||||
|
}
|
||||||
|
for _, detail := range []string{"directory not empty", "show/blocked"} {
|
||||||
|
if strings.Contains(response.Body.String(), detail) {
|
||||||
|
t.Fatalf("エラー詳細が結果画面に出た: %q", detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkDeleteWhenVideoRootCannotOpenDeletesOnlyRecordWithoutFile(t *testing.T) {
|
||||||
|
withFile := libraryJob(1, "show/video.mp4")
|
||||||
|
withFile.Title = `<img src=x onerror=alert(1)>`
|
||||||
|
withoutFile := libraryJob(2, "")
|
||||||
|
withoutFile.EpisodeID = "ep2"
|
||||||
|
withoutFile.Title = "記録だけの回"
|
||||||
|
withoutFile.FilePath = nil
|
||||||
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": withFile, "ep2": withoutFile}}
|
||||||
|
missingRoot := filepath.Join(t.TempDir(), "missing-video-root")
|
||||||
|
|
||||||
|
response := postForm(newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, missingRoot, nil),
|
||||||
|
"/library/delete", url.Values{"id": {"1", "2"}})
|
||||||
|
if response.Code != http.StatusOK ||
|
||||||
|
!strings.Contains(response.Body.String(), "削除: 1件") ||
|
||||||
|
!strings.Contains(response.Body.String(), "失敗: 1件") {
|
||||||
|
t.Fatalf("status=%d body=%s", response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
if _, ok := jobs.jobs["ep1"]; !ok {
|
||||||
|
t.Fatal("動画ルートを開けない回の記録が消えた")
|
||||||
|
}
|
||||||
|
if _, ok := jobs.jobs["ep2"]; ok {
|
||||||
|
t.Fatal("file_pathがNULLの回の記録が残った")
|
||||||
|
}
|
||||||
|
for _, secret := range []string{missingRoot, "no such file or directory", `<img src=x`} {
|
||||||
|
if strings.Contains(response.Body.String(), secret) {
|
||||||
|
t.Fatalf("内部情報または未エスケープHTMLが結果画面に出た: %q", secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !strings.Contains(response.Body.String(), `<img src=x onerror=alert(1)>`) {
|
||||||
|
t.Fatalf("失敗した回のタイトルが安全に表示されていない: %s", response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBulkDeleteRejectsEmptySelection(t *testing.T) {
|
||||||
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
|
||||||
|
response := postForm(newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil), "/library/delete", nil)
|
||||||
|
if response.Code != http.StatusBadRequest || len(jobs.jobs) != 1 {
|
||||||
|
t.Fatalf("status=%d jobs=%d", response.Code, len(jobs.jobs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLibraryDeleteEntryFormsAndOldRoute(t *testing.T) {
|
||||||
|
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
|
||||||
|
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil)
|
||||||
|
for _, path := range []string{"/library", "/library/series/series1", "/library/1"} {
|
||||||
|
response := getPage(handler, path)
|
||||||
|
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), `action="/library/delete/confirm"`) {
|
||||||
|
t.Fatalf("%s: status=%d body=%s", path, response.Code, response.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if body := getPage(handler, "/library/1").Body.String(); strings.Contains(body, "hx-confirm") {
|
||||||
|
t.Fatal("再生ページにhx-confirmが残っている")
|
||||||
|
}
|
||||||
|
if got := postForm(handler, "/library/1/delete", nil).Code; got != http.StatusNotFound && got != http.StatusMethodNotAllowed {
|
||||||
|
t.Fatalf("旧経路 status=%d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sortJobsNaturally(jobs []store.Job) {
|
||||||
|
sort.SliceStable(jobs, func(i, j int) bool {
|
||||||
|
if comparison := compareNaturalTitle(jobs[i].Title, jobs[j].Title); comparison != 0 {
|
||||||
|
return comparison < 0
|
||||||
|
}
|
||||||
|
left, right := jobs[i].FinishedAt, jobs[j].FinishedAt
|
||||||
|
if left == nil || right == nil {
|
||||||
|
if left != right {
|
||||||
|
return left == nil
|
||||||
|
}
|
||||||
|
} else if !left.Equal(*right) {
|
||||||
|
return left.Before(*right)
|
||||||
|
}
|
||||||
|
return jobs[i].ID < jobs[j].ID
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareNaturalTitle(left, right string) int {
|
||||||
|
a, b := []rune(left), []rune(right)
|
||||||
|
ai, bi := 0, 0
|
||||||
|
for ai < len(a) && bi < len(b) {
|
||||||
|
_, aIsDigit := decimalDigit(a[ai])
|
||||||
|
_, bIsDigit := decimalDigit(b[bi])
|
||||||
|
if aIsDigit && bIsDigit {
|
||||||
|
var an, bn strings.Builder
|
||||||
|
for ai < len(a) {
|
||||||
|
digit, ok := decimalDigit(a[ai])
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
an.WriteByte(byte('0' + digit))
|
||||||
|
ai++
|
||||||
|
}
|
||||||
|
for bi < len(b) {
|
||||||
|
digit, ok := decimalDigit(b[bi])
|
||||||
|
if !ok {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
bn.WriteByte(byte('0' + digit))
|
||||||
|
bi++
|
||||||
|
}
|
||||||
|
if comparison := compareDecimal(an.String(), bn.String()); comparison != 0 {
|
||||||
|
return comparison
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if a[ai] < b[bi] {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if a[ai] > b[bi] {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
ai++
|
||||||
|
bi++
|
||||||
|
}
|
||||||
|
if ai == len(a) && bi < len(b) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if bi == len(b) && ai < len(a) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func decimalDigit(r rune) (int, bool) {
|
||||||
|
switch {
|
||||||
|
case '0' <= r && r <= '9':
|
||||||
|
return int(r - '0'), true
|
||||||
|
case '0' <= r && r <= '9':
|
||||||
|
return int(r - '0'), true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareDecimal(left, right string) int {
|
||||||
|
left = strings.TrimLeft(left, "0")
|
||||||
|
right = strings.TrimLeft(right, "0")
|
||||||
|
if left == "" {
|
||||||
|
left = "0"
|
||||||
|
}
|
||||||
|
if right == "" {
|
||||||
|
right = "0"
|
||||||
|
}
|
||||||
|
if len(left) < len(right) {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if len(left) > len(right) {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return strings.Compare(left, right)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.kazu634.com/kazu634/tver/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSortJobsNaturally(t *testing.T) {
|
||||||
|
base := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)
|
||||||
|
jobs := []store.Job{
|
||||||
|
{ID: 8, Title: "第10話", FinishedAt: timePointer(base)},
|
||||||
|
{ID: 7, Title: "第2話", FinishedAt: timePointer(base)},
|
||||||
|
{ID: 6, Title: "#10", FinishedAt: timePointer(base)},
|
||||||
|
{ID: 5, Title: "#9", FinishedAt: timePointer(base)},
|
||||||
|
{ID: 4, Title: "第2話", FinishedAt: timePointer(base.Add(time.Hour))},
|
||||||
|
{ID: 3, Title: "第2話", FinishedAt: timePointer(base.Add(-time.Hour))},
|
||||||
|
{ID: 2, Title: "第02話", FinishedAt: timePointer(base.Add(-time.Hour))},
|
||||||
|
{ID: 1, Title: "おまけ", FinishedAt: timePointer(base)},
|
||||||
|
}
|
||||||
|
sortJobsNaturally(jobs)
|
||||||
|
got := make([]int64, len(jobs))
|
||||||
|
for i := range jobs {
|
||||||
|
got[i] = jobs[i].ID
|
||||||
|
}
|
||||||
|
want := []int64{5, 6, 1, 2, 3, 7, 4, 8}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Fatalf("自然順=%v want=%v", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNaturalOrderUsesIDAfterEqualTitleAndFinishedAt(t *testing.T) {
|
||||||
|
finished := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)
|
||||||
|
jobs := []store.Job{
|
||||||
|
{ID: 20, Title: "#9", FinishedAt: &finished},
|
||||||
|
{ID: 10, Title: "#9", FinishedAt: &finished},
|
||||||
|
}
|
||||||
|
sortJobsNaturally(jobs)
|
||||||
|
if jobs[0].ID != 10 || jobs[1].ID != 20 {
|
||||||
|
t.Fatalf("同順位のID順: %+v", jobs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNaturalOrderTreatsLeadingZerosAsTheSameNumber(t *testing.T) {
|
||||||
|
base := time.Date(2026, 10, 10, 0, 0, 0, 0, time.UTC)
|
||||||
|
jobs := []store.Job{
|
||||||
|
{ID: 30, Title: "数字なし", FinishedAt: timePointer(base.Add(-time.Hour))},
|
||||||
|
{ID: 20, Title: "#09", FinishedAt: timePointer(base)},
|
||||||
|
{ID: 10, Title: "#9", FinishedAt: timePointer(base.Add(time.Hour))},
|
||||||
|
}
|
||||||
|
sortJobsNaturally(jobs)
|
||||||
|
if jobs[0].ID != 20 || jobs[1].ID != 10 || jobs[2].ID != 30 {
|
||||||
|
t.Fatalf("先頭ゼロと数字なしの自然順: %+v", jobs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func timePointer(value time.Time) *time.Time { return &value }
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -7,9 +7,16 @@ main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; }
|
|||||||
#newer-controls select, #newer-controls button { font: inherit; padding: .35rem .6rem; }
|
#newer-controls select, #newer-controls button { font: inherit; padding: .35rem .6rem; }
|
||||||
.episode-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: 1.25rem; }
|
.episode-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: 1.25rem; }
|
||||||
.episode-card { border: 1px solid #d6dee4; border-radius: .5rem; overflow: hidden; }
|
.episode-card { border: 1px solid #d6dee4; border-radius: .5rem; overflow: hidden; }
|
||||||
|
.library-selection { display: flex; align-items: center; gap: .4rem; padding: .6rem .8rem; }
|
||||||
|
.library-select-grid .episode-card { display: flex; flex-direction: column; }
|
||||||
|
.library-select-grid .episode-card > a { flex: 1; }
|
||||||
.episode-card a { color: inherit; text-decoration: none; }
|
.episode-card a { color: inherit; text-decoration: none; }
|
||||||
.episode-card a:hover h2 { text-decoration: underline; }
|
.episode-card a:hover h2 { text-decoration: underline; }
|
||||||
.episode-card img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: #e5ebef; }
|
.episode-card img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: #e5ebef; }
|
||||||
|
.library-card-image { position: relative; }
|
||||||
|
.episode-count-badge, .subscription-badge { display: inline-block; border-radius: 999px; padding: .15rem .5rem; font-size: .8rem; line-height: 1.2; }
|
||||||
|
.episode-count-badge { position: absolute; right: .5rem; bottom: .5rem; color: #fff; background: rgba(23, 33, 43, .85); }
|
||||||
|
.subscription-badge { color: #145a86; background: #e1f1fb; vertical-align: middle; }
|
||||||
.episode-copy { padding: .8rem 1rem; }
|
.episode-copy { padding: .8rem 1rem; }
|
||||||
.episode-copy h2 { font-size: 1.1rem; margin: 0 0 .4rem; }
|
.episode-copy h2 { font-size: 1.1rem; margin: 0 0 .4rem; }
|
||||||
.episode-copy p { margin: .3rem 0; }
|
.episode-copy p { margin: .3rem 0; }
|
||||||
@@ -18,6 +25,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; }
|
||||||
@@ -25,3 +35,28 @@ main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; }
|
|||||||
.queue-job p { margin: .4rem 0; }
|
.queue-job p { margin: .4rem 0; }
|
||||||
.queue-job pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
.queue-job pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
video { display: block; width: 100%; max-height: 75vh; background: #000; }
|
video { display: block; width: 100%; max-height: 75vh; background: #000; }
|
||||||
|
.breadcrumbs { display: flex; flex-wrap: wrap; gap: .45rem; margin-bottom: 1rem; overflow-wrap: anywhere; }
|
||||||
|
.library-sort { margin: 1rem 0; }
|
||||||
|
.library-episode-list { border-top: 1px solid #d6dee4; }
|
||||||
|
.library-episode-select-row { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: center; border-bottom: 1px solid #d6dee4; }
|
||||||
|
.library-episode-row { display: grid; grid-template-columns: 8rem minmax(0, 1fr) auto auto auto; gap: .8rem; align-items: center; padding: .7rem 0; border-bottom: 1px solid #d6dee4; color: inherit; text-decoration: none; }
|
||||||
|
.library-episode-select-row .library-episode-row { border-bottom: 0; }
|
||||||
|
.library-episode-row:hover .library-episode-title { text-decoration: underline; }
|
||||||
|
.library-episode-row img { display: block; width: 8rem; aspect-ratio: 16 / 9; object-fit: cover; background: #e5ebef; }
|
||||||
|
.library-episode-title { min-width: 0; overflow-wrap: anywhere; font-weight: 600; }
|
||||||
|
.episode-navigation { display: flex; justify-content: space-between; gap: 1rem; margin: 1rem 0; }
|
||||||
|
.episode-navigation a, .episode-navigation span { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
.delete-warning { border-left: .3rem solid #b42318; background: #fff1f0; padding: .8rem 1rem; }
|
||||||
|
.delete-group { overflow-wrap: anywhere; }
|
||||||
|
|
||||||
|
@media (max-width: 36rem) {
|
||||||
|
header { align-items: flex-start; flex-direction: column; gap: .75rem; padding: 1rem; }
|
||||||
|
main { margin: 1.25rem auto; padding: 0 1rem; }
|
||||||
|
.episode-grid { grid-template-columns: minmax(0, 1fr); }
|
||||||
|
.library-episode-row { grid-template-columns: 6rem minmax(0, 1fr); gap: .35rem .7rem; }
|
||||||
|
.library-episode-select-row { grid-template-columns: 1fr; }
|
||||||
|
.library-episode-select-row > .library-selection { padding: .7rem 0 0; }
|
||||||
|
.library-episode-row img { grid-row: 1 / span 4; width: 6rem; }
|
||||||
|
.library-episode-row > span:not(.library-episode-title) { grid-column: 2; font-size: .9rem; }
|
||||||
|
.episode-navigation { flex-direction: column; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -231,6 +231,107 @@ func (f *fakeJobStore) ListSucceededJobs(_ context.Context, limit int) ([]store.
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakeJobStore) ListLibrarySeries(_ context.Context) ([]store.LibrarySeries, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
bySeries := make(map[string]*store.LibrarySeries)
|
||||||
|
latestIDs := make(map[string]int64)
|
||||||
|
for _, job := range f.jobs {
|
||||||
|
if job.Status != "succeeded" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := bySeries[job.SeriesID]
|
||||||
|
if item == nil {
|
||||||
|
item = &store.LibrarySeries{SeriesID: job.SeriesID}
|
||||||
|
bySeries[job.SeriesID] = item
|
||||||
|
}
|
||||||
|
item.EpisodeCount++
|
||||||
|
if job.FileSizeBytes != nil {
|
||||||
|
item.TotalSizeBytes += *job.FileSizeBytes
|
||||||
|
}
|
||||||
|
current := store.Job{ID: latestIDs[job.SeriesID], FinishedAt: item.LatestFinishedAt}
|
||||||
|
if item.SeriesTitle == "" || jobFinishedLater(job, current) {
|
||||||
|
item.SeriesTitle = job.SeriesTitle
|
||||||
|
item.LatestTitle = job.Title
|
||||||
|
item.BroadcasterName = job.BroadcasterName
|
||||||
|
item.ThumbnailPath = job.ThumbnailPath
|
||||||
|
if job.FinishedAt != nil {
|
||||||
|
finished := *job.FinishedAt
|
||||||
|
item.LatestFinishedAt = &finished
|
||||||
|
}
|
||||||
|
latestIDs[job.SeriesID] = job.ID
|
||||||
|
}
|
||||||
|
_, item.Subscribed = f.subscriptions[job.SeriesID]
|
||||||
|
}
|
||||||
|
result := make([]store.LibrarySeries, 0, len(bySeries))
|
||||||
|
for _, item := range bySeries {
|
||||||
|
result = append(result, *item)
|
||||||
|
}
|
||||||
|
sort.Slice(result, func(i, j int) bool {
|
||||||
|
left, right := result[i].LatestFinishedAt, result[j].LatestFinishedAt
|
||||||
|
if left == nil || right == nil {
|
||||||
|
if left != right {
|
||||||
|
return left != nil
|
||||||
|
}
|
||||||
|
} else if !left.Equal(*right) {
|
||||||
|
return left.After(*right)
|
||||||
|
}
|
||||||
|
return result[i].SeriesID < result[j].SeriesID
|
||||||
|
})
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeJobStore) ListSucceededJobsBySeries(_ context.Context, seriesID string) ([]store.Job, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
var result []store.Job
|
||||||
|
for _, job := range f.jobs {
|
||||||
|
if job.Status == "succeeded" && job.SeriesID == seriesID {
|
||||||
|
result = append(result, job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sortJobsLatest(result)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeJobStore) ListSucceededJobsByIDs(_ context.Context, ids []int64) ([]store.Job, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
wanted := make(map[int64]struct{}, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
wanted[id] = struct{}{}
|
||||||
|
}
|
||||||
|
var result []store.Job
|
||||||
|
for _, job := range f.jobs {
|
||||||
|
if _, ok := wanted[job.ID]; ok && job.Status == "succeeded" {
|
||||||
|
result = append(result, job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sortJobsLatest(result)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeJobStore) ListSucceededJobsBySeriesIDs(_ context.Context, seriesIDs []string) ([]store.Job, error) {
|
||||||
|
if f.err != nil {
|
||||||
|
return nil, f.err
|
||||||
|
}
|
||||||
|
wanted := make(map[string]struct{}, len(seriesIDs))
|
||||||
|
for _, id := range seriesIDs {
|
||||||
|
wanted[id] = struct{}{}
|
||||||
|
}
|
||||||
|
var result []store.Job
|
||||||
|
for _, job := range f.jobs {
|
||||||
|
if _, ok := wanted[job.SeriesID]; ok && job.Status == "succeeded" {
|
||||||
|
result = append(result, job)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sortJobsLatest(result)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (f *fakeJobStore) GetSucceededJob(_ context.Context, id int64) (store.Job, error) {
|
func (f *fakeJobStore) GetSucceededJob(_ context.Context, id int64) (store.Job, error) {
|
||||||
if f.err != nil {
|
if f.err != nil {
|
||||||
return store.Job{}, f.err
|
return store.Job{}, f.err
|
||||||
|
|||||||
@@ -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})
|
||||||
|
}
|
||||||
@@ -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{"<script>", "番組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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -26,8 +26,14 @@
|
|||||||
{{template "subscriptions_page" .Subscriptions}}
|
{{template "subscriptions_page" .Subscriptions}}
|
||||||
{{else if .Library}}
|
{{else if .Library}}
|
||||||
{{template "library_page" .Library}}
|
{{template "library_page" .Library}}
|
||||||
|
{{else if .Series}}
|
||||||
|
{{template "series_page" .Series}}
|
||||||
{{else if .Video}}
|
{{else if .Video}}
|
||||||
{{template "video_page" .Video}}
|
{{template "video_page" .Video}}
|
||||||
|
{{else if .DeleteConfirm}}
|
||||||
|
{{template "delete_confirm_page" .DeleteConfirm}}
|
||||||
|
{{else if .DeleteResult}}
|
||||||
|
{{template "delete_result_page" .DeleteResult}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<h1>{{.Title}}</h1>
|
<h1>{{.Title}}</h1>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|||||||
@@ -1,34 +1,115 @@
|
|||||||
{{define "library_page"}}
|
{{define "library_page"}}
|
||||||
<h1>ライブラリ</h1>
|
<h1>ライブラリ</h1>
|
||||||
{{if .Items}}
|
{{if .Series}}
|
||||||
<div class="episode-grid">
|
<p>{{.SeriesCount}}番組・{{.EpisodeCount}}本・{{.TotalSizeText}}</p>
|
||||||
{{range .Items}}
|
<form action="/library/delete/confirm" method="post">
|
||||||
|
<div class="episode-grid library-select-grid">
|
||||||
|
{{range .Series}}
|
||||||
<article class="episode-card">
|
<article class="episode-card">
|
||||||
<a href="/library/{{.ID}}">
|
<label class="library-selection"><input type="checkbox" name="series_id" value="{{.SeriesID}}"> この番組を選択</label>
|
||||||
|
<a href="/library/series/{{.SeriesID}}">
|
||||||
|
<div class="library-card-image">
|
||||||
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
|
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
|
||||||
|
<span class="episode-count-badge">{{.EpisodeCount}}本</span>
|
||||||
|
</div>
|
||||||
<div class="episode-copy">
|
<div class="episode-copy">
|
||||||
<h2>{{.SeriesTitle}}</h2>
|
<h2>{{.SeriesTitle}} {{if .Subscribed}}<span class="subscription-badge">登録中</span>{{end}}</h2>
|
||||||
<p>{{.Title}}</p>
|
|
||||||
<p>放送局: {{.BroadcasterName}}</p>
|
<p>放送局: {{.BroadcasterName}}</p>
|
||||||
<p>長さ: {{.DurationText}}</p>
|
<p>最新: {{.LatestTitle}}</p>
|
||||||
<p>サイズ: {{.FileSizeText}}</p>
|
<p>最終完了: {{.LatestFinishedText}}</p>
|
||||||
<p>完了日時: {{.FinishedAtText}}</p>
|
<p>合計サイズ: {{.TotalSizeText}}</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
</article>
|
</article>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
<p><button type="submit">選択した番組を削除</button></p>
|
||||||
|
</form>
|
||||||
{{else}}
|
{{else}}
|
||||||
<p>ダウンロード済みの動画はありません</p>
|
<p>ダウンロード済みの動画はありません</p>
|
||||||
{{end}}
|
{{end}}
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "series_page"}}
|
||||||
|
<nav class="breadcrumbs" aria-label="パンくず"><a href="/library">ライブラリ</a><span aria-hidden="true">›</span><span>{{.SeriesTitle}}</span></nav>
|
||||||
|
<h1>{{.SeriesTitle}} {{if .Subscribed}}<span class="subscription-badge">登録中</span>{{end}}</h1>
|
||||||
|
<p>{{.BroadcasterName}}・{{.EpisodeCount}}本・{{.TotalSizeText}}</p>
|
||||||
|
<p class="library-sort">並び順:
|
||||||
|
{{if eq .Sort "latest"}}<strong>完了が新しい順</strong>{{else}}<a href="/library/series/{{.SeriesID}}">完了が新しい順</a>{{end}}
|
||||||
|
/
|
||||||
|
{{if eq .Sort "natural"}}<strong>話の順</strong>{{else}}<a href="/library/series/{{.SeriesID}}?sort=natural">話の順</a>{{end}}
|
||||||
|
</p>
|
||||||
|
<form action="/library/delete/confirm" method="post">
|
||||||
|
<div class="library-episode-list">
|
||||||
|
{{range .Items}}
|
||||||
|
<div class="library-episode-select-row">
|
||||||
|
<label class="library-selection"><input type="checkbox" name="id" value="{{.ID}}"> 選択</label>
|
||||||
|
<a class="library-episode-row" href="/library/{{.ID}}">
|
||||||
|
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
|
||||||
|
<span class="library-episode-title">{{.Title}}</span>
|
||||||
|
<span>{{.FinishedAtText}}</span>
|
||||||
|
<span>{{.DurationText}}</span>
|
||||||
|
<span>{{.FileSizeText}}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
<p><button type="submit">選択した回を削除</button></p>
|
||||||
|
</form>
|
||||||
|
<form action="/library/delete/confirm" method="post">
|
||||||
|
<input type="hidden" name="series_id" value="{{.SeriesID}}">
|
||||||
|
<button type="submit">この番組をすべて削除</button>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
{{define "video_page"}}
|
{{define "video_page"}}
|
||||||
|
<nav class="breadcrumbs" aria-label="パンくず"><a href="/library">ライブラリ</a><span aria-hidden="true">›</span><a href="{{.SeriesURL}}">{{.SeriesTitle}}</a><span aria-hidden="true">›</span><span>{{.Title}}</span></nav>
|
||||||
<h1>{{.SeriesTitle}}</h1>
|
<h1>{{.SeriesTitle}}</h1>
|
||||||
<h2>{{.Title}}</h2>
|
<h2>{{.Title}}</h2>
|
||||||
<video controls preload="metadata" src="{{.VideoURL}}"></video>
|
<video controls preload="metadata" src="{{.VideoURL}}"></video>
|
||||||
<p>放送局: {{.BroadcasterName}}/長さ: {{.DurationText}}/サイズ: {{.FileSizeText}}/完了日時: {{.FinishedAtText}}</p>
|
<p>放送局: {{.BroadcasterName}}/長さ: {{.DurationText}}/サイズ: {{.FileSizeText}}/完了日時: {{.FinishedAtText}}</p>
|
||||||
<form action="/library/{{.ID}}/delete" method="post" hx-confirm="動画ファイルと記録を削除しますか?">
|
<nav class="episode-navigation" aria-label="前後の回">
|
||||||
|
{{if .Previous}}<a href="/library/{{.Previous.ID}}">← 前の回: {{.Previous.Title}}</a>{{else}}<span aria-disabled="true">前の回はありません</span>{{end}}
|
||||||
|
{{if .Next}}<a href="/library/{{.Next.ID}}">次の回: {{.Next.Title}} →</a>{{else}}<span aria-disabled="true">次の回はありません</span>{{end}}
|
||||||
|
</nav>
|
||||||
|
<form action="/library/delete/confirm" method="post">
|
||||||
|
<input type="hidden" name="id" value="{{.ID}}">
|
||||||
<button type="submit">削除</button>
|
<button type="submit">削除</button>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
|
{{define "delete_confirm_page"}}
|
||||||
|
<nav class="breadcrumbs" aria-label="パンくず"><a href="/library">ライブラリ</a><span aria-hidden="true">›</span><span>削除確認</span></nav>
|
||||||
|
<h1>動画の削除確認</h1>
|
||||||
|
<p class="delete-warning">ファイルと記録を削除します。この操作は取り消せません。</p>
|
||||||
|
<p>{{.Count}}本・合計{{.TotalSizeText}}</p>
|
||||||
|
{{range .Groups}}
|
||||||
|
<section class="delete-group">
|
||||||
|
<h2>{{.SeriesTitle}}</h2>
|
||||||
|
<p>{{len .Items}}本・{{.TotalSizeText}}</p>
|
||||||
|
<ul>
|
||||||
|
{{range .Items}}<li>{{.Title}}({{.FileSizeText}})</li>{{end}}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
<form action="/library/delete" method="post">
|
||||||
|
{{range .IDs}}<input type="hidden" name="id" value="{{.}}">{{end}}
|
||||||
|
<button type="submit">削除を実行</button>
|
||||||
|
<a href="{{.CancelURL}}">取り消す</a>
|
||||||
|
</form>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "delete_result_page"}}
|
||||||
|
<nav class="breadcrumbs" aria-label="パンくず"><a href="/library">ライブラリ</a><span aria-hidden="true">›</span><span>削除結果</span></nav>
|
||||||
|
<h1>動画の削除結果</h1>
|
||||||
|
<p>削除: {{.DeletedCount}}件/失敗: {{len .Failures}}件/対象外: {{.ExcludedCount}}件</p>
|
||||||
|
{{if .Failures}}
|
||||||
|
<h2>失敗した回</h2>
|
||||||
|
<ul>{{range .Failures}}<li>{{.Title}}: {{.Reason}}</li>{{end}}</ul>
|
||||||
|
{{end}}
|
||||||
|
<p><a href="/library">ライブラリへ戻る</a></p>
|
||||||
|
{{if .SeriesLinks}}
|
||||||
|
<h2>回が残っている番組</h2>
|
||||||
|
<ul>{{range .SeriesLinks}}<li><a href="/library/series/{{.SeriesID}}">{{.SeriesTitle}}</a></li>{{end}}</ul>
|
||||||
|
{{end}}
|
||||||
|
{{end}}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user