Merge pull request '番組登録を追加(ステップ4)' (#7) from subscriptions into main

Reviewed-on: #7
This commit was merged in pull request #7.
This commit is contained in:
2026-10-03 19:50:49 +09:00
21 changed files with 1257 additions and 24 deletions
+12 -4
View File
@@ -3,7 +3,7 @@ title: tver の現状
tags: tags:
- homelab - homelab
- tver - tver
status: 待ち行列の画面を実装中 status: 番組登録の画面を実装中
--- ---
# tver の現状 # tver の現状
@@ -14,7 +14,7 @@ status: 待ち行列の画面を実装中
## 今の状態 ## 今の状態
- Go の HTTP サーバー、TVer API クライアント、ドラマ・バラエティの新着表示、テスト、web コンテナの定義がある - Go の HTTP サーバー、TVer API クライアント、ドラマ・バラエティの新着表示、テスト、web コンテナの定義がある
- **待ち行列の画面を実装中。** web は起動時に DB へ接続してマイグレーションを適用し、新着からの登録・状態表示と待ち行列ページを提供する。worker がまだ無いため、登録したジョブは待機中のまま残る - **番組登録の画面を実装中。** 新着から番組を登録し、公開中の回をその場で待ち行列に入れるか選べる。一時停止・再開・解除と登録番組一覧を提供する。worker がまだ無いため、登録済み番組の定期確認とダウンロード実行は行わない
- リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git` - リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git`
## 目的 ## 目的
@@ -41,6 +41,8 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
| 保持期間 | **削除は手動のみ**。自動削除はしない | | 保持期間 | **削除は手動のみ**。自動削除はしない |
| 待ち行列 | **Postgres のテーブルだけで実装する**。専用のキュー基盤は持たない | | 待ち行列 | **Postgres のテーブルだけで実装する**。専用のキュー基盤は持たない |
| 定期確認の間隔 | 登録番組の新しい回を **24 時間ごと**に確認する | | 定期確認の間隔 | 登録番組の新しい回を **24 時間ごと**に確認する |
| 登録時の既存回 | **登録の都度**「公開中の回もダウンロードする」かを選ぶ。選ばない場合も公開中の回を既知として記録する |
| 番組の一時停止 | 登録は残し、自動ダウンロードだけ止める。再開後は停止中に公開された未確認の回も対象にする |
## 要件 ## 要件
@@ -73,7 +75,9 @@ 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`、ドラマ・バラエティの新着ページ(`/`)、新着一覧の HTML 断片(`/newer`)、待ち行列ページ(`/queue`)と更新用断片(`/queue/list`)、登録番組ページ(`/subscriptions`)を提供する。「ライブラリ」のページは空のまま
- 新着の「番組を登録」から、公開中の回をダウンロードするか選ぶ。web はサーバー側の新着キャッシュから番組 ID と名前を引き、TVer の season ごとの公開中エピソードを取得する。取得失敗時は登録しない
- 登録と公開中の回の既知記録・任意の待ち行列投入は1トランザクションで行い、登録時刻を最終確認日時に設定する。`known_episodes` は番組解除時に連動して削除する。worker 用の未確認回の抽出と、未確認回だけの既知記録・投入も `internal/store` に用意した。一時停止中は投入を拒否する
- 新着の登録ボタンはエピソード ID とジャンルだけを送る。web が有効な新着キャッシュから番組情報を引き、`episode_id` の一意制約で重複登録を防ぐ。新着一覧はジョブ状態を一括取得し、失敗・取り消しには再試行を表示する - 新着の登録ボタンはエピソード ID とジャンルだけを送る。web が有効な新着キャッシュから番組情報を引き、`episode_id` の一意制約で重複登録を防ぐ。新着一覧はジョブ状態を一括取得し、失敗・取り消しには再試行を表示する
- 待ち行列は実行中・待機中・失敗・取り消し・完了を状態別に直近50件ずつ表示する。段階別の進捗と失敗理由を表示し、待機中の取り消しと失敗・取り消しの再試行ができる。実行中または待機中のジョブがある間だけ5秒ごとに一覧を更新する - 待ち行列は実行中・待機中・失敗・取り消し・完了を状態別に直近50件ずつ表示する。段階別の進捗と失敗理由を表示し、待機中の取り消しと失敗・取り消しの再試行ができる。実行中または待機中のジョブがある間だけ5秒ごとに一覧を更新する
- 新着はジャンルごとにメモリへ 10 分間キャッシュする。キャッシュ中は TVer セッションの再発行と一覧の再取得を行わない。TVer の取得に失敗した場合、一覧の場所にエラーを表示して詳細をログへ記録する - 新着はジャンルごとにメモリへ 10 分間キャッシュする。キャッシュ中は TVer セッションの再発行と一覧の再取得を行わない。TVer の取得に失敗した場合、一覧の場所にエラーを表示して詳細をログへ記録する
@@ -90,6 +94,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
| 相手 | 用途 | 備考 | | 相手 | 用途 | 備考 |
|---|---|---| |---|---|---|
| TVer `platform-api.tver.jp` | 新着一覧(`/service/api/v1/callNewerDetail/{drama\|variety}`)・番組のエピソード一覧 | **非公開 API**。セッションは `/v2/api/platform_users/browser/create` で発行する(yt-dlp と同じ方式) | | TVer `platform-api.tver.jp` | 新着一覧(`/service/api/v1/callNewerDetail/{drama\|variety}`)・番組のエピソード一覧 | **非公開 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` のみに許可する | | NAS ds923p(NFS) | 動画の保存先 | DSM で NFS を有効化し、共有フォルダを `192.168.10.155` のみに許可する |
| NAS ds923p(Postgres 17) | DB | tver 専用コンテナを使う。DSM 内蔵の Postgres が `5432` を使うため別ポートで公開し、DSM ファイアウォールで `192.168.10.155` のみ許可する。公開ポートは実機で決める | | NAS ds923p(Postgres 17) | DB | tver 専用コンテナを使う。DSM 内蔵の Postgres が `5432` を使うため別ポートで公開し、DSM ファイアウォールで `192.168.10.155` のみ許可する。公開ポートは実機で決める |
@@ -97,6 +102,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
## 確認済みの事実 ## 確認済みの事実
- 新着 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 確認
@@ -111,6 +117,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
- 新着 API が 100 件で打ち切りか、続きを取得できるか [要確認] - 新着 API が 100 件で打ち切りか、続きを取得できるか [要確認]
- 新着のうち DRM 付きの作品の割合 [要確認] - 新着のうち DRM 付きの作品の割合 [要確認]
- 非公開 API と yt-dlp の TVer 対応は、TVer 側の変更で予告なく壊れうる。壊れたことを検知する手段が要る - 非公開 API と yt-dlp の TVer 対応は、TVer 側の変更で予告なく壊れうる。壊れたことを検知する手段が要る
- 登録番組の24時間ごとの確認と、待ち行列のジョブを実行する worker は未実装
- NAS の準備(NFS 共有フォルダ、tver 専用 Postgres 17 コンテナ、DSM ファイアウォール)は DSM での手作業になる。DB の公開ポート(候補 15434)は本番前に実機で確認する [要確認] - NAS の準備(NFS 共有フォルダ、tver 専用 Postgres 17 コンテナ、DSM ファイアウォール)は DSM での手作業になる。DB の公開ポート(候補 15434)は本番前に実機で確認する [要確認]
- DRM・地域制限の yt-dlp 失敗時の文言と終了コードは未実測 [要確認] - DRM・地域制限の yt-dlp 失敗時の文言と終了コードは未実測 [要確認]
@@ -120,6 +127,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
- web を起動するには `.env.example` を参考に、Git 管理外の `.env` に実際の `DATABASE_URL` を設定する。未設定なら起動時にエラー終了する。DB 接続がある環境では起動時にマイグレーションが適用され、`/queue` で待ち行列を確認できる - web を起動するには `.env.example` を参考に、Git 管理外の `.env` に実際の `DATABASE_URL` を設定する。未設定なら起動時にエラー終了する。DB 接続がある環境では起動時にマイグレーションが適用され、`/queue` で待ち行列を確認できる
- 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 表示、登録・状態表示・待ち行列の操作を確認する
- `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` と `WEB_ADDR=127.0.0.1:18080` を設定して `go run ./cmd/web` で起動し、`/healthz`、`/`、`/newer?genre=variety`、`/queue`、`/subscriptions` を確認できる。TVer への実アクセスを伴うため手動確認は少数回に留める
@@ -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);
+5 -10
View File
@@ -59,6 +59,7 @@ type Subscription struct {
Enabled bool Enabled bool
CreatedAt time.Time CreatedAt time.Time
LastCheckedAt *time.Time LastCheckedAt *time.Time
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,
@@ -246,21 +247,15 @@ 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`) (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 +263,7 @@ 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.JobCount); err != nil {
return nil, err return nil, err
} }
subs = append(subs, sub) subs = append(subs, sub)
+41 -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 != 2 || !first.Equal(second) {
t.Fatalf("再適用で履歴が変わった: %d %v %v", count, first, second) t.Fatalf("再適用で履歴が変わった: %d %v %v", count, first, second)
} }
} }
@@ -138,11 +138,48 @@ 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 != 2 {
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 != 2 {
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 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'`)
@@ -617,11 +654,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)
} }
+154
View File
@@ -0,0 +1,154 @@
package store
import (
"context"
"errors"
"fmt"
"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
}
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()
}
+190
View File
@@ -0,0 +1,190 @@
package store
import (
"context"
"errors"
"sync"
"testing"
)
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)
}
}
}
+7 -1
View File
@@ -33,12 +33,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 +51,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}}]}}
+13 -1
View File
@@ -20,6 +20,7 @@ type page struct {
Title string Title string
Newer *newerView Newer *newerView
Queue *queueView Queue *queueView
Subscriptions *subscriptionsView
} }
type JobStore interface { type JobStore interface {
@@ -30,6 +31,11 @@ type JobStore interface {
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 CancelQueued(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)
} }
type server struct { type server struct {
@@ -68,8 +74,14 @@ 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)
mux.HandleFunc("GET /subscriptions", s.subscriptionsPage)
mux.HandleFunc("GET /subscriptions/list", s.subscriptionsFragment)
mux.HandleFunc("GET /subscriptions/choice", s.subscriptionChoice)
mux.HandleFunc("POST /subscriptions/register", s.registerSubscription)
mux.HandleFunc("POST /subscriptions/{id}/pause", s.pauseSubscription)
mux.HandleFunc("POST /subscriptions/{id}/resume", s.resumeSubscription)
mux.HandleFunc("POST /subscriptions/{id}/delete", s.deleteSubscription)
for path, title := range map[string]string{ for path, title := range map[string]string{
"GET /subscriptions": "登録番組",
"GET /library": "ライブラリ", "GET /library": "ライブラリ",
} { } {
mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) { mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) {
+20
View File
@@ -22,6 +22,7 @@ var platformTokenPattern = regexp.MustCompile(`(?i)(platform_token=)[^&\s]+`)
type NewerFetcher interface { type NewerFetcher interface {
FetchNewer(context.Context, string) ([]tver.Episode, error) FetchNewer(context.Context, string) ([]tver.Episode, error)
FetchSeriesEpisodes(context.Context, string) ([]tver.Episode, error)
} }
type cacheEntry struct { type cacheEntry struct {
@@ -36,6 +37,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
@@ -150,10 +153,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]),
+8
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,6 +33,11 @@ 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)
} }
+4
View File
@@ -14,6 +14,10 @@ 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; }
+82
View File
@@ -11,12 +11,19 @@ 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
err error err error
} }
@@ -130,3 +137,78 @@ 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
}
+186
View File
@@ -0,0 +1,186 @@
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
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")
}
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)
}
+196
View File
@@ -0,0 +1,196 @@
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)
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, 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", "最終確認: 未確認", "関連ジョブ: 3件", "有効", "一時停止", `hx-confirm="番組の登録を解除しますか?"`} {
if !strings.Contains(body, want) {
t.Errorf("登録番組の情報 %q がない", want)
}
}
if strings.Contains(body, `<script>alert(1)</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)
}
}
+2
View File
@@ -22,6 +22,8 @@
{{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}} {{else}}
<h1>{{.Title}}</h1> <h1>{{.Title}}</h1>
{{end}} {{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>
+41
View File
@@ -0,0 +1,41 @@
{{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 .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}}