{{.Title}}
+登録日時: {{.CreatedAtText}}/最終確認: {{.LastCheckedText}}/状態: {{if .Enabled}}有効{{else}}一時停止{{end}}/関連ジョブ: {{.Jobs}}件
+ {{if .Enabled}} + + {{else}} + + {{end}} + +diff --git a/PROJECT.md b/PROJECT.md index 2282109..36f05b4 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -3,7 +3,7 @@ title: tver の現状 tags: - homelab - tver -status: 待ち行列の画面を実装中 +status: 番組登録の画面を実装中 --- # tver の現状 @@ -14,7 +14,7 @@ status: 待ち行列の画面を実装中 ## 今の状態 - Go の HTTP サーバー、TVer API クライアント、ドラマ・バラエティの新着表示、テスト、web コンテナの定義がある -- **待ち行列の画面を実装中。** web は起動時に DB へ接続してマイグレーションを適用し、新着からの登録・状態表示と待ち行列ページを提供する。worker がまだ無いため、登録したジョブは待機中のまま残る +- **番組登録の画面を実装中。** 新着から番組を登録し、公開中の回をその場で待ち行列に入れるか選べる。一時停止・再開・解除と登録番組一覧を提供する。worker がまだ無いため、登録済み番組の定期確認とダウンロード実行は行わない - リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git` ## 目的 @@ -41,6 +41,8 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ | 保持期間 | **削除は手動のみ**。自動削除はしない | | 待ち行列 | **Postgres のテーブルだけで実装する**。専用のキュー基盤は持たない | | 定期確認の間隔 | 登録番組の新しい回を **24 時間ごと**に確認する | +| 登録時の既存回 | **登録の都度**「公開中の回もダウンロードする」かを選ぶ。選ばない場合も公開中の回を既知として記録する | +| 番組の一時停止 | 登録は残し、自動ダウンロードだけ止める。再開後は停止中に公開された未確認の回も対象にする | ## 要件 @@ -73,7 +75,9 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ | `web` | UI・API。新着一覧の取得、待ち行列への登録、番組登録、動画の配信(再生) | | `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` の一意制約で重複登録を防ぐ。新着一覧はジョブ状態を一括取得し、失敗・取り消しには再試行を表示する - 待ち行列は実行中・待機中・失敗・取り消し・完了を状態別に直近50件ずつ表示する。段階別の進捗と失敗理由を表示し、待機中の取り消しと失敗・取り消しの再試行ができる。実行中または待機中のジョブがある間だけ5秒ごとに一覧を更新する - 新着はジャンルごとにメモリへ 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 `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 を使う | | NAS ds923p(NFS) | 動画の保存先 | DSM で NFS を有効化し、共有フォルダを `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 確認 +- 番組 `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 確認 - 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 確認 @@ -111,6 +117,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ - 新着 API が 100 件で打ち切りか、続きを取得できるか [要確認] - 新着のうち DRM 付きの作品の割合 [要確認] - 非公開 API と yt-dlp の TVer 対応は、TVer 側の変更で予告なく壊れうる。壊れたことを検知する手段が要る +- 登録番組の24時間ごとの確認と、待ち行列のジョブを実行する worker は未実装 - NAS の準備(NFS 共有フォルダ、tver 専用 Postgres 17 コンテナ、DSM ファイアウォール)は DSM での手作業になる。DB の公開ポート(候補 15434)は本番前に実機で確認する [要確認] - DRM・地域制限の yt-dlp 失敗時の文言と終了コードは未実測 [要確認] @@ -120,6 +127,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ - web を起動するには `.env.example` を参考に、Git 管理外の `.env` に実際の `DATABASE_URL` を設定する。未設定なら起動時にエラー終了する。DB 接続がある環境では起動時にマイグレーションが適用され、`/queue` で待ち行列を確認できる - 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 にはアクセスしない +- 番組一覧の応答は `internal/tver/testdata/series_seasons.json` と `season_episodes.json`(2026-10-03 の実応答を各1件に縮小)に記録した。テストは `httptest` を使い、実 API にアクセスしない - 新着画面のテストは偽物の 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 ./...` で静的解析する -- `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 への実アクセスを伴うため手動確認は少数回に留める diff --git a/internal/store/migrations/002_known_episodes.sql b/internal/store/migrations/002_known_episodes.sql new file mode 100644 index 0000000..c069c6d --- /dev/null +++ b/internal/store/migrations/002_known_episodes.sql @@ -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); diff --git a/internal/store/store.go b/internal/store/store.go index 1037732..d36c1dd 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -59,6 +59,7 @@ type Subscription struct { Enabled bool CreatedAt time.Time LastCheckedAt *time.Time + JobCount int64 } 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() } -// 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) { tag, err := s.pool.Exec(ctx, `DELETE FROM subscriptions WHERE series_id=$1`, seriesID) return tag.RowsAffected() == 1, err } 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 - FROM subscriptions ORDER BY created_at DESC, series_id`) + rows, err := s.pool.Query(ctx, `SELECT s.series_id, s.series_title, s.enabled, s.created_at, s.last_checked_at, + (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 { return nil, err } @@ -268,7 +263,7 @@ func (s *Store) ListSubscriptions(ctx context.Context) ([]Subscription, error) { var subs []Subscription for rows.Next() { 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 } subs = append(subs, sub) diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 10a6522..2e41f5f 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -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 { t.Fatal(err) } - if count != 1 || !first.Equal(second) { + if count != 2 || !first.Equal(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 { t.Fatal(err) } - if count != 1 { + if count != 2 { 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) { pool := testPool(t, true) 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) { s := New(testPool(t, true)) ctx := context.Background() - created, err := s.AddSubscription(ctx, "series", "番組") + created, err := s.RegisterSubscription(ctx, "series", "番組", nil, false) if err != nil || !created { 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 { t.Fatalf("duplicate: %v %v", created, err) } diff --git a/internal/store/subscriptions.go b/internal/store/subscriptions.go new file mode 100644 index 0000000..f4c670e --- /dev/null +++ b/internal/store/subscriptions.go @@ -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() +} diff --git a/internal/store/subscriptions_test.go b/internal/store/subscriptions_test.go new file mode 100644 index 0000000..8b24c64 --- /dev/null +++ b/internal/store/subscriptions_test.go @@ -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) + } + } +} diff --git a/internal/tver/client.go b/internal/tver/client.go index 62d2ef0..aedf647 100644 --- a/internal/tver/client.go +++ b/internal/tver/client.go @@ -33,12 +33,14 @@ type Episode struct { type Client struct { baseURL *url.URL + serviceURL *url.URL httpClient *http.Client } // NewClient はテスト用に API の接続先と HTTP クライアントを差し替えられる。 // 接続先が空、またはクライアントが nil の場合は本番用の既定値を使う。 func NewClient(baseURL string, httpClient *http.Client) (*Client, error) { + defaultAPI := baseURL == "" if baseURL == "" { baseURL = DefaultBaseURL } @@ -49,7 +51,11 @@ func NewClient(baseURL string, httpClient *http.Client) (*Client, error) { if httpClient == nil { 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) { diff --git a/internal/tver/series.go b/internal/tver/series.go new file mode 100644 index 0000000..86f9cbd --- /dev/null +++ b/internal/tver/series.go @@ -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/") +} diff --git a/internal/tver/series_test.go b/internal/tver/series_test.go new file mode 100644 index 0000000..4945628 --- /dev/null +++ b/internal/tver/series_test.go @@ -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) + } + } +} diff --git a/internal/tver/testdata/season_episodes.json b/internal/tver/testdata/season_episodes.json new file mode 100644 index 0000000..5cf2a0c --- /dev/null +++ b/internal/tver/testdata/season_episodes.json @@ -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}}]}} diff --git a/internal/tver/testdata/series_seasons.json b/internal/tver/testdata/series_seasons.json new file mode 100644 index 0000000..7243f3d --- /dev/null +++ b/internal/tver/testdata/series_seasons.json @@ -0,0 +1 @@ +{"code":0,"result":{"contents":[{"type":"season","content":{"id":"ss3i6njoq3","version":1,"title":"本編","season_extended":null}}]}} diff --git a/internal/web/handler.go b/internal/web/handler.go index e5f4070..add70b4 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -17,9 +17,10 @@ import ( var assets embed.FS type page struct { - Title string - Newer *newerView - Queue *queueView + Title string + Newer *newerView + Queue *queueView + Subscriptions *subscriptionsView } type JobStore interface { @@ -30,6 +31,11 @@ type JobStore interface { Retry(context.Context, int64) error RetryEpisode(context.Context, string) 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 { @@ -68,9 +74,15 @@ func NewHandler(fetcher NewerFetcher, jobs JobStore, now func() time.Time, logge mux.HandleFunc("GET /queue/list", s.queueFragment) mux.HandleFunc("POST /jobs/{id}/retry", s.retryJob) 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{ - "GET /subscriptions": "登録番組", - "GET /library": "ライブラリ", + "GET /library": "ライブラリ", } { mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "text/html; charset=utf-8") diff --git a/internal/web/newer.go b/internal/web/newer.go index 100b543..a1b1bc7 100644 --- a/internal/web/newer.go +++ b/internal/web/newer.go @@ -22,6 +22,7 @@ var platformTokenPattern = regexp.MustCompile(`(?i)(platform_token=)[^&\s]+`) type NewerFetcher interface { FetchNewer(context.Context, string) ([]tver.Episode, error) + FetchSeriesEpisodes(context.Context, string) ([]tver.Episode, error) } type cacheEntry struct { @@ -36,6 +37,8 @@ type newerOptions struct { type episodeView struct { ID string + SeriesID string + SubscriptionAction subscriptionActionView Genre string Status string StatusText string @@ -150,10 +153,27 @@ func (s *server) newerView(ctx context.Context, options newerOptions) newerView view.ErrorText = "状態を取得できませんでした" 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 { endAt := time.Unix(episode.EndAt, 0).In(s.zone) view.Episodes = append(view.Episodes, episodeView{ ID: episode.ID, + SeriesID: episode.SeriesID, + SubscriptionAction: subscriptionActionView{ID: episode.ID, Genre: options.genre, Subscribed: subscribed[episode.SeriesID]}, Genre: options.genre, Status: statuses[episode.ID], StatusText: statusText(statuses[episode.ID]), diff --git a/internal/web/newer_test.go b/internal/web/newer_test.go index 1a089c9..d1b6bbf 100644 --- a/internal/web/newer_test.go +++ b/internal/web/newer_test.go @@ -20,6 +20,9 @@ type fakeFetcher struct { episodesByGenre map[string][]tver.Episode err error calls []string + seriesEpisodes []tver.Episode + seriesErr error + seriesCalls []string } 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 } +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 { return newTestHandlerWithStore(fetcher, &fakeJobStore{}, now) } diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 3c2ed79..3abd487 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -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 p { margin: .3rem 0; } .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 button, .queue-job button { font: inherit; padding: .35rem .7rem; } .queue-job { border: 1px solid #d6dee4; border-radius: .5rem; margin: .75rem 0; padding: .8rem 1rem; } diff --git a/internal/web/store_test.go b/internal/web/store_test.go index ea70b0e..ed17f80 100644 --- a/internal/web/store_test.go +++ b/internal/web/store_test.go @@ -11,12 +11,19 @@ import ( type fakeJobStore struct { jobs map[string]store.Job + subscriptions map[string]store.Subscription nextID int64 lastEpisode store.Episode enqueueCalls, statusCalls int statusIDs []string listStatuses []string listLimits []int + registerCalls int + registerEpisodes []store.Episode + registerDownload bool + registerErr error + subscribedCalls int + subscribedIDs []string err error } @@ -130,3 +137,78 @@ func (f *fakeJobStore) CancelQueued(ctx context.Context, id int64) error { } 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 +} diff --git a/internal/web/subscriptions.go b/internal/web/subscriptions.go new file mode 100644 index 0000000..20673f1 --- /dev/null +++ b/internal/web/subscriptions.go @@ -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) +} diff --git a/internal/web/subscriptions_test.go b/internal/web/subscriptions_test.go new file mode 100644 index 0000000..7b6d7e9 --- /dev/null +++ b/internal/web/subscriptions_test.go @@ -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(), ">登録") { + 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(), ">番組を登録") { + 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, ">番組を登録") || 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: ``, 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, ``) { + 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(), "{{.Title}} {{end}} diff --git a/internal/web/templates/newer.html b/internal/web/templates/newer.html index 080c7a4..d1adec7 100644 --- a/internal/web/templates/newer.html +++ b/internal/web/templates/newer.html @@ -40,6 +40,7 @@ {{template "episode_action" .}} + {{template "subscription_action" .SubscriptionAction}} {{end}} diff --git a/internal/web/templates/subscriptions.html b/internal/web/templates/subscriptions.html new file mode 100644 index 0000000..d71a15d --- /dev/null +++ b/internal/web/templates/subscriptions.html @@ -0,0 +1,41 @@ +{{define "subscription_action"}} +
{{.ErrorText}}
{{end}} + + {{else}} + + {{end}} +登録日時: {{.CreatedAtText}}/最終確認: {{.LastCheckedText}}/状態: {{if .Enabled}}有効{{else}}一時停止{{end}}/関連ジョブ: {{.Jobs}}件
+ {{if .Enabled}} + + {{else}} + + {{end}} + +登録番組はありません
+ {{end}} +