Compare commits

15 Commits
Author SHA1 Message Date
kazu634 d4346c06fe Merge pull request 'ファイル名の上限拡大と os.Root への置き換え(ステップ9)' (#13) from path-hardening into main
Reviewed-on: #13
2026-10-04 14:56:03 +09:00
kazu634 a2dd67b8b6 Merge remote-tracking branch 'origin/main' into path-hardening
# Conflicts:
#	PROJECT.md
2026-10-04 14:53:53 +09:00
kazu634 3fb3227aa7 Merge pull request '本番配置の結果を反映(ステップ8)' (#12) from deploy-results into main
Reviewed-on: #12
2026-10-04 14:51:32 +09:00
kazu634 9b856d63fa ファイル名の上限を拡大し、動画の配信・削除を os.Root に置き換え(エピソード ID は 64 バイト上限) 2026-10-04 13:43:07 +09:00
kazu634 3ddd2063b6 本番配置の結果を反映(配置用ユーザーでの clone、動作確認結果、新着 100 件の調査) 2026-10-04 13:13:02 +09:00
kazu634 f963dea2d1 Merge pull request '本番配置の準備を追加(ステップ7)' (#11) from deploy-setup into main
Reviewed-on: #11
2026-10-04 12:20:08 +09:00
kazu634 37d42851ff 本番配置の準備を追加(NAS の DB 定義、配置手順書、保存先の決定) 2026-10-04 12:01:19 +09:00
kazu634 80d9606750 Merge pull request '登録番組の定期確認を追加(ステップ6b)' (#10) from subscription-check into main
Reviewed-on: #10
2026-10-04 10:44:02 +09:00
kazu634 944086c8ae 登録番組の定期確認を追加(24 時間ごと、予告の除外、確認エラーの記録) 2026-10-03 23:42:25 +09:00
kazu634 82536c6398 Merge pull request 'worker を追加(ステップ6a)' (#9) from worker-download into main
Reviewed-on: #9
2026-10-03 23:27:49 +09:00
kazu634 6f150495d5 worker を追加(yt-dlp によるダウンロード、段階別進捗、ffprobe 検査、実行中の取り消し) 2026-10-03 22:56:14 +09:00
kazu634 ebf1cfcbc2 Merge pull request 'ライブラリ・再生・削除を追加(ステップ5)' (#8) from library into main
Reviewed-on: #8
2026-10-03 20:57:02 +09:00
kazu634 276cd7b5b8 ライブラリ・再生・削除を追加(Range 配信、パスの安全確認、共有ファイル名規則) 2026-10-03 20:54:16 +09:00
kazu634 637ee5b81a Merge pull request '番組登録を追加(ステップ4)' (#7) from subscriptions into main
Reviewed-on: #7
2026-10-03 19:50:49 +09:00
kazu634 787eda79ae 番組登録を追加(登録時の過去回の選択、一時停止・再開・解除、既知の回の記録) 2026-10-03 19:44:57 +09:00
48 changed files with 5030 additions and 93 deletions
+3
View File
@@ -1 +1,4 @@
DATABASE_URL= DATABASE_URL=
VIDEO_HOST_DIR=/mnt/nas-tver/tver
VIDEO_DIR=/videos
WORKER_CONCURRENCY=1
+21
View File
@@ -0,0 +1,21 @@
FROM golang:1.27.1-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY cmd ./cmd
COPY internal ./internal
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /worker ./cmd/worker
FROM python:3.13-slim
ARG YT_DLP_VERSION=2026.08.19
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates ffmpeg tzdata \
&& pip install --no-cache-dir "yt-dlp[default]==${YT_DLP_VERSION}" \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY --from=build /worker /app/worker
ENV HOME=/tmp \
XDG_CACHE_HOME=/tmp/yt-dlp-cache
USER 65532:65532
ENTRYPOINT ["/app/worker"]
+62 -21
View File
@@ -3,7 +3,7 @@ title: tver の現状
tags: tags:
- homelab - homelab
- tver - tver
status: 待ち行列の画面を実装中 status: 本番稼働中
--- ---
# tver の現状 # tver の現状
@@ -13,8 +13,8 @@ status: 待ち行列の画面を実装中
## 今の状態 ## 今の状態
- Go の HTTP サーバー、TVer API クライアント、ドラマ・バラエティの新着表示、テスト、web コンテナの定義がある - Go の HTTP サーバー、TVer API クライアント、ドラマ・バラエティの新着表示、テスト、web・worker コンテナの定義がある
- **待ち行列の画面を実装中。** web は起動時に DB へ接続してマイグレーションを適用し、新着からの登録・状態表示と待ち行列ページを提供する。worker がまだ無いため、登録したジョブは待機中のまま残る - **本番稼働中。** dockageのweb・worker、NASのPostgreSQL 17、NFS動画保存を構成し、実動画の取得・再生・Range配信まで確認済み
- リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git` - リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git`
## 目的 ## 目的
@@ -27,20 +27,38 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
|---|---| |---|---|
| TVer 利用規約との関係 | 鑑賞を前提とするダウンロードのため、問題無しと判断(ユーザー判断) | | TVer 利用規約との関係 | 鑑賞を前提とするダウンロードのため、問題無しと判断(ユーザー判断) |
| 利用者・公開範囲 | 自分のみ・**LAN 内だけ**。EC2 nginx では公開しない | | 利用者・公開範囲 | 自分のみ・**LAN 内だけ**。EC2 nginx では公開しない |
| 保存先 | **NAS(ds923p)** | | 保存先 | **NAS(ds923p)の `/volume1/video/tver`**。既存の`anime`・`drama`・`misc`・`personal`・`variety`と分け、admin相当の書き込み範囲を限定する。`/volume1/video`をdockageの`/mnt/nas-tver`へNFSマウントし、`.env`は`VIDEO_HOST_DIR=/mnt/nas-tver/tver`とする |
| 自動ダウンロード | **作る**(番組を登録し、新しい回を自動でダウンロードする) | | 自動ダウンロード | **作る**(番組を登録し、新しい回を自動でダウンロードする) |
| 再生 | **アプリの中で再生する** | | 再生 | **アプリの中で再生する** |
| ダウンロード | yt-dlp を使う | | ダウンロード | yt-dlp を使う |
| デプロイ | Dockge で管理する複数コンテナ構成。dockage VM の `/opt/stacks/tver` | | デプロイ | 読み取り専用のGiteaユーザー`tver-deploy`でdockage VMの`/opt/stacks/tver`へcloneし、`docker compose up -d --build`で起動する。Dockgeにも同じスタックが表示される。更新は`git pull --ff-only`後に再ビルドする |
| 配置用のGit認証 | `tver-deploy`を`tver`の読み取り共同作業者とし、repository読み取りトークンをdockageの権限`600`の専用credentialファイルに保存する。トークンの実値はGitや文書へ残さない |
| 開発言語 | **Go**(`web`・`worker` とも)。yt-dlp は外部コマンドとして呼び、進捗・結果は yt-dlp の出力から読み取る | | 開発言語 | **Go**(`web`・`worker` とも)。yt-dlp は外部コマンドとして呼び、進捗・結果は yt-dlp の出力から読み取る |
| DB | **NAS 上の tver 専用 Postgres 17 コンテナ**(Gitea と同じ方式)。バックアップは規約どおり `pg_dump` → NAS。公開ポートは未定(候補 15434、実機で確認する) | | DB | **NAS上のtver専用Postgres 17コンテナ**(Giteaと同じ方式)。ユーザー・DB名は`tver`、公開ポートは`15434`。dockageからの到達を確認済み |
| DB ドライバ | `github.com/jackc/pgx/v5` の `pgxpool` | | DB ドライバ | `github.com/jackc/pgx/v5` の `pgxpool` |
| DB テスト | `github.com/fergusstrange/embedded-postgres` v1.34.0 の Postgres 17 を起動する。Apple Silicon では Rosetta 2 が必要 | | DB テスト | `github.com/fergusstrange/embedded-postgres` v1.34.0 の Postgres 17 を起動する。Apple Silicon では Rosetta 2 が必要 |
| 定期実行 | **`worker` 内のスケジューラ** | | 定期実行 | **`worker` 内のスケジューラ** |
| NAS のマウント | **NFS**。DSM で `192.168.10.155` のみ許可する | | NAS のマウント | **NFS**。dockageの`/etc/fstab`へ`_netdev,nofail`付きで登録し、DSMでは接続元`192.168.10.155`だけを許可する |
| NFS 権限 | 読み書き、Squash「すべてのユーザーをadminに割り当てる」、`sys`、非同期オン。DSMのadminアカウントが無効でもUID `65532`から書き込み確認済み |
| DBの通信制限 | DSMファイアウォールでTCP `15434`を`192.168.10.155`からだけ許可する |
| DBバックアップ | 稼働後に、コンテナ内の`pg_dump`からNASへ保存する日次処理を用意する |
| 保持期間 | **削除は手動のみ**。自動削除はしない | | 保持期間 | **削除は手動のみ**。自動削除はしない |
| 待ち行列 | **Postgres のテーブルだけで実装する**。専用のキュー基盤は持たない | | 待ち行列 | **Postgres のテーブルだけで実装する**。専用のキュー基盤は持たない |
| 定期確認の間隔 | 登録番組の新しい回を **24 時間ごと**に確認する | | 定期確認の間隔 | 登録番組の新しい回を **24 時間ごと**に確認する |
| 定期確認の走査 | worker のスケジューラが起動直後と1時間ごとに、最終確認から24時間以上経過した有効な番組を探す。番組は1件ずつ確認し、間に数秒空ける |
| 定期確認の予告 | **180秒以下**の回は自動登録せず、既知としてだけ記録する。新着画面の予告判定と同じしきい値を使う |
| 定期確認の失敗 | `last_checked_at` を更新せず、失敗日時と秘匿値を伏せた短いメッセージを保存する。次の1時間走査で再試行し、成功時にエラーを消す |
| 登録時の既存回 | **登録の都度**「公開中の回もダウンロードする」かを選ぶ。選ばない場合も公開中の回を既知として記録する |
| 番組の一時停止 | 登録は残し、自動ダウンロードだけ止める。再開後は停止中に公開された未確認の回も対象にする |
| 動画のマウント先 | web と worker は `VIDEO_DIR`(既定 `/videos`)に同じ NAS 共有をマウントする。`download_jobs.file_path` は `VIDEO_DIR` からの相対パスを保存する |
| ファイル名 | `<番組名>/<番組名> <回タイトル> [<エピソードID>].mp4`。禁止文字と制御文字を `_` に置換し、空の要素は `名称未設定`とする。番組名はUTF-8で90バイト、回タイトルは140バイト、サニタイズ後のエピソードIDは64バイトまでとし、ファイル名全体を255バイト以下に収める。64バイトを超えるIDはworkerで`invalid_output`にする。実際のTVer IDは約10文字で、yt-dlpのextractorも英数字として扱うため64バイトを上限とした。既存ファイルは改名せず、新しい保存から適用する |
| 動画ファイルの境界 | webは`VIDEO_DIR`を`os.OpenRoot`で開き、DBの相対`file_path`を`Root.Open`・`Root.Remove`へ渡す。`VIDEO_DIR`外へのパストラバーサルと外向きシンボリックリンクをOSのディレクトリ境界で拒否する |
| 同名ファイル | 移動先に同名ファイルがあれば上書きする。ファイル名にエピソード ID が入り `episode_id` は一意なので、同名は同じ回の前回処理の残りに限られる。再実行で同じ結果になることを優先する |
| 動画の削除 | ファイルを先に削除してからジョブ記録を削除する。ファイルが既に無い場合も記録は削除する。登録番組の `known_episodes` は残す |
| 同時ダウンロード数 | 既定は **1**。`WORKER_CONCURRENCY` で **2まで**増やせる |
| ダウンロードの再試行 | 自動では再試行しない。失敗を `failed` で残し、画面から手動で再試行する |
| 実行中の取り消し | web で `running` を `canceled` にし、worker が検知して yt-dlp のプロセスグループを停止する |
| yt-dlp の作業場所 | `VIDEO_DIR/.incoming/<job id>/` に `%(id)s.%(ext)s` で保存し、検査後に Go が `media.RelativeVideoPath` の場所へ同一ファイルシステム内で移動する。終了状態にかかわらず作業場所を片付ける |
## 要件 ## 要件
@@ -73,30 +91,43 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
| `web` | UI・API。新着一覧の取得、待ち行列への登録、番組登録、動画の配信(再生) | | `web` | UI・API。新着一覧の取得、待ち行列への登録、番組登録、動画の配信(再生) |
| `worker` | Go + yt-dlp + ffmpeg。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する | | `worker` | Go + yt-dlp + ffmpeg。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する |
- 現在の `web` は `/healthz`、ドラマ・バラエティの新着ページ(`/`)、新着一覧の HTML 断片(`/newer`)、待ち行列ページ(`/queue`)と更新用断片(`/queue/list`)を提供する。「登録番組/ライブラリ」のページは空のまま - 現在の `web` は `/healthz`、新着(`/`)、待ち行列(`/queue`)、登録番組(`/subscriptions`)、完了済みライブラリ(`/library`)、再生ページ(`/library/{id}`)、Range 対応の MP4 配信(`/videos/{id}`)を提供する
- 動画配信と削除はクライアントからジョブ ID だけを受け取り、DB の相対 `file_path` を `os.Root` の `Open`・`Remove` で扱う。絶対パス・`..`・`VIDEO_DIR` 外を指すシンボリックリンクをディレクトリ境界で拒否する。削除後は新着から手動で再登録できるが、登録番組の既知記録は残るため自動再登録しない
- 新着の「番組を登録」から、公開中の回をダウンロードするか選ぶ。web はサーバー側の新着キャッシュから番組 ID と名前を引き、TVer の season ごとの公開中エピソードを取得する。取得失敗時は登録しない
- 登録と公開中の回の既知記録・任意の待ち行列投入は1トランザクションで行い、登録時刻を最終確認日時に設定する。`known_episodes` は番組解除時に連動して削除する。一時停止中は投入を拒否する
- worker のスケジューラは起動直後と1時間ごとに、`enabled` かつ最終確認から24時間以上の番組を抽出する。TVer から公開中の回を順次取得し、未確認の180秒超の回だけを `source=subscription` で投入する。180秒以下も既知にし、成功時の最終確認更新・エラー消去まで1トランザクションで行う
- TVer の番組取得に失敗した場合は最終確認日時を変えず、失敗日時と伏字済みの短いメッセージを `subscriptions` に記録して画面に表示する。番組間には3秒空け、SIGTERM では取得中または待機中の確認を中断する
- 新着の登録ボタンはエピソード ID とジャンルだけを送る。web が有効な新着キャッシュから番組情報を引き、`episode_id` の一意制約で重複登録を防ぐ。新着一覧はジョブ状態を一括取得し、失敗・取り消しには再試行を表示する - 新着の登録ボタンはエピソード ID とジャンルだけを送る。web が有効な新着キャッシュから番組情報を引き、`episode_id` の一意制約で重複登録を防ぐ。新着一覧はジョブ状態を一括取得し、失敗・取り消しには再試行を表示する
- 待ち行列は実行中・待機中・失敗・取り消し・完了を状態別に直近50件ずつ表示する。段階別の進捗と失敗理由を表示し、待機中の取り消しと失敗・取り消しの再試行ができる。実行中または待機中のジョブがある間だけ5秒ごとに一覧を更新する - 待ち行列は実行中・待機中・失敗・取り消し・完了を状態別に直近50件ずつ表示する。段階別の進捗と失敗理由を表示し、待機中の取り消しと失敗・取り消しの再試行ができる。実行中または待機中のジョブがある間だけ5秒ごとに一覧を更新する
- 実行中のジョブにも取り消しボタンを表示する。worker は DB の `canceled` を定期確認し、yt-dlp をプロセスグループごと停止する
- 新着はジャンルごとにメモリへ 10 分間キャッシュする。キャッシュ中は TVer セッションの再発行と一覧の再取得を行わない。TVer の取得に失敗した場合、一覧の場所にエラーを表示して詳細をログへ記録する - 新着はジャンルごとにメモリへ 10 分間キャッシュする。キャッシュ中は TVer セッションの再発行と一覧の再取得を行わない。TVer の取得に失敗した場合、一覧の場所にエラーを表示して詳細をログへ記録する
- 「予告を隠す」は既定でオンとし、180 秒以下の件を非表示にする。長さは秒から分へ切り上げて表示し、配信終了日時は Asia/Tokyo で表示する。ジャンルと予告表示の状態は URL に残す - 「予告を隠す」は既定でオンとし、180 秒以下の件を非表示にする。長さは秒から分へ切り上げて表示し、配信終了日時は Asia/Tokyo で表示する。ジャンルと予告表示の状態は URL に残す
- 画面は `html/template` と htmx 2.0.11 を使い、htmx は Go の `embed` から `/static/` でローカル配信する - 画面は `html/template` と htmx 2.0.11 を使い、htmx は Go の `embed` から `/static/` でローカル配信する
- `compose.yaml` は web のみを定義し、dockage VM の LAN アドレス `192.168.10.155:8080` で公開する。待ち受けは `WEB_ADDR` で変更でき、既定は `:8080`。DB 接続先は必須の `DATABASE_URL` を `.env` から渡す(値の無い雛形は `.env.example`) - `compose.yaml` は web と worker を定義する。web は dockage VM の LAN アドレス `192.168.10.155:8080` で公開し、待ち受けは `WEB_ADDR` で変更できる。両方へ `DATABASE_URL` と同じ動画ボリュームを渡し、worker の並行数は `WORKER_CONCURRENCY`(既定1、最大2)で指定する
- NAS用の`deploy/nas/tver-db/compose.yaml`は`postgres:17`を`15434:5432`で公開し、`/volume1/docker/tver-db/pgdata`へ保存する。パスワードは同ディレクトリのGit管理外`.env`から注入する
- 本番配置と更新の手順は[[docs/deploy|tver 本番配置手順]]にまとめる。dockageでは`/volume1/video`を`/mnt/nas-tver`へNFSマウントし、その配下の`tver`だけをコンテナ内の`/videos`へ渡す
- Giteaは匿名閲覧とSSHを無効にしているため、dockageのclone・pullは読み取り専用`tver-deploy`のrepository読み取りトークンを使う。Gitのcredential helperはGiteaのHTTPS URLに限定する
- DB は NAS 上に tver 専用 Postgres 17 コンテナを置く。`internal/store` の埋め込み SQL を `schema_migrations` で管理し、`pg_advisory_lock` で複数プロセスからの同時適用を直列化する。web は起動時に `pgxpool` で接続してマイグレーションを適用する - DB は NAS 上に tver 専用 Postgres 17 コンテナを置く。`internal/store` の埋め込み SQL を `schema_migrations` で管理し、`pg_advisory_lock` で複数プロセスからの同時適用を直列化する。web は起動時に `pgxpool` で接続してマイグレーションを適用する
- `download_jobs` は `episode_id` を一意にして二重登録を防ぎ、ジョブの状態・段階別進捗・結果を持つ。`subscriptions` はシリーズ単位の登録と最終確認日時を持つ。worker からの操作と画面用の一覧取得を `internal/store` にまとめる - `download_jobs` は `episode_id` を一意にして二重登録を防ぎ、ジョブの状態・段階別進捗・結果を持つ。`subscriptions` はシリーズ単位の登録と最終確認日時を持つ。worker からの操作と画面用の一覧取得を `internal/store` にまとめる
- worker は `yt-dlp[default]` と ffmpeg を外部コマンドとして使い、映像・音声・結合・移動の段階別進捗を扱う。正確な全体パーセントは保存しない。`after_move:filepath` で実パスを取り、ffprobe で H.264 と AAC の MP4 を確認して成功とする。失敗は DRM、地域制限、ネットワーク、配信不可、抽出失敗、出力不正、その他の順に分類する - worker は `yt-dlp[default]` と ffmpeg を外部コマンドとして使い、映像・音声・結合・移動の段階別進捗を1秒間隔に間引いて保存する。独立した heartbeat を5秒ごとに更新し、正確な全体パーセントは保存しない。`after_move:filepath` で実パスを取り、ffprobe で H.264 と AAC の MP4 を確認して成功とする。失敗は取り消し、DRM、地域制限、ネットワーク、配信不可、抽出失敗、出力不正、その他の順に扱い、自動再試行しない
- 保存先の NAS 共有フォルダは dockage VM に NFS でマウントし、`worker`(書き込み)と `web`(読み取り・削除)にバインドマウントする - worker は起動時にマイグレーションと停止中ジョブの `RequeueStale` を行い、登録番組の確認も直ちに1回行う。SIGTERM では yt-dlp と確認処理を止め、担当中ジョブを `queued` に戻してから終了する。`Dockerfile.worker` は yt-dlp 2026.08.19 を ARG で固定し、ffmpeg・ffprobe とともに非 root で実行する
- 保存先の NAS 共有フォルダは dockage VM に NFS でマウントし、`worker`(書き込み)と `web`(読み取り・削除)に同じ `VIDEO_DIR` でバインドマウントする
## 外部とのつながり ## 外部とのつながり
| 相手 | 用途 | 備考 | | 相手 | 用途 | 備考 |
|---|---|---| |---|---|---|
| TVer `platform-api.tver.jp` | 新着一覧(`/service/api/v1/callNewerDetail/{drama\|variety}`)・番組のエピソード一覧 | **非公開 API**。セッションは `/v2/api/platform_users/browser/create` で発行する(yt-dlp と同じ方式) | | TVer `platform-api.tver.jp` | 新着一覧(`/service/api/v1/callNewerDetail/{drama\|variety}`)・番組のエピソード一覧 | **非公開 API**。セッションは `/v2/api/platform_users/browser/create` で発行する(yt-dlp と同じ方式) |
| TVer `service-api.tver.jp` | 番組の season 一覧(`/api/v1/callSeriesSeasons/{series_id}`) | **非公開 API**。[yt-dlp の TVer extractor](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/tver.py) と同じ取得方式 |
| yt-dlp | ダウンロード | TVer extractor を使う | | yt-dlp | ダウンロード | TVer extractor を使う |
| NAS ds923p(NFS) | 動画の保存先 | DSM で NFS を有効化し、共有フォルダを `192.168.10.155` のみに許可する | | Gitea `gitea.kazu634.com` | 配置元 | 匿名cloneとSSHは使えない。`tver-deploy`のHTTPSトークンでclone・pullする |
| NAS ds923p(Postgres 17) | DB | tver 専用コンテナを使う。DSM 内蔵の Postgres が `5432` を使うため別ポートで公開し、DSM ファイアウォールで `192.168.10.155` のみ許可する。公開ポートは実機で決める | | NAS ds923p(NFS) | 動画の保存先 | `/volume1/video`をdockageの`/mnt/nas-tver`へNFSv3でマウントし、アプリは`tver`サブフォルダを使う。DSMのNFS権限は`192.168.10.155`のみ |
| NAS ds923p(Postgres 17) | DB | tver専用コンテナを`15434:5432`で公開し、DSMファイアウォールで`192.168.10.155`のみ許可する。dockageから接続確認済み |
## 確認済みの事実 ## 確認済みの事実
- 新着 API は `drama` / `variety` それぞれ 100 件を返す。予告動画も含まれる。各件に `id`・`seriesID`・`seriesTitle`・`title`・`broadcasterName`・`duration`・`endAt`・`thumbnailPath` がある [実機] 2026-10-03 確認 - 新着 API は `drama` / `variety` それぞれ 100 件を返す。予告動画も含まれる。各件に `id`・`seriesID`・`seriesTitle`・`title`・`broadcasterName`・`duration`・`endAt`・`thumbnailPath` がある [実機] 2026-10-03 確認
- 番組 `sr0lhdrtdb` の `callSeriesSeasons` は `result.contents` に `type=season` の1件(`ss3i6njoq3`)を返し、`callSeasonEpisodes` は `type=episode` の5件を返した。エピソードには新着と同じ `id`・`seriesID`・`seriesTitle`・`title`・`broadcasterName`・`duration`・`endAt`・`thumbnailPath` が含まれる。他の番組での season 数や一覧件数は未確認 [実機] 2026-10-03 確認
- dockage VM から TVer API に到達でき(HTTP 200)、送信元は日本と判定される [実機] 2026-10-03 確認 - dockage VM から TVer API に到達でき(HTTP 200)、送信元は日本と判定される [実機] 2026-10-03 確認
- yt-dlp 2026.08.19 で TVer のエピソードを抽出できる(`--simulate`)。映像(H.264 / `avc1`)と音声が別ストリームで、結合に ffmpeg が要る。1080p・約 50 分で約 1.2GiB [実機] 2026-10-03 確認 - yt-dlp 2026.08.19 で TVer のエピソードを抽出できる(`--simulate`)。映像(H.264 / `avc1`)と音声が別ストリームで、結合に ffmpeg が要る。1080p・約 50 分で約 1.2GiB [実機] 2026-10-03 確認
- 5 分の `epq432nee4` を yt-dlp 2026.08.19 で取得した。`--newline` と `--progress-template` の 1 行 1 JSON は Go の外部プロセスから逐次読める。ただし `uvx yt-dlp` のみで `pycryptodomex` が無いと AES-128 HLS を ffmpeg に委譲し、途中のダウンロード進捗が出なかった。`yt-dlp[default]` に含まれる `pycryptodomex` を加えた試行では途中の進捗が出た [実機] 2026-10-03 確認 - 5 分の `epq432nee4` を yt-dlp 2026.08.19 で取得した。`--newline` と `--progress-template` の 1 行 1 JSON は Go の外部プロセスから逐次読める。ただし `uvx yt-dlp` のみで `pycryptodomex` が無いと AES-128 HLS を ffmpeg に委譲し、途中のダウンロード進捗が出なかった。`yt-dlp[default]` に含まれる `pycryptodomex` を加えた試行では途中の進捗が出た [実機] 2026-10-03 確認
@@ -104,22 +135,32 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
- SIGTERM で止めると `.part` と `.ytdl` が残り、再実行で 437,066 バイトから 438,090 バイトへ続きから取得できた [実機] 2026-10-03 確認 - SIGTERM で止めると `.part` と `.ytdl` が残り、再実行で 437,066 バイトから 438,090 バイトへ続きから取得できた [実機] 2026-10-03 確認
- 到達不能のプロキシ経由の通信失敗と存在しないエピソードは、ともに yt-dlp の終了コード 1 だった。stderr の文言で分類が必要。DRM・地域制限の失敗文言と終了コードは実測していない(yt-dlp の extractor ソースのみ確認) [実機] 2026-10-03 確認 - 到達不能のプロキシ経由の通信失敗と存在しないエピソードは、ともに yt-dlp の終了コード 1 だった。stderr の文言で分類が必要。DRM・地域制限の失敗文言と終了コードは実測していない(yt-dlp の extractor ソースのみ確認) [実機] 2026-10-03 確認
- yt-dlp の TVer extractor は日本のみ(`_GEO_COUNTRIES = ['JP']`)。DRM 付きの作品は取得できない [公式] https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/tver.py (取得日: 2026-10-03) - yt-dlp の TVer extractor は日本のみ(`_GEO_COUNTRIES = ['JP']`)。DRM 付きの作品は取得できない [公式] https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/tver.py (取得日: 2026-10-03)
- dockage VM に NAS のマウントは無い。ルートの空きは約 20GB [実機] 2026-10-03 確認 - DSMのNFS権限を接続元`192.168.10.155`、読み書き、全ユーザーをadminへ割り当て、`sys`、非同期オンにすると、dockageからNFSv3で`/volume1/video`をマウントできた。設定直後の1回目は`access denied`、直後の2回目は成功したため、権限の反映待ちと考えられる [実機] 2026-10-04 確認
- DSMのadminアカウントが無効でも、UID `65532`のコンテナから`tver`サブフォルダの作成・書き込み・読み込み・rename・削除に成功した。NAS上の所有者はUID `1024`・GID `100`。`/mnt/nas-tver/tver`は作成済み [実機] 2026-10-04 確認
- NFSマウントのファイル名上限は`namlen=255` [実機] 2026-10-04 確認
- Giteaは`REQUIRE_SIGNIN_VIEW = true`・`DISABLE_SSH = true`で、匿名HTTPSの`info/refs`はHTTP `401`だった。読み取り専用ユーザー`tver-deploy`とrepository読み取りトークンで`/opt/stacks/tver`へのcloneに成功した [実機] 2026-10-04 確認
- dockageで`docker compose up -d --build`を実行してweb・workerを起動でき、Dockgeにも`tver`スタックとして表示された。DSMファイアウォール設定後、dockageからNASのTCP `15434`へ到達できた [実機] 2026-10-04 確認
- 本番の`/healthz`はHTTP `200`、新着バラエティは98件だった。4分の本編`ep5hcgopp2`は登録から20秒以内に完了し、92,340,468バイトの1080p H.264/AAC MP4をNASへ保存した。所有者はUID `1024`・GID `100`、`.incoming`は空で、`/library`とHTTP Range `206`も確認した [実機] 2026-10-04 確認
- NASは番組フォルダ内に所有者UID `0`の`@eaDir`を作る。ライブラリ一覧はDBから作るため、アプリへの影響はない [実機] 2026-10-04 確認
- `callNewerDetail/drama`は毎回100件を返し、応答に続きや総件数を示す項目はなかった。`result.contents`は`type=newer`で、`content`は`id`・`version`・`title`のみ。`page=2`・`offset=100`・`limit=200`を付けても先頭・末尾IDが同じ100件となり、パラメータは無視された。100件を超えて取得する手段は見つからなかったが、非公開APIのため不可能とは断定しない。新着一覧は直近100件までとなる一方、登録番組の自動確認は番組ごとの`callSeriesSeasons`→`callSeasonEpisodes`を使うため、新着から外れた回も取りこぼさない [実機] 2026-10-04 確認
## 課題 ## 課題
- 新着 API が 100 件で打ち切りか、続きを取得できるか [要確認] - **DBバックアップは未着手。** コンテナ内の`pg_dump`→gzip→NAS `/volume1/backups/tver/`、既定14日保持の日次処理を用意する
- 新着のうち DRM 付きの作品の割合 [要確認] - DRM・地域制限の実例探しはTVerへの大量アクセスになるため行わず、運用中に観測する。workerは失敗時の`error_message`をDBへ保存し、既知の文言に一致しない失敗は`unknown`にする。実例が出た時点で分類を見直す
- 非公開 API と yt-dlp の TVer 対応は、TVer 側の変更で予告なく壊れうる。壊れたことを検知する手段が要る
- NAS の準備(NFS 共有フォルダ、tver 専用 Postgres 17 コンテナ、DSM ファイアウォール)は DSM での手作業になる。DB の公開ポート(候補 15434)は本番前に実機で確認する [要確認]
- DRM・地域制限の yt-dlp 失敗時の文言と終了コードは未実測 [要確認]
## 確かめ方 ## 確かめ方
- Codex では `go test -count=1 ./...` を環境変数の前置なしで実行する。`go test` を許可する rules に一致させ、Postgres の共有メモリ作成に必要な権限で実行するため。初回のモジュール取得にはネットワーク接続が必要 - Codex では `go test -count=1 ./...` を環境変数の前置なしで実行する。`go test` を許可する rules に一致させ、Postgres の共有メモリ作成に必要な権限で実行するため。初回のモジュール取得にはネットワーク接続が必要
- web を起動するには `.env.example` を参考に、Git 管理外の `.env` に実際の `DATABASE_URL` を設定する。未設定なら起動時にエラー終了する。DB 接続がある環境では起動時にマイグレーションが適用され、`/queue` で待ち行列を確認できる - web を起動するには `.env.example` を参考に、Git 管理外の `.env` に実際の `DATABASE_URL` と `VIDEO_HOST_DIR` を設定する。`VIDEO_DIR` の既定は `/videos`。DB 接続がある環境では起動時にマイグレーションが適用される
- DB テストは `embedded-postgres` v1.34.0 が Postgres 17 を一度起動し、テストごとに独立した DB を作る。バイナリキャッシュ、実行ファイル、データは `/private/tmp` 配下に置く。Apple Silicon での実行には Rosetta 2 の導入が必要 - 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 にアクセスしない
- 新着画面のテストは偽物の TVer クライアント・store と差し替え可能な時計を使い、表示項目、絞り込みの境界、10 分キャッシュ、取得失敗、JST 表示、登録・状態表示・待ち行列の操作を確認する - 新着画面のテストは偽物の TVer クライアント・store と差し替え可能な時計を使い、表示項目、絞り込みの境界、10 分キャッシュ、取得失敗、JST 表示、登録・状態表示・待ち行列の操作を確認する
- ライブラリのテストは一時ディレクトリの小さな動画を使い、Range の 206・範囲外の 416、`os.Root`による相対パスとシンボリックリンクの拒否、404、ファイル削除後の記録削除を確認する。共有のファイル名規則は`internal/media`で90・140・255バイトの境界とUTF-8の完全性をテストする
- worker のテストは `internal/worker/testdata` の偽 yt-dlp・ffprobe を使い、本物の TVer へ接続せずに正常完了、失敗分類、取消、終了時の待機復帰、進捗の間引き、heartbeat、出力検査、移動先、作業場所の片付けを確認する
- 登録番組の定期確認は偽の TVer クライアントと差し替え可能な時計・タイマーを使い、24時間の境界、順次確認、1時間走査、失敗記録、予告の既知化、終了時の中断を確認する。store の抽出・既知化・投入・日時更新・エラー消去は embedded Postgres 17 で確認する
- NAS用Composeは`deploy/nas/tver-db/compose.yaml`をYAMLパーサーで読み、`services.postgres`、環境変数、ポート、保存先、healthcheckを確認する。Dockerが使える環境では`docker compose --env-file deploy/nas/tver-db/.env.example -f deploy/nas/tver-db/compose.yaml config`でも展開結果を確認する
- 本番の確認手順は[[docs/deploy|tver 本番配置手順]]に従い、NFS上の`/mnt/nas-tver/tver`をUID `65532`で読み書き・rename・削除できること、DBがhealthyであること、webの`/healthz`、実動画の保存・再生・シークを確認する
- `env GOPATH=/private/tmp/tver-gopath GOCACHE=/private/tmp/tver-go-cache GOMODCACHE=/private/tmp/tver-go-modcache GOPROXY=off go vet ./...` で静的解析する - `env GOPATH=/private/tmp/tver-gopath GOCACHE=/private/tmp/tver-go-cache GOMODCACHE=/private/tmp/tver-go-modcache GOPROXY=off go vet ./...` で静的解析する
- `DATABASE_URL` と `WEB_ADDR=127.0.0.1:18080` を設定して `go run ./cmd/web` で起動し、`/healthz`、`/`、`/newer?genre=variety`、`/queue` を確認できる。TVer への実アクセスを伴うため手動確認は少数回に留める - `DATABASE_URL`、`VIDEO_DIR`、`WEB_ADDR=127.0.0.1:18080` を設定して `go run ./cmd/web` で web を起動できる。worker は同じ `DATABASE_URL` と `VIDEO_DIR` を設定して `go run ./cmd/worker` で起動する。worker の実行は TVer への実アクセスを伴うため手動確認は少数回に留める
+5 -1
View File
@@ -31,9 +31,13 @@ func main() {
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
videoDir := os.Getenv("VIDEO_DIR")
if videoDir == "" {
videoDir = "/videos"
}
server := &http.Server{ server := &http.Server{
Addr: address, Addr: address,
Handler: web.NewHandler(client, store.New(pool), time.Now, log.Default()), Handler: web.NewHandler(client, store.New(pool), videoDir, time.Now, log.Default()),
ReadHeaderTimeout: 5 * time.Second, ReadHeaderTimeout: 5 * time.Second,
} }
log.Printf("web listening on %s", address) log.Printf("web listening on %s", address)
+99
View File
@@ -0,0 +1,99 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"os"
"os/signal"
"strconv"
"strings"
"syscall"
"time"
"gitea.kazu634.com/kazu634/tver/internal/store"
"gitea.kazu634.com/kazu634/tver/internal/tver"
workerpkg "gitea.kazu634.com/kazu634/tver/internal/worker"
"github.com/jackc/pgx/v5/pgxpool"
)
const staleAfter = 30 * time.Second
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
config, err := loadConfig(os.Getenv)
if err != nil {
log.Fatal(err)
}
pool, err := initializeDatabase(ctx, os.Getenv("DATABASE_URL"), time.Now(), pgxpool.New,
store.Migrate, func(ctx context.Context, pool *pgxpool.Pool, cutoff time.Time) (int64, error) {
return store.New(pool).RequeueStale(ctx, cutoff)
})
if err != nil {
log.Fatal(err)
}
defer pool.Close()
jobStore := store.New(pool)
runner, err := workerpkg.New(jobStore, config, log.Default())
if err != nil {
log.Fatal(err)
}
tverClient, err := tver.NewClient("", nil)
if err != nil {
log.Fatal(err)
}
scheduler, err := workerpkg.NewSubscriptionScheduler(jobStore, tverClient,
workerpkg.SubscriptionSchedulerConfig{PreviewMaxSeconds: tver.PreviewMaxSeconds}, log.Default())
if err != nil {
log.Fatal(err)
}
schedulerDone := make(chan struct{})
go func() {
defer close(schedulerDone)
scheduler.Run(ctx)
}()
log.Printf("worker started (concurrency=%d)", config.Concurrency)
if err := runner.Run(ctx); err != nil {
log.Fatal(err)
}
<-schedulerDone
log.Print("worker stopped")
}
func loadConfig(getenv func(string) string) (workerpkg.Config, error) {
concurrency := 1
if value := strings.TrimSpace(getenv("WORKER_CONCURRENCY")); value != "" {
parsed, err := strconv.Atoi(value)
if err != nil || parsed < 1 || parsed > 2 {
return workerpkg.Config{}, errors.New("WORKER_CONCURRENCY は 1 または 2 を指定してください")
}
concurrency = parsed
}
return workerpkg.Config{VideoDir: getenv("VIDEO_DIR"), YTDLPPath: getenv("YT_DLP_PATH"),
FFprobePath: getenv("FFPROBE_PATH"), Concurrency: concurrency}, nil
}
func initializeDatabase(ctx context.Context, databaseURL string, now time.Time,
connect func(context.Context, string) (*pgxpool.Pool, error),
migrate func(context.Context, *pgxpool.Pool) error,
requeue func(context.Context, *pgxpool.Pool, time.Time) (int64, error),
) (*pgxpool.Pool, error) {
if strings.TrimSpace(databaseURL) == "" {
return nil, errors.New("DATABASE_URL が設定されていません")
}
pool, err := connect(ctx, databaseURL)
if err != nil {
return nil, errors.New("DATABASE_URL から接続プールを作成できません")
}
if err := migrate(ctx, pool); err != nil {
pool.Close()
return nil, fmt.Errorf("DB マイグレーションに失敗: %w", err)
}
if _, err := requeue(ctx, pool, now.Add(-staleAfter)); err != nil {
pool.Close()
return nil, fmt.Errorf("停止中ジョブの復帰に失敗: %w", err)
}
return pool, nil
}
+97
View File
@@ -0,0 +1,97 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestLoadConfigConcurrency(t *testing.T) {
values := map[string]string{"VIDEO_DIR": "/media", "YT_DLP_PATH": "/bin/downloader", "FFPROBE_PATH": "/bin/probe"}
getenv := func(key string) string { return values[key] }
config, err := loadConfig(getenv)
if err != nil || config.Concurrency != 1 || config.VideoDir != "/media" || config.YTDLPPath != "/bin/downloader" || config.FFprobePath != "/bin/probe" {
t.Fatalf("既定設定: %+v %v", config, err)
}
values["WORKER_CONCURRENCY"] = "2"
config, err = loadConfig(getenv)
if err != nil || config.Concurrency != 2 {
t.Fatalf("並行数2: %+v %v", config, err)
}
for _, invalid := range []string{"0", "3", "bad"} {
values["WORKER_CONCURRENCY"] = invalid
if _, err := loadConfig(getenv); err == nil {
t.Errorf("不正な並行数 %q を許した", invalid)
}
}
}
func TestInitializeDatabaseRequiresURLMigratesAndRequeues(t *testing.T) {
ctx := context.Background()
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
connectCalls, migrateCalls, requeueCalls := 0, 0, 0
connect := func(_ context.Context, url string) (*pgxpool.Pool, error) {
connectCalls++
if url != "postgres://test:secret@localhost/test" {
t.Errorf("URL=%q", url)
}
return pgxpool.New(ctx, url)
}
migrate := func(context.Context, *pgxpool.Pool) error { migrateCalls++; return nil }
requeue := func(_ context.Context, _ *pgxpool.Pool, cutoff time.Time) (int64, error) {
requeueCalls++
if want := now.Add(-staleAfter); !cutoff.Equal(want) {
t.Errorf("cutoff=%v want=%v", cutoff, want)
}
return 2, nil
}
if _, err := initializeDatabase(ctx, " ", now, connect, migrate, requeue); err == nil || !strings.Contains(err.Error(), "DATABASE_URL") {
t.Fatalf("未設定 URL: %v", err)
}
if connectCalls != 0 || migrateCalls != 0 || requeueCalls != 0 {
t.Fatalf("未設定時の呼び出し: %d %d %d", connectCalls, migrateCalls, requeueCalls)
}
pool, err := initializeDatabase(ctx, "postgres://test:secret@localhost/test", now, connect, migrate, requeue)
if err != nil || pool == nil {
t.Fatalf("初期化: %v", err)
}
pool.Close()
if connectCalls != 1 || migrateCalls != 1 || requeueCalls != 1 {
t.Fatalf("呼び出し回数: %d %d %d", connectCalls, migrateCalls, requeueCalls)
}
}
func TestInitializeDatabaseStopsOnMigrationOrRequeueFailure(t *testing.T) {
ctx := context.Background()
connect := func(context.Context, string) (*pgxpool.Pool, error) {
return pgxpool.New(ctx, "postgres://localhost/test")
}
requeueCalled := false
pool, err := initializeDatabase(ctx, "postgres://localhost/test", time.Now(), connect,
func(context.Context, *pgxpool.Pool) error { return errors.New("migration failed") },
func(context.Context, *pgxpool.Pool, time.Time) (int64, error) { requeueCalled = true; return 0, nil })
if pool != nil || err == nil || !strings.Contains(err.Error(), "migration failed") || requeueCalled {
t.Fatalf("migration failure: pool=%v err=%v requeue=%v", pool, err, requeueCalled)
}
pool, err = initializeDatabase(ctx, "postgres://localhost/test", time.Now(), connect,
func(context.Context, *pgxpool.Pool) error { return nil },
func(context.Context, *pgxpool.Pool, time.Time) (int64, error) { return 0, errors.New("requeue failed") })
if pool != nil || err == nil || !strings.Contains(err.Error(), "requeue failed") {
t.Fatalf("requeue failure: pool=%v err=%v", pool, err)
}
}
func TestInitializeDatabaseDoesNotLeakURL(t *testing.T) {
_, err := initializeDatabase(context.Background(), "postgres://user:secret@localhost/test", time.Now(),
func(context.Context, string) (*pgxpool.Pool, error) {
return nil, errors.New("postgres://user:secret@localhost/test refused")
}, func(context.Context, *pgxpool.Pool) error { return nil },
func(context.Context, *pgxpool.Pool, time.Time) (int64, error) { return 0, nil })
if err == nil || strings.Contains(err.Error(), "secret") {
t.Fatalf("URL 漏えい: %v", err)
}
}
+15
View File
@@ -5,6 +5,21 @@ services:
environment: environment:
TZ: Asia/Tokyo TZ: Asia/Tokyo
WEB_ADDR: ":8080" WEB_ADDR: ":8080"
VIDEO_DIR: "${VIDEO_DIR:-/videos}"
volumes:
- "${VIDEO_HOST_DIR:?VIDEO_HOST_DIR must be set}:${VIDEO_DIR:-/videos}"
ports: ports:
- "192.168.10.155:8080:8080" - "192.168.10.155:8080:8080"
restart: unless-stopped restart: unless-stopped
worker:
build:
context: .
dockerfile: Dockerfile.worker
env_file: .env
environment:
TZ: Asia/Tokyo
VIDEO_DIR: "${VIDEO_DIR:-/videos}"
WORKER_CONCURRENCY: "${WORKER_CONCURRENCY:-1}"
volumes:
- "${VIDEO_HOST_DIR:?VIDEO_HOST_DIR must be set}:${VIDEO_DIR:-/videos}"
restart: unless-stopped
+1
View File
@@ -0,0 +1 @@
POSTGRES_PASSWORD=
+36
View File
@@ -0,0 +1,36 @@
# tver 用 PostgreSQL スタック(Synology ds923p / 192.168.10.201)
#
# 配置先: ds923p の Container Manager「プロジェクト」
# /volume1/docker/tver-db/ に本ファイルを compose.yaml として配置する。
# パスワードは同じディレクトリの .env(POSTGRES_PASSWORD)から注入し、
# このファイルには実値を書かない。
#
# 注意(初回起動チェックポイント):
# 起動前に /volume1/docker/tver-db/pgdata を作成する。
# 初回起動後は Container Manager のヘルス状態とログを確認し、
# "database system is ready to accept connections" が出てからアプリを起動する。
# bind mount の所有権・権限で初期化に失敗した場合は、原因を確認してから
# 名前付きボリュームへの切り替えを検討する。
services:
postgres:
# Debian ベースの PostgreSQL 17。initdb の UTF-8 ロケールを利用する。
image: postgres:17
container_name: tver-postgres
restart: always
environment:
POSTGRES_USER: tver
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_DB: tver
# 初回の initdb でのみ有効。既存の pgdata には後から反映されない。
POSTGRES_INITDB_ARGS: "--encoding=UTF8 --lc-collate=en_US.UTF-8 --lc-ctype=en_US.UTF-8"
ports:
# [要確認] Container Manager で 15434 が未使用であることを起動前に確認する。
# DSM ファイアウォールでは dockage(192.168.10.155)だけを許可する。
- "15434:5432"
volumes:
- /volume1/docker/tver-db/pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tver -d tver"]
interval: 10s
retries: 5
+238
View File
@@ -0,0 +1,238 @@
---
title: tver 本番配置手順
tags:
- homelab
- tver
- deployment
---
# tver 本番配置手順
> [!info] 対象
> Synology NAS `ds923p`(`192.168.10.201`)に PostgreSQL 17 と動画共有を用意し、dockage(`192.168.10.155`)の `/opt/stacks/tver` で web・worker を起動する。
> [!danger] 秘匿値を記録しない
> PostgreSQL のパスワードや完成した `DATABASE_URL` は、この文書や Git 管理下のファイルに書かない。NAS と dockage の `.env` にだけ保存し、権限を `600` にする。
## 事前に確認する値
| 項目 | 値 |
|---|---|
| NAS | `ds923p` / `192.168.10.201` |
| dockage | `192.168.10.155` |
| NAS のNFS共有 | `/volume1/video` |
| dockage のNFSマウント | `/mnt/nas-tver` |
| tverの動画保存先 | NAS: `/volume1/video/tver`、dockage: `/mnt/nas-tver/tver` |
| PostgreSQL 公開ポート | `15434` |
| PostgreSQL ユーザー / DB | `tver` / `tver` |
| アプリ配置先 | `/opt/stacks/tver` |
| Gitea配置用ユーザー | `tver-deploy`(リポジトリ読み取りのみ) |
## 1. NASを準備する
### 1.1 NFSサービスを有効にする
1. DSMの「コントロール パネル」→「ファイル サービス」→「NFS」でNFSサービスを有効にする。
2. 共有フォルダ `video` が `/volume1/video` にあることを確認する。
3. `video` の「編集」→「NFS 権限」で次を追加する。
| 設定 | 値 |
|---|---|
| ホスト名またはIP | `192.168.10.155` |
| 権限 | 読み書き |
| Squash | すべてのユーザーをadminに割り当てる |
| セキュリティ | `sys` |
| 非同期を有効にする | オン |
> [!success] adminへの割り当てを確認済み
> DSMの`admin`アカウントが無効な状態でも、上記設定でUID `65532`からフォルダ作成・書き込み・読み込み・rename・削除に成功した。NAS上の所有者はUID `1024`・GID `100`だった。[実機] 2026-10-04 確認
### 1.2 PostgreSQLのファイルを用意する
1. NAS上に次のディレクトリを事前作成する。
```text
/volume1/docker/tver-db/
/volume1/docker/tver-db/pgdata/
```
2. [[../deploy/nas/tver-db/compose.yaml|NAS用compose.yaml]] と `.env.example` を `/volume1/docker/tver-db/` に配置する。
3. `.env.example`を`.env`へコピーし、`POSTGRES_PASSWORD`に十分長いランダムな値を設定する。値には、URLへの埋め込みで追加のエスケープが不要な16進文字列を使える。
```bash
cd /volume1/docker/tver-db
umask 077
cp .env.example .env
# openssl rand -hex 64 で値を生成し、.env の POSTGRES_PASSWORD に設定する。
chmod 600 .env
```
> [!important] DBとアプリで同じパスワードを使う
> ここで設定した値を、後述するdockage側の`DATABASE_URL`にも使う。値そのものは文書やGitへ残さない。
### 1.3 Container ManagerでPostgreSQLを起動する
1. Container Managerの「プロジェクト」で、`/volume1/docker/tver-db/compose.yaml`を使う`tver-db`プロジェクトを作成する。
2. 起動前に、他のプロジェクトとポート一覧を確認し、`15434`が空いていることを確認する。
3. プロジェクトをビルド・起動する。
4. `tver-postgres`が`healthy`になることを確認する。
5. コンテナログに`database system is ready to accept connections`があることを確認する。
初期化に失敗した場合は、先に`pgdata`の所有権・権限とログを確認する。空のDBを作り直す場合を除き、既存の`pgdata`を削除しない。
### 1.4 DSMファイアウォールを設定する
TCP `15434`について、送信元`192.168.10.155`だけを許可するルールを追加する。他の送信元を許可する広いルールより前に配置し、LAN内の別ホストからDBへ接続できない構成にする。
DSMファイアウォールを設定した状態で、dockageからNASのTCP `15434`へ到達できることを確認済みである。[実機] 2026-10-04 確認
## 2. dockageを準備する
### 2.1 NFSクライアントとマウント先を用意する
```bash
sudo apt update
sudo apt install -y nfs-common
sudo mkdir -p /mnt/nas-tver
```
`/etc/fstab`へ次の1行を追加する。`_netdev`でネットワーク後に扱い、`nofail`でNAS停止時もdockageの起動を妨げない。
```fstab
192.168.10.201:/volume1/video /mnt/nas-tver nfs defaults,_netdev,nofail 0 0
```
設定を反映し、想定したNFS共有がマウントされたことを確認する。
```bash
sudo mount -a
findmnt --target /mnt/nas-tver
```
実機ではNFSv3でマウントできた。NFS権限を追加した直後の1回目は`access denied`となり、直後の2回目で成功した。NFS権限の反映待ちと考えられるため、設定内容が正しい場合は少し待って`sudo mount -a`を再実行する。[実機] 2026-10-04 確認
### 2.2 workerのUIDで書き込みを確認する
既存の`anime`・`drama`・`misc`・`personal`・`variety`とアプリのファイルを分け、admin相当の書き込み範囲を限定するため、tver専用サブフォルダを使う。Dockerグループの`kazu634`で、workerコンテナと同じUID・GID `65532:65532`の確認用コンテナを実行し、フォルダ作成、書き込み、読み取り、rename、削除を確認する。`sudo`は不要である。
```bash
docker run --rm --user 65532:65532 -v /mnt/nas-tver/tver:/videos busybox:stable sh -c 'mkdir /videos/.tver-write-test && echo ok > /videos/.tver-write-test/f && cat /videos/.tver-write-test/f && mv /videos/.tver-write-test/f /videos/.tver-write-test/g && ls -ln /videos/.tver-write-test && rm -r /videos/.tver-write-test && echo cleanup-ok'
```
期待する結果は、内容の`ok`、`ls -ln`で表示される`g`の所有者`1024 100`、最後の`cleanup-ok`である。確認後は確認用イメージを削除する。
```bash
docker rmi busybox:stable
```
`/mnt/nas-tver/tver`は作成済みで、上記のDockerコンテナによる操作は実機確認済みである。[実機] 2026-10-04 確認。別環境へ作り直す場合も、書き込み確認が成功するまでworkerを起動しない。
### 2.3 Giteaに配置用ユーザーを用意する
Giteaは`REQUIRE_SIGNIN_VIEW = true`かつ`DISABLE_SSH = true`で動作している。匿名HTTPSの`info/refs`は`401`となり、SSHも使えないため、配置専用の読み取り資格情報を使う。[実機] 2026-10-04 確認
1. Giteaの管理画面で、配置専用ユーザー`tver-deploy`を作成する。
2. `kazu634/tver`リポジトリの「設定」→「共同作業者」で`tver-deploy`を追加し、権限を「読み取り」にする。
3. `tver-deploy`でGiteaへログインし、ユーザー設定の「アプリケーション」からrepository読み取り権限だけを持つトークンを作成する。
4. トークンは作成時に安全な場所へ控え、この文書、シェル履歴、Git管理下のファイルへ実値を書かない。
### 2.4 dockageにclone用トークンを保存する
Dockerホストの管理ユーザー`kazu634`で次を実行する。トークンは非表示で読み取り、権限`600`の専用credentialファイルへ保存する。credential helperはGiteaのHTTPS URLにだけ設定する。
```bash
install -d -m 700 ~/.config/git
read -rs -p 'Gitea token: ' TOKEN; echo
umask 077
printf 'https://tver-deploy:%s@gitea.kazu634.com\n' "$TOKEN" > ~/.config/git/credentials-tver
unset TOKEN
git config --global credential.https://gitea.kazu634.com.helper 'store --file ~/.config/git/credentials-tver'
git clone https://tver-deploy@gitea.kazu634.com/kazu634/tver.git /opt/stacks/tver
cd /opt/stacks/tver
```
この手順で認証付きcloneに成功した。[実機] 2026-10-04 確認。トークンを失効・更新した場合は、同じ手順で`credentials-tver`を作り直す。
### 2.5 アプリの環境変数を用意する
`.env.example`をGit管理外の`.env`へコピーし、`DATABASE_URL`へパスワード用の伏字を入れる。ファイルを`600`にしてから、パスワードを非表示で読み取り、伏字だけを置換する。
```bash
cd /opt/stacks/tver
umask 077
cp .env.example .env
sed -i 's|^DATABASE_URL=.*|DATABASE_URL=postgres://tver:__SET_PASSWORD__@192.168.10.201:15434/tver?sslmode=disable|' .env
chmod 600 .env
read -rs -p 'PostgreSQL password: ' DB_PASSWORD; echo
sed -i "s|__SET_PASSWORD__|${DB_PASSWORD}|" .env
unset DB_PASSWORD
if grep -q '__SET_PASSWORD__' .env; then echo 'DATABASE_URLのパスワードが未設定です' >&2; exit 1; fi
```
完成した`.env`は次の項目を持つ。`DATABASE_URL`の実値は表示したり、文書へ転記したりしない。
```dotenv
DATABASE_URL=<設定済み・表示しない>
VIDEO_HOST_DIR=/mnt/nas-tver/tver
VIDEO_DIR=/videos
WORKER_CONCURRENCY=1
```
> [!warning] パスワードの文字種
> この手順で使うPostgreSQLパスワードには、URLや`sed`の区切りに影響する`@`・`:`・`/`・`?`・`#`・`%`・`|`を含めない。NAS準備時に16進文字列で生成すれば、この制約を満たせる。
### 2.6 起動する
`/opt/stacks/tver`でwebとworkerをビルドして起動する。
```bash
cd /opt/stacks/tver
docker compose up -d --build
```
この方法で起動すると、Dockgeの画面にも`tver`スタックとして表示された。[実機] 2026-10-04 確認。Dockge上でweb・workerがともにrunningになり、再起動を繰り返していないことを確認する。
コマンドでも状態とログを確認できる。
```bash
cd /opt/stacks/tver
docker compose ps
docker compose logs --tail=100 web worker
```
## 3. 動作を確認する
1. ヘルスチェックが`200`と`ok`を返すことを確認する。
```bash
curl --fail --show-error http://192.168.10.155:8080/healthz
```
2. ブラウザで`http://192.168.10.155:8080/`を開き、新着一覧が表示されることを確認する。
3. 番組またはエピソードを1件登録し、`/queue`で待ち行列、進捗、完了を確認する。
4. `/library`から動画を再生し、シークできることを確認する。
5. NASの`/volume1/video/tver`に規則どおりのMP4が作成されたことを確認する。
6. `tver-postgres`のログとweb・workerのログに、接続失敗やマイグレーション失敗がないことを確認する。
> [!success] 本番の動作確認結果
> `/healthz`はHTTP `200`、新着バラエティは98件を表示した。4分の本編`ep5hcgopp2`を登録すると20秒以内に完了し、NASの`video/tver/<番組名>/`へ92,340,468バイトの1080p H.264/AAC MP4が保存された。所有者はUID `1024`・GID `100`、`.incoming`は空だった。`/library`の表示・再生とHTTP Range `206`も確認した。[実機] 2026-10-04 確認
> [!note] Synologyの`@eaDir`
> NASが番組フォルダ内に所有者UID `0`の`@eaDir`を作る。アプリのライブラリ一覧はファイルシステムの走査ではなくDBから作るため、表示や再生には影響しない。[実機] 2026-10-04 確認
## 4. 更新する
```bash
cd /opt/stacks/tver
git status --short
git pull --ff-only
```
`git status --short`に追跡対象ファイルの変更がある場合は、内容を確認してから更新する。pull後、Dockgeで`tver`スタックを再ビルドして再配置し、[[#3. 動作を確認する]]を繰り返す。
yt-dlpを更新するときは`Dockerfile.worker`の`YT_DLP_VERSION`の既定値を変更してリポジトリへ反映し、workerイメージをキャッシュなしで再ビルドする。更新後はworkerログと少数の実動画で抽出・保存を確認する。
## 5. 稼働後に残る作業
> [!todo] PostgreSQLバックアップ
> tver専用PostgreSQL 17コンテナ内の`pg_dump`で取得し、gzip圧縮してNASの`/volume1/backups/tver/`へ保存する日次処理を用意する。保持日数は既定14日とし、稼働確認後に別作業として実装する。
+82
View File
@@ -0,0 +1,82 @@
package media
import (
"fmt"
"path/filepath"
"strings"
"unicode"
)
const (
maxSeriesBytes = 90
maxEpisodeBytes = 140
maxEpisodeIDBytes = 64
maxFilenameBytes = 255
)
// RelativeVideoPath は worker と web が共有する保存先の相対パスを返す。
func RelativeVideoPath(seriesTitle, episodeTitle, episodeID string) (string, error) {
series := sanitizeComponent(seriesTitle, maxSeriesBytes)
episode := sanitizeComponent(episodeTitle, maxEpisodeBytes)
id := sanitizeComponent(episodeID, -1)
if len(id) > maxEpisodeIDBytes {
return "", fmt.Errorf("エピソードIDはサニタイズ後%dバイト以下である必要があります: %dバイト", maxEpisodeIDBytes, len(id))
}
// A directory entry must fit within the NFS namlen limit. Keep the episode
// ID intact and use the remaining bytes for the episode title.
const separators = " [].mp4"
seriesInFilename := series
titleBytes := maxFilenameBytes - len(seriesInFilename) - len(id) - len(separators)
if titleBytes < 0 {
titleBytes = 0
}
episode = truncateComponent(episode, titleBytes, false)
return filepath.Join(series, seriesInFilename+" "+episode+" ["+id+"].mp4"), nil
}
func sanitizeComponent(value string, maxBytes int) string {
var result []rune
lastReplacement := false
for _, r := range value {
invalid := unicode.IsControl(r) || strings.ContainsRune(`<>:"/\|?*`, r)
if invalid {
if !lastReplacement {
result = append(result, '_')
lastReplacement = true
}
continue
}
if unicode.IsSpace(r) {
r = ' '
}
result = append(result, r)
lastReplacement = false
}
clean := strings.Trim(string(result), " .")
if clean == "" || clean == "." || clean == ".." {
return "名称未設定"
}
return truncateComponent(clean, maxBytes, true)
}
func truncateComponent(value string, maxBytes int, fallback bool) string {
if maxBytes < 0 {
return value
}
var limited []rune
bytes := 0
for _, r := range value {
size := len(string(r))
if bytes+size > maxBytes {
break
}
limited = append(limited, r)
bytes += size
}
clean := strings.Trim(string(limited), " .")
if fallback && (clean == "" || clean == "." || clean == "..") {
return "名称未設定"
}
return clean
}
+267
View File
@@ -0,0 +1,267 @@
package media
import (
"fmt"
"path/filepath"
"strings"
"testing"
"unicode"
"unicode/utf8"
)
func TestRelativeVideoPath(t *testing.T) {
got, err := RelativeVideoPath("番組名", "第1話", "ep123")
if err != nil {
t.Fatal(err)
}
want := filepath.Join("番組名", "番組名 第1話 [ep123].mp4")
if got != want {
t.Fatalf("path=%q want=%q", got, want)
}
other, err := RelativeVideoPath("番組名", "第1話", "ep456")
if err != nil {
t.Fatal(err)
}
if got == other || !strings.Contains(other, "[ep456]") {
t.Fatalf("ID による衝突回避: %q %q", got, other)
}
}
func TestRelativeVideoPathSanitizesComponents(t *testing.T) {
got, err := RelativeVideoPath(" 番組:/\\*?<>|\"\x00\n\t\x7f\u0085. ", "\x00\n", "../")
if err != nil {
t.Fatal(err)
}
if filepath.IsAbs(got) || strings.ContainsAny(got, `:*?<>|"`) || strings.Contains(got, "..") {
t.Fatalf("禁止文字が残った: %q", got)
}
for _, r := range got {
if unicode.IsControl(r) {
t.Errorf("制御文字が残った: %U in %q", r, got)
}
}
parts := strings.Split(filepath.ToSlash(got), "/")
if len(parts) != 2 || parts[0] == "" || !strings.HasSuffix(got, ".mp4") {
t.Fatalf("相対パスの形: %q", got)
}
}
func TestRelativeVideoPathHandlesEmptyAndLongNames(t *testing.T) {
for _, name := range []string{"", " ", ".", "..", "..."} {
got, err := RelativeVideoPath(name, name, "")
if err != nil {
t.Fatal(err)
}
if !strings.Contains(got, "名称未設定") {
t.Errorf("空の代替名 %q: %q", name, got)
}
}
long := strings.Repeat("界", 200)
got, err := RelativeVideoPath(long, long, "id")
if err != nil {
t.Fatal(err)
}
parts := strings.Split(filepath.ToSlash(got), "/")
if len(parts) != 2 || len([]byte(parts[0])) != maxSeriesBytes || !utf8.ValidString(parts[0]) {
t.Fatalf("番組名の%dバイト境界: %q (%d bytes)", maxSeriesBytes, parts[0], len([]byte(parts[0])))
}
if len([]byte(parts[1])) > maxFilenameBytes || !utf8.ValidString(parts[1]) {
t.Fatalf("ファイル名の上限: %q (%d bytes)", parts[1], len([]byte(parts[1])))
}
if got := sanitizeComponent(strings.Repeat("a", maxEpisodeBytes+1), maxEpisodeBytes); len([]byte(got)) != maxEpisodeBytes {
t.Fatalf("ASCII の%dバイト境界: %d %q", maxEpisodeBytes, len([]byte(got)), got)
}
}
func TestRelativeVideoPathEpisodeIDLimit(t *testing.T) {
id := strings.Repeat("i", maxEpisodeIDBytes)
got, err := RelativeVideoPath(strings.Repeat("s", maxSeriesBytes), strings.Repeat("t", maxEpisodeBytes), id)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(filepath.ToSlash(got), "/")
if len(parts) != 2 || len([]byte(parts[1])) > maxFilenameBytes || !strings.Contains(parts[1], "["+id+"]") {
t.Fatalf("64バイトIDのファイル名=%q", got)
}
if _, err := RelativeVideoPath("series", "episode", strings.Repeat("i", maxEpisodeIDBytes+1)); err == nil {
t.Fatal("65バイトのエピソードIDを受理した")
}
}
func TestRelativeVideoPathUsesFilenameBudgetInRequiredOrder(t *testing.T) {
series := strings.Repeat("s", maxSeriesBytes)
title := strings.Repeat("t", maxEpisodeBytes)
for _, tt := range []struct {
name string
idBytes int
wantSeriesInFile int
wantEpisodeInFile int
wantFilenameBytes int
}{
{"ちょうど255バイト", 17, 90, 140, 255},
{"回タイトルから1バイト削る", 18, 90, 139, 255},
{"64バイトIDでも番組名を維持", 64, 90, 93, 255},
} {
t.Run(tt.name, func(t *testing.T) {
id := strings.Repeat("i", tt.idBytes)
path, err := RelativeVideoPath(series, title, id)
if err != nil {
t.Fatal(err)
}
parts := strings.Split(filepath.ToSlash(path), "/")
if len(parts) != 2 || parts[0] != series {
t.Fatalf("ディレクトリ名=%q", parts)
}
wantFilename := strings.Repeat("s", tt.wantSeriesInFile) + " " + strings.Repeat("t", tt.wantEpisodeInFile) + " [" + id + "].mp4"
if parts[1] != wantFilename || len([]byte(parts[1])) != tt.wantFilenameBytes {
t.Fatalf("filename=%q (%d bytes) want=%q (%d bytes)", parts[1], len([]byte(parts[1])), wantFilename, tt.wantFilenameBytes)
}
})
}
}
func TestRelativeVideoPathEpisodeIDNearAndBeyondFilenameLimit(t *testing.T) {
for _, idBytes := range []int{64, 65, 248, 255, 256} {
t.Run(fmt.Sprintf("id_%d_bytes", idBytes), func(t *testing.T) {
id := strings.Repeat("i", idBytes)
path, err := RelativeVideoPath("series", "episode", id)
if idBytes > 64 {
if err == nil || path != "" {
t.Fatalf("%dバイトID: path=%q err=%v, want error", idBytes, path, err)
}
if !strings.Contains(err.Error(), "64バイト以下") {
t.Fatalf("%dバイトIDのエラー理由=%q", idBytes, err)
}
return
}
if err != nil {
t.Fatal(err)
}
parts := strings.Split(filepath.ToSlash(path), "/")
if len(parts) != 2 {
t.Fatalf("parts=%q", parts)
}
if !strings.Contains(parts[1], "["+id+"]") {
t.Fatalf("episode ID was changed: %q", parts[1])
}
if got := len([]byte(parts[1])); got > maxFilenameBytes {
t.Fatalf("episode ID %d bytes produced %d-byte filename above %d: %q", idBytes, got, maxFilenameBytes, parts[1])
}
if !utf8.ValidString(parts[1]) {
t.Fatalf("filename is not valid UTF-8: %q", parts[1])
}
})
}
}
func TestRelativeVideoPathHasNoRuneLimit(t *testing.T) {
series := strings.Repeat("a", 90)
title := strings.Repeat("b", 100)
got, err := RelativeVideoPath(series, title, "id")
if err != nil {
t.Fatal(err)
}
parts := strings.Split(filepath.ToSlash(got), "/")
if parts[0] != series || !strings.Contains(parts[1], " "+title+" [id].mp4") {
t.Fatalf("80 rune limit remains: %q", got)
}
}
func TestSanitizeComponentTrimsWhitespaceBeforeLengthLimit(t *testing.T) {
for _, tt := range []struct {
name, input, want string
}{
{"前後の空白", " \t 番組名\n ", "_ 番組名_"},
{"空白のみ", "   ", "名称未設定"},
{"制御文字のみ", "\t\n", "_"},
{"多数の先頭空白", strings.Repeat(" ", 100) + "番組名", "番組名"},
{"先頭空白と長い名前", strings.Repeat(" ", 70) + strings.Repeat("a", 100), strings.Repeat("a", maxSeriesBytes)},
} {
t.Run(tt.name, func(t *testing.T) {
if got := sanitizeComponent(tt.input, maxSeriesBytes); got != tt.want {
t.Errorf("sanitizeComponent()=%q want=%q", got, tt.want)
}
})
}
}
func TestSanitizeComponentTrimsAgainAfterLengthLimit(t *testing.T) {
for _, tt := range []struct {
name, input, want string
}{
{"90バイト目が空白", strings.Repeat("a", 89) + " " + "b", strings.Repeat("a", 89)},
{"90バイト目がドット", strings.Repeat("a", 89) + "." + "b", strings.Repeat("a", 89)},
{"切り詰め部分が空白のみ", "a" + strings.Repeat(" ", 89) + "b", "a"},
{"切り詰め部分がドットのみ", "a" + strings.Repeat(".", 89) + "b", "a"},
{"複数バイト文字の直後がドット", strings.Repeat("界", 29) + ".続", strings.Repeat("界", 29)},
} {
t.Run(tt.name, func(t *testing.T) {
got := sanitizeComponent(tt.input, maxSeriesBytes)
if got != tt.want {
t.Errorf("sanitizeComponent()=%q want=%q", got, tt.want)
}
if strings.Trim(got, " .") != got || got == "" {
t.Errorf("切り詰め後の名前が不正: %q", got)
}
})
}
}
func TestSanitizeComponentSafetyProperties(t *testing.T) {
var controls strings.Builder
for r := rune(0); r <= 0x9f; r++ {
if unicode.IsControl(r) {
controls.WriteRune(r)
}
}
inputs := []string{
"", " ", ".", "..", "...", `<>:"/\|?*`, controls.String(),
" \t 番組名\n. ", "a/b\\c", strings.Repeat("a", 500),
strings.Repeat("界", 500), strings.Repeat("😀", 500),
strings.Repeat(".", 100) + "name" + strings.Repeat(" ", 100),
string([]byte{0xff, 0xfe, 'a', 0xc0, 0xaf}),
}
for i, input := range inputs {
got := sanitizeComponent(input, maxEpisodeBytes)
if got == "" || got == "." || got == ".." {
t.Errorf("case %d: 空またはdot segment: %q", i, got)
}
if strings.Trim(got, " .") != got {
t.Errorf("case %d: 前後に空白またはdot: %q", i, got)
}
if len([]byte(got)) > 140 {
t.Errorf("case %d: 上限超過: bytes=%d %q", i, len([]byte(got)), got)
}
if !utf8.ValidString(got) {
t.Errorf("case %d: 不正なUTF-8: %q", i, got)
}
if strings.ContainsAny(got, `<>:"/\|?*`) {
t.Errorf("case %d: 禁止文字が残った: %q", i, got)
}
for _, r := range got {
if unicode.IsControl(r) {
t.Errorf("case %d: 制御文字が残った: %U in %q", i, r, got)
}
}
if again := sanitizeComponent(got, maxEpisodeBytes); again != got {
t.Errorf("case %d: 再適用で変化: %q -> %q", i, got, again)
}
path, err := RelativeVideoPath(input, input, "id")
if err != nil {
t.Errorf("case %d: パス生成エラー: %v", i, err)
continue
}
parts := strings.Split(filepath.ToSlash(path), "/")
if filepath.IsAbs(path) || len(parts) != 2 || parts[0] == "" || parts[0] == "." || parts[0] == ".." || !strings.HasSuffix(parts[1], ".mp4") {
t.Errorf("case %d: 安全でない相対パス: %q parts=%q", i, path, parts)
continue
}
if len([]byte(parts[0])) > 90 || len([]byte(parts[1])) > 255 {
t.Errorf("case %d: パス要素の上限超過: dir=%d filename=%d path=%q", i, len([]byte(parts[0])), len([]byte(parts[1])), path)
}
if !utf8.ValidString(path) {
t.Errorf("case %d: パスが不正なUTF-8: %q", i, path)
}
}
}
+98
View File
@@ -0,0 +1,98 @@
package store
import (
"context"
"errors"
"testing"
"github.com/jackc/pgx/v5"
)
func completeTestJob(t *testing.T, s *Store, id string) Job {
t.Helper()
ctx := context.Background()
job, _, err := s.Enqueue(ctx, sampleEpisode(id), "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, "番組/video.mp4", 1234); err != nil {
t.Fatal(err)
}
result, err := s.GetSucceededJob(ctx, job.ID)
if err != nil {
t.Fatal(err)
}
return result
}
func TestLibraryStoreOperations(t *testing.T) {
s := New(testPool(t, true))
ctx := context.Background()
first := completeTestJob(t, s, "library-1")
second := completeTestJob(t, s, "library-2")
jobs, err := s.ListSucceededJobs(ctx, 1)
if err != nil || len(jobs) != 1 || jobs[0].ID != second.ID {
t.Fatalf("完了一覧: %+v %v", jobs, err)
}
if first.FilePath == nil || *first.FilePath != "番組/video.mp4" || first.FileSizeBytes == nil || *first.FileSizeBytes != 1234 || first.FinishedAt == nil {
t.Fatalf("完了情報: %+v", first)
}
queued, _, err := s.Enqueue(ctx, sampleEpisode("not-finished"), "manual")
if err != nil {
t.Fatal(err)
}
if _, err := s.GetSucceededJob(ctx, queued.ID); !errors.Is(err, pgx.ErrNoRows) {
t.Fatalf("未完了の取得: %v", err)
}
if err := s.DeleteSucceededJob(ctx, queued.ID); !errors.Is(err, ErrInvalidTransition) {
t.Fatalf("未完了の削除: %v", err)
}
if err := s.DeleteSucceededJob(ctx, first.ID); err != nil {
t.Fatal(err)
}
if _, err := s.GetJob(ctx, first.ID); !errors.Is(err, pgx.ErrNoRows) {
t.Fatalf("削除後の取得: %v", err)
}
if err := s.DeleteSucceededJob(ctx, first.ID); !errors.Is(err, ErrInvalidTransition) {
t.Fatalf("再削除: %v", err)
}
if _, err := s.ListSucceededJobs(ctx, 0); err == nil {
t.Fatal("0件の上限を許した")
}
}
func TestDeleteSucceededJobKeepsKnownEpisodeAndAllowsManualRequeue(t *testing.T) {
s := New(testPool(t, true))
ctx := context.Background()
ep := seriesEpisode("known-library", "known-series")
created, err := s.RegisterSubscription(ctx, "known-series", "番組", []Episode{ep}, false)
if err != nil || !created {
t.Fatalf("番組登録: %t %v", created, err)
}
job, created, err := s.Enqueue(ctx, ep, "manual")
if err != nil || !created {
t.Fatalf("手動投入: %+v %t %v", job, created, err)
}
claimed, err := s.ClaimNext(ctx)
if err != nil || claimed.ID != job.ID {
t.Fatalf("取得: %+v %v", claimed, err)
}
if err := s.Complete(ctx, job.ID, "番組/video.mp4", 10); err != nil {
t.Fatal(err)
}
if err := s.DeleteSucceededJob(ctx, job.ID); err != nil {
t.Fatal(err)
}
unknown, err := s.UnknownEpisodeIDs(ctx, "known-series", []string{ep.ID})
if err != nil || len(unknown) != 0 {
t.Fatalf("削除後の既知記録: %v %v", unknown, err)
}
requeued, created, err := s.Enqueue(ctx, ep, "manual")
if err != nil || !created || requeued.Status != "queued" || requeued.Source != "manual" {
t.Fatalf("削除後の手動再投入: %+v %t %v", requeued, created, err)
}
}
@@ -0,0 +1,8 @@
CREATE TABLE known_episodes (
series_id TEXT NOT NULL REFERENCES subscriptions(series_id) ON DELETE CASCADE,
episode_id TEXT NOT NULL,
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (series_id, episode_id)
);
CREATE INDEX download_jobs_series_idx ON download_jobs (series_id);
@@ -0,0 +1,6 @@
ALTER TABLE subscriptions
ADD COLUMN last_check_error_at TIMESTAMPTZ,
ADD COLUMN last_check_error TEXT;
CREATE INDEX subscriptions_due_idx
ON subscriptions (last_checked_at) WHERE enabled;
+56 -10
View File
@@ -59,6 +59,9 @@ type Subscription struct {
Enabled bool Enabled bool
CreatedAt time.Time CreatedAt time.Time
LastCheckedAt *time.Time LastCheckedAt *time.Time
LastCheckErrorAt *time.Time
LastCheckError *string
JobCount int64
} }
const jobColumns = `id, episode_id, series_id, series_title, title, broadcaster_name, const jobColumns = `id, episode_id, series_id, series_title, title, broadcaster_name,
@@ -110,6 +113,36 @@ func (s *Store) GetJob(ctx context.Context, id int64) (Job, error) {
return scanJob(s.pool.QueryRow(ctx, `SELECT `+jobColumns+` FROM download_jobs WHERE id=$1`, id)) return scanJob(s.pool.QueryRow(ctx, `SELECT `+jobColumns+` FROM download_jobs WHERE id=$1`, id))
} }
func (s *Store) GetSucceededJob(ctx context.Context, id int64) (Job, error) {
return scanJob(s.pool.QueryRow(ctx, `SELECT `+jobColumns+` FROM download_jobs WHERE id=$1 AND status='succeeded'`, id))
}
func (s *Store) ListSucceededJobs(ctx context.Context, limit int) ([]Job, error) {
if limit <= 0 {
return nil, errors.New("limit must be positive")
}
rows, err := s.pool.Query(ctx, `SELECT `+jobColumns+` FROM download_jobs
WHERE status='succeeded' ORDER BY finished_at DESC NULLS LAST, id DESC LIMIT $1`, limit)
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 {
tag, err := s.pool.Exec(ctx, `DELETE FROM download_jobs WHERE id=$1 AND status='succeeded'`, id)
return transitionResult(tag.RowsAffected(), err)
}
// Retry は失敗・中断されたジョブの実行時情報を消して再投入する。 // Retry は失敗・中断されたジョブの実行時情報を消して再投入する。
func (s *Store) Retry(ctx context.Context, id int64) error { func (s *Store) Retry(ctx context.Context, id int64) error {
tag, err := s.pool.Exec(ctx, `UPDATE download_jobs SET status='queued', phase=NULL, tag, err := s.pool.Exec(ctx, `UPDATE download_jobs SET status='queued', phase=NULL,
@@ -152,6 +185,13 @@ func (s *Store) UpdateProgress(ctx context.Context, id int64, p Progress) error
return transitionResult(tag.RowsAffected(), err) return transitionResult(tag.RowsAffected(), err)
} }
// Heartbeat は進捗出力がない間も実行中ジョブの生存を記録する。
func (s *Store) Heartbeat(ctx context.Context, id int64) error {
tag, err := s.pool.Exec(ctx, `UPDATE download_jobs SET heartbeat_at=now(), updated_at=now()
WHERE id=$1 AND status='running'`, id)
return transitionResult(tag.RowsAffected(), err)
}
func (s *Store) Complete(ctx context.Context, id int64, filePath string, fileSizeBytes int64) error { func (s *Store) Complete(ctx context.Context, id int64, filePath string, fileSizeBytes int64) error {
tag, err := s.pool.Exec(ctx, `UPDATE download_jobs SET status='succeeded', phase=NULL, tag, err := s.pool.Exec(ctx, `UPDATE download_jobs SET status='succeeded', phase=NULL,
file_path=$2, file_size_bytes=$3, heartbeat_at=NULL, finished_at=now(), updated_at=now() file_path=$2, file_size_bytes=$3, heartbeat_at=NULL, finished_at=now(), updated_at=now()
@@ -191,6 +231,16 @@ func (s *Store) RequeueStale(ctx context.Context, cutoff time.Time) (int64, erro
return tag.RowsAffected(), err return tag.RowsAffected(), err
} }
// RequeueRunning は worker の正常終了時に担当中ジョブを待機中へ戻す。
func (s *Store) RequeueRunning(ctx context.Context, id int64) error {
tag, err := s.pool.Exec(ctx, `UPDATE download_jobs SET status='queued', phase=NULL,
downloaded_bytes=NULL, total_bytes=NULL, total_is_estimate=false, speed_bps=NULL,
eta_seconds=NULL, fragment_index=NULL, fragment_count=NULL, format_id=NULL,
heartbeat_at=NULL, started_at=NULL, updated_at=now()
WHERE id=$1 AND status='running'`, id)
return transitionResult(tag.RowsAffected(), err)
}
func transitionResult(count int64, err error) error { func transitionResult(count int64, err error) error {
if err != nil { if err != nil {
return err return err
@@ -246,21 +296,16 @@ func (s *Store) EpisodeStatuses(ctx context.Context, ids []string) (map[string]s
return result, rows.Err() return result, rows.Err()
} }
// AddSubscription は登録済みシリーズのタイトルと有効状態を変更しない。
func (s *Store) AddSubscription(ctx context.Context, seriesID, title string) (bool, error) {
tag, err := s.pool.Exec(ctx, `INSERT INTO subscriptions (series_id, series_title)
VALUES ($1,$2) ON CONFLICT (series_id) DO NOTHING`, seriesID, title)
return tag.RowsAffected() == 1, err
}
func (s *Store) DeleteSubscription(ctx context.Context, seriesID string) (bool, error) { func (s *Store) DeleteSubscription(ctx context.Context, seriesID string) (bool, error) {
tag, err := s.pool.Exec(ctx, `DELETE FROM subscriptions WHERE series_id=$1`, seriesID) tag, err := s.pool.Exec(ctx, `DELETE FROM subscriptions WHERE series_id=$1`, seriesID)
return tag.RowsAffected() == 1, err return tag.RowsAffected() == 1, err
} }
func (s *Store) ListSubscriptions(ctx context.Context) ([]Subscription, error) { func (s *Store) ListSubscriptions(ctx context.Context) ([]Subscription, error) {
rows, err := s.pool.Query(ctx, `SELECT series_id, series_title, enabled, created_at, last_checked_at rows, err := s.pool.Query(ctx, `SELECT s.series_id, s.series_title, s.enabled, s.created_at, s.last_checked_at,
FROM subscriptions ORDER BY created_at DESC, series_id`) s.last_check_error_at, s.last_check_error,
(SELECT count(*) FROM download_jobs j WHERE j.series_id=s.series_id)
FROM subscriptions s ORDER BY s.created_at DESC, s.series_id`)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -268,7 +313,8 @@ func (s *Store) ListSubscriptions(ctx context.Context) ([]Subscription, error) {
var subs []Subscription var subs []Subscription
for rows.Next() { for rows.Next() {
var sub Subscription var sub Subscription
if err := rows.Scan(&sub.SeriesID, &sub.SeriesTitle, &sub.Enabled, &sub.CreatedAt, &sub.LastCheckedAt); err != nil { if err := rows.Scan(&sub.SeriesID, &sub.SeriesTitle, &sub.Enabled, &sub.CreatedAt, &sub.LastCheckedAt,
&sub.LastCheckErrorAt, &sub.LastCheckError, &sub.JobCount); err != nil {
return nil, err return nil, err
} }
subs = append(subs, sub) subs = append(subs, sub)
+123 -4
View File
@@ -113,7 +113,7 @@ func TestMigrate(t *testing.T) {
if err := pool.QueryRow(ctx, `SELECT count(*), min(applied_at) FROM schema_migrations`).Scan(&count, &second); err != nil { if err := pool.QueryRow(ctx, `SELECT count(*), min(applied_at) FROM schema_migrations`).Scan(&count, &second); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if count != 1 || !first.Equal(second) { if count != 3 || !first.Equal(second) {
t.Fatalf("再適用で履歴が変わった: %d %v %v", count, first, second) t.Fatalf("再適用で履歴が変わった: %d %v %v", count, first, second)
} }
} }
@@ -138,11 +138,89 @@ func TestMigrateConcurrent(t *testing.T) {
if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM schema_migrations`).Scan(&count); err != nil { if err := pool.QueryRow(context.Background(), `SELECT count(*) FROM schema_migrations`).Scan(&count); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if count != 1 { if count != 3 {
t.Fatalf("適用件数: %d", count) t.Fatalf("適用件数: %d", count)
} }
} }
func TestMigrateAddsKnownEpisodesToExistingDatabase(t *testing.T) {
pool := testPool(t, false)
ctx := context.Background()
initial, err := migrations.ReadFile("migrations/001_initial.sql")
if err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, string(initial), pgx.QueryExecModeSimpleProtocol); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ('migrations/001_initial.sql')`); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO subscriptions (series_id, series_title) VALUES ('existing', '既存番組')`); err != nil {
t.Fatal(err)
}
if err := Migrate(ctx, pool); err != nil {
t.Fatal(err)
}
var count int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&count); err != nil || count != 3 {
t.Fatalf("移行履歴: %d %v", count, err)
}
if _, err := pool.Exec(ctx, `INSERT INTO known_episodes (series_id, episode_id) VALUES ('existing', 'ep1')`); err != nil {
t.Fatalf("既存番組への既知記録: %v", err)
}
if _, err := pool.Exec(ctx, `DELETE FROM subscriptions WHERE series_id='existing'`); err != nil {
t.Fatal(err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM known_episodes WHERE series_id='existing'`).Scan(&count); err != nil || count != 0 {
t.Fatalf("アップグレード後の cascade: %d %v", count, err)
}
}
func TestMigrateAddsSubscriptionCheckErrorsToVersionTwoDatabase(t *testing.T) {
pool := testPool(t, false)
ctx := context.Background()
for _, name := range []string{"migrations/001_initial.sql", "migrations/002_known_episodes.sql"} {
sql, err := migrations.ReadFile(name)
if err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, string(sql), pgx.QueryExecModeSimpleProtocol); err != nil {
t.Fatal(err)
}
}
if _, err := pool.Exec(ctx, `CREATE TABLE schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`); err != nil {
t.Fatal(err)
}
if _, err := pool.Exec(ctx, `INSERT INTO schema_migrations (version) VALUES ('migrations/001_initial.sql'), ('migrations/002_known_episodes.sql')`); err != nil {
t.Fatal(err)
}
checked := time.Date(2026, 10, 1, 2, 3, 4, 0, time.UTC)
if _, err := pool.Exec(ctx, `INSERT INTO subscriptions (series_id, series_title, last_checked_at) VALUES ('existing-v2', '既存番組', $1)`, checked); err != nil {
t.Fatal(err)
}
if err := Migrate(ctx, pool); err != nil {
t.Fatal(err)
}
var title string
var last time.Time
var errorAt, errorMessage any
if err := pool.QueryRow(ctx, `SELECT series_title, last_checked_at, last_check_error_at, last_check_error FROM subscriptions WHERE series_id='existing-v2'`).Scan(&title, &last, &errorAt, &errorMessage); err != nil || title != "既存番組" || !last.Equal(checked) || errorAt != nil || errorMessage != nil {
t.Fatalf("v2データ保持と追加列: title=%q last=%v errorAt=%v error=%v err=%v", title, last, errorAt, errorMessage, err)
}
var indexDef string
if err := pool.QueryRow(ctx, `SELECT indexdef FROM pg_indexes WHERE schemaname=current_schema() AND indexname='subscriptions_due_idx'`).Scan(&indexDef); err != nil || !strings.Contains(indexDef, "last_checked_at") || !strings.Contains(indexDef, "WHERE enabled") {
t.Fatalf("期限抽出索引: %q %v", indexDef, err)
}
var versions int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&versions); err != nil || versions != 3 {
t.Fatalf("移行履歴: %d %v", versions, err)
}
}
func TestDownloadJobIndexes(t *testing.T) { func TestDownloadJobIndexes(t *testing.T) {
pool := testPool(t, true) pool := testPool(t, true)
rows, err := pool.Query(context.Background(), `SELECT indexname, indexdef FROM pg_indexes WHERE schemaname=current_schema() AND tablename='download_jobs'`) rows, err := pool.Query(context.Background(), `SELECT indexname, indexdef FROM pg_indexes WHERE schemaname=current_schema() AND tablename='download_jobs'`)
@@ -368,6 +446,47 @@ func TestCancelAndStaleBoundary(t *testing.T) {
} }
} }
func TestHeartbeatAndGracefulRequeue(t *testing.T) {
s := New(testPool(t, true))
ctx := context.Background()
job, _, err := s.Enqueue(ctx, sampleEpisode("heartbeat"), "manual")
if err != nil {
t.Fatal(err)
}
if err := s.Heartbeat(ctx, job.ID); !errors.Is(err, ErrInvalidTransition) {
t.Fatalf("待機中の heartbeat: %v", err)
}
claimed, err := s.ClaimNext(ctx)
if err != nil {
t.Fatal(err)
}
old := time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)
if _, err := s.pool.Exec(ctx, `UPDATE download_jobs SET heartbeat_at=$2 WHERE id=$1`, claimed.ID, old); err != nil {
t.Fatal(err)
}
if err := s.Heartbeat(ctx, claimed.ID); err != nil {
t.Fatal(err)
}
heartbeated, err := s.GetJob(ctx, claimed.ID)
if err != nil || heartbeated.HeartbeatAt == nil || !heartbeated.HeartbeatAt.After(old) {
t.Fatalf("heartbeat: %+v %v", heartbeated, err)
}
bytes := int64(10)
if err := s.UpdateProgress(ctx, claimed.ID, Progress{Phase: "video", DownloadedBytes: &bytes}); err != nil {
t.Fatal(err)
}
if err := s.RequeueRunning(ctx, claimed.ID); err != nil {
t.Fatal(err)
}
requeued, err := s.GetJob(ctx, claimed.ID)
if err != nil || requeued.Status != "queued" || requeued.StartedAt != nil || requeued.HeartbeatAt != nil || requeued.Phase != nil || requeued.DownloadedBytes != nil || requeued.Attempts != 1 {
t.Fatalf("graceful requeue: %+v %v", requeued, err)
}
if err := s.RequeueRunning(ctx, claimed.ID); !errors.Is(err, ErrInvalidTransition) {
t.Fatalf("待機中の再復帰: %v", err)
}
}
func TestRejectedTransitions(t *testing.T) { func TestRejectedTransitions(t *testing.T) {
pool := testPool(t, true) pool := testPool(t, true)
s := New(pool) s := New(pool)
@@ -617,11 +736,11 @@ func TestListJobsFiltersAndLimit(t *testing.T) {
func TestSubscriptions(t *testing.T) { func TestSubscriptions(t *testing.T) {
s := New(testPool(t, true)) s := New(testPool(t, true))
ctx := context.Background() ctx := context.Background()
created, err := s.AddSubscription(ctx, "series", "番組") created, err := s.RegisterSubscription(ctx, "series", "番組", nil, false)
if err != nil || !created { if err != nil || !created {
t.Fatalf("add: %v %v", created, err) t.Fatalf("add: %v %v", created, err)
} }
created, err = s.AddSubscription(ctx, "series", "別タイトル") created, err = s.RegisterSubscription(ctx, "series", "別タイトル", nil, false)
if err != nil || created { if err != nil || created {
t.Fatalf("duplicate: %v %v", created, err) t.Fatalf("duplicate: %v %v", created, err)
} }
+244
View File
@@ -0,0 +1,244 @@
package store
import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
)
var ErrSubscriptionPaused = errors.New("subscription is paused")
// RegisterSubscription は登録と公開中の回の記録・任意の投入を一つの取引で行う。
func (s *Store) RegisterSubscription(ctx context.Context, seriesID, title string, episodes []Episode, downloadExisting bool) (bool, error) {
if seriesID == "" || title == "" {
return false, errors.New("series id and title are required")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return false, err
}
defer tx.Rollback(ctx)
var inserted string
err = tx.QueryRow(ctx, `INSERT INTO subscriptions (series_id, series_title, last_checked_at)
VALUES ($1,$2,now()) ON CONFLICT (series_id) DO NOTHING RETURNING series_id`, seriesID, title).Scan(&inserted)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
if _, err := addKnownAndMaybeQueue(ctx, tx, seriesID, episodes, downloadExisting); err != nil {
return false, err
}
if err := tx.Commit(ctx); err != nil {
return false, err
}
return true, nil
}
// QueueUnseenEpisodes は未確認の回だけを既知に記録し、同じ取引で待ち行列へ入れる。
func (s *Store) QueueUnseenEpisodes(ctx context.Context, seriesID string, episodes []Episode) (int, error) {
tx, err := s.pool.Begin(ctx)
if err != nil {
return 0, err
}
defer tx.Rollback(ctx)
var enabled bool
err = tx.QueryRow(ctx, `SELECT enabled FROM subscriptions WHERE series_id=$1 FOR UPDATE`, seriesID).Scan(&enabled)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrInvalidTransition
}
if err != nil {
return 0, err
}
if !enabled {
return 0, ErrSubscriptionPaused
}
count, err := addKnownAndMaybeQueue(ctx, tx, seriesID, episodes, true)
if err != nil {
return 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, err
}
return count, nil
}
// ListDueSubscriptions は有効で、cutoff 以前に確認された(または未確認の)番組を返す。
func (s *Store) ListDueSubscriptions(ctx context.Context, cutoff time.Time) ([]Subscription, error) {
rows, err := s.pool.Query(ctx, `SELECT series_id, series_title, enabled, created_at, last_checked_at,
last_check_error_at, last_check_error, 0::bigint
FROM subscriptions
WHERE enabled AND (last_checked_at IS NULL OR last_checked_at <= $1)
ORDER BY last_checked_at ASC NULLS FIRST, created_at, series_id`, cutoff)
if err != nil {
return nil, err
}
defer rows.Close()
var result []Subscription
for rows.Next() {
var sub Subscription
if err := rows.Scan(&sub.SeriesID, &sub.SeriesTitle, &sub.Enabled, &sub.CreatedAt,
&sub.LastCheckedAt, &sub.LastCheckErrorAt, &sub.LastCheckError, &sub.JobCount); err != nil {
return nil, err
}
result = append(result, sub)
}
return result, rows.Err()
}
// CompleteSubscriptionCheck は全公開回を既知にし、しきい値を超える未確認回だけを投入する。
// 最終確認日時の更新と前回エラーの消去も同じ取引で行う。
func (s *Store) CompleteSubscriptionCheck(ctx context.Context, seriesID string, episodes []Episode, previewMaxSeconds int, checkedAt time.Time) (int, error) {
if previewMaxSeconds < 0 {
return 0, errors.New("preview threshold must not be negative")
}
tx, err := s.pool.Begin(ctx)
if err != nil {
return 0, err
}
defer tx.Rollback(ctx)
var enabled bool
err = tx.QueryRow(ctx, `SELECT enabled FROM subscriptions WHERE series_id=$1 FOR UPDATE`, seriesID).Scan(&enabled)
if errors.Is(err, pgx.ErrNoRows) {
return 0, ErrInvalidTransition
}
if err != nil {
return 0, err
}
if !enabled {
return 0, ErrSubscriptionPaused
}
queued := 0
for _, ep := range episodes {
if ep.ID == "" || ep.SeriesID != seriesID || ep.DurationSeconds < 0 {
return 0, fmt.Errorf("invalid episode identity for series %s", seriesID)
}
var inserted string
err := tx.QueryRow(ctx, `INSERT INTO known_episodes (series_id, episode_id)
VALUES ($1,$2) ON CONFLICT DO NOTHING RETURNING episode_id`, seriesID, ep.ID).Scan(&inserted)
if errors.Is(err, pgx.ErrNoRows) {
continue
}
if err != nil {
return 0, err
}
if ep.DurationSeconds <= previewMaxSeconds {
continue
}
tag, err := tx.Exec(ctx, `INSERT INTO download_jobs
(episode_id, series_id, series_title, title, broadcaster_name, duration_seconds, end_at, thumbnail_path, source)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'subscription')
ON CONFLICT (episode_id) DO NOTHING`, ep.ID, seriesID, ep.SeriesTitle, ep.Title,
ep.BroadcasterName, ep.DurationSeconds, ep.EndAt, ep.ThumbnailPath)
if err != nil {
return 0, err
}
queued += int(tag.RowsAffected())
}
if _, err := tx.Exec(ctx, `UPDATE subscriptions SET last_checked_at=$2,
last_check_error_at=NULL, last_check_error=NULL WHERE series_id=$1`, seriesID, checkedAt); err != nil {
return 0, err
}
if err := tx.Commit(ctx); err != nil {
return 0, err
}
return queued, nil
}
// RecordSubscriptionCheckFailure は取得失敗を記録し、最終確認日時は変更しない。
func (s *Store) RecordSubscriptionCheckFailure(ctx context.Context, seriesID string, failedAt time.Time, message string) error {
tag, err := s.pool.Exec(ctx, `UPDATE subscriptions SET last_check_error_at=$2, last_check_error=$3
WHERE series_id=$1 AND enabled`, seriesID, failedAt, message)
return transitionResult(tag.RowsAffected(), err)
}
func addKnownAndMaybeQueue(ctx context.Context, tx pgx.Tx, seriesID string, episodes []Episode, queue bool) (int, error) {
count := 0
for _, ep := range episodes {
if ep.ID == "" || ep.SeriesID != seriesID {
return 0, fmt.Errorf("invalid episode identity for series %s", seriesID)
}
var inserted string
err := tx.QueryRow(ctx, `INSERT INTO known_episodes (series_id, episode_id)
VALUES ($1,$2) ON CONFLICT DO NOTHING RETURNING episode_id`, seriesID, ep.ID).Scan(&inserted)
if errors.Is(err, pgx.ErrNoRows) {
continue
}
if err != nil {
return 0, err
}
count++
if queue {
_, err := tx.Exec(ctx, `INSERT INTO download_jobs
(episode_id, series_id, series_title, title, broadcaster_name, duration_seconds, end_at, thumbnail_path, source)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,'subscription')
ON CONFLICT (episode_id) DO NOTHING`, ep.ID, seriesID, ep.SeriesTitle, ep.Title,
ep.BroadcasterName, ep.DurationSeconds, ep.EndAt, ep.ThumbnailPath)
if err != nil {
return 0, err
}
}
}
return count, nil
}
// UnknownEpisodeIDs は指定シリーズの未確認のエピソード ID を入力順で返す。
func (s *Store) UnknownEpisodeIDs(ctx context.Context, seriesID string, ids []string) ([]string, error) {
if len(ids) == 0 {
return []string{}, nil
}
rows, err := s.pool.Query(ctx, `SELECT episode_id FROM known_episodes WHERE series_id=$1 AND episode_id=ANY($2)`, seriesID, ids)
if err != nil {
return nil, err
}
defer rows.Close()
known := make(map[string]bool)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
known[id] = true
}
if err := rows.Err(); err != nil {
return nil, err
}
var unknown []string
emitted := make(map[string]bool)
for _, id := range ids {
if !known[id] && !emitted[id] {
unknown = append(unknown, id)
emitted[id] = true
}
}
return unknown, nil
}
func (s *Store) SetSubscriptionEnabled(ctx context.Context, seriesID string, enabled bool) error {
tag, err := s.pool.Exec(ctx, `UPDATE subscriptions SET enabled=$2 WHERE series_id=$1`, seriesID, enabled)
return transitionResult(tag.RowsAffected(), err)
}
func (s *Store) SubscribedSeries(ctx context.Context, ids []string) (map[string]bool, error) {
result := make(map[string]bool)
if len(ids) == 0 {
return result, nil
}
rows, err := s.pool.Query(ctx, `SELECT series_id FROM subscriptions WHERE series_id=ANY($1)`, ids)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return nil, err
}
result[id] = true
}
return result, rows.Err()
}
+326
View File
@@ -0,0 +1,326 @@
package store
import (
"context"
"errors"
"sync"
"testing"
"time"
)
func TestListDueSubscriptionsBoundaryAndPaused(t *testing.T) {
pool := testPool(t, true)
s := New(pool)
ctx := context.Background()
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
for _, id := range []string{"null", "old", "boundary", "fresh", "paused"} {
if created, err := s.RegisterSubscription(ctx, id, "番組 "+id, nil, false); err != nil || !created {
t.Fatalf("登録 %s: %t %v", id, created, err)
}
}
updates := map[string]*time.Time{
"null": nil, "old": timePointer(now.Add(-25 * time.Hour)),
"boundary": timePointer(now.Add(-24 * time.Hour)), "fresh": timePointer(now.Add(-24*time.Hour + time.Microsecond)),
"paused": timePointer(now.Add(-48 * time.Hour)),
}
for id, checked := range updates {
if _, err := pool.Exec(ctx, `UPDATE subscriptions SET last_checked_at=$2 WHERE series_id=$1`, id, checked); err != nil {
t.Fatal(err)
}
}
if err := s.SetSubscriptionEnabled(ctx, "paused", false); err != nil {
t.Fatal(err)
}
due, err := s.ListDueSubscriptions(ctx, now.Add(-24*time.Hour))
if err != nil {
t.Fatal(err)
}
if len(due) != 3 || due[0].SeriesID != "null" || due[1].SeriesID != "old" || due[2].SeriesID != "boundary" {
t.Fatalf("24時間境界と一時停止: %+v", due)
}
}
func TestCompleteSubscriptionCheckKnownPreviewErrorAndAtomicity(t *testing.T) {
pool := testPool(t, true)
s := New(pool)
ctx := context.Background()
if created, err := s.RegisterSubscription(ctx, "series-check", "番組", []Episode{seriesEpisode("known", "series-check")}, false); err != nil || !created {
t.Fatalf("登録: %t %v", created, err)
}
before := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)
failedAt := time.Date(2026, 10, 3, 1, 2, 3, 0, time.UTC)
if _, err := pool.Exec(ctx, `UPDATE subscriptions SET last_checked_at=$2 WHERE series_id=$1`, "series-check", before); err != nil {
t.Fatal(err)
}
if err := s.RecordSubscriptionCheckFailure(ctx, "series-check", failedAt, "network token=[REDACTED]"); err != nil {
t.Fatal(err)
}
var unchanged time.Time
if err := pool.QueryRow(ctx, `SELECT last_checked_at FROM subscriptions WHERE series_id='series-check'`).Scan(&unchanged); err != nil || !unchanged.Equal(before) {
t.Fatalf("失敗時に最終確認を更新: %v %v", unchanged, err)
}
checkedAt := time.Date(2026, 10, 3, 4, 5, 6, 0, time.UTC)
preview := seriesEpisode("preview", "series-check")
preview.DurationSeconds = 180
normal := seriesEpisode("normal", "series-check")
normal.DurationSeconds = 181
count, err := s.CompleteSubscriptionCheck(ctx, "series-check", []Episode{
seriesEpisode("known", "series-check"), preview, normal,
}, 180, checkedAt)
if err != nil || count != 1 {
t.Fatalf("確認完了: count=%d err=%v", count, err)
}
unknown, err := s.UnknownEpisodeIDs(ctx, "series-check", []string{"known", "preview", "normal"})
if err != nil || len(unknown) != 0 {
t.Fatalf("予告を含む既知化: %v %v", unknown, err)
}
var jobs int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM download_jobs WHERE series_id='series-check'`).Scan(&jobs); err != nil || jobs != 1 {
t.Fatalf("予告の投入除外: %d %v", jobs, err)
}
var last time.Time
var errorAt, errorMessage any
if err := pool.QueryRow(ctx, `SELECT last_checked_at, last_check_error_at, last_check_error FROM subscriptions WHERE series_id='series-check'`).Scan(&last, &errorAt, &errorMessage); err != nil || !last.Equal(checkedAt) || errorAt != nil || errorMessage != nil {
t.Fatalf("成功時の日時・エラー消去: %v %v %v %v", last, errorAt, errorMessage, err)
}
bad := seriesEpisode("bad", "series-check")
bad.DurationSeconds = -1
if _, err := s.CompleteSubscriptionCheck(ctx, "series-check", []Episode{seriesEpisode("rollback", "series-check"), bad}, 180, checkedAt.Add(time.Hour)); err == nil {
t.Fatal("不正な回を含む確認が成功した")
}
unknown, err = s.UnknownEpisodeIDs(ctx, "series-check", []string{"rollback"})
if err != nil || len(unknown) != 1 {
t.Fatalf("失敗時の既知記録を巻き戻さない: %v %v", unknown, err)
}
if err := pool.QueryRow(ctx, `SELECT last_checked_at FROM subscriptions WHERE series_id='series-check'`).Scan(&last); err != nil || !last.Equal(checkedAt) {
t.Fatalf("失敗時の最終確認を巻き戻さない: %v %v", last, err)
}
if err := s.SetSubscriptionEnabled(ctx, "series-check", false); err != nil {
t.Fatal(err)
}
if _, err := s.CompleteSubscriptionCheck(ctx, "series-check", nil, 180, checkedAt.Add(time.Hour)); !errors.Is(err, ErrSubscriptionPaused) {
t.Fatalf("一時停止中の確認完了: %v", err)
}
if err := s.RecordSubscriptionCheckFailure(ctx, "series-check", failedAt, "ignored"); !errors.Is(err, ErrInvalidTransition) {
t.Fatalf("一時停止中のエラー記録: %v", err)
}
}
func TestCompleteSubscriptionCheckKeepsExistingManualJob(t *testing.T) {
pool := testPool(t, true)
s := New(pool)
ctx := context.Background()
if created, err := s.RegisterSubscription(ctx, "series-manual", "番組", nil, false); err != nil || !created {
t.Fatalf("登録: %t %v", created, err)
}
episode := seriesEpisode("manual-existing", "series-manual")
manual, _, err := s.Enqueue(ctx, episode, "manual")
if err != nil {
t.Fatal(err)
}
checkedAt := time.Date(2026, 10, 3, 4, 5, 6, 0, time.UTC)
queued, err := s.CompleteSubscriptionCheck(ctx, "series-manual", []Episode{episode, episode}, 180, checkedAt)
if err != nil || queued != 0 {
t.Fatalf("既存手動ジョブの確認: queued=%d err=%v", queued, err)
}
got, err := s.GetJob(ctx, manual.ID)
if err != nil || got.Source != "manual" {
t.Fatalf("既存手動ジョブを変更した: %+v %v", got, err)
}
var jobs, known int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM download_jobs WHERE episode_id=$1`, episode.ID).Scan(&jobs); err != nil || jobs != 1 {
t.Fatalf("ジョブが二重化した: %d %v", jobs, err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM known_episodes WHERE series_id=$1 AND episode_id=$2`, episode.SeriesID, episode.ID).Scan(&known); err != nil || known != 1 {
t.Fatalf("既知記録: %d %v", known, err)
}
var last time.Time
if err := pool.QueryRow(ctx, `SELECT last_checked_at FROM subscriptions WHERE series_id=$1`, episode.SeriesID).Scan(&last); err != nil || !last.Equal(checkedAt) {
t.Fatalf("最終確認日時: %v %v", last, err)
}
}
func timePointer(value time.Time) *time.Time { return &value }
func seriesEpisode(id, seriesID string) Episode {
ep := sampleEpisode(id)
ep.SeriesID = seriesID
return ep
}
func TestRegisterSubscriptionSnapshotAndRollback(t *testing.T) {
pool := testPool(t, true)
s := New(pool)
ctx := context.Background()
episodes := []Episode{seriesEpisode("ep1", "series-1"), seriesEpisode("ep2", "series-1")}
created, err := s.RegisterSubscription(ctx, "series-1", "番組", episodes, false)
if err != nil || !created {
t.Fatalf("既存回をダウンロードしない登録: %t %v", created, err)
}
subs, err := s.ListSubscriptions(ctx)
if err != nil || len(subs) != 1 || subs[0].LastCheckedAt == nil || subs[0].LastCheckedAt.Before(subs[0].CreatedAt) {
t.Fatalf("登録時の最終確認日時: %+v %v", subs, err)
}
unknown, err := s.UnknownEpisodeIDs(ctx, "series-1", []string{"ep1", "ep2", "ep3"})
if err != nil || len(unknown) != 1 || unknown[0] != "ep3" {
t.Fatalf("既知の記録: %v %v", unknown, err)
}
var jobs int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM download_jobs WHERE series_id='series-1'`).Scan(&jobs); err != nil || jobs != 0 {
t.Fatalf("既存回が投入された: %d %v", jobs, err)
}
created, err = s.RegisterSubscription(ctx, "series-1", "別の名前", episodes, true)
if err != nil || created {
t.Fatalf("重複登録: %t %v", created, err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM download_jobs WHERE series_id='series-1'`).Scan(&jobs); err != nil || jobs != 0 {
t.Fatalf("重複登録が投入した: %d %v", jobs, err)
}
bad := seriesEpisode("bad", "series-bad")
bad.DurationSeconds = -1
created, err = s.RegisterSubscription(ctx, "series-bad", "番組", []Episode{seriesEpisode("good", "series-bad"), bad}, true)
if err == nil || created {
t.Fatalf("壊れた回の登録が成功: %t %v", created, err)
}
for _, table := range []string{"subscriptions", "known_episodes", "download_jobs"} {
var count int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM `+table+` WHERE series_id='series-bad'`).Scan(&count); err != nil || count != 0 {
t.Errorf("取引の巻き戻し %s: %d %v", table, count, err)
}
}
deleted, err := s.DeleteSubscription(ctx, "series-1")
if err != nil || !deleted {
t.Fatalf("解除: %t %v", deleted, err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM known_episodes WHERE series_id='series-1'`).Scan(&jobs); err != nil || jobs != 0 {
t.Fatalf("解除後に既知が残った: %d %v", jobs, err)
}
created, err = s.RegisterSubscription(ctx, "series-1", "番組", episodes, true)
if err != nil || !created {
t.Fatalf("解除後の再登録: %t %v", created, err)
}
if err := pool.QueryRow(ctx, `SELECT count(*) FROM download_jobs WHERE series_id='series-1'`).Scan(&jobs); err != nil || jobs != 2 {
t.Fatalf("再登録で既存回を選び直せない: %d %v", jobs, err)
}
}
func TestRegisterAndQueueUnseenEpisodes(t *testing.T) {
pool := testPool(t, true)
s := New(pool)
ctx := context.Background()
manual, _, err := s.Enqueue(ctx, seriesEpisode("manual", "series-2"), "manual")
if err != nil {
t.Fatal(err)
}
created, err := s.RegisterSubscription(ctx, "series-2", "番組", []Episode{seriesEpisode("manual", "series-2"), seriesEpisode("past", "series-2")}, true)
if err != nil || !created {
t.Fatalf("公開中の回を投入: %t %v", created, err)
}
manualAfter, err := s.GetJob(ctx, manual.ID)
if err != nil || manualAfter.Source != "manual" {
t.Fatalf("既存の手動ジョブを変更: %+v %v", manualAfter, err)
}
var subscriptionJobs int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM download_jobs WHERE series_id='series-2' AND source='subscription' AND status='queued'`).Scan(&subscriptionJobs); err != nil || subscriptionJobs != 1 {
t.Fatalf("登録時に作ったジョブ: %d %v", subscriptionJobs, err)
}
subs, err := s.ListSubscriptions(ctx)
if err != nil || len(subs) != 1 || subs[0].JobCount != 2 || !subs[0].Enabled || subs[0].LastCheckedAt == nil {
t.Fatalf("登録一覧と関連ジョブ数: %+v %v", subs, err)
}
unknown, err := s.UnknownEpisodeIDs(ctx, "series-2", []string{"past", "new", "manual", "new"})
if err != nil || len(unknown) != 1 || unknown[0] != "new" {
t.Fatalf("未確認の抽出: %v %v", unknown, err)
}
if err := s.SetSubscriptionEnabled(ctx, "series-2", false); err != nil {
t.Fatal(err)
}
if _, err := s.QueueUnseenEpisodes(ctx, "series-2", []Episode{seriesEpisode("new", "series-2")}); !errors.Is(err, ErrSubscriptionPaused) {
t.Fatalf("一時停止中の投入: %v", err)
}
unknown, err = s.UnknownEpisodeIDs(ctx, "series-2", []string{"new"})
if err != nil || len(unknown) != 1 {
t.Fatalf("一時停止中に既知となった: %v %v", unknown, err)
}
if err := s.SetSubscriptionEnabled(ctx, "series-2", true); err != nil {
t.Fatal(err)
}
invalid := seriesEpisode("invalid", "series-2")
invalid.DurationSeconds = -1
if _, err := s.QueueUnseenEpisodes(ctx, "series-2", []Episode{seriesEpisode("newer", "series-2"), invalid}); err == nil {
t.Fatal("新規回の投入が途中失敗でも確定した")
}
unknown, err = s.UnknownEpisodeIDs(ctx, "series-2", []string{"newer"})
if err != nil || len(unknown) != 1 {
t.Fatalf("新規回の投入失敗が巻き戻らない: %v %v", unknown, err)
}
count, err := s.QueueUnseenEpisodes(ctx, "series-2", []Episode{seriesEpisode("past", "series-2"), seriesEpisode("new", "series-2")})
if err != nil || count != 1 {
t.Fatalf("再開後の新しい回: %d %v", count, err)
}
count, err = s.QueueUnseenEpisodes(ctx, "series-2", []Episode{seriesEpisode("new", "series-2")})
if err != nil || count != 0 {
t.Fatalf("重複した新しい回: %d %v", count, err)
}
var source string
if err := pool.QueryRow(ctx, `SELECT source FROM download_jobs WHERE episode_id='new'`).Scan(&source); err != nil || source != "subscription" {
t.Fatalf("新しい回の登録元: %q %v", source, err)
}
statuses, err := s.SubscribedSeries(ctx, []string{"series-2", "missing"})
if err != nil || !statuses["series-2"] || statuses["missing"] {
t.Fatalf("登録済みシリーズの一括取得: %v %v", statuses, err)
}
}
func TestQueueUnseenEpisodesConcurrent(t *testing.T) {
pool := testPool(t, true)
s := New(pool)
ctx := context.Background()
created, err := s.RegisterSubscription(ctx, "series-concurrent", "番組", nil, false)
if err != nil || !created {
t.Fatalf("登録: %t %v", created, err)
}
episodes := []Episode{
seriesEpisode("concurrent-1", "series-concurrent"),
seriesEpisode("concurrent-2", "series-concurrent"),
}
const workers = 8
counts := make(chan int, workers)
errs := make(chan error, workers)
var wg sync.WaitGroup
for range workers {
wg.Add(1)
go func() {
defer wg.Done()
count, err := s.QueueUnseenEpisodes(ctx, "series-concurrent", episodes)
counts <- count
errs <- err
}()
}
wg.Wait()
close(counts)
close(errs)
for err := range errs {
if err != nil {
t.Errorf("同時投入: %v", err)
}
}
total := 0
positive := 0
for count := range counts {
total += count
if count > 0 {
positive++
}
}
if total != 2 || positive != 1 {
t.Fatalf("同時投入の記録件数: total=%d positive workers=%d", total, positive)
}
for table := range map[string]struct{}{"known_episodes": {}, "download_jobs": {}} {
var count int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM `+table+` WHERE series_id='series-concurrent'`).Scan(&count); err != nil || count != 2 {
t.Errorf("%s の重複防止: %d %v", table, count, err)
}
}
}
+11 -2
View File
@@ -12,7 +12,10 @@ import (
"time" "time"
) )
const DefaultBaseURL = "https://platform-api.tver.jp" const (
DefaultBaseURL = "https://platform-api.tver.jp"
PreviewMaxSeconds = 180
)
type Session struct { type Session struct {
PlatformUID string `json:"platform_uid"` PlatformUID string `json:"platform_uid"`
@@ -33,12 +36,14 @@ type Episode struct {
type Client struct { type Client struct {
baseURL *url.URL baseURL *url.URL
serviceURL *url.URL
httpClient *http.Client httpClient *http.Client
} }
// 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) {
defaultAPI := baseURL == ""
if baseURL == "" { if baseURL == "" {
baseURL = DefaultBaseURL baseURL = DefaultBaseURL
} }
@@ -49,7 +54,11 @@ func NewClient(baseURL string, httpClient *http.Client) (*Client, error) {
if httpClient == nil { if httpClient == nil {
httpClient = &http.Client{Timeout: 15 * time.Second} httpClient = &http.Client{Timeout: 15 * time.Second}
} }
return &Client{baseURL: parsed, httpClient: httpClient}, nil service := parsed
if defaultAPI {
service = &url.URL{Scheme: "https", Host: "service-api.tver.jp"}
}
return &Client{baseURL: parsed, serviceURL: service, httpClient: httpClient}, nil
} }
func (c *Client) CreateSession(ctx context.Context) (Session, error) { func (c *Client) CreateSession(ctx context.Context) (Session, error) {
+99
View File
@@ -0,0 +1,99 @@
package tver
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
)
var tverSeriesIDPattern = regexp.MustCompile(`^[A-Za-z0-9]+$`)
type seriesContents struct {
Code int `json:"code"`
Result struct {
Contents []struct {
Type string `json:"type"`
Content Episode `json:"content"`
} `json:"contents"`
} `json:"result"`
}
func (c *Client) FetchSeriesEpisodes(ctx context.Context, seriesID string) ([]Episode, error) {
if !tverSeriesIDPattern.MatchString(seriesID) {
return nil, errors.New("invalid series id")
}
seasonsURL := c.serviceURL.ResolveReference(&url.URL{Path: "/api/v1/callSeriesSeasons/" + seriesID})
request, err := http.NewRequestWithContext(ctx, http.MethodGet, seasonsURL.String(), nil)
if err != nil {
return nil, fmt.Errorf("series seasons request: %w", err)
}
setTVerHeaders(request)
var seasons seriesContents
if err := c.getJSON(request, &seasons); err != nil {
return nil, fmt.Errorf("series seasons: %w", err)
}
if seasons.Code != 0 || seasons.Result.Contents == nil {
return nil, fmt.Errorf("series seasons: invalid API result (code %d)", seasons.Code)
}
var seasonIDs []string
for _, item := range seasons.Result.Contents {
if item.Type != "season" {
continue
}
if !tverSeriesIDPattern.MatchString(item.Content.ID) {
return nil, errors.New("series seasons: invalid season id")
}
seasonIDs = append(seasonIDs, item.Content.ID)
}
if len(seasonIDs) == 0 {
return []Episode{}, nil
}
session, err := c.CreateSession(ctx)
if err != nil {
return nil, err
}
seen := make(map[string]bool)
var episodes []Episode
for _, seasonID := range seasonIDs {
endpoint := c.baseURL.ResolveReference(&url.URL{Path: "/service/api/v1/callSeasonEpisodes/" + seasonID})
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 nil, fmt.Errorf("season episodes request: %w", err)
}
setTVerHeaders(request)
var response seriesContents
if err := c.getJSON(request, &response); err != nil {
return nil, fmt.Errorf("season episodes: %w", err)
}
if response.Code != 0 || response.Result.Contents == nil {
return nil, fmt.Errorf("season episodes: invalid API result (code %d)", response.Code)
}
for _, item := range response.Result.Contents {
if item.Type != "episode" {
continue
}
ep := item.Content
if !tverSeriesIDPattern.MatchString(ep.ID) || ep.SeriesID != seriesID {
return nil, errors.New("season episodes: invalid episode identity")
}
if !seen[ep.ID] {
episodes = append(episodes, ep)
seen[ep.ID] = true
}
}
}
return episodes, nil
}
func setTVerHeaders(request *http.Request) {
request.Header.Set("x-tver-platform-type", "web")
request.Header.Set("Origin", "https://tver.jp")
request.Header.Set("Referer", "https://tver.jp/")
}
+182
View File
@@ -0,0 +1,182 @@
package tver
import (
"context"
"net/http"
"os"
"strings"
"testing"
)
func TestFetchSeriesEpisodesFromRecordedResponses(t *testing.T) {
seasons, err := os.ReadFile("testdata/series_seasons.json")
if err != nil {
t.Fatal(err)
}
episodes, err := os.ReadFile("testdata/season_episodes.json")
if err != nil {
t.Fatal(err)
}
var seasonCalls, sessionCalls, episodeCalls int
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/callSeriesSeasons/sr0lhdrtdb":
seasonCalls++
if r.Method != http.MethodGet || r.Header.Get("x-tver-platform-type") != "web" || r.Header.Get("Origin") != "https://tver.jp" || r.Header.Get("Referer") != "https://tver.jp/" {
t.Errorf("seasons request: method=%s headers=%v", r.Method, r.Header)
}
_, _ = w.Write(seasons)
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/v1/callSeasonEpisodes/ss3i6njoq3":
episodeCalls++
if r.Method != http.MethodGet || r.URL.Query().Get("platform_uid") != "uid-test" || r.URL.Query().Get("platform_token") != "token-test" || r.Header.Get("x-tver-platform-type") != "web" || r.Header.Get("Origin") != "https://tver.jp" || r.Header.Get("Referer") != "https://tver.jp/" {
t.Errorf("season episode request: method=%s query=%v headers=%v", r.Method, r.URL.Query(), r.Header)
}
_, _ = w.Write(episodes)
default:
t.Errorf("unexpected path: %s", r.URL.Path)
http.NotFound(w, r)
}
})
got, err := client.FetchSeriesEpisodes(context.Background(), "sr0lhdrtdb")
if err != nil || len(got) != 1 {
t.Fatalf("series episodes: %+v %v", got, err)
}
ep := got[0]
if ep.ID != "eps0z5761a" || ep.SeriesID != "sr0lhdrtdb" || ep.Title != "#10(最終話)" || ep.SeriesTitle != "韓国ドラマ「良いが悪い、ドンジェ」" || ep.BroadcasterName != "BS12トゥエルビ" || ep.Duration != 2712 || ep.EndAt != 1792209540 || !strings.Contains(ep.ThumbnailPath, "eps0z5761a.jpg") {
t.Errorf("episode fields: %+v", ep)
}
if seasonCalls != 1 || sessionCalls != 1 || episodeCalls != 1 {
t.Errorf("calls: seasons=%d session=%d episodes=%d", seasonCalls, sessionCalls, episodeCalls)
}
}
func TestFetchSeriesEpisodesTransportAndDecodeErrors(t *testing.T) {
validSeasons := `{"code":0,"result":{"contents":[{"type":"season","content":{"id":"ss1"}}]}}`
validSession := `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`
validEpisodes := `{"code":0,"result":{"contents":[]}}`
for _, tt := range []struct {
name string
seasonStatus, sessionStatus, episodeStatus int
seasonBody, sessionBody, episodeBody string
want string
wantSession, wantEpisode int
}{
{"seasons HTTP", 503, 200, 200, "", validSession, validEpisodes, "series seasons: HTTP 503", 0, 0},
{"seasons JSON", 200, 200, 200, "{", validSession, validEpisodes, "series seasons: decode JSON", 0, 0},
{"session HTTP", 200, 503, 200, validSeasons, "", validEpisodes, "create session: HTTP 503", 1, 0},
{"episodes HTTP", 200, 200, 502, validSeasons, validSession, "", "season episodes: HTTP 502", 1, 1},
{"episodes JSON", 200, 200, 200, validSeasons, validSession, "{", "season episodes: decode JSON", 1, 1},
} {
t.Run(tt.name, func(t *testing.T) {
var seasonCalls, sessionCalls, episodeCalls int
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/callSeriesSeasons/sr1":
seasonCalls++
w.WriteHeader(tt.seasonStatus)
_, _ = w.Write([]byte(tt.seasonBody))
case "/v2/api/platform_users/browser/create":
sessionCalls++
w.WriteHeader(tt.sessionStatus)
_, _ = w.Write([]byte(tt.sessionBody))
case "/service/api/v1/callSeasonEpisodes/ss1":
episodeCalls++
w.WriteHeader(tt.episodeStatus)
_, _ = w.Write([]byte(tt.episodeBody))
default:
t.Errorf("unexpected request: %s", r.URL.Path)
}
})
_, err := client.FetchSeriesEpisodes(context.Background(), "sr1")
if err == nil || !strings.Contains(err.Error(), tt.want) {
t.Fatalf("error=%v; want %q", err, tt.want)
}
if seasonCalls != 1 || sessionCalls != tt.wantSession || episodeCalls != tt.wantEpisode {
t.Errorf("calls: seasons=%d session=%d episodes=%d", seasonCalls, sessionCalls, episodeCalls)
}
})
}
}
func TestFetchSeriesEpisodesRejectsBadResponses(t *testing.T) {
for _, tt := range []struct {
name, seasons, episodes string
}{
{"season API code", `{"code":1,"result":{"contents":[]}}`, ""},
{"missing seasons", `{"code":0,"result":{}}`, ""},
{"season without id", `{"code":0,"result":{"contents":[{"type":"season","content":{}}]}}`, ""},
{"episode API code", `{"code":0,"result":{"contents":[{"type":"season","content":{"id":"ss1"}}]}}`, `{"code":1,"result":{"contents":[]}}`},
{"wrong series", `{"code":0,"result":{"contents":[{"type":"season","content":{"id":"ss1"}}]}}`, `{"code":0,"result":{"contents":[{"type":"episode","content":{"id":"ep1","seriesID":"other"}}]}}`},
{"episode without id", `{"code":0,"result":{"contents":[{"type":"season","content":{"id":"ss1"}}]}}`, `{"code":0,"result":{"contents":[{"type":"episode","content":{"seriesID":"sr1"}}]}}`},
} {
t.Run(tt.name, func(t *testing.T) {
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.Contains(r.URL.Path, "callSeriesSeasons"):
_, _ = w.Write([]byte(tt.seasons))
case strings.Contains(r.URL.Path, "browser/create"):
_, _ = w.Write([]byte(`{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`))
default:
_, _ = w.Write([]byte(tt.episodes))
}
})
if _, err := client.FetchSeriesEpisodes(context.Background(), "sr1"); err == nil {
t.Fatal("invalid response accepted")
}
})
}
}
func TestFetchSeriesEpisodesMultipleSeasonsDeduplicates(t *testing.T) {
var sessionCalls, episodeCalls int
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/v1/callSeriesSeasons/sr1":
_, _ = w.Write([]byte(`{"code":0,"result":{"contents":[{"type":"season","content":{"id":"ss1"}},{"type":"season","content":{"id":"ss2"}}]}}`))
case "/v2/api/platform_users/browser/create":
sessionCalls++
_, _ = w.Write([]byte(`{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`))
case "/service/api/v1/callSeasonEpisodes/ss1":
episodeCalls++
_, _ = w.Write([]byte(`{"code":0,"result":{"contents":[{"type":"episode","content":{"id":"ep1","seriesID":"sr1"}}]}}`))
case "/service/api/v1/callSeasonEpisodes/ss2":
episodeCalls++
_, _ = w.Write([]byte(`{"code":0,"result":{"contents":[{"type":"episode","content":{"id":"ep1","seriesID":"sr1"}},{"type":"episode","content":{"id":"ep2","seriesID":"sr1"}}]}}`))
default:
t.Errorf("unexpected request: %s", r.URL.Path)
}
})
episodes, err := client.FetchSeriesEpisodes(context.Background(), "sr1")
if err != nil || len(episodes) != 2 || episodes[0].ID != "ep1" || episodes[1].ID != "ep2" || sessionCalls != 1 || episodeCalls != 2 {
t.Fatalf("複数 season: %+v %v session=%d episode=%d", episodes, err, sessionCalls, episodeCalls)
}
}
func TestFetchSeriesEpisodesNoSeasonsSkipsSession(t *testing.T) {
var calls int
client := testClient(t, func(w http.ResponseWriter, r *http.Request) {
calls++
if r.URL.Path != "/api/v1/callSeriesSeasons/sr1" {
t.Errorf("unexpected request: %s", r.URL.Path)
}
_, _ = w.Write([]byte(`{"code":0,"result":{"contents":[]}}`))
})
episodes, err := client.FetchSeriesEpisodes(context.Background(), "sr1")
if err != nil || len(episodes) != 0 || calls != 1 {
t.Fatalf("season 無し: %+v %v calls=%d", episodes, err, calls)
}
}
func TestFetchSeriesEpisodesRejectsInvalidSeriesIDBeforeRequest(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"} {
if _, err := client.FetchSeriesEpisodes(context.Background(), id); err == nil {
t.Errorf("invalid series id accepted: %q", id)
}
}
}
+1
View File
@@ -0,0 +1 @@
{"code":0,"result":{"contents":[{"type":"episode","content":{"id":"eps0z5761a","version":4,"title":"#10(最終話)","seriesID":"sr0lhdrtdb","duration":2712,"endAt":1792209540,"broadcastDateLabel":"10月3日(土)放送分","isNHKContent":false,"isSubtitle":false,"ribbonID":0,"seriesTitle":"韓国ドラマ「良いが悪い、ドンジェ」","isAvailable":true,"broadcasterName":"BS12トゥエルビ","productionProviderName":"BS12 トゥエルビ","isEndingSoon":false,"thumbnailPath":"/images/content/thumbnail/episode/xlarge/eps0z5761a.jpg?v=b45ddb51","isChannelDefaultSeries":false}}]}}
+1
View File
@@ -0,0 +1 @@
{"code":0,"result":{"contents":[{"type":"season","content":{"id":"ss3i6njoq3","version":1,"title":"本編","season_extended":null}}]}}
+40 -25
View File
@@ -17,9 +17,12 @@ import (
var assets embed.FS var assets embed.FS
type page struct { type page struct {
Title string Title string
Newer *newerView Newer *newerView
Queue *queueView Queue *queueView
Subscriptions *subscriptionsView
Library *libraryView
Video *videoView
} }
type JobStore interface { type JobStore interface {
@@ -29,21 +32,30 @@ type JobStore interface {
GetJob(context.Context, int64) (store.Job, error) GetJob(context.Context, int64) (store.Job, error)
Retry(context.Context, int64) error Retry(context.Context, int64) error
RetryEpisode(context.Context, string) error RetryEpisode(context.Context, string) error
CancelQueued(context.Context, int64) error Cancel(context.Context, int64) error
SubscribedSeries(context.Context, []string) (map[string]bool, error)
RegisterSubscription(context.Context, string, string, []store.Episode, bool) (bool, error)
ListSubscriptions(context.Context) ([]store.Subscription, error)
SetSubscriptionEnabled(context.Context, string, bool) error
DeleteSubscription(context.Context, string) (bool, error)
ListSucceededJobs(context.Context, int) ([]store.Job, error)
GetSucceededJob(context.Context, int64) (store.Job, error)
DeleteSucceededJob(context.Context, int64) error
} }
type server struct { type server struct {
fetcher NewerFetcher fetcher NewerFetcher
jobs JobStore jobs JobStore
now func() time.Time videoDir string
logger *log.Logger now func() time.Time
zone *time.Location logger *log.Logger
tmpl *template.Template zone *time.Location
cacheMu sync.Mutex tmpl *template.Template
cache map[string]cacheEntry cacheMu sync.Mutex
cache map[string]cacheEntry
} }
func NewHandler(fetcher NewerFetcher, jobs JobStore, now func() time.Time, logger *log.Logger) http.Handler { func NewHandler(fetcher NewerFetcher, jobs JobStore, videoDir string, now func() time.Time, logger *log.Logger) http.Handler {
tmpl := template.Must(template.ParseFS(assets, "templates/*.html")) tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
staticFiles, err := fs.Sub(assets, "static") staticFiles, err := fs.Sub(assets, "static")
if err != nil { if err != nil {
@@ -53,7 +65,10 @@ func NewHandler(fetcher NewerFetcher, jobs JobStore, now func() time.Time, logge
if err != nil { if err != nil {
panic(err) panic(err)
} }
s := &server{fetcher: fetcher, jobs: jobs, now: now, logger: logger, zone: zone, tmpl: tmpl, cache: make(map[string]cacheEntry)} if videoDir == "" {
videoDir = "/videos"
}
s := &server{fetcher: fetcher, jobs: jobs, videoDir: videoDir, now: now, logger: logger, zone: zone, tmpl: tmpl, cache: make(map[string]cacheEntry)}
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFiles))) mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFiles)))
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
@@ -68,16 +83,16 @@ func NewHandler(fetcher NewerFetcher, jobs JobStore, now func() time.Time, logge
mux.HandleFunc("GET /queue/list", s.queueFragment) mux.HandleFunc("GET /queue/list", s.queueFragment)
mux.HandleFunc("POST /jobs/{id}/retry", s.retryJob) mux.HandleFunc("POST /jobs/{id}/retry", s.retryJob)
mux.HandleFunc("POST /jobs/{id}/cancel", s.cancelJob) mux.HandleFunc("POST /jobs/{id}/cancel", s.cancelJob)
for path, title := range map[string]string{ mux.HandleFunc("GET /subscriptions", s.subscriptionsPage)
"GET /subscriptions": "登録番組", mux.HandleFunc("GET /subscriptions/list", s.subscriptionsFragment)
"GET /library": "ライブラリ", mux.HandleFunc("GET /subscriptions/choice", s.subscriptionChoice)
} { mux.HandleFunc("POST /subscriptions/register", s.registerSubscription)
mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc("POST /subscriptions/{id}/pause", s.pauseSubscription)
w.Header().Set("Content-Type", "text/html; charset=utf-8") mux.HandleFunc("POST /subscriptions/{id}/resume", s.resumeSubscription)
if err := s.tmpl.ExecuteTemplate(w, "layout", page{Title: title}); err != nil { mux.HandleFunc("POST /subscriptions/{id}/delete", s.deleteSubscription)
http.Error(w, "template error", http.StatusInternalServerError) mux.HandleFunc("GET /library", s.libraryPage)
} mux.HandleFunc("GET /library/{id}", s.videoPage)
}) mux.HandleFunc("GET /videos/{id}", s.serveVideo)
} mux.HandleFunc("POST /library/{id}/delete", s.deleteVideo)
return mux return mux
} }
+153
View File
@@ -0,0 +1,153 @@
package web
import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"gitea.kazu634.com/kazu634/tver/internal/store"
"github.com/jackc/pgx/v5"
)
const libraryLimit = 100
type libraryItemView struct {
ID int64
ThumbnailURL, SeriesTitle, Title string
BroadcasterName, DurationText string
FileSizeText, FinishedAtText string
}
type libraryView struct {
Items []libraryItemView
}
type videoView struct {
libraryItemView
VideoURL string
}
func (s *server) libraryPage(w http.ResponseWriter, r *http.Request) {
jobs, err := s.jobs.ListSucceededJobs(r.Context(), libraryLimit)
if err != nil {
s.logger.Printf("ライブラリの取得に失敗: %v", err)
http.Error(w, "ライブラリを取得できませんでした", http.StatusInternalServerError)
return
}
view := libraryView{Items: make([]libraryItemView, 0, len(jobs))}
for _, job := range jobs {
view.Items = append(view.Items, s.libraryItem(job))
}
s.render(w, "layout", page{Title: "ライブラリ", Library: &view})
}
func (s *server) videoPage(w http.ResponseWriter, r *http.Request) {
job, ok := s.succeededJob(w, r)
if !ok {
return
}
item := s.libraryItem(job)
s.render(w, "layout", page{Title: job.SeriesTitle + " " + job.Title,
Video: &videoView{libraryItemView: item, VideoURL: "/videos/" + strconv.FormatInt(job.ID, 10)}})
}
func (s *server) libraryItem(job store.Job) libraryItemView {
item := libraryItemView{ID: job.ID, ThumbnailURL: "https://statics.tver.jp" + job.ThumbnailPath,
SeriesTitle: job.SeriesTitle, Title: job.Title, BroadcasterName: job.BroadcasterName,
DurationText: fmt.Sprintf("%d分", durationMinutes(int64(job.DurationSeconds)))}
if job.FileSizeBytes != nil {
item.FileSizeText = formatBytes(float64(*job.FileSizeBytes))
}
if job.FinishedAt != nil {
item.FinishedAtText = job.FinishedAt.In(s.zone).Format("2006-01-02 15:04 MST")
}
return item
}
func (s *server) serveVideo(w http.ResponseWriter, r *http.Request) {
job, ok := s.succeededJob(w, r)
if !ok || job.FilePath == nil {
if ok {
http.NotFound(w, r)
}
return
}
root, err := os.OpenRoot(s.videoDir)
if err != nil {
http.NotFound(w, r)
return
}
defer root.Close()
file, err := root.Open(*job.FilePath)
if err != nil {
http.NotFound(w, r)
return
}
defer file.Close()
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "video/mp4")
http.ServeContent(w, r, filepath.Base(*job.FilePath), info.ModTime(), file)
}
func (s *server) deleteVideo(w http.ResponseWriter, r *http.Request) {
job, ok := s.succeededJob(w, r)
if !ok {
return
}
if job.FilePath != nil {
if !filepath.IsLocal(*job.FilePath) {
http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict)
return
}
root, err := os.OpenRoot(s.videoDir)
if err != nil {
http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict)
return
}
defer root.Close()
file, err := root.Open(*job.FilePath)
if err == nil {
_ = file.Close()
} else if !errors.Is(err, os.ErrNotExist) {
http.Error(w, "動画ファイルの場所が不正です", http.StatusConflict)
return
}
if err := root.Remove(*job.FilePath); err != nil && !errors.Is(err, os.ErrNotExist) {
s.logger.Printf("動画ファイルの削除に失敗: %v", err)
http.Error(w, "動画ファイルを削除できませんでした", http.StatusInternalServerError)
return
}
}
if err := s.jobs.DeleteSucceededJob(r.Context(), job.ID); err != nil {
s.logger.Printf("動画の記録削除に失敗: %v", err)
http.Error(w, "動画の記録を削除できませんでした", http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/library", http.StatusSeeOther)
}
func (s *server) succeededJob(w http.ResponseWriter, r *http.Request) (store.Job, bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.NotFound(w, r)
return store.Job{}, false
}
job, err := s.jobs.GetSucceededJob(r.Context(), id)
if errors.Is(err, pgx.ErrNoRows) {
http.NotFound(w, r)
return store.Job{}, false
}
if err != nil {
s.logger.Printf("完了ジョブの取得に失敗: %v", err)
http.Error(w, "動画を取得できませんでした", http.StatusInternalServerError)
return store.Job{}, false
}
return job, true
}
+294
View File
@@ -0,0 +1,294 @@
package web
import (
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"gitea.kazu634.com/kazu634/tver/internal/store"
)
func libraryJob(id int64, relative string) store.Job {
size := int64(10)
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>`,
BroadcasterName: "放送局", DurationSeconds: 61, ThumbnailPath: "/thumb.jpg", Status: "succeeded",
FilePath: &relative, FileSizeBytes: &size, FinishedAt: &finished}
}
func TestLibraryAndPlaybackPage(t *testing.T) {
dir := t.TempDir()
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
page := getPage(handler, "/library")
if page.Code != http.StatusOK {
t.Fatalf("library status=%d", page.Code)
}
body := page.Body.String()
for _, want := range []string{"&lt;script&gt;番組&lt;/script&gt;", "&lt;b&gt;第1話&lt;/b&gt;", "放送局", "長さ: 2分", "10.0 B", "2026-10-03 10:02 JST", "https://statics.tver.jp/thumb.jpg", `/library/1`} {
if !strings.Contains(body, want) {
t.Errorf("library missing %q", want)
}
}
if strings.Contains(body, "<script>番組</script>") {
t.Fatal("library fields were not escaped")
}
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="動画ファイルと記録を削除しますか?"`) {
t.Fatalf("video page: %d %s", video.Code, video.Body.String())
}
for _, path := range []string{"/library/bad", "/library/99", "/library/1/delete"} {
response := getPage(handler, path)
want := http.StatusNotFound
if strings.HasSuffix(path, "/delete") {
want = http.StatusMethodNotAllowed
}
if 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 TestLibraryRejectsInvalidJobIDs(t *testing.T) {
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, t.TempDir(), nil)
for _, id := range []string{"0", "-1", "bad", "999999999999999999999999999999999999"} {
for _, path := range []string{"/library/" + id, "/videos/" + id} {
if got := getPage(handler, path).Code; got != http.StatusNotFound {
t.Errorf("GET %s: %d", path, got)
}
}
if got := postForm(handler, "/library/"+id+"/delete", nil).Code; got != http.StatusNotFound {
t.Errorf("POST delete id=%q: %d", id, got)
}
}
}
func TestVideoRangeAndMissingFile(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "show"), 0o755); err != nil {
t.Fatal(err)
}
content := []byte("0123456789")
if err := os.WriteFile(filepath.Join(dir, "show", "video.mp4"), content, 0o644); err != nil {
t.Fatal(err)
}
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
request := httptest.NewRequest(http.MethodGet, "/videos/1?file_path=../../outside.mp4", nil)
request.Header.Set("Range", "bytes=2-5")
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusPartialContent || response.Body.String() != "2345" || response.Header().Get("Content-Range") != "bytes 2-5/10" || response.Header().Get("Content-Type") != "video/mp4" {
t.Fatalf("range: status=%d range=%q type=%q body=%q", response.Code, response.Header().Get("Content-Range"), response.Header().Get("Content-Type"), response.Body.String())
}
full := getPage(handler, "/videos/1")
if full.Code != http.StatusOK || full.Body.String() != string(content) || full.Header().Get("Content-Type") != "video/mp4" || full.Header().Get("Accept-Ranges") != "bytes" {
t.Fatalf("full video: status=%d type=%q ranges=%q body=%q", full.Code, full.Header().Get("Content-Type"), full.Header().Get("Accept-Ranges"), full.Body.String())
}
request = httptest.NewRequest(http.MethodGet, "/videos/1", nil)
request.Header.Set("Range", "bytes=20-30")
response = httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusRequestedRangeNotSatisfiable || response.Header().Get("Content-Range") != "bytes */10" {
t.Fatalf("range outside: status=%d range=%q", response.Code, response.Header().Get("Content-Range"))
}
if err := os.Remove(filepath.Join(dir, "show", "video.mp4")); err != nil {
t.Fatal(err)
}
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
t.Fatalf("missing file status=%d", got)
}
jobs.jobs["ep1"] = store.Job{ID: 1, Status: "queued"}
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
t.Fatalf("unfinished status=%d", got)
}
if got := getPage(handler, "/library/1").Code; got != http.StatusNotFound {
t.Fatalf("unfinished playback page status=%d", got)
}
missingPath := libraryJob(1, "show/video.mp4")
missingPath.FilePath = nil
jobs.jobs["ep1"] = missingPath
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
t.Fatalf("missing DB file path status=%d", got)
}
}
func TestVideoPathTraversalAndSymlinkAreRejected(t *testing.T) {
dir := t.TempDir()
outside := t.TempDir()
outsideFile := filepath.Join(outside, "outside.mp4")
if err := os.WriteFile(outsideFile, []byte("secret"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideFile, filepath.Join(dir, "link.mp4")); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(dir, "linked-directory")); err != nil {
t.Fatal(err)
}
relativeOutsideFile, err := filepath.Rel(dir, outsideFile)
if err != nil {
t.Fatal(err)
}
relativeOutsideDir, err := filepath.Rel(dir, outside)
if err != nil {
t.Fatal(err)
}
if err := os.Symlink(relativeOutsideFile, filepath.Join(dir, "relative-link.mp4")); err != nil {
t.Fatal(err)
}
if err := os.Symlink(relativeOutsideDir, filepath.Join(dir, "relative-linked-directory")); err != nil {
t.Fatal(err)
}
for _, relative := range []string{"../outside.mp4", "show/../../outside.mp4", outsideFile,
"link.mp4", "linked-directory/outside.mp4", "linked-directory/missing.mp4",
"relative-link.mp4", "relative-linked-directory/outside.mp4", "relative-linked-directory/missing.mp4"} {
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, relative)}}
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
t.Errorf("path %q status=%d", relative, got)
}
deleted := postForm(handler, "/library/1/delete", nil)
if deleted.Code != http.StatusConflict || len(jobs.jobs) != 1 {
t.Errorf("delete path %q status=%d jobs=%d", relative, deleted.Code, len(jobs.jobs))
}
}
if got, err := os.ReadFile(outsideFile); err != nil || string(got) != "secret" {
t.Fatalf("outside target was changed: content=%q err=%v", got, err)
}
for _, link := range []string{"link.mp4", "linked-directory", "relative-link.mp4", "relative-linked-directory"} {
if _, err := os.Lstat(filepath.Join(dir, link)); err != nil {
t.Errorf("rejected link %q was changed: %v", link, err)
}
}
}
func TestVideoSymlinkWithinRootCanBeServedAndDeleted(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target.mp4")
if err := os.WriteFile(target, []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink("target.mp4", filepath.Join(dir, "link.mp4")); err != nil {
t.Fatal(err)
}
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "link.mp4")}}
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
if response := getPage(handler, "/videos/1"); response.Code != http.StatusOK || response.Body.String() != "video" {
t.Fatalf("serve: status=%d body=%q", response.Code, response.Body.String())
}
if response := postForm(handler, "/library/1/delete", nil); response.Code != http.StatusSeeOther || len(jobs.jobs) != 0 {
t.Fatalf("delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
}
if _, err := os.Stat(target); err != nil {
t.Fatalf("symlink target was removed: %v", err)
}
if _, err := os.Lstat(filepath.Join(dir, "link.mp4")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("symlink itself remains: %v", err)
}
}
func TestVideoSymlinkedDirectoryWithinRootCanBeServedAndDeleted(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "target"), 0o755); err != nil {
t.Fatal(err)
}
target := filepath.Join(dir, "target", "video.mp4")
if err := os.WriteFile(target, []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink("target", filepath.Join(dir, "linked-directory")); err != nil {
t.Fatal(err)
}
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "linked-directory/video.mp4")}}
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
if response := getPage(handler, "/videos/1"); response.Code != http.StatusOK || response.Body.String() != "video" {
t.Fatalf("serve: status=%d body=%q", response.Code, response.Body.String())
}
if response := postForm(handler, "/library/1/delete", nil); response.Code != http.StatusSeeOther || len(jobs.jobs) != 0 {
t.Fatalf("delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
}
if _, err := os.Stat(target); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("target file remains: %v", err)
}
if _, err := os.Lstat(filepath.Join(dir, "linked-directory")); err != nil {
t.Fatalf("directory symlink was removed: %v", err)
}
}
func TestVideoAbsoluteSymlinkWithinRootIsRejectedByOSRoot(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target.mp4")
if err := os.WriteFile(target, []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Symlink(target, filepath.Join(dir, "absolute-link.mp4")); err != nil {
t.Fatal(err)
}
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "absolute-link.mp4")}}
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
if got := getPage(handler, "/videos/1").Code; got != http.StatusNotFound {
t.Fatalf("absolute symlink serve status=%d", got)
}
if response := postForm(handler, "/library/1/delete", nil); response.Code != http.StatusConflict || len(jobs.jobs) != 1 {
t.Fatalf("absolute symlink delete: status=%d jobs=%d", response.Code, len(jobs.jobs))
}
if _, err := os.Stat(target); err != nil {
t.Fatalf("target was changed: %v", err)
}
}
func TestDeleteVideoFileThenRecord(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "show"), 0o755); err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "show", "video.mp4")
if err := os.WriteFile(path, []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "show/video.mp4")}}
fileWasGoneBeforeRecordDelete := false
jobs.beforeDeleteSucceeded = func() {
_, err := os.Stat(path)
fileWasGoneBeforeRecordDelete = errors.Is(err, os.ErrNotExist)
}
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
response := postForm(handler, "/library/1/delete", nil)
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/library" || len(jobs.jobs) != 0 || !fileWasGoneBeforeRecordDelete {
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) {
t.Fatalf("file remains: %v", err)
}
jobs.jobs["ep1"] = libraryJob(1, "show/missing.mp4")
if got := postForm(handler, "/library/1/delete", nil).Code; got != http.StatusSeeOther || len(jobs.jobs) != 0 {
t.Fatalf("missing file delete: status=%d jobs=%d", got, len(jobs.jobs))
}
}
func TestDeleteFailureKeepsRecord(t *testing.T) {
dir := t.TempDir()
blocked := filepath.Join(dir, "blocked")
if err := os.Mkdir(blocked, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(blocked, "child"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
jobs := &fakeJobStore{jobs: map[string]store.Job{"ep1": libraryJob(1, "blocked")}}
handler := newTestHandlerWithVideoDir(&fakeFetcher{}, jobs, dir, nil)
response := postForm(handler, "/library/1/delete", nil)
if response.Code != http.StatusInternalServerError || len(jobs.jobs) != 1 || !strings.Contains(response.Body.String(), "動画ファイルを削除できませんでした") {
t.Fatalf("failure: status=%d jobs=%d body=%s", response.Code, len(jobs.jobs), response.Body.String())
}
}
+22 -3
View File
@@ -14,14 +14,14 @@ import (
) )
const ( const (
previewMaxSeconds = 180 newerCacheTTL = 10 * time.Minute
newerCacheTTL = 10 * time.Minute
) )
var platformTokenPattern = regexp.MustCompile(`(?i)(platform_token=)[^&\s]+`) 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)
FetchSeriesEpisodes(context.Context, string) ([]tver.Episode, error)
} }
type cacheEntry struct { type cacheEntry struct {
@@ -36,6 +36,8 @@ type newerOptions struct {
type episodeView struct { type episodeView struct {
ID string ID string
SeriesID string
SubscriptionAction subscriptionActionView
Genre string Genre string
Status string Status string
StatusText string StatusText string
@@ -134,7 +136,7 @@ func (s *server) newerView(ctx context.Context, options newerOptions) newerView
} }
visible := make([]tver.Episode, 0, len(episodes)) visible := make([]tver.Episode, 0, len(episodes))
for _, episode := range episodes { for _, episode := range episodes {
if options.hidePreviews && episode.Duration <= previewMaxSeconds { if options.hidePreviews && episode.Duration <= tver.PreviewMaxSeconds {
continue continue
} }
visible = append(visible, episode) visible = append(visible, episode)
@@ -150,10 +152,27 @@ func (s *server) newerView(ctx context.Context, options newerOptions) newerView
view.ErrorText = "状態を取得できませんでした" view.ErrorText = "状態を取得できませんでした"
return view return view
} }
seriesIDs := make([]string, 0, len(visible))
seenSeries := make(map[string]bool)
for _, episode := range visible {
if episode.SeriesID != "" && !seenSeries[episode.SeriesID] {
seriesIDs = append(seriesIDs, episode.SeriesID)
seenSeries[episode.SeriesID] = true
}
}
subscribed, err := s.jobs.SubscribedSeries(ctx, seriesIDs)
if err != nil {
s.logger.Printf("登録番組の状態取得に失敗: %v", err)
view.Failed = true
view.ErrorText = "登録番組の状態を取得できませんでした"
return view
}
for _, episode := range visible { for _, episode := range visible {
endAt := time.Unix(episode.EndAt, 0).In(s.zone) endAt := time.Unix(episode.EndAt, 0).In(s.zone)
view.Episodes = append(view.Episodes, episodeView{ view.Episodes = append(view.Episodes, episodeView{
ID: episode.ID, ID: episode.ID,
SeriesID: episode.SeriesID,
SubscriptionAction: subscriptionActionView{ID: episode.ID, Genre: options.genre, Subscribed: subscribed[episode.SeriesID]},
Genre: options.genre, Genre: options.genre,
Status: statuses[episode.ID], Status: statuses[episode.ID],
StatusText: statusText(statuses[episode.ID]), StatusText: statusText(statuses[episode.ID]),
+14 -2
View File
@@ -20,6 +20,9 @@ type fakeFetcher struct {
episodesByGenre map[string][]tver.Episode episodesByGenre map[string][]tver.Episode
err error err error
calls []string calls []string
seriesEpisodes []tver.Episode
seriesErr error
seriesCalls []string
} }
func (f *fakeFetcher) FetchNewer(_ context.Context, genre string) ([]tver.Episode, error) { func (f *fakeFetcher) FetchNewer(_ context.Context, genre string) ([]tver.Episode, error) {
@@ -30,15 +33,24 @@ func (f *fakeFetcher) FetchNewer(_ context.Context, genre string) ([]tver.Episod
return f.episodes, f.err return f.episodes, f.err
} }
func (f *fakeFetcher) FetchSeriesEpisodes(_ context.Context, seriesID string) ([]tver.Episode, error) {
f.seriesCalls = append(f.seriesCalls, seriesID)
return f.seriesEpisodes, f.seriesErr
}
func newTestHandler(fetcher *fakeFetcher, now func() time.Time) http.Handler { func newTestHandler(fetcher *fakeFetcher, now func() time.Time) http.Handler {
return newTestHandlerWithStore(fetcher, &fakeJobStore{}, now) return newTestHandlerWithStore(fetcher, &fakeJobStore{}, now)
} }
func newTestHandlerWithStore(fetcher *fakeFetcher, jobs *fakeJobStore, now func() time.Time) http.Handler { func newTestHandlerWithStore(fetcher *fakeFetcher, jobs *fakeJobStore, now func() time.Time) http.Handler {
return newTestHandlerWithVideoDir(fetcher, jobs, "", now)
}
func newTestHandlerWithVideoDir(fetcher *fakeFetcher, jobs *fakeJobStore, videoDir string, now func() time.Time) http.Handler {
if now == nil { if now == nil {
now = func() time.Time { return time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC) } now = func() time.Time { return time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC) }
} }
return NewHandler(fetcher, jobs, now, log.New(io.Discard, "", 0)) return NewHandler(fetcher, jobs, videoDir, now, log.New(io.Discard, "", 0))
} }
func getPage(handler http.Handler, path string) *httptest.ResponseRecorder { func getPage(handler http.Handler, path string) *httptest.ResponseRecorder {
@@ -230,7 +242,7 @@ func TestNewerPageEscapesUntrustedEpisodeFields(t *testing.T) {
func TestNewerFailureShowsFriendlyMessageAndRedactsToken(t *testing.T) { func TestNewerFailureShowsFriendlyMessageAndRedactsToken(t *testing.T) {
fetcher := &fakeFetcher{err: errors.New("GET https://platform-api.tver.jp/?platform_token=secret-123&x=1: upstream unavailable")} fetcher := &fakeFetcher{err: errors.New("GET https://platform-api.tver.jp/?platform_token=secret-123&x=1: upstream unavailable")}
var logs bytes.Buffer var logs bytes.Buffer
handler := NewHandler(fetcher, &fakeJobStore{}, time.Now, log.New(&logs, "", 0)) handler := NewHandler(fetcher, &fakeJobStore{}, "", time.Now, log.New(&logs, "", 0))
response := getPage(handler, "/") response := getPage(handler, "/")
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "新着を取得できませんでした") { if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "新着を取得できませんでした") {
t.Errorf("error page: status=%d body=%s", response.Code, response.Body.String()) t.Errorf("error page: status=%d body=%s", response.Code, response.Body.String())
+23 -20
View File
@@ -145,34 +145,37 @@ func errorKindText(kind string) string {
} }
func (s *server) retryJob(w http.ResponseWriter, r *http.Request) { func (s *server) retryJob(w http.ResponseWriter, r *http.Request) {
s.queueMutation(w, r, s.jobs.Retry, false) s.queueMutation(w, r, s.jobs.Retry)
} }
func (s *server) cancelJob(w http.ResponseWriter, r *http.Request) { func (s *server) cancelJob(w http.ResponseWriter, r *http.Request) {
s.queueMutation(w, r, s.jobs.CancelQueued, true)
}
func (s *server) queueMutation(w http.ResponseWriter, r *http.Request, change func(context.Context, int64) error, queuedOnly bool) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64) id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 { if err != nil || id <= 0 {
http.Error(w, "ジョブ ID が不正です", http.StatusBadRequest) http.Error(w, "ジョブ ID が不正です", http.StatusBadRequest)
return return
} }
if queuedOnly { job, err := s.jobs.GetJob(r.Context(), id)
job, err := s.jobs.GetJob(r.Context(), id) if errors.Is(err, pgx.ErrNoRows) {
if errors.Is(err, pgx.ErrNoRows) { http.Error(w, "ジョブが見つかりません", http.StatusNotFound)
http.Error(w, "ジョブが見つかりません", http.StatusNotFound) return
return }
} 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 }
} if job.Status != "queued" && job.Status != "running" {
if job.Status != "queued" { http.Error(w, "待機中またはダウンロード中のジョブだけ取り消せます", http.StatusConflict)
http.Error(w, "待機中のジョブだけ取り消せます", http.StatusConflict) return
return }
} s.queueMutation(w, r, s.jobs.Cancel)
}
func (s *server) queueMutation(w http.ResponseWriter, r *http.Request, change func(context.Context, int64) error) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
http.Error(w, "ジョブ ID が不正です", http.StatusBadRequest)
return
} }
if err := change(r.Context(), id); err != nil { if err := change(r.Context(), id); err != nil {
if errors.Is(err, store.ErrInvalidTransition) { if errors.Is(err, store.ErrInvalidTransition) {
+8 -4
View File
@@ -155,7 +155,7 @@ func TestQueueGroupsProgressAndPolling(t *testing.T) {
t.Errorf("待ち行列に %q がない", want) t.Errorf("待ち行列に %q がない", want)
} }
} }
if strings.Contains(body, errorMessage) || strings.Contains(body, `action="/jobs/1/cancel"`) || strings.Contains(body, "全体") { if strings.Contains(body, errorMessage) || !strings.Contains(body, `action="/jobs/1/cancel"`) || strings.Contains(body, "全体") {
t.Errorf("不正な詳細・操作・全体進捗: %s", body) t.Errorf("不正な詳細・操作・全体進捗: %s", body)
} }
fragment := getPage(handler, "/queue/list") fragment := getPage(handler, "/queue/list")
@@ -195,7 +195,7 @@ func TestQueueJobsAppearInCorrectSectionsWithLimit(t *testing.T) {
cancelAction := `action="/jobs/` + string(rune('1'+i)) + `/cancel"` cancelAction := `action="/jobs/` + string(rune('1'+i)) + `/cancel"`
retryAction := `action="/jobs/` + string(rune('1'+i)) + `/retry"` retryAction := `action="/jobs/` + string(rune('1'+i)) + `/retry"`
switch statuses[i] { switch statuses[i] {
case "queued": case "running", "queued":
if !strings.Contains(section[1], cancelAction) || strings.Contains(section[1], retryAction) { if !strings.Contains(section[1], cancelAction) || strings.Contains(section[1], retryAction) {
t.Errorf("%s の操作が不正: %s", statuses[i], section[1]) t.Errorf("%s の操作が不正: %s", statuses[i], section[1])
} }
@@ -246,9 +246,13 @@ func TestQueueCancelRetryAndStopPolling(t *testing.T) {
if got := postForm(handler, "/jobs/bad/cancel", nil).Code; got != http.StatusBadRequest { if got := postForm(handler, "/jobs/bad/cancel", nil).Code; got != http.StatusBadRequest {
t.Fatalf("不正な ID: %d", got) t.Fatalf("不正な ID: %d", got)
} }
if got := postForm(handler, "/jobs/99/cancel", nil).Code; got != http.StatusNotFound {
t.Fatalf("存在しないジョブ: %d", got)
}
jobs.jobs["ep"] = store.Job{ID: 1, Status: "running"} jobs.jobs["ep"] = store.Job{ID: 1, Status: "running"}
if got := postForm(handler, "/jobs/1/cancel", nil).Code; got != http.StatusConflict { runningCanceled := postForm(handler, "/jobs/1/cancel", nil)
t.Fatalf("実行中の取り消し: %d", got) if runningCanceled.Code != http.StatusOK || jobs.jobs["ep"].Status != "canceled" || !strings.Contains(runningCanceled.Body.String(), `action="/jobs/1/retry"`) {
t.Fatalf("実行中の取り消し: %d %+v", runningCanceled.Code, jobs.jobs["ep"])
} }
} }
+5
View File
@@ -14,9 +14,14 @@ main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; }
.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; }
.episode-action { padding: 0 .8rem .8rem; } .episode-action { padding: 0 .8rem .8rem; }
.subscription-action { padding: 0 .8rem .8rem; }
.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 form { display: inline-block; margin-right: .5rem; }
.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; }
.queue-job h3 { margin: 0 0 .4rem; } .queue-job h3 { margin: 0 0 .4rem; }
.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; }
+129 -1
View File
@@ -11,12 +11,20 @@ import (
type fakeJobStore struct { type fakeJobStore struct {
jobs map[string]store.Job jobs map[string]store.Job
subscriptions map[string]store.Subscription
nextID int64 nextID int64
lastEpisode store.Episode lastEpisode store.Episode
enqueueCalls, statusCalls int enqueueCalls, statusCalls int
statusIDs []string statusIDs []string
listStatuses []string listStatuses []string
listLimits []int listLimits []int
registerCalls int
registerEpisodes []store.Episode
registerDownload bool
registerErr error
subscribedCalls int
subscribedIDs []string
beforeDeleteSucceeded func()
err error err error
} }
@@ -116,7 +124,7 @@ func (f *fakeJobStore) Cancel(_ context.Context, id int64) error {
return f.err return f.err
} }
for ep, job := range f.jobs { for ep, job := range f.jobs {
if job.ID == id && job.Status == "queued" { if job.ID == id && (job.Status == "queued" || job.Status == "running") {
job.Status = "canceled" job.Status = "canceled"
f.jobs[ep] = job f.jobs[ep] = job
return nil return nil
@@ -130,3 +138,123 @@ func (f *fakeJobStore) CancelQueued(ctx context.Context, id int64) error {
} }
var _ JobStore = (*fakeJobStore)(nil) var _ JobStore = (*fakeJobStore)(nil)
func (f *fakeJobStore) SubscribedSeries(_ context.Context, ids []string) (map[string]bool, error) {
f.subscribedCalls++
f.subscribedIDs = append([]string(nil), ids...)
if f.err != nil {
return nil, f.err
}
result := make(map[string]bool)
for _, id := range ids {
_, result[id] = f.subscriptions[id]
}
return result, nil
}
func (f *fakeJobStore) RegisterSubscription(_ context.Context, seriesID, title string, episodes []store.Episode, download bool) (bool, error) {
f.registerCalls++
f.registerEpisodes = append([]store.Episode(nil), episodes...)
f.registerDownload = download
if f.registerErr != nil {
return false, f.registerErr
}
if f.err != nil {
return false, f.err
}
if f.subscriptions == nil {
f.subscriptions = make(map[string]store.Subscription)
}
if _, exists := f.subscriptions[seriesID]; exists {
return false, nil
}
var count int64
if download {
count = int64(len(episodes))
}
checked := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
f.subscriptions[seriesID] = store.Subscription{SeriesID: seriesID, SeriesTitle: title,
Enabled: true, CreatedAt: checked, LastCheckedAt: &checked, JobCount: count}
return true, nil
}
func (f *fakeJobStore) ListSubscriptions(_ context.Context) ([]store.Subscription, error) {
if f.err != nil {
return nil, f.err
}
var result []store.Subscription
for _, sub := range f.subscriptions {
result = append(result, sub)
}
sort.Slice(result, func(i, j int) bool { return result[i].SeriesID < result[j].SeriesID })
return result, nil
}
func (f *fakeJobStore) SetSubscriptionEnabled(_ context.Context, seriesID string, enabled bool) error {
if f.err != nil {
return f.err
}
sub, exists := f.subscriptions[seriesID]
if !exists {
return store.ErrInvalidTransition
}
sub.Enabled = enabled
f.subscriptions[seriesID] = sub
return nil
}
func (f *fakeJobStore) DeleteSubscription(_ context.Context, seriesID string) (bool, error) {
if f.err != nil {
return false, f.err
}
if _, exists := f.subscriptions[seriesID]; !exists {
return false, nil
}
delete(f.subscriptions, seriesID)
return true, nil
}
func (f *fakeJobStore) ListSucceededJobs(_ context.Context, limit int) ([]store.Job, error) {
if f.err != nil {
return nil, f.err
}
var result []store.Job
for _, job := range f.jobs {
if job.Status == "succeeded" {
result = append(result, job)
}
}
sort.Slice(result, func(i, j int) bool { return result[i].ID > result[j].ID })
if len(result) > limit {
result = result[:limit]
}
return result, nil
}
func (f *fakeJobStore) GetSucceededJob(_ context.Context, id int64) (store.Job, error) {
if f.err != nil {
return store.Job{}, f.err
}
for _, job := range f.jobs {
if job.ID == id && job.Status == "succeeded" {
return job, nil
}
}
return store.Job{}, pgx.ErrNoRows
}
func (f *fakeJobStore) DeleteSucceededJob(_ context.Context, id int64) error {
if f.beforeDeleteSucceeded != nil {
f.beforeDeleteSucceeded()
}
if f.err != nil {
return f.err
}
for episodeID, job := range f.jobs {
if job.ID == id && job.Status == "succeeded" {
delete(f.jobs, episodeID)
return nil
}
}
return store.ErrInvalidTransition
}
+191
View File
@@ -0,0 +1,191 @@
package web
import (
"context"
"errors"
"net/http"
"gitea.kazu634.com/kazu634/tver/internal/store"
)
type subscriptionActionView struct {
ID, Genre, ErrorText string
Subscribed, Choosing, DownloadExisting bool
}
type subscriptionView struct {
ID, Title, CreatedAtText, LastCheckedText string
CheckErrorAtText, CheckErrorText string
Enabled bool
Jobs int64
}
type subscriptionsView struct {
Items []subscriptionView
}
func (s *server) subscriptionChoice(w http.ResponseWriter, r *http.Request) {
genre, id, ok := episodeForm(w, r)
if !ok {
return
}
ep, found := s.cachedEpisode(genre, id)
if !found || ep.SeriesID == "" {
http.Error(w, "新着キャッシュに番組がありません。新着を再表示してください", http.StatusNotFound)
return
}
registered, err := s.isSubscribed(r.Context(), ep.SeriesID)
if err != nil {
s.logger.Printf("番組の登録状態取得に失敗: %v", err)
http.Error(w, "登録状態を取得できませんでした", http.StatusInternalServerError)
return
}
if registered {
s.subscriptionRegisteredResponse(w, r, id, genre)
return
}
s.render(w, "subscription_action", subscriptionActionView{ID: id, Genre: genre, Choosing: true})
}
func (s *server) registerSubscription(w http.ResponseWriter, r *http.Request) {
genre, id, ok := episodeForm(w, r)
if !ok {
return
}
choice := r.Form.Get("download_existing")
if choice != "" && choice != "1" {
http.Error(w, "公開中の回の選択が不正です", http.StatusBadRequest)
return
}
ep, found := s.cachedEpisode(genre, id)
if !found || ep.SeriesID == "" || ep.SeriesTitle == "" {
http.Error(w, "新着キャッシュに番組がありません。新着を再表示してください", http.StatusNotFound)
return
}
registered, err := s.isSubscribed(r.Context(), ep.SeriesID)
if err != nil {
s.logger.Printf("番組の登録状態取得に失敗: %v", err)
http.Error(w, "登録状態を取得できませんでした", http.StatusInternalServerError)
return
}
if registered {
s.subscriptionRegisteredResponse(w, r, id, genre)
return
}
episodes, err := s.fetcher.FetchSeriesEpisodes(r.Context(), ep.SeriesID)
if err != nil {
s.logger.Printf("番組の公開中エピソード取得に失敗: %s", platformTokenPattern.ReplaceAllString(err.Error(), "${1}[REDACTED]"))
s.render(w, "subscription_action", subscriptionActionView{ID: id, Genre: genre, Choosing: true, DownloadExisting: choice == "1", ErrorText: "公開中の回を取得できませんでした。再試行してください"})
return
}
items := make([]store.Episode, 0, len(episodes))
for _, episode := range episodes {
items = append(items, storeEpisode(episode))
}
_, err = s.jobs.RegisterSubscription(r.Context(), ep.SeriesID, ep.SeriesTitle, items, choice == "1")
if err != nil {
s.logger.Printf("番組登録に失敗: %v", err)
s.render(w, "subscription_action", subscriptionActionView{ID: id, Genre: genre, Choosing: true, DownloadExisting: choice == "1", ErrorText: "番組を登録できませんでした。再試行してください"})
return
}
s.subscriptionRegisteredResponse(w, r, id, genre)
}
func (s *server) subscriptionRegisteredResponse(w http.ResponseWriter, r *http.Request, id, genre string) {
if r.Header.Get("HX-Request") != "true" && r.Method == http.MethodPost {
http.Redirect(w, r, "/?genre="+genre, http.StatusSeeOther)
return
}
s.render(w, "subscription_action", subscriptionActionView{ID: id, Genre: genre, Subscribed: true})
}
func (s *server) isSubscribed(ctx context.Context, seriesID string) (bool, error) {
result, err := s.jobs.SubscribedSeries(ctx, []string{seriesID})
return result[seriesID], err
}
func (s *server) subscriptionsPage(w http.ResponseWriter, r *http.Request) {
view, err := s.loadSubscriptions(r.Context())
if err != nil {
s.logger.Printf("登録番組の取得に失敗: %v", err)
http.Error(w, "登録番組を取得できませんでした", http.StatusInternalServerError)
return
}
s.render(w, "layout", page{Title: "登録番組", Subscriptions: &view})
}
func (s *server) subscriptionsFragment(w http.ResponseWriter, r *http.Request) {
view, err := s.loadSubscriptions(r.Context())
if err != nil {
s.logger.Printf("登録番組の取得に失敗: %v", err)
http.Error(w, "登録番組を取得できませんでした", http.StatusInternalServerError)
return
}
s.render(w, "subscriptions_list", view)
}
func (s *server) loadSubscriptions(ctx context.Context) (subscriptionsView, error) {
subs, err := s.jobs.ListSubscriptions(ctx)
if err != nil {
return subscriptionsView{}, err
}
view := subscriptionsView{Items: make([]subscriptionView, 0, len(subs))}
for _, sub := range subs {
item := subscriptionView{ID: sub.SeriesID, Title: sub.SeriesTitle,
CreatedAtText: sub.CreatedAt.In(s.zone).Format("2006-01-02 15:04 MST"),
LastCheckedText: "未確認", Enabled: sub.Enabled, Jobs: sub.JobCount}
if sub.LastCheckedAt != nil {
item.LastCheckedText = sub.LastCheckedAt.In(s.zone).Format("2006-01-02 15:04 MST")
}
if sub.LastCheckErrorAt != nil && sub.LastCheckError != nil {
item.CheckErrorAtText = sub.LastCheckErrorAt.In(s.zone).Format("2006-01-02 15:04 MST")
item.CheckErrorText = *sub.LastCheckError
}
view.Items = append(view.Items, item)
}
return view, nil
}
func (s *server) pauseSubscription(w http.ResponseWriter, r *http.Request) {
s.subscriptionMutation(w, r, func(ctx context.Context, id string) error {
return s.jobs.SetSubscriptionEnabled(ctx, id, false)
})
}
func (s *server) resumeSubscription(w http.ResponseWriter, r *http.Request) {
s.subscriptionMutation(w, r, func(ctx context.Context, id string) error {
return s.jobs.SetSubscriptionEnabled(ctx, id, true)
})
}
func (s *server) deleteSubscription(w http.ResponseWriter, r *http.Request) {
s.subscriptionMutation(w, r, func(ctx context.Context, id string) error {
deleted, err := s.jobs.DeleteSubscription(ctx, id)
if err == nil && !deleted {
return store.ErrInvalidTransition
}
return err
})
}
func (s *server) subscriptionMutation(w http.ResponseWriter, r *http.Request, change func(context.Context, string) error) {
id := r.PathValue("id")
if id == "" {
http.Error(w, "番組 ID が不正です", http.StatusBadRequest)
return
}
if err := change(r.Context(), id); err != nil {
if errors.Is(err, store.ErrInvalidTransition) {
http.Error(w, "番組が見つかりません", http.StatusNotFound)
} else {
s.logger.Printf("登録番組の操作に失敗: %v", err)
http.Error(w, "登録番組を変更できませんでした", http.StatusInternalServerError)
}
return
}
if r.Header.Get("HX-Request") != "true" {
http.Redirect(w, r, "/subscriptions", http.StatusSeeOther)
return
}
s.subscriptionsFragment(w, r)
}
+199
View File
@@ -0,0 +1,199 @@
package web
import (
"errors"
"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 TestRegisterSubscriptionChoicesAndServerOwnedSeries(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{episodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "本当の番組", Duration: 200}},
seriesEpisodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "本当の番組", Title: "公開中", Duration: 200}}}
jobs := &fakeJobStore{}
handler := newTestHandlerWithStore(fetcher, jobs, nil)
page := getPage(handler, "/")
if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "番組を登録") || jobs.subscribedCalls != 1 || len(jobs.subscribedIDs) != 1 || jobs.subscribedIDs[0] != "sr1" {
t.Fatalf("新着の登録状態: %d ids=%v body=%s", page.Code, jobs.subscribedIDs, page.Body.String())
}
choice := getPage(handler, "/subscriptions/choice?genre=drama&episode_id=ep1")
if choice.Code != http.StatusOK || !strings.Contains(choice.Body.String(), "公開中の回もダウンロードする") || !strings.Contains(choice.Body.String(), ">登録</button>") {
t.Fatalf("登録時の選択: %d %s", choice.Code, choice.Body.String())
}
values := url.Values{"genre": {"drama"}, "episode_id": {"ep1"}, "series_id": {"forged"}, "series_title": {"偽番組"}}
if download {
values.Set("download_existing", "1")
}
registered := postForm(handler, "/subscriptions/register", values)
if registered.Code != http.StatusOK || !strings.Contains(registered.Body.String(), "登録済み") {
t.Fatalf("登録応答: %d %s", registered.Code, registered.Body.String())
}
if jobs.registerCalls != 1 || jobs.registerDownload != download || len(jobs.registerEpisodes) != 1 || jobs.registerEpisodes[0].ID != "ep1" || jobs.registerEpisodes[0].SeriesID != "sr1" || jobs.subscriptions["sr1"].SeriesTitle != "本当の番組" {
t.Fatalf("登録内容: calls=%d download=%t episodes=%+v subs=%+v", jobs.registerCalls, jobs.registerDownload, jobs.registerEpisodes, jobs.subscriptions)
}
if len(fetcher.seriesCalls) != 1 || fetcher.seriesCalls[0] != "sr1" {
t.Fatalf("番組一覧取得: %v", fetcher.seriesCalls)
}
if !strings.Contains(getPage(handler, "/subscriptions").Body.String(), "最終確認: 2026-10-03 09:00 JST") {
t.Fatal("登録直後の最終確認日時が表示されない")
}
fetcher.seriesErr = errors.New("API unavailable")
duplicate := postForm(handler, "/subscriptions/register", values)
if duplicate.Code != http.StatusOK || !strings.Contains(duplicate.Body.String(), "登録済み") || len(fetcher.seriesCalls) != 1 || jobs.registerCalls != 1 {
t.Fatalf("重複登録が API を再取得: %d %s calls=%v register=%d", duplicate.Code, duplicate.Body.String(), fetcher.seriesCalls, jobs.registerCalls)
}
reloaded := getPage(handler, "/")
if !strings.Contains(reloaded.Body.String(), "登録済み") || strings.Contains(reloaded.Body.String(), ">番組を登録</button>") {
t.Fatalf("登録済みの表示: %s", reloaded.Body.String())
}
})
}
}
func TestRegisterSubscriptionRejectsMissingCacheAndFetchFailure(t *testing.T) {
fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200}}}
jobs := &fakeJobStore{}
handler := newTestHandlerWithStore(fetcher, jobs, nil)
values := url.Values{"genre": {"drama"}, "episode_id": {"ep1"}}
if got := postForm(handler, "/subscriptions/register", values).Code; got != http.StatusNotFound {
t.Fatalf("キャッシュ無しの登録: %d", got)
}
getPage(handler, "/")
for _, path := range []string{"/subscriptions/register", "/subscriptions/sr1/pause", "/subscriptions/sr1/resume", "/subscriptions/sr1/delete"} {
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"))
}
}
for _, tt := range []struct {
path string
want int
}{
{"/subscriptions/choice?genre=news&episode_id=ep1", http.StatusBadRequest},
{"/subscriptions/choice?genre=drama", http.StatusBadRequest},
{"/subscriptions/choice?genre=drama&episode_id=missing", http.StatusNotFound},
} {
response := getPage(handler, tt.path)
if response.Code != tt.want {
t.Errorf("不正な選択 %s: got=%d want=%d", tt.path, response.Code, tt.want)
}
}
for _, tt := range []struct {
values url.Values
want int
}{
{url.Values{"genre": {"news"}, "episode_id": {"ep1"}}, http.StatusBadRequest},
{url.Values{"genre": {"drama"}}, http.StatusBadRequest},
{url.Values{"genre": {"drama"}, "episode_id": {"missing"}}, http.StatusNotFound},
} {
response := postForm(handler, "/subscriptions/register", tt.values)
if response.Code != tt.want {
t.Errorf("不正な登録 %v: got=%d want=%d", tt.values, response.Code, tt.want)
}
}
values.Set("download_existing", "bad")
if got := postForm(handler, "/subscriptions/register", values).Code; got != http.StatusBadRequest {
t.Fatalf("不正な選択: %d", got)
}
values.Del("download_existing")
fetcher.seriesErr = errors.New("platform_token=secret& upstream failure")
failed := postForm(handler, "/subscriptions/register", values)
if failed.Code != http.StatusOK || !strings.Contains(failed.Body.String(), "公開中の回を取得できませんでした") || strings.Contains(failed.Body.String(), "secret") || jobs.registerCalls != 0 {
t.Fatalf("取得失敗時に未登録: %d %s calls=%d", failed.Code, failed.Body.String(), jobs.registerCalls)
}
fetcher.seriesErr = nil
fetcher.seriesEpisodes = []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200}}
jobs.registerErr = errors.New("secret database detail")
failed = postForm(handler, "/subscriptions/register", values)
if failed.Code != http.StatusOK || !strings.Contains(failed.Body.String(), "番組を登録できませんでした") || strings.Contains(failed.Body.String(), "secret") || len(jobs.subscriptions) != 0 {
t.Fatalf("DB 失敗時に未登録: %d %s subscriptions=%v", failed.Code, failed.Body.String(), jobs.subscriptions)
}
}
func TestNewerShowsInitiallySubscribedSeriesOncePerSeriesLookup(t *testing.T) {
fetcher := &fakeFetcher{episodes: []tver.Episode{
{ID: "ep1", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200},
{ID: "ep2", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200},
}}
jobs := &fakeJobStore{subscriptions: map[string]store.Subscription{
"sr1": {SeriesID: "sr1", SeriesTitle: "番組", Enabled: true},
}}
response := getPage(newTestHandlerWithStore(fetcher, jobs, nil), "/")
body := response.Body.String()
if response.Code != http.StatusOK || strings.Count(body, "登録済み") != 2 || strings.Contains(body, ">番組を登録</button>") || jobs.subscribedCalls != 1 || len(jobs.subscribedIDs) != 1 || jobs.subscribedIDs[0] != "sr1" {
t.Fatalf("登録済み番組の初期表示: status=%d calls=%d ids=%v body=%s", response.Code, jobs.subscribedCalls, jobs.subscribedIDs, body)
}
}
func TestSubscriptionMutationsWithoutHtmxRedirect(t *testing.T) {
fetcher := &fakeFetcher{
episodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200}},
seriesEpisodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1", SeriesTitle: "番組", Duration: 200}},
}
jobs := &fakeJobStore{}
handler := newTestHandlerWithStore(fetcher, jobs, nil)
getPage(handler, "/")
form := url.Values{"genre": {"drama"}, "episode_id": {"ep1"}}
request := httptest.NewRequest(http.MethodPost, "/subscriptions/register", strings.NewReader(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") != "/?genre=drama" {
t.Fatalf("通常登録 POST: status=%d Location=%q", response.Code, response.Header().Get("Location"))
}
request = httptest.NewRequest(http.MethodPost, "/subscriptions/sr1/pause", nil)
response = httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusSeeOther || response.Header().Get("Location") != "/subscriptions" || jobs.subscriptions["sr1"].Enabled {
t.Fatalf("通常操作 POST: status=%d Location=%q subscription=%+v", response.Code, response.Header().Get("Location"), jobs.subscriptions["sr1"])
}
}
func TestSubscriptionsPagePauseResumeDeleteAndEscaping(t *testing.T) {
checked := time.Date(2026, 10, 4, 2, 30, 0, 0, time.UTC)
failedAt := time.Date(2026, 10, 4, 3, 45, 0, 0, time.UTC)
checkError := `<script>alert("error")</script> token=[REDACTED]`
jobs := &fakeJobStore{subscriptions: map[string]store.Subscription{
"sr1": {SeriesID: "sr1", SeriesTitle: `<script>alert(1)</script>`, Enabled: true,
CreatedAt: time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC), LastCheckedAt: &checked,
LastCheckErrorAt: &failedAt, LastCheckError: &checkError, JobCount: 3},
"sr2": {SeriesID: "sr2", SeriesTitle: "別番組", Enabled: false, JobCount: 0},
}}
handler := newTestHandlerWithStore(&fakeFetcher{}, jobs, nil)
page := getPage(handler, "/subscriptions")
if page.Code != http.StatusOK {
t.Fatalf("登録番組ページ: %d", page.Code)
}
body := page.Body.String()
for _, want := range []string{"&lt;script&gt;", "登録日時: 2026-10-03 09:00 JST", "最終確認: 2026-10-04 11:30 JST", "最終確認: 未確認", "最後の確認エラー(2026-10-04 12:45 JST)", `&lt;script&gt;alert(&#34;error&#34;)&lt;/script&gt; token=[REDACTED]`, "関連ジョブ: 3件", "有効", "一時停止", `hx-confirm="番組の登録を解除しますか?"`} {
if !strings.Contains(body, want) {
t.Errorf("登録番組の情報 %q がない", want)
}
}
if strings.Contains(body, `<script>`) {
t.Fatal("番組名または確認エラーがエスケープされていない")
}
paused := postForm(handler, "/subscriptions/sr1/pause", nil)
if paused.Code != http.StatusOK || jobs.subscriptions["sr1"].Enabled || !strings.Contains(paused.Body.String(), `action="/subscriptions/sr1/resume"`) || strings.Contains(paused.Body.String(), "<html") {
t.Fatalf("一時停止: %d %s", paused.Code, paused.Body.String())
}
resumed := postForm(handler, "/subscriptions/sr1/resume", nil)
if resumed.Code != http.StatusOK || !jobs.subscriptions["sr1"].Enabled || !strings.Contains(resumed.Body.String(), `action="/subscriptions/sr1/pause"`) {
t.Fatalf("再開: %d %s", resumed.Code, resumed.Body.String())
}
deleted := postForm(handler, "/subscriptions/sr1/delete", nil)
if deleted.Code != http.StatusOK || len(jobs.subscriptions) != 1 || strings.Contains(deleted.Body.String(), "&lt;script&gt;") {
t.Fatalf("解除: %d %s", deleted.Code, deleted.Body.String())
}
if got := postForm(handler, "/subscriptions/sr1/delete", nil).Code; got != http.StatusNotFound {
t.Fatalf("存在しない番組の解除: %d", got)
}
}
+6
View File
@@ -22,6 +22,12 @@
{{template "newer_page" .Newer}} {{template "newer_page" .Newer}}
{{else if .Queue}} {{else if .Queue}}
{{template "queue_page" .Queue}} {{template "queue_page" .Queue}}
{{else if .Subscriptions}}
{{template "subscriptions_page" .Subscriptions}}
{{else if .Library}}
{{template "library_page" .Library}}
{{else if .Video}}
{{template "video_page" .Video}}
{{else}} {{else}}
<h1>{{.Title}}</h1> <h1>{{.Title}}</h1>
{{end}} {{end}}
+34
View File
@@ -0,0 +1,34 @@
{{define "library_page"}}
<h1>ライブラリ</h1>
{{if .Items}}
<div class="episode-grid">
{{range .Items}}
<article class="episode-card">
<a href="/library/{{.ID}}">
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
<div class="episode-copy">
<h2>{{.SeriesTitle}}</h2>
<p>{{.Title}}</p>
<p>放送局: {{.BroadcasterName}}</p>
<p>長さ: {{.DurationText}}</p>
<p>サイズ: {{.FileSizeText}}</p>
<p>完了日時: {{.FinishedAtText}}</p>
</div>
</a>
</article>
{{end}}
</div>
{{else}}
<p>ダウンロード済みの動画はありません</p>
{{end}}
{{end}}
{{define "video_page"}}
<h1>{{.SeriesTitle}}</h1>
<h2>{{.Title}}</h2>
<video controls preload="metadata" src="{{.VideoURL}}"></video>
<p>放送局: {{.BroadcasterName}}/長さ: {{.DurationText}}/サイズ: {{.FileSizeText}}/完了日時: {{.FinishedAtText}}</p>
<form action="/library/{{.ID}}/delete" method="post" hx-confirm="動画ファイルと記録を削除しますか?">
<button type="submit">削除</button>
</form>
{{end}}
+1
View File
@@ -40,6 +40,7 @@
</div> </div>
</a> </a>
{{template "episode_action" .}} {{template "episode_action" .}}
{{template "subscription_action" .SubscriptionAction}}
</article> </article>
{{end}} {{end}}
</div> </div>
+3
View File
@@ -38,6 +38,9 @@
{{if .SpeedText}}/{{.SpeedText}}{{end}} {{if .SpeedText}}/{{.SpeedText}}{{end}}
{{if .ETAText}}/{{.ETAText}}{{end}} {{if .ETAText}}/{{.ETAText}}{{end}}
{{if .FragmentText}}/{{.FragmentText}}{{end}}</p> {{if .FragmentText}}/{{.FragmentText}}{{end}}</p>
<form action="/jobs/{{.ID}}/cancel" method="post" hx-post="/jobs/{{.ID}}/cancel" hx-target="#queue-list" hx-swap="outerHTML">
<button type="submit">取り消し</button>
</form>
{{end}} {{end}}
{{if eq .Status "failed"}} {{if eq .Status "failed"}}
<p>失敗理由: {{.ErrorKindText}}</p> <p>失敗理由: {{.ErrorKindText}}</p>
+44
View File
@@ -0,0 +1,44 @@
{{define "subscription_action"}}
<div class="subscription-action">
{{if .Subscribed}}
<span role="status">登録済み</span>
{{else if .Choosing}}
{{if .ErrorText}}<p role="alert">{{.ErrorText}}</p>{{end}}
<form action="/subscriptions/register" method="post" hx-post="/subscriptions/register" hx-target="closest .subscription-action" hx-swap="outerHTML">
<input type="hidden" name="episode_id" value="{{.ID}}">
<input type="hidden" name="genre" value="{{.Genre}}">
<label><input type="checkbox" name="download_existing" value="1" {{if .DownloadExisting}}checked{{end}}>公開中の回もダウンロードする</label>
<button type="submit">登録</button>
</form>
{{else}}
<button type="button" hx-get="/subscriptions/choice?genre={{.Genre}}&amp;episode_id={{.ID}}" hx-target="closest .subscription-action" hx-swap="outerHTML">番組を登録</button>
{{end}}
</div>
{{end}}
{{define "subscriptions_page"}}
<h1>登録番組</h1>
{{template "subscriptions_list" .}}
{{end}}
{{define "subscriptions_list"}}
<div id="subscriptions-list">
{{range .Items}}
<article class="subscription-item">
<h2>{{.Title}}</h2>
<p>登録日時: {{.CreatedAtText}}/最終確認: {{.LastCheckedText}}/状態: {{if .Enabled}}有効{{else}}一時停止{{end}}/関連ジョブ: {{.Jobs}}件</p>
{{if .CheckErrorText}}
<details class="error"><summary>最後の確認エラー({{.CheckErrorAtText}})</summary><p>{{.CheckErrorText}}</p></details>
{{end}}
{{if .Enabled}}
<form action="/subscriptions/{{.ID}}/pause" method="post" hx-post="/subscriptions/{{.ID}}/pause" hx-target="#subscriptions-list" hx-swap="outerHTML"><button type="submit">一時停止</button></form>
{{else}}
<form action="/subscriptions/{{.ID}}/resume" method="post" hx-post="/subscriptions/{{.ID}}/resume" hx-target="#subscriptions-list" hx-swap="outerHTML"><button type="submit">再開</button></form>
{{end}}
<form action="/subscriptions/{{.ID}}/delete" method="post" hx-post="/subscriptions/{{.ID}}/delete" hx-target="#subscriptions-list" hx-swap="outerHTML" hx-confirm="番組の登録を解除しますか?"><button type="submit">解除</button></form>
</article>
{{else}}
<p>登録番組はありません</p>
{{end}}
</div>
{{end}}
+81
View File
@@ -0,0 +1,81 @@
package worker
import (
"encoding/json"
"strings"
"gitea.kazu634.com/kazu634/tver/internal/store"
)
type outputEvent struct {
Event string `json:"event"`
FormatID string `json:"format_id"`
Filepath string `json:"filepath"`
Progress struct {
DownloadedBytes *float64 `json:"downloaded_bytes"`
TotalBytes *float64 `json:"total_bytes"`
TotalBytesEstimate *float64 `json:"total_bytes_estimate"`
Speed *float64 `json:"speed"`
ETA *float64 `json:"eta"`
FragmentIndex *float64 `json:"fragment_index"`
FragmentCount *float64 `json:"fragment_count"`
} `json:"progress"`
}
func parseProgress(line string) (store.Progress, bool) {
var event outputEvent
if err := json.Unmarshal([]byte(line), &event); err != nil {
return store.Progress{}, false
}
phase := ""
switch event.Event {
case "download":
if strings.Contains(strings.ToLower(event.FormatID), "audio") {
phase = "audio"
} else {
phase = "video"
}
case "postprocess":
phase = "merging"
default:
return store.Progress{}, false
}
total := int64Pointer(event.Progress.TotalBytes)
estimate := false
if total == nil && event.Progress.TotalBytesEstimate != nil {
total = int64Pointer(event.Progress.TotalBytesEstimate)
estimate = true
}
var formatID *string
if event.FormatID != "" {
formatID = &event.FormatID
}
return store.Progress{Phase: phase, DownloadedBytes: int64Pointer(event.Progress.DownloadedBytes),
TotalBytes: total, TotalIsEstimate: estimate, SpeedBPS: event.Progress.Speed,
ETASeconds: event.Progress.ETA, FragmentIndex: int32Pointer(event.Progress.FragmentIndex),
FragmentCount: int32Pointer(event.Progress.FragmentCount), FormatID: formatID}, true
}
func int64Pointer(value *float64) *int64 {
if value == nil {
return nil
}
result := int64(*value)
return &result
}
func int32Pointer(value *float64) *int32 {
if value == nil {
return nil
}
result := int32(*value)
return &result
}
func parseAfterMove(line string) (string, bool) {
var event outputEvent
if err := json.Unmarshal([]byte(line), &event); err != nil || event.Event != "after_move" || event.Filepath == "" {
return "", false
}
return event.Filepath, true
}
+27
View File
@@ -0,0 +1,27 @@
package worker
import "testing"
func TestParseProgress(t *testing.T) {
video, ok := parseProgress(`{"event":"download","format_id":"hls-698-1","progress":{"downloaded_bytes":25,"total_bytes_estimate":100.0,"speed":10.5,"eta":3,"fragment_index":1,"fragment_count":4}}`)
if !ok || video.Phase != "video" || video.DownloadedBytes == nil || *video.DownloadedBytes != 25 || video.TotalBytes == nil || *video.TotalBytes != 100 || !video.TotalIsEstimate || video.SpeedBPS == nil || *video.SpeedBPS != 10.5 || video.ETASeconds == nil || *video.ETASeconds != 3 || video.FragmentIndex == nil || *video.FragmentIndex != 1 || video.FragmentCount == nil || *video.FragmentCount != 4 || video.FormatID == nil || *video.FormatID != "hls-698-1" {
t.Fatalf("映像進捗: %+v ok=%v", video, ok)
}
audio, ok := parseProgress(`{"event":"download","format_id":"hls-ts_AUDIO-0_2","progress":{"downloaded_bytes":50,"total_bytes":50}}`)
if !ok || audio.Phase != "audio" || audio.TotalIsEstimate || audio.TotalBytes == nil || *audio.TotalBytes != 50 {
t.Fatalf("音声進捗: %+v ok=%v", audio, ok)
}
merging, ok := parseProgress(`{"event":"postprocess","progress":{"status":"started","postprocessor":"Merger"}}`)
if !ok || merging.Phase != "merging" {
t.Fatalf("結合進捗: %+v ok=%v", merging, ok)
}
path, ok := parseAfterMove(`{"event":"after_move","filepath":"/videos/incoming/ep.mp4"}`)
if !ok || path != "/videos/incoming/ep.mp4" {
t.Fatalf("移動後パス: %q ok=%v", path, ok)
}
for _, invalid := range []string{"noise", `{"event":"other"}`} {
if _, ok := parseProgress(invalid); ok {
t.Errorf("不正な進捗を許した: %q", invalid)
}
}
}
+504
View File
@@ -0,0 +1,504 @@
package worker
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"strconv"
"strings"
"sync"
"syscall"
"time"
"gitea.kazu634.com/kazu634/tver/internal/media"
"gitea.kazu634.com/kazu634/tver/internal/store"
"github.com/jackc/pgx/v5"
)
var (
errCanceled = errors.New("job canceled")
errShutdown = errors.New("worker shutting down")
errInvalidOutput = errors.New("invalid output")
headerSecretRE = regexp.MustCompile(`(?im)(authorization|cookie)([=:][ \t]*)([^\r\n]+)`)
paramSecretRE = regexp.MustCompile(`(?i)(token|platform_uid|platform_token)([=:][[:space:]]*)([^&[:space:]]+)`)
)
type JobStore interface {
ClaimNext(context.Context) (store.Job, error)
UpdateProgress(context.Context, int64, store.Progress) error
Heartbeat(context.Context, int64) error
GetJob(context.Context, int64) (store.Job, error)
Complete(context.Context, int64, string, int64) error
Fail(context.Context, int64, string, string) error
RequeueRunning(context.Context, int64) error
}
type Config struct {
VideoDir string
YTDLPPath string
FFprobePath string
Concurrency int
PollInterval time.Duration
ProgressInterval time.Duration
HeartbeatInterval time.Duration
CancelInterval time.Duration
}
type Runner struct {
store JobStore
config Config
logger *log.Logger
}
type cappedBuffer struct {
buffer bytes.Buffer
limit int
}
func (b *cappedBuffer) Write(p []byte) (int, error) {
written := len(p)
remaining := b.limit - b.buffer.Len()
if remaining > 0 {
if remaining < len(p) {
p = p[:remaining]
}
_, _ = b.buffer.Write(p)
}
return written, nil
}
func (b *cappedBuffer) String() string { return b.buffer.String() }
func scanLines(reader io.Reader, capture *cappedBuffer) <-chan string {
lines := make(chan string)
go func() {
defer close(lines)
scanner := bufio.NewScanner(reader)
scanner.Buffer(make([]byte, 4096), 1<<20)
for scanner.Scan() {
line := scanner.Text()
if capture != nil {
_, _ = capture.Write([]byte(line + "\n"))
}
lines <- line
}
}()
return lines
}
func New(jobs JobStore, config Config, logger *log.Logger) (*Runner, error) {
if config.VideoDir == "" {
config.VideoDir = "/videos"
}
if config.YTDLPPath == "" {
config.YTDLPPath = "yt-dlp"
}
if config.FFprobePath == "" {
config.FFprobePath = "ffprobe"
}
if config.Concurrency == 0 {
config.Concurrency = 1
}
if config.Concurrency < 1 || config.Concurrency > 2 {
return nil, errors.New("worker concurrency must be 1 or 2")
}
if config.PollInterval <= 0 {
config.PollInterval = time.Second
}
if config.ProgressInterval <= 0 {
config.ProgressInterval = time.Second
}
if config.HeartbeatInterval <= 0 {
config.HeartbeatInterval = 5 * time.Second
}
if config.CancelInterval <= 0 {
config.CancelInterval = 500 * time.Millisecond
}
if logger == nil {
logger = log.Default()
}
return &Runner{store: jobs, config: config, logger: logger}, nil
}
func (r *Runner) Run(ctx context.Context) error {
sem := make(chan struct{}, r.config.Concurrency)
var wg sync.WaitGroup
ticker := time.NewTicker(r.config.PollInterval)
defer ticker.Stop()
for {
for len(sem) < cap(sem) && ctx.Err() == nil {
job, err := r.store.ClaimNext(ctx)
if errors.Is(err, pgx.ErrNoRows) {
break
}
if err != nil {
r.logger.Printf("ジョブ取得に失敗: %v", err)
break
}
sem <- struct{}{}
wg.Add(1)
go func() {
defer wg.Done()
defer func() { <-sem }()
r.ProcessJob(ctx, job)
}()
}
select {
case <-ctx.Done():
wg.Wait()
return nil
case <-ticker.C:
}
}
}
func (r *Runner) ProcessJob(ctx context.Context, job store.Job) {
workDir := filepath.Join(r.config.VideoDir, ".incoming", strconv.FormatInt(job.ID, 10))
if err := os.RemoveAll(workDir); err != nil {
r.fail(job.ID, "unknown", fmt.Sprintf("作業ディレクトリを初期化できません: %v", err))
return
}
if err := os.MkdirAll(workDir, 0o755); err != nil {
r.fail(job.ID, "unknown", fmt.Sprintf("作業ディレクトリを作成できません: %v", err))
return
}
defer os.RemoveAll(workDir)
relative, err := media.RelativeVideoPath(job.SeriesTitle, job.Title, job.EpisodeID)
if err != nil {
r.failIfRunning(job.ID, "invalid_output", redact(err.Error()))
return
}
downloaded, stderr, err := r.download(ctx, job, workDir)
if errors.Is(err, errShutdown) {
r.requeue(job.ID)
return
}
if errors.Is(err, errCanceled) {
return
}
if err != nil {
message := redact(stderr)
if message == "" {
message = redact(err.Error())
}
kind := classifyFailure(message)
if errors.Is(err, errInvalidOutput) {
kind = "invalid_output"
}
r.failIfRunning(job.ID, kind, message)
return
}
if r.canceled(job.ID) {
return
}
if err := r.probe(ctx, downloaded); err != nil {
if ctx.Err() != nil {
r.requeue(job.ID)
return
}
r.failIfRunning(job.ID, "invalid_output", redact(err.Error()))
return
}
if r.canceled(job.ID) {
return
}
if err := r.store.UpdateProgress(ctx, job.ID, store.Progress{Phase: "moving"}); err != nil {
if r.canceled(job.ID) {
return
}
r.failIfRunning(job.ID, "unknown", "移動状態を記録できません: "+err.Error())
return
}
destination := filepath.Join(r.config.VideoDir, relative)
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
r.failIfRunning(job.ID, "unknown", "保存先を作成できません: "+err.Error())
return
}
if err := os.Rename(downloaded, destination); err != nil {
r.failIfRunning(job.ID, "unknown", "動画を保存先へ移動できません: "+err.Error())
return
}
info, err := os.Stat(destination)
if err != nil {
r.failIfRunning(job.ID, "unknown", "保存後の動画を確認できません: "+err.Error())
return
}
if err := r.store.Complete(ctx, job.ID, filepath.ToSlash(relative), info.Size()); err != nil {
if r.canceled(job.ID) {
if removeErr := os.Remove(destination); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
r.logger.Printf("取り消されたジョブ %d の保存済みファイル削除に失敗: %v", job.ID, removeErr)
}
return
}
r.logger.Printf("ジョブ %d の完了記録に失敗: %v", job.ID, err)
}
}
func (r *Runner) download(ctx context.Context, job store.Job, workDir string) (string, string, error) {
output := filepath.Join(workDir, "%(id)s.%(ext)s")
args := []string{"--no-playlist", "--no-simulate", "--quiet", "--progress", "--newline",
"--continue", "--retries", "0", "--fragment-retries", "0", "--extractor-retries", "0",
"-f", "bv*[vcodec^=avc1]+ba/b", "--merge-output-format", "mp4",
"--progress-template", `download:{"event":"download","format_id":%(info.format_id)j,"progress":%(progress)j}`,
"--progress-template", `postprocess:{"event":"postprocess","progress":%(progress)j}`,
"--print", `after_move:{"event":"after_move","filepath":%(filepath)j}`, "-o", output,
"https://tver.jp/episodes/" + job.EpisodeID}
cmd := exec.Command(r.config.YTDLPPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
stdout, err := cmd.StdoutPipe()
if err != nil {
return "", "", err
}
stderrPipe, err := cmd.StderrPipe()
if err != nil {
return "", "", err
}
if err := cmd.Start(); err != nil {
return "", "", err
}
stderr := cappedBuffer{limit: 64 << 10}
stdoutLines := scanLines(stdout, nil)
stderrLines := scanLines(stderrPipe, &stderr)
waited := make(chan error, 1)
go func() { waited <- cmd.Wait() }()
heartbeat := time.NewTicker(r.config.HeartbeatInterval)
cancelCheck := time.NewTicker(r.config.CancelInterval)
defer heartbeat.Stop()
defer cancelCheck.Stop()
_ = r.store.Heartbeat(ctx, job.ID)
var afterMove string
var pending *store.Progress
nextProgress := time.Time{}
lastPhase := ""
recordProgress := func(progress store.Progress) {
pending = &progress
now := time.Now()
if progress.Phase != lastPhase || now.After(nextProgress) {
_ = r.store.UpdateProgress(ctx, job.ID, progress)
nextProgress = now.Add(r.config.ProgressInterval)
lastPhase = progress.Phase
pending = nil
}
}
stopping := ""
var forceKill <-chan time.Time
ctxDone := ctx.Done()
waitComplete := false
var waitErr error
for {
if waitComplete && stdoutLines == nil && stderrLines == nil {
if pending != nil && stopping == "" {
updateCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = r.store.UpdateProgress(updateCtx, job.ID, *pending)
cancel()
}
if stopping == "canceled" {
return "", stderr.String(), errCanceled
}
if stopping == "shutdown" {
return "", stderr.String(), errShutdown
}
if waitErr != nil {
r.logger.Printf("yt-dlp ジョブ %d 失敗: %s", job.ID, redact(stderr.String()))
return "", stderr.String(), waitErr
}
path, err := pathInside(workDir, afterMove)
if err != nil {
return "", stderr.String(), err
}
return path, stderr.String(), nil
}
select {
case line, ok := <-stdoutLines:
if !ok {
stdoutLines = nil
continue
}
if path, ok := parseAfterMove(line); ok {
afterMove = path
continue
}
if progress, ok := parseProgress(line); ok {
recordProgress(progress)
}
case line, ok := <-stderrLines:
if !ok {
stderrLines = nil
continue
}
if progress, ok := parseProgress(line); ok {
recordProgress(progress)
}
case <-heartbeat.C:
_ = r.store.Heartbeat(ctx, job.ID)
case <-cancelCheck.C:
if r.canceled(job.ID) && stopping == "" {
stopping = "canceled"
if !waitComplete {
stopProcessGroup(cmd)
forceKill = time.After(2 * time.Second)
}
}
case <-ctxDone:
if stopping == "" {
stopping = "shutdown"
if !waitComplete {
stopProcessGroup(cmd)
forceKill = time.After(2 * time.Second)
}
}
ctxDone = nil
case <-forceKill:
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
forceKill = nil
case waitErr = <-waited:
waitComplete = true
waited = nil
forceKill = nil
}
}
}
func stopProcessGroup(cmd *exec.Cmd) {
if cmd.Process == nil {
return
}
_ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM)
}
func pathInside(root, candidate string) (string, error) {
if candidate == "" {
return "", errors.New("yt-dlp did not report after_move filepath")
}
rootAbs, err := filepath.Abs(root)
if err != nil {
return "", err
}
candidateAbs, err := filepath.Abs(candidate)
if err != nil {
return "", err
}
rel, err := filepath.Rel(rootAbs, candidateAbs)
if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("%w: yt-dlp filepath leaves working directory", errInvalidOutput)
}
info, err := os.Lstat(candidateAbs)
if err != nil {
return "", fmt.Errorf("%w: yt-dlp output file is missing: %v", errInvalidOutput, err)
}
if !info.Mode().IsRegular() {
return "", fmt.Errorf("%w: yt-dlp output is not a regular file", errInvalidOutput)
}
rootReal, err := filepath.EvalSymlinks(rootAbs)
if err != nil {
return "", fmt.Errorf("%w: resolve working directory: %v", errInvalidOutput, err)
}
candidateReal, err := filepath.EvalSymlinks(candidateAbs)
if err != nil {
return "", fmt.Errorf("%w: resolve yt-dlp output: %v", errInvalidOutput, err)
}
realRelative, err := filepath.Rel(rootReal, candidateReal)
if err != nil || realRelative == ".." || strings.HasPrefix(realRelative, ".."+string(filepath.Separator)) {
return "", fmt.Errorf("%w: yt-dlp output resolves outside working directory", errInvalidOutput)
}
return candidateAbs, nil
}
func (r *Runner) probe(ctx context.Context, path string) error {
cmd := exec.CommandContext(ctx, r.config.FFprobePath, "-v", "error", "-show_entries",
"format=format_name", "-show_entries", "stream=codec_type,codec_name", "-of", "json", path)
output, err := cmd.Output()
if err != nil {
return fmt.Errorf("ffprobe failed: %w", err)
}
var result struct {
Format struct {
FormatName string `json:"format_name"`
} `json:"format"`
Streams []struct {
CodecType string `json:"codec_type"`
CodecName string `json:"codec_name"`
} `json:"streams"`
}
if err := json.Unmarshal(output, &result); err != nil {
return fmt.Errorf("ffprobe output is not JSON: %w", err)
}
video, audio := false, false
for _, stream := range result.Streams {
video = video || stream.CodecType == "video" && stream.CodecName == "h264"
audio = audio || stream.CodecType == "audio" && stream.CodecName == "aac"
}
if !strings.Contains(result.Format.FormatName, "mp4") || !video || !audio {
return fmt.Errorf("expected MP4 with H.264 and AAC, got format=%s video=%t audio=%t", result.Format.FormatName, video, audio)
}
return nil
}
func (r *Runner) canceled(id int64) bool {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
job, err := r.store.GetJob(ctx, id)
return err == nil && job.Status == "canceled"
}
func (r *Runner) failIfRunning(id int64, kind, message string) {
if r.canceled(id) {
return
}
r.fail(id, kind, message)
}
func (r *Runner) fail(id int64, kind, message string) {
if len(message) > 8192 {
message = message[:8192]
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := r.store.Fail(ctx, id, kind, message); err != nil {
r.logger.Printf("ジョブ %d の失敗記録に失敗: %v", id, err)
}
}
func (r *Runner) requeue(id int64) {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := r.store.RequeueRunning(ctx, id); err != nil && !errors.Is(err, store.ErrInvalidTransition) {
r.logger.Printf("ジョブ %d の待機中への復帰に失敗: %v", id, err)
}
}
func classifyFailure(message string) string {
lower := strings.ToLower(message)
for _, test := range []struct {
kind string
patterns []string
}{
{"drm", []string{"drm", "digital rights management", "protected by"}},
{"geo", []string{"geo-restricted", "not available in your country", "not available from your location", "geo restriction", "region restriction", "geographic restriction"}},
{"network", []string{"network", "connection", "timed out", "temporary failure", "unable to download", "proxy error", "http error 5"}},
{"unavailable", []string{"media_not_found", "video unavailable", "not available", "no longer available", "http error 404", "episode does not exist"}},
{"extract", []string{"extractor error", "unable to extract", "unsupported url", "no video formats", "after_move", "output file is missing"}},
} {
for _, pattern := range test.patterns {
if strings.Contains(lower, pattern) {
return test.kind
}
}
}
return "unknown"
}
func redact(message string) string {
message = headerSecretRE.ReplaceAllString(message, "$1$2[REDACTED]")
return strings.TrimSpace(paramSecretRE.ReplaceAllString(message, "$1$2[REDACTED]"))
}
+678
View File
@@ -0,0 +1,678 @@
package worker
import (
"context"
"errors"
"io"
"log"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"gitea.kazu634.com/kazu634/tver/internal/media"
"gitea.kazu634.com/kazu634/tver/internal/store"
"github.com/jackc/pgx/v5"
)
type fakeStore struct {
mu sync.Mutex
job store.Job
progress []store.Progress
heartbeats int
completed bool
completePath string
completeSize int64
failedKind, failedMessage string
requeued bool
claimed bool
}
type queueStore struct {
mu sync.Mutex
jobs []store.Job
claimCounts map[int64]int
active int
maxActive int
}
func (f *queueStore) ClaimNext(context.Context) (store.Job, error) {
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.jobs {
if f.jobs[i].Status != "queued" {
continue
}
f.jobs[i].Status = "running"
f.claimCounts[f.jobs[i].ID]++
f.active++
if f.active > f.maxActive {
f.maxActive = f.active
}
return f.jobs[i], nil
}
return store.Job{}, pgx.ErrNoRows
}
func (f *queueStore) UpdateProgress(_ context.Context, id int64, _ store.Progress) error {
return f.requireRunning(id)
}
func (f *queueStore) Heartbeat(_ context.Context, id int64) error {
return f.requireRunning(id)
}
func (f *queueStore) requireRunning(id int64) error {
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.jobs {
if f.jobs[i].ID == id && f.jobs[i].Status == "running" {
return nil
}
}
return store.ErrInvalidTransition
}
func (f *queueStore) GetJob(_ context.Context, id int64) (store.Job, error) {
f.mu.Lock()
defer f.mu.Unlock()
for _, job := range f.jobs {
if job.ID == id {
return job, nil
}
}
return store.Job{}, pgx.ErrNoRows
}
func (f *queueStore) Complete(_ context.Context, id int64, _ string, _ int64) error {
return f.finish(id, "succeeded")
}
func (f *queueStore) Fail(_ context.Context, id int64, _, _ string) error {
return f.finish(id, "failed")
}
func (f *queueStore) RequeueRunning(_ context.Context, id int64) error {
return f.finish(id, "queued")
}
func (f *queueStore) finish(id int64, status string) error {
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.jobs {
if f.jobs[i].ID == id && f.jobs[i].Status == "running" {
f.jobs[i].Status = status
f.active--
return nil
}
}
return store.ErrInvalidTransition
}
func (f *queueStore) state() ([]store.Job, map[int64]int, int) {
f.mu.Lock()
defer f.mu.Unlock()
jobs := append([]store.Job(nil), f.jobs...)
counts := make(map[int64]int, len(f.claimCounts))
for id, count := range f.claimCounts {
counts[id] = count
}
return jobs, counts, f.maxActive
}
func (f *fakeStore) ClaimNext(context.Context) (store.Job, error) {
f.mu.Lock()
defer f.mu.Unlock()
if f.claimed || f.job.Status != "queued" {
return store.Job{}, pgx.ErrNoRows
}
f.claimed = true
f.job.Status = "running"
return f.job, nil
}
func (f *fakeStore) UpdateProgress(_ context.Context, _ int64, p store.Progress) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.job.Status != "running" {
return store.ErrInvalidTransition
}
f.progress = append(f.progress, p)
return nil
}
func (f *fakeStore) Heartbeat(context.Context, int64) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.job.Status != "running" {
return store.ErrInvalidTransition
}
f.heartbeats++
return nil
}
func (f *fakeStore) GetJob(context.Context, int64) (store.Job, error) {
f.mu.Lock()
defer f.mu.Unlock()
return f.job, nil
}
func (f *fakeStore) Complete(_ context.Context, _ int64, path string, size int64) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.job.Status != "running" {
return store.ErrInvalidTransition
}
f.job.Status = "succeeded"
f.completed, f.completePath, f.completeSize = true, path, size
return nil
}
func (f *fakeStore) Fail(_ context.Context, _ int64, kind, message string) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.job.Status != "running" {
return store.ErrInvalidTransition
}
f.job.Status = "failed"
f.failedKind, f.failedMessage = kind, message
return nil
}
func (f *fakeStore) RequeueRunning(context.Context, int64) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.job.Status != "running" {
return store.ErrInvalidTransition
}
f.job.Status = "queued"
f.requeued = true
return nil
}
func (f *fakeStore) setStatus(status string) {
f.mu.Lock()
defer f.mu.Unlock()
f.job.Status = status
}
func (f *fakeStore) heartbeatCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.heartbeats
}
func testRunner(t *testing.T, jobs *fakeStore, videoDir string) *Runner {
t.Helper()
ytdlp := filepath.Join("testdata", "fake-yt-dlp.sh")
ffprobe := filepath.Join("testdata", "fake-ffprobe.sh")
for _, path := range []string{ytdlp, ffprobe} {
info, err := os.Stat(path)
if err != nil || info.Mode().Perm()&0o111 == 0 {
t.Fatalf("偽コマンドが実行可能ではありません: %s: %v", path, err)
}
}
runner, err := New(jobs, Config{VideoDir: videoDir, YTDLPPath: ytdlp, FFprobePath: ffprobe,
Concurrency: 1, PollInterval: 5 * time.Millisecond, ProgressInterval: time.Millisecond,
HeartbeatInterval: 10 * time.Millisecond, CancelInterval: 5 * time.Millisecond}, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
return runner
}
func runningJob() store.Job {
return store.Job{ID: 7, EpisodeID: "ep123", SeriesTitle: "番組名", Title: "第1話", Status: "running"}
}
func relativeVideoPath(t *testing.T, seriesTitle, episodeTitle, episodeID string) string {
t.Helper()
path, err := media.RelativeVideoPath(seriesTitle, episodeTitle, episodeID)
if err != nil {
t.Fatal(err)
}
return path
}
func TestProcessJobDownloadsProbesMovesAndCleans(t *testing.T) {
t.Setenv("FAKE_YTDLP_MODE", "success")
t.Setenv("FAKE_FFPROBE_MODE", "valid")
dir := t.TempDir()
argsFile := filepath.Join(dir, "args")
t.Setenv("FAKE_ARGS_FILE", argsFile)
jobs := &fakeStore{job: runningJob()}
testRunner(t, jobs, dir).ProcessJob(context.Background(), jobs.job)
want := filepath.ToSlash(relativeVideoPath(t, "番組名", "第1話", "ep123"))
if !jobs.completed || jobs.completePath != want || jobs.completeSize == 0 {
t.Fatalf("complete: done=%v path=%q size=%d", jobs.completed, jobs.completePath, jobs.completeSize)
}
if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(want))); err != nil {
t.Fatalf("保存先: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, ".incoming", "7")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("作業ディレクトリが残った: %v", err)
}
args, err := os.ReadFile(argsFile)
if err != nil {
t.Fatal(err)
}
argumentText := string(args)
for _, want := range []string{"--newline", "--no-playlist", "--no-simulate", "--quiet", "--progress", "--continue", "--merge-output-format", "bv*[vcodec^=avc1]+ba/b", `download:{"event":"download","format_id":%(info.format_id)j,"progress":%(progress)j}`, `postprocess:{"event":"postprocess","progress":%(progress)j}`, `after_move:{"event":"after_move","filepath":%(filepath)j}`, filepath.Join(dir, ".incoming", "7", "%(id)s.%(ext)s")} {
if !strings.Contains(argumentText, want) {
t.Errorf("yt-dlp 引数に %q がない: %s", want, argumentText)
}
}
if strings.Contains(argumentText, "番組名") || strings.Contains(argumentText, "第1話") {
t.Errorf("出力テンプレートに番組情報が入った: %s", argumentText)
}
phases := make(map[string]bool)
for _, progress := range jobs.progress {
phases[progress.Phase] = true
}
for _, phase := range []string{"video", "audio", "merging", "moving"} {
if !phases[phase] {
t.Errorf("進捗段階 %s がない: %+v", phase, jobs.progress)
}
}
if jobs.heartbeats < 2 {
t.Errorf("heartbeat が少ない: %d", jobs.heartbeats)
}
}
func TestProcessJobRejectsLongEpisodeIDAndCleans(t *testing.T) {
dir := t.TempDir()
argsFile := filepath.Join(dir, "yt-dlp-args")
t.Setenv("FAKE_ARGS_FILE", argsFile)
workDir := filepath.Join(dir, ".incoming", "7")
if err := os.MkdirAll(workDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(workDir, "stale"), []byte("stale"), 0o644); err != nil {
t.Fatal(err)
}
job := runningJob()
job.EpisodeID = strings.Repeat("i", 65)
jobs := &fakeStore{job: job}
testRunner(t, jobs, dir).ProcessJob(context.Background(), job)
if jobs.completed || jobs.failedKind != "invalid_output" || !strings.Contains(jobs.failedMessage, "64バイト以下") {
t.Fatalf("長いIDの失敗: kind=%q message=%q completed=%v", jobs.failedKind, jobs.failedMessage, jobs.completed)
}
if _, err := os.Stat(argsFile); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("yt-dlp が起動された: %v", err)
}
if _, err := os.Stat(workDir); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("作業ディレクトリが残った: %v", err)
}
}
func TestProcessJobKeepsPercentNamesOutOfYTDLPTemplate(t *testing.T) {
t.Setenv("FAKE_YTDLP_MODE", "success")
t.Setenv("FAKE_FFPROBE_MODE", "valid")
dir := t.TempDir()
argsFile := filepath.Join(dir, "args")
t.Setenv("FAKE_ARGS_FILE", argsFile)
job := runningJob()
job.SeriesTitle = "100%番組"
job.Title = "第%1話"
jobs := &fakeStore{job: job}
testRunner(t, jobs, dir).ProcessJob(context.Background(), job)
if !jobs.completed {
t.Fatalf("%% を含む番組を完了できなかった: %+v", jobs)
}
args, err := os.ReadFile(argsFile)
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(args), job.SeriesTitle) || strings.Contains(string(args), job.Title) {
t.Fatalf("番組情報が yt-dlp 引数に入った: %s", args)
}
want := filepath.ToSlash(relativeVideoPath(t, job.SeriesTitle, job.Title, job.EpisodeID))
if jobs.completePath != want {
t.Fatalf("保存先=%q want=%q", jobs.completePath, want)
}
}
func TestProcessJobReplacesExistingDestination(t *testing.T) {
t.Setenv("FAKE_YTDLP_MODE", "success")
t.Setenv("FAKE_FFPROBE_MODE", "valid")
dir := t.TempDir()
job := runningJob()
relative := relativeVideoPath(t, job.SeriesTitle, job.Title, job.EpisodeID)
destination := filepath.Join(dir, relative)
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(destination, []byte("stale file from previous attempt"), 0o644); err != nil {
t.Fatal(err)
}
jobs := &fakeStore{job: job}
testRunner(t, jobs, dir).ProcessJob(context.Background(), job)
got, err := os.ReadFile(destination)
if err != nil {
t.Fatal(err)
}
if !jobs.completed || string(got) != "fake mp4" || jobs.completeSize != int64(len(got)) {
t.Fatalf("既存ファイルの置換: completed=%v content=%q size=%d", jobs.completed, got, jobs.completeSize)
}
}
func TestProcessJobRejectsAfterMoveSymlinkOutsideWorkDirectory(t *testing.T) {
t.Setenv("FAKE_YTDLP_MODE", "symlink")
t.Setenv("FAKE_FFPROBE_MODE", "valid")
dir := t.TempDir()
outside := filepath.Join(t.TempDir(), "outside.mp4")
if err := os.WriteFile(outside, []byte("outside video"), 0o644); err != nil {
t.Fatal(err)
}
t.Setenv("FAKE_OUTSIDE_FILE", outside)
jobs := &fakeStore{job: runningJob()}
testRunner(t, jobs, dir).ProcessJob(context.Background(), jobs.job)
if jobs.completed || jobs.failedKind != "invalid_output" {
t.Fatalf("作業ディレクトリ外を指すシンボリックリンクを受理した: %+v", jobs)
}
if got, err := os.ReadFile(outside); err != nil || string(got) != "outside video" {
t.Fatalf("作業ディレクトリ外のファイルを変更した: %q %v", got, err)
}
}
func TestPathInsideSymlinkDerivedCases(t *testing.T) {
root := t.TempDir()
insideDir := filepath.Join(root, "real")
if err := os.Mkdir(insideDir, 0o755); err != nil {
t.Fatal(err)
}
insideFile := filepath.Join(insideDir, "video.mp4")
if err := os.WriteFile(insideFile, []byte("video"), 0o644); err != nil {
t.Fatal(err)
}
outsideDir := t.TempDir()
outsideFile := filepath.Join(outsideDir, "outside.mp4")
if err := os.WriteFile(outsideFile, []byte("outside"), 0o644); err != nil {
t.Fatal(err)
}
internalFileLink := filepath.Join(root, "internal-file-link.mp4")
internalDirLink := filepath.Join(root, "internal-dir-link")
externalDirLink := filepath.Join(root, "external-dir-link")
rootLink := filepath.Join(t.TempDir(), "root-link")
for link, target := range map[string]string{
internalFileLink: insideFile,
internalDirLink: insideDir,
externalDirLink: outsideDir,
rootLink: root,
} {
if err := os.Symlink(target, link); err != nil {
t.Fatal(err)
}
}
for _, tt := range []struct {
name, testRoot, candidate string
wantOK bool
}{
{"通常ファイル", root, insideFile, true},
{"作業ディレクトリ内を指すファイルリンク", root, internalFileLink, false},
{"after_moveがディレクトリ", root, insideDir, false},
{"作業ディレクトリ内を指すディレクトリリンク配下", root, filepath.Join(internalDirLink, "video.mp4"), true},
{"作業ディレクトリ外を指すディレクトリリンク配下", root, filepath.Join(externalDirLink, "outside.mp4"), false},
{"作業ディレクトリ自体がリンク", rootLink, filepath.Join(rootLink, "real", "video.mp4"), true},
} {
t.Run(tt.name, func(t *testing.T) {
got, err := pathInside(tt.testRoot, tt.candidate)
if tt.wantOK {
want, absErr := filepath.Abs(tt.candidate)
if absErr != nil {
t.Fatal(absErr)
}
if err != nil || got != want {
t.Fatalf("pathInside()=(%q, %v) want=(%q, nil)", got, err, want)
}
return
}
if !errors.Is(err, errInvalidOutput) || got != "" {
t.Fatalf("pathInside()=(%q, %v), invalid_output を期待", got, err)
}
})
}
}
func TestProcessJobDestinationDirectoryFailureCleansIncoming(t *testing.T) {
t.Setenv("FAKE_YTDLP_MODE", "success")
t.Setenv("FAKE_FFPROBE_MODE", "valid")
dir := t.TempDir()
job := runningJob()
seriesDir := filepath.Join(dir, filepath.Dir(relativeVideoPath(t, job.SeriesTitle, job.Title, job.EpisodeID)))
if err := os.WriteFile(seriesDir, []byte("blocks directory"), 0o644); err != nil {
t.Fatal(err)
}
jobs := &fakeStore{job: job}
testRunner(t, jobs, dir).ProcessJob(context.Background(), job)
if jobs.completed || jobs.failedKind != "unknown" {
t.Fatalf("保存先作成失敗: %+v", jobs)
}
if _, err := os.Stat(filepath.Join(dir, ".incoming", "7")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("作業ディレクトリが残った: %v", err)
}
}
func TestFailureClassificationAndRedaction(t *testing.T) {
for _, tt := range []struct{ mode, kind string }{
{"drm", "drm"}, {"geo", "geo"}, {"network", "network"},
{"unavailable", "unavailable"}, {"extract", "extract"}, {"unknown", "unknown"},
} {
t.Run(tt.mode, func(t *testing.T) {
t.Setenv("FAKE_YTDLP_MODE", tt.mode)
dir := t.TempDir()
jobs := &fakeStore{job: runningJob()}
testRunner(t, jobs, dir).ProcessJob(context.Background(), jobs.job)
if jobs.failedKind != tt.kind || jobs.completed || strings.Contains(jobs.failedMessage, "secret-value") {
t.Fatalf("failure: kind=%q message=%q completed=%v", jobs.failedKind, jobs.failedMessage, jobs.completed)
}
if _, err := os.Stat(filepath.Join(dir, ".incoming", "7")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("作業ディレクトリが残った: %v", err)
}
})
}
}
func TestInvalidProbeFailsAndCleans(t *testing.T) {
t.Setenv("FAKE_YTDLP_MODE", "success")
for _, mode := range []string{"bad-container", "bad-video", "bad-audio", "non-json", "failure"} {
t.Run(mode, func(t *testing.T) {
t.Setenv("FAKE_FFPROBE_MODE", mode)
dir := t.TempDir()
jobs := &fakeStore{job: runningJob()}
testRunner(t, jobs, dir).ProcessJob(context.Background(), jobs.job)
if jobs.failedKind != "invalid_output" || jobs.completed {
t.Fatalf("ffprobe 不合格: kind=%q complete=%v", jobs.failedKind, jobs.completed)
}
if _, err := os.Stat(filepath.Join(dir, ".incoming", "7")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("作業ディレクトリが残った: %v", err)
}
})
}
}
func TestProgressIsThrottledButHeartbeatContinues(t *testing.T) {
t.Setenv("FAKE_YTDLP_MODE", "burst")
t.Setenv("FAKE_FFPROBE_MODE", "valid")
dir := t.TempDir()
jobs := &fakeStore{job: runningJob()}
runner := testRunner(t, jobs, dir)
runner.config.ProgressInterval = 50 * time.Millisecond
runner.ProcessJob(context.Background(), jobs.job)
downloadUpdates := 0
for _, progress := range jobs.progress {
if progress.Phase == "video" {
downloadUpdates++
}
}
if downloadUpdates > 2 || downloadUpdates == 0 {
t.Fatalf("進捗の間引き: %d updates (%+v)", downloadUpdates, jobs.progress)
}
if jobs.heartbeats < 5 {
t.Fatalf("進捗が止まった間の heartbeat: %d", jobs.heartbeats)
}
}
func TestRunConcurrencyTwoDoesNotDuplicateAndIsolatesFailure(t *testing.T) {
t.Setenv("FAKE_YTDLP_MODE", "mixed")
t.Setenv("FAKE_FFPROBE_MODE", "valid")
dir := t.TempDir()
jobs := &queueStore{
jobs: []store.Job{
{ID: 7, EpisodeID: "ep-ok", SeriesTitle: "成功番組", Title: "成功回", Status: "queued"},
{ID: 8, EpisodeID: "ep-fail", SeriesTitle: "失敗番組", Title: "失敗回", Status: "queued"},
},
claimCounts: make(map[int64]int),
}
runner, err := New(jobs, Config{VideoDir: dir,
YTDLPPath: filepath.Join("testdata", "fake-yt-dlp.sh"), FFprobePath: filepath.Join("testdata", "fake-ffprobe.sh"),
Concurrency: 2, PollInterval: 5 * time.Millisecond, ProgressInterval: time.Millisecond,
HeartbeatInterval: 10 * time.Millisecond, CancelInterval: 5 * time.Millisecond}, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- runner.Run(ctx) }()
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
state, _, _ := jobs.state()
if state[0].Status == "succeeded" && state[1].Status == "failed" {
break
}
time.Sleep(5 * time.Millisecond)
}
cancel()
if err := <-done; err != nil {
t.Fatal(err)
}
state, counts, maxActive := jobs.state()
if state[0].Status != "succeeded" || state[1].Status != "failed" {
t.Fatalf("片方の失敗がもう片方へ波及した: %+v", state)
}
if counts[7] != 1 || counts[8] != 1 {
t.Fatalf("ジョブを二重取得した: %+v", counts)
}
if maxActive != 2 {
t.Fatalf("並行実行数の最大値=%d want=2", maxActive)
}
for _, id := range []int64{7, 8} {
if _, err := os.Stat(filepath.Join(dir, ".incoming", strconv.FormatInt(id, 10))); !errors.Is(err, os.ErrNotExist) {
t.Errorf("ジョブ %d の作業ディレクトリが残った: %v", id, err)
}
}
}
func TestCancelStopsProcessGroupAndLeavesCanceled(t *testing.T) {
t.Setenv("FAKE_YTDLP_MODE", "hang-drm")
dir := t.TempDir()
ready := filepath.Join(dir, "ready")
terminated := filepath.Join(dir, "terminated")
t.Setenv("FAKE_READY_FILE", ready)
t.Setenv("FAKE_TERM_FILE", terminated)
jobs := &fakeStore{job: runningJob()}
runner := testRunner(t, jobs, dir)
job := jobs.job
done := make(chan struct{})
go func() { runner.ProcessJob(context.Background(), job); close(done) }()
waitForFile(t, ready)
waitForHeartbeatCount(t, jobs, 2)
jobs.setStatus("canceled")
waitDone(t, done)
if jobs.failedKind != "" || jobs.requeued || jobs.completed || jobs.job.Status != "canceled" {
t.Fatalf("取り消し後: %+v", jobs)
}
waitForFile(t, terminated)
if _, err := os.Stat(filepath.Join(dir, ".incoming", "7")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("作業ディレクトリが残った: %v", err)
}
}
func TestShutdownStopsProcessAndRequeues(t *testing.T) {
t.Setenv("FAKE_YTDLP_MODE", "hang")
dir := t.TempDir()
ready := filepath.Join(dir, "ready")
t.Setenv("FAKE_READY_FILE", ready)
jobs := &fakeStore{job: runningJob()}
ctx, cancel := context.WithCancel(context.Background())
runner := testRunner(t, jobs, dir)
job := jobs.job
done := make(chan struct{})
go func() { runner.ProcessJob(ctx, job); close(done) }()
waitForFile(t, ready)
cancel()
waitDone(t, done)
if !jobs.requeued || jobs.job.Status != "queued" || jobs.failedKind != "" {
t.Fatalf("終了時の復帰: %+v", jobs)
}
if _, err := os.Stat(filepath.Join(dir, ".incoming", "7")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("作業ディレクトリが残った: %v", err)
}
}
func TestRunnerRejectsConcurrencyOutsideRange(t *testing.T) {
for _, concurrency := range []int{-1, 3} {
if _, err := New(&fakeStore{}, Config{Concurrency: concurrency}, nil); err == nil {
t.Errorf("concurrency=%d を許した", concurrency)
}
}
}
func TestFailureClassificationOrderAndRedaction(t *testing.T) {
for _, tt := range []struct{ message, want string }{
{"DRM geo-restricted connection video unavailable unable to extract", "drm"},
{"geo-restricted connection video unavailable unable to extract", "geo"},
{"connection video unavailable unable to extract", "network"},
{"video unavailable unable to extract", "unavailable"},
{"unable to extract", "extract"},
{"different error", "unknown"},
} {
if got := classifyFailure(tt.message); got != tt.want {
t.Errorf("classifyFailure(%q)=%q want=%q", tt.message, got, tt.want)
}
}
redacted := redact("Authorization: Bearer bearer-secret\nCookie: session=session-secret\ntoken=token-secret platform_uid=uid-secret")
for _, secret := range []string{"bearer-secret", "session-secret", "token-secret", "uid-secret"} {
if strings.Contains(redacted, secret) {
t.Errorf("秘密が残った: %q", redacted)
}
}
}
func waitForFile(t *testing.T, path string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if _, err := os.Stat(path); err == nil {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("ファイル待ちがタイムアウト: %s", path)
}
func waitDone(t *testing.T, done <-chan struct{}) {
t.Helper()
select {
case <-done:
case <-time.After(3 * time.Second):
t.Fatal("worker の終了待ちがタイムアウト")
}
}
func waitForHeartbeatCount(t *testing.T, jobs *fakeStore, want int) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if jobs.heartbeatCount() >= want {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("heartbeat 待ちがタイムアウト: got=%d want=%d", jobs.heartbeatCount(), want)
}
+156
View File
@@ -0,0 +1,156 @@
package worker
import (
"context"
"errors"
"log"
"strings"
"time"
"unicode/utf8"
"gitea.kazu634.com/kazu634/tver/internal/store"
"gitea.kazu634.com/kazu634/tver/internal/tver"
)
type SubscriptionStore interface {
ListDueSubscriptions(context.Context, time.Time) ([]store.Subscription, error)
CompleteSubscriptionCheck(context.Context, string, []store.Episode, int, time.Time) (int, error)
RecordSubscriptionCheckFailure(context.Context, string, time.Time, string) error
}
type SeriesFetcher interface {
FetchSeriesEpisodes(context.Context, string) ([]tver.Episode, error)
}
type SubscriptionSchedulerConfig struct {
ScanInterval time.Duration
DueAfter time.Duration
SeriesInterval time.Duration
PreviewMaxSeconds int
Now func() time.Time
After func(time.Duration) <-chan time.Time
}
type SubscriptionScheduler struct {
store SubscriptionStore
fetcher SeriesFetcher
config SubscriptionSchedulerConfig
logger *log.Logger
}
func NewSubscriptionScheduler(subscriptions SubscriptionStore, fetcher SeriesFetcher, config SubscriptionSchedulerConfig, logger *log.Logger) (*SubscriptionScheduler, error) {
if subscriptions == nil || fetcher == nil {
return nil, errors.New("subscription store and fetcher are required")
}
if config.ScanInterval <= 0 {
config.ScanInterval = time.Hour
}
if config.DueAfter <= 0 {
config.DueAfter = 24 * time.Hour
}
if config.SeriesInterval <= 0 {
config.SeriesInterval = 3 * time.Second
}
if config.PreviewMaxSeconds == 0 {
config.PreviewMaxSeconds = tver.PreviewMaxSeconds
}
if config.PreviewMaxSeconds < 0 {
return nil, errors.New("preview threshold must not be negative")
}
if config.Now == nil {
config.Now = time.Now
}
if config.After == nil {
config.After = time.After
}
if logger == nil {
logger = log.Default()
}
return &SubscriptionScheduler{store: subscriptions, fetcher: fetcher, config: config, logger: logger}, nil
}
// Run は起動直後に確認し、その後は ScanInterval ごとに繰り返す。
func (s *SubscriptionScheduler) Run(ctx context.Context) {
for {
s.checkDue(ctx)
if !s.wait(ctx, s.config.ScanInterval) {
return
}
}
}
func (s *SubscriptionScheduler) checkDue(ctx context.Context) {
now := s.config.Now()
subscriptions, err := s.store.ListDueSubscriptions(ctx, now.Add(-s.config.DueAfter))
if err != nil {
if ctx.Err() == nil {
s.logger.Printf("確認対象の登録番組を取得できません: %v", err)
}
return
}
for index, subscription := range subscriptions {
if index > 0 && !s.wait(ctx, s.config.SeriesInterval) {
return
}
episodes, err := s.fetcher.FetchSeriesEpisodes(ctx, subscription.SeriesID)
if err != nil {
if ctx.Err() != nil {
return
}
message := shortError(err)
if recordErr := s.store.RecordSubscriptionCheckFailure(ctx, subscription.SeriesID, s.config.Now(), message); recordErr != nil &&
!errors.Is(recordErr, store.ErrInvalidTransition) {
s.logger.Printf("登録番組 %s の確認エラーを記録できません: %v", subscription.SeriesID, recordErr)
}
s.logger.Printf("登録番組 %s の公開中エピソードを取得できません: %s", subscription.SeriesID, message)
continue
}
items := make([]store.Episode, 0, len(episodes))
for _, episode := range episodes {
items = append(items, subscriptionEpisode(episode))
}
queued, err := s.store.CompleteSubscriptionCheck(ctx, subscription.SeriesID, items,
s.config.PreviewMaxSeconds, s.config.Now())
if err != nil {
if ctx.Err() != nil {
return
}
if !errors.Is(err, store.ErrSubscriptionPaused) && !errors.Is(err, store.ErrInvalidTransition) {
s.logger.Printf("登録番組 %s の確認結果を保存できません: %v", subscription.SeriesID, err)
}
continue
}
s.logger.Printf("登録番組 %s を確認しました(新規ジョブ=%d)", subscription.SeriesID, queued)
}
}
func (s *SubscriptionScheduler) wait(ctx context.Context, duration time.Duration) bool {
select {
case <-ctx.Done():
return false
case <-s.config.After(duration):
return true
}
}
func subscriptionEpisode(episode tver.Episode) store.Episode {
item := store.Episode{ID: episode.ID, SeriesID: episode.SeriesID, SeriesTitle: episode.SeriesTitle,
Title: episode.Title, BroadcasterName: episode.BroadcasterName,
DurationSeconds: int(episode.Duration), ThumbnailPath: episode.ThumbnailPath}
if episode.EndAt > 0 {
end := time.Unix(episode.EndAt, 0)
item.EndAt = &end
}
return item
}
func shortError(err error) string {
message := redact(err.Error())
message = strings.Join(strings.Fields(message), " ")
const maxRunes = 512
if utf8.RuneCountInString(message) <= maxRunes {
return message
}
runes := []rune(message)
return string(runes[:maxRunes])
}
+253
View File
@@ -0,0 +1,253 @@
package worker
import (
"bytes"
"context"
"errors"
"log"
"strings"
"sync"
"testing"
"time"
"gitea.kazu634.com/kazu634/tver/internal/store"
"gitea.kazu634.com/kazu634/tver/internal/tver"
)
type schedulerCompletion struct {
seriesID string
episodes []store.Episode
limit int
at time.Time
}
type schedulerFailure struct {
seriesID, message string
at time.Time
}
type fakeSubscriptionStore struct {
mu sync.Mutex
due []store.Subscription
cutoffs []time.Time
completions []schedulerCompletion
failures []schedulerFailure
listErr error
completeErr error
}
func (f *fakeSubscriptionStore) ListDueSubscriptions(_ context.Context, cutoff time.Time) ([]store.Subscription, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.cutoffs = append(f.cutoffs, cutoff)
return append([]store.Subscription(nil), f.due...), f.listErr
}
func (f *fakeSubscriptionStore) CompleteSubscriptionCheck(_ context.Context, seriesID string, episodes []store.Episode, limit int, at time.Time) (int, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.completions = append(f.completions, schedulerCompletion{seriesID, append([]store.Episode(nil), episodes...), limit, at})
return len(episodes), f.completeErr
}
func (f *fakeSubscriptionStore) RecordSubscriptionCheckFailure(_ context.Context, seriesID string, at time.Time, message string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.failures = append(f.failures, schedulerFailure{seriesID, message, at})
return nil
}
type fakeSeriesFetcher struct {
mu sync.Mutex
calls []string
episodes map[string][]tver.Episode
errors map[string]error
block bool
started chan string
}
func (f *fakeSeriesFetcher) FetchSeriesEpisodes(ctx context.Context, seriesID string) ([]tver.Episode, error) {
f.mu.Lock()
f.calls = append(f.calls, seriesID)
block := f.block
started := f.started
episodes := append([]tver.Episode(nil), f.episodes[seriesID]...)
err := f.errors[seriesID]
f.mu.Unlock()
if started != nil {
started <- seriesID
}
if block {
<-ctx.Done()
return nil, ctx.Err()
}
return episodes, err
}
func TestSubscriptionSchedulerChecksImmediatelyAndMapsEpisodes(t *testing.T) {
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
subscriptions := &fakeSubscriptionStore{due: []store.Subscription{{SeriesID: "one"}, {SeriesID: "two"}}}
fetcher := &fakeSeriesFetcher{episodes: map[string][]tver.Episode{
"one": {{ID: "ep1", SeriesID: "one", SeriesTitle: "番組", Title: "回", BroadcasterName: "局", Duration: 180, EndAt: 1_800_000_000, ThumbnailPath: "/image"}},
"two": {{ID: "ep2", SeriesID: "two", Duration: 181}},
}, errors: map[string]error{}}
var waits []time.Duration
after := func(duration time.Duration) <-chan time.Time {
waits = append(waits, duration)
ch := make(chan time.Time, 1)
ch <- now
return ch
}
scheduler, err := NewSubscriptionScheduler(subscriptions, fetcher, SubscriptionSchedulerConfig{
DueAfter: 24 * time.Hour, SeriesInterval: 4 * time.Second,
Now: func() time.Time { return now }, After: after,
}, log.New(&bytes.Buffer{}, "", 0))
if err != nil {
t.Fatal(err)
}
scheduler.checkDue(context.Background())
if len(subscriptions.cutoffs) != 1 || !subscriptions.cutoffs[0].Equal(now.Add(-24*time.Hour)) {
t.Fatalf("確認対象日時: %v", subscriptions.cutoffs)
}
if len(fetcher.calls) != 2 || fetcher.calls[0] != "one" || fetcher.calls[1] != "two" || len(waits) != 1 || waits[0] != 4*time.Second {
t.Fatalf("順次確認と間隔: calls=%v waits=%v", fetcher.calls, waits)
}
if len(subscriptions.completions) != 2 || subscriptions.completions[0].limit != tver.PreviewMaxSeconds || !subscriptions.completions[0].at.Equal(now) {
t.Fatalf("確認完了: %+v", subscriptions.completions)
}
got := subscriptions.completions[0].episodes[0]
if got.ID != "ep1" || got.DurationSeconds != 180 || got.EndAt == nil || got.EndAt.Unix() != 1_800_000_000 {
t.Fatalf("エピソード変換: %+v", got)
}
}
func TestShortErrorLimitsUnicodeByCharacters(t *testing.T) {
message := shortError(errors.New(strings.Repeat("界", 260) + "\n\t" + strings.Repeat("語", 300)))
if got := len([]rune(message)); got != 512 {
t.Fatalf("エラー文字数=%d want=512", got)
}
if strings.ContainsAny(message, "\r\n\t") || strings.Contains(message, " ") {
t.Fatalf("空白を1行へ正規化していない: %q", message)
}
if !strings.HasSuffix(message, strings.Repeat("語", 251)) {
t.Fatalf("UTF-8文字の途中で切断または境界が不正: %q", message)
}
}
func TestSubscriptionSchedulerRecordsRedactedFailureWithoutCompleting(t *testing.T) {
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
subscriptions := &fakeSubscriptionStore{due: []store.Subscription{{SeriesID: "failed"}}}
fetcher := &fakeSeriesFetcher{errors: map[string]error{
"failed": errors.New("request failed\nplatform_token=secret-token Cookie: session-secret"),
}}
var logs bytes.Buffer
scheduler, err := NewSubscriptionScheduler(subscriptions, fetcher, SubscriptionSchedulerConfig{Now: func() time.Time { return now }}, log.New(&logs, "", 0))
if err != nil {
t.Fatal(err)
}
scheduler.checkDue(context.Background())
if len(subscriptions.completions) != 0 || len(subscriptions.failures) != 1 || !subscriptions.failures[0].at.Equal(now) {
t.Fatalf("失敗の記録: completions=%v failures=%v", subscriptions.completions, subscriptions.failures)
}
message := subscriptions.failures[0].message
if strings.Contains(message, "secret-token") || strings.Contains(message, "session-secret") || strings.Contains(logs.String(), "secret") || strings.Contains(message, "\n") {
t.Fatalf("秘密値または改行が残った: message=%q logs=%q", message, logs.String())
}
}
func TestSubscriptionSchedulerRunStartsImmediatelyThenUsesScanInterval(t *testing.T) {
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
subscriptions := &fakeSubscriptionStore{}
fetcher := &fakeSeriesFetcher{}
requested := make(chan time.Duration, 2)
tick := make(chan time.Time, 1)
after := func(duration time.Duration) <-chan time.Time {
requested <- duration
return tick
}
scheduler, err := NewSubscriptionScheduler(subscriptions, fetcher, SubscriptionSchedulerConfig{
ScanInterval: time.Hour, Now: func() time.Time { return now }, After: after,
}, nil)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { scheduler.Run(ctx); close(done) }()
select {
case duration := <-requested:
if duration != time.Hour {
t.Fatalf("走査間隔: %v", duration)
}
case <-time.After(time.Second):
t.Fatal("起動直後の確認が行われなかった")
}
subscriptions.mu.Lock()
initialCalls := len(subscriptions.cutoffs)
subscriptions.mu.Unlock()
if initialCalls != 1 {
t.Fatalf("起動直後の確認回数: %d", initialCalls)
}
tick <- now
select {
case <-requested:
case <-time.After(time.Second):
t.Fatal("1時間後の確認が行われなかった")
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("終了できなかった")
}
subscriptions.mu.Lock()
defer subscriptions.mu.Unlock()
if len(subscriptions.cutoffs) != 2 {
t.Fatalf("確認回数: %d", len(subscriptions.cutoffs))
}
}
func TestSubscriptionSchedulerCancellationInterruptsFetchAndSeriesWait(t *testing.T) {
now := time.Date(2026, 10, 3, 12, 0, 0, 0, time.UTC)
t.Run("fetch", func(t *testing.T) {
subscriptions := &fakeSubscriptionStore{due: []store.Subscription{{SeriesID: "one"}}}
fetcher := &fakeSeriesFetcher{block: true, started: make(chan string, 1)}
scheduler, _ := NewSubscriptionScheduler(subscriptions, fetcher, SubscriptionSchedulerConfig{Now: func() time.Time { return now }}, nil)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { scheduler.checkDue(ctx); close(done) }()
select {
case <-fetcher.started:
case <-time.After(time.Second):
t.Fatal("取得が開始されなかった")
}
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("取得中断で終了しなかった")
}
if len(subscriptions.failures) != 0 || len(subscriptions.completions) != 0 {
t.Fatalf("中断した番組を記録した: failures=%v completions=%v", subscriptions.failures, subscriptions.completions)
}
})
t.Run("between series", func(t *testing.T) {
subscriptions := &fakeSubscriptionStore{due: []store.Subscription{{SeriesID: "one"}, {SeriesID: "two"}}}
fetcher := &fakeSeriesFetcher{episodes: map[string][]tver.Episode{}, errors: map[string]error{}}
waiting := make(chan struct{})
never := make(chan time.Time)
scheduler, _ := NewSubscriptionScheduler(subscriptions, fetcher, SubscriptionSchedulerConfig{
Now: func() time.Time { return now }, After: func(time.Duration) <-chan time.Time { close(waiting); return never },
}, nil)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { scheduler.checkDue(ctx); close(done) }()
<-waiting
cancel()
<-done
if len(fetcher.calls) != 1 || fetcher.calls[0] != "one" {
t.Fatalf("番組間の中断: %v", fetcher.calls)
}
})
}
+17
View File
@@ -0,0 +1,17 @@
#!/bin/sh
set -eu
case "${FAKE_FFPROBE_MODE:-valid}" in
invalid|bad-container)
echo '{"format":{"format_name":"matroska"},"streams":[{"codec_type":"video","codec_name":"h264"},{"codec_type":"audio","codec_name":"aac"}]}' ;;
bad-video)
echo '{"format":{"format_name":"mp4"},"streams":[{"codec_type":"video","codec_name":"vp9"},{"codec_type":"audio","codec_name":"aac"}]}' ;;
bad-audio)
echo '{"format":{"format_name":"mp4"},"streams":[{"codec_type":"video","codec_name":"h264"},{"codec_type":"audio","codec_name":"opus"}]}' ;;
non-json)
echo 'not JSON' ;;
failure)
echo 'probe failed' >&2
exit 1 ;;
*)
echo '{"format":{"format_name":"mov,mp4,m4a,3gp,3g2,mj2"},"streams":[{"codec_type":"video","codec_name":"h264"},{"codec_type":"audio","codec_name":"aac"}]}' ;;
esac
+67
View File
@@ -0,0 +1,67 @@
#!/bin/sh
set -eu
[ -z "${FAKE_ARGS_FILE:-}" ] || printf '%s\n' "$@" > "$FAKE_ARGS_FILE"
output_template=
episode_url=
while [ "$#" -gt 0 ]; do
if [ "$1" = "-o" ]; then
output_template=$2
shift 2
continue
fi
case "$1" in
https://tver.jp/episodes/*) episode_url=$1 ;;
esac
shift
done
mode=${FAKE_YTDLP_MODE:-success}
if [ "$mode" = "mixed" ]; then
case "$episode_url" in
*/ep-fail) mode=unknown ;;
*) mode=success ;;
esac
fi
case "$mode" in
drm) echo 'ERROR: This video is DRM protected token=secret-value' >&2; exit 1 ;;
geo) echo 'ERROR: This video is not available from your location due to geo restriction' >&2; exit 1 ;;
network) echo 'ERROR: Unable to download JSON metadata: [Errno 61] Connection refused' >&2; exit 1 ;;
unavailable) echo 'ERROR: MEDIA_NOT_FOUND: episode does not exist' >&2; exit 1 ;;
extract) echo 'ERROR: Unable to extract player data' >&2; exit 1 ;;
unknown) echo 'ERROR: surprising failure' >&2; exit 1 ;;
hang|hang-drm)
[ -z "${FAKE_READY_FILE:-}" ] || : > "$FAKE_READY_FILE"
if [ "$mode" = "hang-drm" ]; then
trap 'echo "ERROR: DRM protected" >&2; [ -z "${FAKE_TERM_FILE:-}" ] || : > "$FAKE_TERM_FILE"; exit 143' TERM INT
else
trap '[ -z "${FAKE_TERM_FILE:-}" ] || : > "$FAKE_TERM_FILE"; exit 143' TERM INT
fi
while :; do sleep 1; done
;;
esac
output=$(printf '%s' "$output_template" | sed 's/%(id)s/fake/g; s/%(ext)s/mp4/g')
mkdir -p "$(dirname "$output")"
printf 'fake mp4' > "$output"
if [ "$mode" = "symlink" ]; then
rm "$output"
ln -s "$FAKE_OUTSIDE_FILE" "$output"
fi
if [ "$mode" = "burst" ]; then
i=1
while [ "$i" -le 20 ]; do
printf '{"event":"download","format_id":"hls-698-1","progress":{"status":"downloading","downloaded_bytes":%s,"total_bytes":100,"speed":10,"eta":2,"fragment_index":%s,"fragment_count":20}}\n' "$i" "$i"
i=$((i + 1))
done
sleep 0.12
else
echo '{"event":"download","format_id":"hls-698-1","progress":{"status":"downloading","downloaded_bytes":1024,"fragment_index":0,"fragment_count":50,"filename":"/private/tmp/tver-spike/epq432nee4.fhls-698-1.mp4","tmpfilename":"/private/tmp/tver-spike/epq432nee4.fhls-698-1.mp4.part","max_progress":null,"progress_idx":null,"elapsed":0,"total_bytes_estimate":29798400.0,"speed":4834.271158821972,"eta":null,"_eta_str":"Unknown","_speed_str":" 4.72KiB/s","_percent":0.003436426116838488,"_percent_str":" 0.0%","_total_bytes_str":" N/A","_total_bytes_estimate_str":" 28.42MiB","_downloaded_bytes_str":" 1.00KiB","_elapsed_str":"00:00:00","_default_template":" 0.0% of ~ 28.42MiB at 4.72KiB/s ETA Unknown (frag 0/50)"}}'
sleep 0.02
echo '{"event":"download","format_id":"hls-ts_AUDIO-0_2","progress":{"status":"finished","downloaded_bytes":50,"total_bytes":50,"speed":20,"eta":0}}'
sleep 0.02
echo '{"event":"postprocess","progress":{"status":"started","postprocessor":"Merger"}}' >&2
fi
printf '{"event":"after_move","filepath":"%s"}\n' "$output"