番組登録を追加(登録時の過去回の選択、一時停止・再開・解除、既知の回の記録)
This commit is contained in:
+12
-4
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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/")
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -0,0 +1 @@
|
|||||||
|
{"code":0,"result":{"contents":[{"type":"season","content":{"id":"ss3i6njoq3","version":1,"title":"本編","season_extended":null}}]}}
|
||||||
+13
-1
@@ -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) {
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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{"<script>", "登録日時: 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(), "<script>") {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}}
|
||||||
|
|||||||
@@ -40,6 +40,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{{template "episode_action" .}}
|
{{template "episode_action" .}}
|
||||||
|
{{template "subscription_action" .SubscriptionAction}}
|
||||||
</article>
|
</article>
|
||||||
{{end}}
|
{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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}}&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}}
|
||||||
Reference in New Issue
Block a user