diff --git a/PROJECT.md b/PROJECT.md index 77da2d6..825495e 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -71,7 +71,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ | F3 | 選んだ動画をダウンロードの待ち行列に入れる | 複数選択できる | | F4 | ダウンロードの進捗・結果を表示する | 失敗理由(DRM・地域制限・TVer 側の変更による抽出失敗)を区別して表示する | | F5 | 同じ回を二重にダウンロードしない | 手動・自動の両方で共通 | -| F6 | 番組(シリーズ)を登録し、新しい回を自動でダウンロードする | 登録・解除・一覧。定期的に新しい回を確認する | +| F6 | 番組(シリーズ)を登録し、新しい回を自動でダウンロードする | 登録・解除・一覧。定期的に新しい回を確認する。番組URL(`tver.jp/series/…`)から登録でき、登録前に確認画面を表示する | | F7 | ダウンロード済みの動画をアプリ内で再生する | シーク(HTTP Range)に対応する | | F8 | ダウンロード済みの動画を一覧・削除する | NAS の容量管理のため | | F9 | 保存ファイルの名前と置き場所を規則で決める | 例: `<番組名>/<番組名> <回タイトル> [<エピソードID>].mp4` | @@ -88,12 +88,13 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ | コンテナ | 役割 | | -------- | ----------------------------------------------------- | -| `web` | UI・API。新着一覧の取得、待ち行列への登録、番組登録、動画の配信(再生) | +| `web` | UI・API。新着一覧の取得、待ち行列への登録、番組URLを含む番組登録、動画の配信(再生) | | `worker` | Go + yt-dlp + ffmpeg。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する | - 現在の `web` は `/healthz`、新着(`/`)、待ち行列(`/queue`)、登録番組(`/subscriptions`)、完了済みライブラリ(`/library`)、再生ページ(`/library/{id}`)、Range 対応の MP4 配信(`/videos/{id}`)を提供する - 動画配信と削除はクライアントからジョブ ID だけを受け取り、DB の相対 `file_path` を `os.Root` の `Open`・`Remove` で扱う。絶対パス・`..`・`VIDEO_DIR` 外を指すシンボリックリンクをディレクトリ境界で拒否する。削除後は新着から手動で再登録できるが、登録番組の既知記録は残るため自動再登録しない - 新着の「番組を登録」から、公開中の回をダウンロードするか選ぶ。web はサーバー側の新着キャッシュから番組 ID と名前を引き、TVer の season ごとの公開中エピソードを取得する。取得失敗時は登録しない +- `/subscriptions`では`https://tver.jp/series/`形式の番組URLを受け付け、検証した番組IDからTVer APIの接続先を組み立てる。登録前に番組名・番組ID・公開中の回数を確認し、登録時にもサーバー側で番組名と公開中の回を取り直す。クライアントから番組名は受け取らない - 登録と公開中の回の既知記録・任意の待ち行列投入は1トランザクションで行い、登録時刻を最終確認日時に設定する。`known_episodes` は番組解除時に連動して削除する。一時停止中は投入を拒否する - worker のスケジューラは起動直後と1時間ごとに、`enabled` かつ最終確認から24時間以上の番組を抽出する。TVer から公開中の回を順次取得し、未確認の180秒超の回だけを `source=subscription` で投入する。180秒以下も既知にし、成功時の最終確認更新・エラー消去まで1トランザクションで行う - TVer の番組取得に失敗した場合は最終確認日時を変えず、失敗日時と伏字済みの短いメッセージを `subscriptions` に記録して画面に表示する。番組間には3秒空け、SIGTERM では取得中または待機中の確認を中断する @@ -118,7 +119,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ | 相手 | 用途 | 備考 | |---|---|---| -| TVer `platform-api.tver.jp` | 新着一覧(`/service/api/v1/callNewerDetail/{drama\|variety}`)・番組のエピソード一覧 | **非公開 API**。セッションは `/v2/api/platform_users/browser/create` で発行する(yt-dlp と同じ方式) | +| TVer `platform-api.tver.jp` | 新着一覧(`/service/api/v1/callNewerDetail/{drama\|variety}`)・番組名(`/service/api/v2/callSeries/{series_id}`)・番組のエピソード一覧 | **非公開 API**。セッションは `/v2/api/platform_users/browser/create` で発行する(yt-dlp と同じ方式) | | TVer `service-api.tver.jp` | 番組の season 一覧(`/api/v1/callSeriesSeasons/{series_id}`) | **非公開 API**。[yt-dlp の TVer extractor](https://github.com/yt-dlp/yt-dlp/blob/master/yt_dlp/extractor/tver.py) と同じ取得方式 | | yt-dlp | ダウンロード | TVer extractor を使う | | Gitea `gitea.kazu634.com` | 配置元 | 匿名cloneとSSHは使えない。`tver-deploy`のHTTPSトークンでclone・pullする | @@ -147,6 +148,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ - dockage上の使い捨て`postgres:17`コンテナへマイグレーション001〜003を適用してテスト用の行を投入し、`backup-db.sh`と`restore-test.sh`を実行した。gzip圧縮・権限600のダンプ作成、同じ`STAMP`での2回目が`ln`の失敗で非0終了して既存ダンプを保持すること、主要4表の行数一致、一時DBの削除、ログに`psql`の標準出力が残らないことを確認した [実機] 2026-10-04 確認 - NASの`/volume1/docker/tver-db/`へスクリプトを配置し、DSMタスクスケジューラのrootタスクから実行して、`/volume1/backups/tver/`へ`tver_20261004_182838.sql.gz`(2,286バイト)と`tver_20261004_183256.sql.gz`(2,289バイト)を作成した。ダンプはroot所有・権限600で、バックアップディレクトリはroot所有・`drwxr-xr-x`だった。`restore-test.sh`を`sudo`で実行し、`download_jobs` 1件・`subscriptions` 1件・`known_episodes` 1件・`schema_migrations` 3件の一致、ログへの結果記録、一時DBの削除を確認した。一般ユーザーではroot所有のログへ書き込めず失敗する [実機] 2026-10-04 確認 - DSMのACLで全員書き込み可だった`/volume1/docker`配下について、`/volume1/docker/tver-db`を`chmod 755`、`backup-db.sh`と`restore-test.sh`を`chmod 700`にしてACLが外れたことを確認した。変更後も本番アプリの`/queue`と`/subscriptions`はHTTP `200`でDBを読み取れた。PostgreSQLコンテナ再起動後の動作は未確認 [実機] 2026-10-04 確認 +- platform-apiの`v2/callSeries/{series_id}`は`result.content.content.title`に番組名を返し、存在しないIDにはHTTP `404`を返した。`sr8a864g0w`では番組名がエピソード側の`seriesTitle`と一致した。`v1/callEpisode/{episode_id}`は`seriesID`・`seriesTitle`を返した(今回は使わない) [実機] 2026-10-07 確認 ## 課題 @@ -160,6 +162,7 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ - 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 にアクセスしない +- 番組情報の応答は`internal/tver/testdata/series.json`(2026-10-07の実応答を番組情報だけに縮小)に記録した。番組URLは純関数でscheme・host・path・IDを検証し、TVerクライアントのテストは`httptest`だけへ接続する - 新着画面のテストは偽物の TVer クライアント・store と差し替え可能な時計を使い、表示項目、絞り込みの境界、10 分キャッシュ、取得失敗、JST 表示、登録・状態表示・待ち行列の操作を確認する - ライブラリのテストは一時ディレクトリの小さな動画を使い、Range の 206・範囲外の 416、`os.Root`による相対パスとシンボリックリンクの拒否、404、ファイル削除後の記録削除を確認する。共有のファイル名規則は`internal/media`で90・140・255バイトの境界とUTF-8の完全性をテストする - worker のテストは `internal/worker/testdata` の偽 yt-dlp・ffprobe を使い、本物の TVer へ接続せずに正常完了、失敗分類、取消、終了時の待機復帰、進捗の間引き、heartbeat、出力検査、移動先、作業場所の片付けを確認する diff --git a/internal/tver/client.go b/internal/tver/client.go index 6e52fba..26d6bb1 100644 --- a/internal/tver/client.go +++ b/internal/tver/client.go @@ -40,6 +40,14 @@ type Client struct { httpClient *http.Client } +type httpStatusError struct { + statusCode int +} + +func (e *httpStatusError) Error() string { + return fmt.Sprintf("HTTP %d", e.statusCode) +} + // NewClient はテスト用に API の接続先と HTTP クライアントを差し替えられる。 // 接続先が空、またはクライアントが nil の場合は本番用の既定値を使う。 func NewClient(baseURL string, httpClient *http.Client) (*Client, error) { @@ -154,7 +162,7 @@ func (c *Client) getJSON(req *http.Request, dst any) error { } defer response.Body.Close() if response.StatusCode < 200 || response.StatusCode >= 300 { - return fmt.Errorf("HTTP %d", response.StatusCode) + return &httpStatusError{statusCode: response.StatusCode} } if err := json.NewDecoder(io.LimitReader(response.Body, 8<<20)).Decode(dst); err != nil { return fmt.Errorf("decode JSON: %w", err) diff --git a/internal/tver/series.go b/internal/tver/series.go index 86f9cbd..50c3f79 100644 --- a/internal/tver/series.go +++ b/internal/tver/series.go @@ -11,6 +11,9 @@ import ( var tverSeriesIDPattern = regexp.MustCompile(`^[A-Za-z0-9]+$`) +// ErrSeriesNotFound は callSeries が HTTP 404 を返したことを表す。 +var ErrSeriesNotFound = errors.New("series not found") + type seriesContents struct { Code int `json:"code"` Result struct { @@ -21,6 +24,47 @@ type seriesContents struct { } `json:"result"` } +func (c *Client) FetchSeries(ctx context.Context, seriesID string) (string, error) { + if !tverSeriesIDPattern.MatchString(seriesID) { + return "", errors.New("invalid series id") + } + session, err := c.CreateSession(ctx) + if err != nil { + return "", err + } + endpoint := c.baseURL.ResolveReference(&url.URL{Path: "/service/api/v2/callSeries/" + seriesID}) + query := endpoint.Query() + query.Set("platform_uid", session.PlatformUID) + query.Set("platform_token", session.PlatformToken) + endpoint.RawQuery = query.Encode() + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint.String(), nil) + if err != nil { + return "", fmt.Errorf("series request: %w", err) + } + setTVerHeaders(request) + var response struct { + Code int `json:"code"` + Result struct { + Content struct { + Content struct { + Title string `json:"title"` + } `json:"content"` + } `json:"content"` + } `json:"result"` + } + if err := c.getJSON(request, &response); err != nil { + var statusErr *httpStatusError + if errors.As(err, &statusErr) && statusErr.statusCode == http.StatusNotFound { + return "", ErrSeriesNotFound + } + return "", fmt.Errorf("series: %w", err) + } + if response.Code != 0 || response.Result.Content.Content.Title == "" { + return "", fmt.Errorf("series: invalid API result (code %d)", response.Code) + } + return response.Result.Content.Content.Title, nil +} + func (c *Client) FetchSeriesEpisodes(ctx context.Context, seriesID string) ([]Episode, error) { if !tverSeriesIDPattern.MatchString(seriesID) { return nil, errors.New("invalid series id") diff --git a/internal/tver/series_test.go b/internal/tver/series_test.go index 4945628..abc10b7 100644 --- a/internal/tver/series_test.go +++ b/internal/tver/series_test.go @@ -2,6 +2,7 @@ package tver import ( "context" + "errors" "net/http" "os" "strings" @@ -180,3 +181,125 @@ func TestFetchSeriesEpisodesRejectsInvalidSeriesIDBeforeRequest(t *testing.T) { } } } + +func TestFetchSeriesFromRecordedResponse(t *testing.T) { + fixture, err := os.ReadFile("testdata/series.json") + if err != nil { + t.Fatal(err) + } + var sessionCalls, seriesCalls int + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/api/platform_users/browser/create": + sessionCalls++ + _, _ = w.Write([]byte(`{"code":0,"result":{"platform_uid":"uid-test","platform_token":"token-test"}}`)) + case "/service/api/v2/callSeries/sr0lhdrtdb": + seriesCalls++ + if r.Method != http.MethodGet || r.URL.Query().Get("platform_uid") != "uid-test" || r.URL.Query().Get("platform_token") != "token-test" { + t.Errorf("series request method/query: %s %v", r.Method, r.URL.Query()) + } + for name, want := range map[string]string{"x-tver-platform-type": "web", "Origin": "https://tver.jp", "Referer": "https://tver.jp/"} { + if got := r.Header.Get(name); got != want { + t.Errorf("series header %s = %q; want %q", name, got, want) + } + } + _, _ = w.Write(fixture) + default: + t.Errorf("unexpected request: %s", r.URL.Path) + http.NotFound(w, r) + } + }) + title, err := client.FetchSeries(context.Background(), "sr0lhdrtdb") + if err != nil || title != "韓国ドラマ「良いが悪い、ドンジェ」" { + t.Fatalf("series: title=%q error=%v", title, err) + } + if sessionCalls != 1 || seriesCalls != 1 { + t.Fatalf("calls: session=%d series=%d", sessionCalls, seriesCalls) + } +} + +func TestFetchSeriesNotFound(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v2/api/platform_users/browser/create" { + _, _ = w.Write([]byte(`{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`)) + return + } + http.NotFound(w, r) + }) + _, err := client.FetchSeries(context.Background(), "missing") + if !errors.Is(err, ErrSeriesNotFound) { + t.Fatalf("error=%v; want ErrSeriesNotFound", err) + } +} + +func TestFetchSeriesTransportAndDecodeErrors(t *testing.T) { + validSession := `{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}` + for _, tt := range []struct { + name string + sessionStatus, seriesStatus int + sessionBody, seriesBody string + want string + wantSeriesCalls int + }{ + {"session HTTP is not series not found", http.StatusNotFound, http.StatusOK, "", "", "create session: HTTP 404", 0}, + {"series HTTP", http.StatusOK, http.StatusBadGateway, validSession, "", "series: HTTP 502", 1}, + {"series JSON", http.StatusOK, http.StatusOK, validSession, "{", "series: decode JSON", 1}, + } { + t.Run(tt.name, func(t *testing.T) { + var seriesCalls int + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/v2/api/platform_users/browser/create": + w.WriteHeader(tt.sessionStatus) + _, _ = w.Write([]byte(tt.sessionBody)) + case "/service/api/v2/callSeries/sr1": + seriesCalls++ + w.WriteHeader(tt.seriesStatus) + _, _ = w.Write([]byte(tt.seriesBody)) + default: + t.Errorf("unexpected request: %s", r.URL.Path) + } + }) + _, err := client.FetchSeries(context.Background(), "sr1") + if err == nil || !strings.Contains(err.Error(), tt.want) || errors.Is(err, ErrSeriesNotFound) { + t.Fatalf("error=%v; want %q and not ErrSeriesNotFound", err, tt.want) + } + if seriesCalls != tt.wantSeriesCalls { + t.Fatalf("series calls=%d; want %d", seriesCalls, tt.wantSeriesCalls) + } + }) + } +} + +func TestFetchSeriesRejectsBadResponses(t *testing.T) { + for _, tt := range []struct { + name, body string + }{ + {"empty title", `{"code":0,"result":{"content":{"content":{"title":""}}}}`}, + {"API code", `{"code":7,"result":{"content":{"content":{"title":"番組"}}}}`}, + } { + t.Run(tt.name, func(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v2/api/platform_users/browser/create" { + _, _ = w.Write([]byte(`{"code":0,"result":{"platform_uid":"u","platform_token":"t"}}`)) + return + } + _, _ = w.Write([]byte(tt.body)) + }) + if _, err := client.FetchSeries(context.Background(), "sr1"); err == nil { + t.Fatal("invalid response accepted") + } + }) + } +} + +func TestFetchSeriesRejectsInvalidSeriesIDBeforeRequest(t *testing.T) { + client := testClient(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request: %s", r.URL.Path) + }) + for _, id := range []string{"", "../other", "sr1?x=1", "sr_1"} { + if _, err := client.FetchSeries(context.Background(), id); err == nil { + t.Errorf("invalid series id accepted: %q", id) + } + } +} diff --git a/internal/tver/testdata/series.json b/internal/tver/testdata/series.json new file mode 100644 index 0000000..fbc6bd4 --- /dev/null +++ b/internal/tver/testdata/series.json @@ -0,0 +1 @@ +{"code":0,"result":{"content":{"type":"series","content":{"id":"sr0lhdrtdb","title":"韓国ドラマ「良いが悪い、ドンジェ」"}}}} diff --git a/internal/web/handler.go b/internal/web/handler.go index 10661a3..58538bd 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -85,6 +85,9 @@ func NewHandler(fetcher NewerFetcher, jobs JobStore, videoDir string, now func() mux.HandleFunc("POST /jobs/{id}/cancel", s.cancelJob) mux.HandleFunc("GET /subscriptions", s.subscriptionsPage) mux.HandleFunc("GET /subscriptions/list", s.subscriptionsFragment) + mux.HandleFunc("POST /subscriptions/from-url/confirm", s.confirmSubscriptionURL) + mux.HandleFunc("POST /subscriptions/from-url/register", s.registerSubscriptionURL) + mux.HandleFunc("GET /subscriptions/from-url/cancel", s.cancelSubscriptionURL) mux.HandleFunc("GET /subscriptions/choice", s.subscriptionChoice) mux.HandleFunc("POST /subscriptions/register", s.registerSubscription) mux.HandleFunc("POST /subscriptions/{id}/pause", s.pauseSubscription) diff --git a/internal/web/newer.go b/internal/web/newer.go index bd0603b..70536f9 100644 --- a/internal/web/newer.go +++ b/internal/web/newer.go @@ -21,6 +21,7 @@ var platformTokenPattern = regexp.MustCompile(`(?i)(platform_token=)[^&\s]+`) type NewerFetcher interface { FetchNewer(context.Context, string) ([]tver.Episode, error) + FetchSeries(context.Context, string) (string, error) FetchSeriesEpisodes(context.Context, string) ([]tver.Episode, error) } diff --git a/internal/web/newer_test.go b/internal/web/newer_test.go index e1fb557..9b021aa 100644 --- a/internal/web/newer_test.go +++ b/internal/web/newer_test.go @@ -16,13 +16,21 @@ import ( ) type fakeFetcher struct { - episodes []tver.Episode - episodesByGenre map[string][]tver.Episode - err error - calls []string - seriesEpisodes []tver.Episode - seriesErr error - seriesCalls []string + episodes []tver.Episode + episodesByGenre map[string][]tver.Episode + err error + calls []string + seriesEpisodes []tver.Episode + seriesErr error + seriesCalls []string + seriesTitle string + seriesTitleErr error + seriesTitleCalls []string +} + +func (f *fakeFetcher) FetchSeries(_ context.Context, seriesID string) (string, error) { + f.seriesTitleCalls = append(f.seriesTitleCalls, seriesID) + return f.seriesTitle, f.seriesTitleErr } func (f *fakeFetcher) FetchNewer(_ context.Context, genre string) ([]tver.Episode, error) { diff --git a/internal/web/static/app.css b/internal/web/static/app.css index 6de4eee..b466237 100644 --- a/internal/web/static/app.css +++ b/internal/web/static/app.css @@ -18,6 +18,9 @@ main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; } .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; } +.subscription-url-entry { border: 1px solid #d6dee4; border-radius: .5rem; margin-bottom: 1.5rem; padding: .8rem 1rem; } +.subscription-url-entry input[type="url"] { font: inherit; min-width: min(32rem, 70vw); padding: .35rem .5rem; } +.subscription-url-entry button { font: inherit; padding: .35rem .7rem; } .episode-action form { display: inline; } .episode-action 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/subscription_url.go b/internal/web/subscription_url.go new file mode 100644 index 0000000..33177c3 --- /dev/null +++ b/internal/web/subscription_url.go @@ -0,0 +1,161 @@ +package web + +import ( + "errors" + "net/http" + "net/url" + "regexp" + "strings" + + "gitea.kazu634.com/kazu634/tver/internal/store" + "gitea.kazu634.com/kazu634/tver/internal/tver" +) + +const seriesURLInputError = "番組ページ(https://tver.jp/series/…)の URL を入力してください" + +var seriesURLPathPattern = regexp.MustCompile(`^/series/([A-Za-z0-9]+)/?$`) +var seriesIDPattern = regexp.MustCompile(`^[A-Za-z0-9]+$`) + +type subscriptionURLResultView struct { + SeriesID, Title, ErrorText string + EpisodeCount int + Subscribed bool +} + +type subscriptionURLRegisteredView struct { + Subscriptions subscriptionsView +} + +func parseSeriesURL(raw string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil || parsed.Scheme != "https" || parsed.User != nil || (!strings.EqualFold(parsed.Host, "tver.jp") && !strings.EqualFold(parsed.Host, "www.tver.jp")) { + return "", errors.New(seriesURLInputError) + } + match := seriesURLPathPattern.FindStringSubmatch(parsed.Path) + if len(match) != 2 { + return "", errors.New(seriesURLInputError) + } + return match[1], nil +} + +func (s *server) confirmSubscriptionURL(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("HX-Request") != "true" { + http.Redirect(w, r, "/subscriptions", http.StatusSeeOther) + return + } + if err := r.ParseForm(); err != nil { + http.Error(w, "入力が不正です", http.StatusBadRequest) + return + } + seriesID, err := parseSeriesURL(r.Form.Get("series_url")) + if err != nil { + s.render(w, "subscription_url_result", subscriptionURLResultView{ErrorText: seriesURLInputError}) + return + } + title, episodes, ok := s.fetchSubscriptionSeries(w, r, seriesID) + if !ok { + return + } + subscribed, err := s.isSubscribed(r.Context(), seriesID) + if err != nil { + s.logger.Printf("番組の登録状態取得に失敗: %v", err) + http.Error(w, "登録状態を取得できませんでした", http.StatusInternalServerError) + return + } + s.render(w, "subscription_url_result", subscriptionURLResultView{ + SeriesID: seriesID, Title: title, EpisodeCount: len(episodes), Subscribed: subscribed, + }) +} + +func (s *server) registerSubscriptionURL(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "入力が不正です", http.StatusBadRequest) + return + } + seriesID := r.Form.Get("series_id") + if !seriesIDPattern.MatchString(seriesID) { + http.Error(w, "番組 ID が不正です", http.StatusBadRequest) + return + } + choice := r.Form.Get("download_existing") + if choice != "" && choice != "1" { + http.Error(w, "公開中の回の選択が不正です", http.StatusBadRequest) + return + } + subscribed, err := s.isSubscribed(r.Context(), seriesID) + if err != nil { + s.logger.Printf("番組の登録状態取得に失敗: %v", err) + http.Error(w, "登録状態を取得できませんでした", http.StatusInternalServerError) + return + } + if subscribed { + s.subscriptionURLRegisteredResponse(w, r) + return + } + title, episodes, ok := s.fetchSubscriptionSeries(w, r, seriesID) + if !ok { + return + } + items := make([]store.Episode, 0, len(episodes)) + for _, episode := range episodes { + items = append(items, storeEpisode(episode)) + } + _, err = s.jobs.RegisterSubscription(r.Context(), seriesID, title, items, choice == "1") + if err != nil { + s.logger.Printf("番組URLからの登録に失敗: %v", err) + s.subscriptionURLFailure(w, r, "番組を登録できませんでした。再試行してください") + return + } + s.subscriptionURLRegisteredResponse(w, r) +} + +func (s *server) cancelSubscriptionURL(w http.ResponseWriter, _ *http.Request) { + s.render(w, "subscription_url_result", subscriptionURLResultView{}) +} + +func (s *server) fetchSubscriptionSeries(w http.ResponseWriter, r *http.Request, seriesID string) (string, []tver.Episode, bool) { + title, err := s.fetcher.FetchSeries(r.Context(), seriesID) + if err != nil { + if errors.Is(err, tver.ErrSeriesNotFound) { + s.subscriptionURLFailure(w, r, "番組が見つかりません") + return "", nil, false + } + s.logSubscriptionFetchError(err) + s.subscriptionURLFailure(w, r, "番組情報を取得できませんでした。再試行してください") + return "", nil, false + } + episodes, err := s.fetcher.FetchSeriesEpisodes(r.Context(), seriesID) + if err != nil { + s.logSubscriptionFetchError(err) + s.subscriptionURLFailure(w, r, "番組情報を取得できませんでした。再試行してください") + return "", nil, false + } + return title, episodes, true +} + +func (s *server) logSubscriptionFetchError(err error) { + s.logger.Printf("番組URLの情報取得に失敗: %s", platformTokenPattern.ReplaceAllString(err.Error(), "${1}[REDACTED]")) +} + +func (s *server) subscriptionURLFailure(w http.ResponseWriter, r *http.Request, message string) { + if r.Header.Get("HX-Request") != "true" { + http.Redirect(w, r, "/subscriptions", http.StatusSeeOther) + return + } + s.render(w, "subscription_url_result", subscriptionURLResultView{ErrorText: message}) +} + +func (s *server) subscriptionURLRegisteredResponse(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("HX-Request") != "true" { + http.Redirect(w, r, "/subscriptions", http.StatusSeeOther) + return + } + view, err := s.loadSubscriptions(r.Context()) + if err != nil { + s.logger.Printf("登録番組の取得に失敗: %v", err) + http.Error(w, "登録番組を取得できませんでした", http.StatusInternalServerError) + return + } + view.OutOfBand = true + s.render(w, "subscription_url_registered", subscriptionURLRegisteredView{Subscriptions: view}) +} diff --git a/internal/web/subscription_url_test.go b/internal/web/subscription_url_test.go new file mode 100644 index 0000000..cda18db --- /dev/null +++ b/internal/web/subscription_url_test.go @@ -0,0 +1,260 @@ +package web + +import ( + "bytes" + "errors" + "log" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" + + "gitea.kazu634.com/kazu634/tver/internal/store" + "gitea.kazu634.com/kazu634/tver/internal/tver" +) + +func TestParseSeriesURL(t *testing.T) { + tests := []struct { + name, raw, want string + valid bool + }{ + {"without www", "https://tver.jp/series/sr123", "sr123", true}, + {"with www", "https://www.tver.jp/series/ABC123", "ABC123", true}, + {"uppercase host", "https://TVER.JP/series/sr123", "sr123", true}, + {"trailing slash", "https://tver.jp/series/sr123/", "sr123", true}, + {"query and fragment", "https://tver.jp/series/sr123/?foo=bar#section", "sr123", true}, + {"surrounding whitespace", " \thttps://tver.jp/series/sr123\n", "sr123", true}, + {"http", "http://tver.jp/series/sr123", "", false}, + {"subdomain suffix", "https://tver.jp.example.com/series/sr123", "", false}, + {"evil prefix", "https://eviltver.jp/series/sr123", "", false}, + {"userinfo on allowed host", "https://evil@tver.jp/series/sr123", "", false}, + {"allowed host as userinfo", "https://tver.jp@evil.example/series/sr123", "", false}, + {"episode", "https://tver.jp/episodes/ep123", "", false}, + {"lp", "https://tver.jp/lp/example", "", false}, + {"corner", "https://tver.jp/corner/example", "", false}, + {"feature", "https://tver.jp/feature/example", "", false}, + {"missing id", "https://tver.jp/series/", "", false}, + {"invalid id", "https://tver.jp/series/sr_123", "", false}, + {"extra path", "https://tver.jp/series/sr123/episodes", "", false}, + {"encoded slash in id", "https://tver.jp/series/sr123%2Fevil", "", false}, + {"encoded dot segments", "https://tver.jp/series/%2E%2E/episodes", "", false}, + {"duplicate slash", "https://tver.jp//series/sr123", "", false}, + {"port", "https://tver.jp:443/series/sr123", "", false}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseSeriesURL(tt.raw) + if tt.valid { + if err != nil || got != tt.want { + t.Fatalf("parseSeriesURL(%q)=%q, %v; want %q", tt.raw, got, err, tt.want) + } + return + } + if err == nil || err.Error() != seriesURLInputError { + t.Fatalf("parseSeriesURL(%q)=%q, %v; want input error", tt.raw, got, err) + } + }) + } +} + +func TestSubscriptionsPageShowsSeriesURLForm(t *testing.T) { + page := getPage(newTestHandlerWithStore(&fakeFetcher{}, &fakeJobStore{}, nil), "/subscriptions") + body := page.Body.String() + if page.Code != http.StatusOK || !strings.Contains(body, `name="series_url"`) || !strings.Contains(body, `hx-post="/subscriptions/from-url/confirm"`) || !strings.Contains(body, `id="subscriptions-list"`) { + t.Fatalf("URL入力フォーム: status=%d body=%s", page.Code, body) + } +} + +func TestConfirmSubscriptionURL(t *testing.T) { + fetcher := &fakeFetcher{ + seriesTitle: ``, + seriesEpisodes: []tver.Episode{ + {ID: "ep1", SeriesID: "sr1"}, + {ID: "ep2", SeriesID: "sr1"}, + }, + } + handler := newTestHandlerWithStore(fetcher, &fakeJobStore{}, nil) + response := postForm(handler, "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/sr1?x=1#fragment"}}) + body := response.Body.String() + for _, want := range []string{"<script>", "番組ID: sr1", "公開中の回数: 2件", `name="series_id" value="sr1"`, "公開中の回もダウンロードする", ">登録", ">キャンセル"} { + if !strings.Contains(body, want) { + t.Errorf("確認画面に %q がない: %s", want, body) + } + } + if response.Code != http.StatusOK || strings.Contains(body, `