Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
511365f0d4 | ||
|
|
8e0b03483b |
+6
-3
@@ -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/<ID>`形式の番組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、出力検査、移動先、作業場所の片付けを確認する
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
{"code":0,"result":{"content":{"type":"series","content":{"id":"sr0lhdrtdb","title":"韓国ドラマ「良いが悪い、ドンジェ」"}}}}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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: `<script>alert("title")</script>`,
|
||||
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"`, "公開中の回もダウンロードする", ">登録</button>", ">キャンセル</button>"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("確認画面に %q がない: %s", want, body)
|
||||
}
|
||||
}
|
||||
if response.Code != http.StatusOK || strings.Contains(body, `<script>`) || strings.Contains(body, `name="series_title"`) || strings.Contains(body, `checked`) {
|
||||
t.Fatalf("確認画面: status=%d body=%s", response.Code, body)
|
||||
}
|
||||
if len(fetcher.seriesTitleCalls) != 1 || fetcher.seriesTitleCalls[0] != "sr1" || len(fetcher.seriesCalls) != 1 || fetcher.seriesCalls[0] != "sr1" {
|
||||
t.Fatalf("取得したID: title=%v episodes=%v", fetcher.seriesTitleCalls, fetcher.seriesCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfirmSubscriptionURLAlreadyRegisteredIncludingPaused(t *testing.T) {
|
||||
fetcher := &fakeFetcher{seriesTitle: "番組", seriesEpisodes: []tver.Episode{{ID: "ep1", SeriesID: "sr1"}}}
|
||||
jobs := &fakeJobStore{subscriptions: map[string]store.Subscription{"sr1": {SeriesID: "sr1", SeriesTitle: "番組", Enabled: false}}}
|
||||
response := postForm(newTestHandlerWithStore(fetcher, jobs, nil), "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/sr1"}})
|
||||
body := response.Body.String()
|
||||
if response.Code != http.StatusOK || !strings.Contains(body, "番組") || !strings.Contains(body, "公開中の回数: 1件") || !strings.Contains(body, "登録済み") || strings.Contains(body, ">登録</button>") || len(fetcher.seriesTitleCalls) != 1 || len(fetcher.seriesCalls) != 1 {
|
||||
t.Fatalf("登録済み確認: status=%d body=%s title=%v episodes=%v", response.Code, body, fetcher.seriesTitleCalls, fetcher.seriesCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionURLNotFoundAndInvalidURL(t *testing.T) {
|
||||
fetcher := &fakeFetcher{seriesTitleErr: tver.ErrSeriesNotFound}
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, nil)
|
||||
notFound := postForm(handler, "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/missing"}})
|
||||
if notFound.Code != http.StatusOK || !strings.Contains(notFound.Body.String(), "番組が見つかりません") || len(fetcher.seriesCalls) != 0 {
|
||||
t.Fatalf("404: status=%d body=%s episodeCalls=%v", notFound.Code, notFound.Body.String(), fetcher.seriesCalls)
|
||||
}
|
||||
registerNotFound := postForm(handler, "/subscriptions/from-url/register", url.Values{"series_id": {"missing"}})
|
||||
if registerNotFound.Code != http.StatusOK || !strings.Contains(registerNotFound.Body.String(), "番組が見つかりません") || jobs.registerCalls != 0 {
|
||||
t.Fatalf("登録時404: status=%d body=%s registerCalls=%d", registerNotFound.Code, registerNotFound.Body.String(), jobs.registerCalls)
|
||||
}
|
||||
invalid := postForm(handler, "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://evil.example/series/sr1"}})
|
||||
if invalid.Code != http.StatusOK || !strings.Contains(invalid.Body.String(), seriesURLInputError) || len(fetcher.seriesTitleCalls) != 2 {
|
||||
t.Fatalf("不正URL: status=%d body=%s titleCalls=%v", invalid.Code, invalid.Body.String(), fetcher.seriesTitleCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSubscriptionURLWithAndWithoutExistingEpisodes(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{seriesTitle: "番組", seriesEpisodes: []tver.Episode{}}
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, nil)
|
||||
confirmation := postForm(handler, "/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/sr1"}})
|
||||
if confirmation.Code != http.StatusOK || !strings.Contains(confirmation.Body.String(), "公開中の回はありません") {
|
||||
t.Fatalf("0件の確認: %d %s", confirmation.Code, confirmation.Body.String())
|
||||
}
|
||||
values := url.Values{"series_id": {"sr1"}, "series_title": {"偽番組"}}
|
||||
if download {
|
||||
values.Set("download_existing", "1")
|
||||
}
|
||||
registered := postForm(handler, "/subscriptions/from-url/register", values)
|
||||
body := registered.Body.String()
|
||||
if registered.Code != http.StatusOK || !strings.Contains(body, "登録済み") || !strings.Contains(body, `id="subscriptions-list"`) || !strings.Contains(body, `hx-swap-oob="outerHTML"`) || !strings.Contains(body, `id="subscription-url-input"`) {
|
||||
t.Fatalf("登録応答: %d %s", registered.Code, body)
|
||||
}
|
||||
if jobs.registerCalls != 1 || jobs.registerDownload != download || len(jobs.registerEpisodes) != 0 || jobs.subscriptions["sr1"].SeriesTitle != "番組" {
|
||||
t.Fatalf("登録内容: calls=%d download=%t episodes=%v subscriptions=%v", jobs.registerCalls, jobs.registerDownload, jobs.registerEpisodes, jobs.subscriptions)
|
||||
}
|
||||
if len(fetcher.seriesTitleCalls) != 2 || len(fetcher.seriesCalls) != 2 {
|
||||
t.Fatalf("確認と登録で再取得していない: title=%v episodes=%v", fetcher.seriesTitleCalls, fetcher.seriesCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSubscriptionURLUsesServerFetchedData(t *testing.T) {
|
||||
fetcher := &fakeFetcher{seriesTitle: "本当の番組", seriesEpisodes: []tver.Episode{{
|
||||
ID: "ep1", SeriesID: "sr1", SeriesTitle: "本当の番組", Title: "公開中の回", Duration: 200,
|
||||
}}}
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, nil)
|
||||
response := postForm(handler, "/subscriptions/from-url/register", url.Values{
|
||||
"series_id": {"sr1"}, "series_title": {"偽番組"}, "download_existing": {"1"},
|
||||
})
|
||||
if response.Code != http.StatusOK || jobs.registerCalls != 1 || !jobs.registerDownload || len(jobs.registerEpisodes) != 1 || jobs.registerEpisodes[0].ID != "ep1" || jobs.subscriptions["sr1"].SeriesTitle != "本当の番組" {
|
||||
t.Fatalf("サーバー取得値での登録: status=%d calls=%d download=%t episodes=%+v subscriptions=%+v", response.Code, jobs.registerCalls, jobs.registerDownload, jobs.registerEpisodes, jobs.subscriptions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSubscriptionURLAlreadyRegisteredDoesNothing(t *testing.T) {
|
||||
fetcher := &fakeFetcher{seriesTitleErr: errors.New("must not fetch"), seriesErr: errors.New("must not fetch")}
|
||||
jobs := &fakeJobStore{subscriptions: map[string]store.Subscription{"sr1": {SeriesID: "sr1", SeriesTitle: "番組", Enabled: false}}}
|
||||
response := postForm(newTestHandlerWithStore(fetcher, jobs, nil), "/subscriptions/from-url/register", url.Values{"series_id": {"sr1"}})
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "登録済み") || jobs.registerCalls != 0 || len(fetcher.seriesTitleCalls) != 0 || len(fetcher.seriesCalls) != 0 {
|
||||
t.Fatalf("登録済みの再登録: status=%d body=%s register=%d title=%v episodes=%v", response.Code, response.Body.String(), jobs.registerCalls, fetcher.seriesTitleCalls, fetcher.seriesCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSubscriptionURLFetchFailuresDoNotRegisterAndRedactToken(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
seriesErr error
|
||||
episodesErr error
|
||||
wantEpisodeCall int
|
||||
}{
|
||||
{"series", errors.New("platform_token=secret-series& upstream"), nil, 0},
|
||||
{"episodes", nil, errors.New("platform_token=secret-episodes& upstream"), 1},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
fetcher := &fakeFetcher{seriesTitle: "番組", seriesTitleErr: tt.seriesErr, seriesErr: tt.episodesErr}
|
||||
jobs := &fakeJobStore{}
|
||||
var logs bytes.Buffer
|
||||
handler := NewHandler(fetcher, jobs, "", time.Now, log.New(&logs, "", 0))
|
||||
response := postForm(handler, "/subscriptions/from-url/register", url.Values{"series_id": {"sr1"}})
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "番組情報を取得できませんでした。再試行してください") || jobs.registerCalls != 0 || len(jobs.subscriptions) != 0 {
|
||||
t.Fatalf("取得失敗: status=%d body=%s calls=%d subscriptions=%v", response.Code, response.Body.String(), jobs.registerCalls, jobs.subscriptions)
|
||||
}
|
||||
if strings.Contains(logs.String(), "secret-") || !strings.Contains(logs.String(), "platform_token=[REDACTED]") || len(fetcher.seriesCalls) != tt.wantEpisodeCall {
|
||||
t.Fatalf("ログまたは呼出回数: logs=%s episodeCalls=%v", logs.String(), fetcher.seriesCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterSubscriptionURLRejectsInvalidChoice(t *testing.T) {
|
||||
fetcher := &fakeFetcher{}
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, nil)
|
||||
for _, values := range []url.Values{
|
||||
{},
|
||||
{"series_id": {"bad_id"}},
|
||||
{"series_id": {"../sr1"}},
|
||||
{"series_id": {"sr1"}, "download_existing": {"yes"}},
|
||||
} {
|
||||
response := postForm(handler, "/subscriptions/from-url/register", values)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Errorf("不正値 %v: status=%d body=%s", values, response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
if len(fetcher.seriesTitleCalls) != 0 || len(fetcher.seriesCalls) != 0 || jobs.subscribedCalls != 0 || jobs.registerCalls != 0 {
|
||||
t.Fatalf("不正な hidden 値で外部取得または DB 操作を実行: title=%v episodes=%v subscribed=%d register=%d", fetcher.seriesTitleCalls, fetcher.seriesCalls, jobs.subscribedCalls, jobs.registerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCancelSubscriptionURLClearsConfirmation(t *testing.T) {
|
||||
response := getPage(newTestHandlerWithStore(&fakeFetcher{}, &fakeJobStore{}, nil), "/subscriptions/from-url/cancel")
|
||||
body := response.Body.String()
|
||||
if response.Code != http.StatusOK || !strings.Contains(body, `id="subscription-url-result"`) || strings.Contains(body, `name="series_id"`) || strings.Contains(body, `role="alert"`) || strings.Contains(body, `role="status"`) {
|
||||
t.Fatalf("キャンセル応答: status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionURLPostsWithoutHtmxRedirect(t *testing.T) {
|
||||
fetcher := &fakeFetcher{seriesTitle: "番組", seriesEpisodes: []tver.Episode{}}
|
||||
jobs := &fakeJobStore{}
|
||||
handler := newTestHandlerWithStore(fetcher, jobs, nil)
|
||||
for _, tt := range []struct {
|
||||
path string
|
||||
form url.Values
|
||||
}{
|
||||
{"/subscriptions/from-url/confirm", url.Values{"series_url": {"https://tver.jp/series/sr1"}}},
|
||||
{"/subscriptions/from-url/register", url.Values{"series_id": {"sr1"}}},
|
||||
} {
|
||||
request := httptest.NewRequest(http.MethodPost, tt.path, strings.NewReader(tt.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") != "/subscriptions" {
|
||||
t.Errorf("通常POST %s: status=%d Location=%q", tt.path, response.Code, response.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
if len(fetcher.seriesTitleCalls) != 1 || jobs.registerCalls != 1 {
|
||||
t.Fatalf("登録POSTが処理されていない: fetch=%v register=%d", fetcher.seriesTitleCalls, jobs.registerCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscriptionURLPostRoutesRejectGet(t *testing.T) {
|
||||
handler := newTestHandlerWithStore(&fakeFetcher{}, &fakeJobStore{}, nil)
|
||||
for _, path := range []string{"/subscriptions/from-url/confirm", "/subscriptions/from-url/register"} {
|
||||
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"))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@ type subscriptionView struct {
|
||||
}
|
||||
|
||||
type subscriptionsView struct {
|
||||
Items []subscriptionView
|
||||
Items []subscriptionView
|
||||
OutOfBand bool
|
||||
}
|
||||
|
||||
func (s *server) subscriptionChoice(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -18,11 +18,48 @@
|
||||
|
||||
{{define "subscriptions_page"}}
|
||||
<h1>登録番組</h1>
|
||||
<section class="subscription-url-entry">
|
||||
<h2>番組URLから登録</h2>
|
||||
<form action="/subscriptions/from-url/confirm" method="post" hx-post="/subscriptions/from-url/confirm" hx-target="#subscription-url-result" hx-swap="outerHTML">
|
||||
<label>番組URL <input id="subscription-url-input" type="url" name="series_url" placeholder="https://tver.jp/series/…" required></label>
|
||||
<button type="submit">確認</button>
|
||||
</form>
|
||||
<div id="subscription-url-result"></div>
|
||||
</section>
|
||||
{{template "subscriptions_list" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "subscription_url_result"}}
|
||||
<div id="subscription-url-result">
|
||||
{{if .ErrorText}}
|
||||
<p role="alert">{{.ErrorText}}</p>
|
||||
{{else if .SeriesID}}
|
||||
<h3>{{.Title}}</h3>
|
||||
<p>番組ID: {{.SeriesID}}</p>
|
||||
{{if eq .EpisodeCount 0}}<p>公開中の回はありません</p>{{else}}<p>公開中の回数: {{.EpisodeCount}}件</p>{{end}}
|
||||
{{if .Subscribed}}
|
||||
<p role="status">登録済み</p>
|
||||
<button type="button" hx-get="/subscriptions/from-url/cancel" hx-target="#subscription-url-result" hx-swap="outerHTML">キャンセル</button>
|
||||
{{else}}
|
||||
<form action="/subscriptions/from-url/register" method="post" hx-post="/subscriptions/from-url/register" hx-target="#subscription-url-result" hx-swap="outerHTML">
|
||||
<input type="hidden" name="series_id" value="{{.SeriesID}}">
|
||||
<label><input type="checkbox" name="download_existing" value="1">公開中の回もダウンロードする</label>
|
||||
<button type="submit">登録</button>
|
||||
<button type="button" hx-get="/subscriptions/from-url/cancel" hx-target="#subscription-url-result" hx-swap="outerHTML">キャンセル</button>
|
||||
</form>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "subscription_url_registered"}}
|
||||
<div id="subscription-url-result"><p role="status">登録済み</p></div>
|
||||
<input id="subscription-url-input" type="url" name="series_url" placeholder="https://tver.jp/series/…" required hx-swap-oob="outerHTML">
|
||||
{{template "subscriptions_list" .Subscriptions}}
|
||||
{{end}}
|
||||
|
||||
{{define "subscriptions_list"}}
|
||||
<div id="subscriptions-list">
|
||||
<div id="subscriptions-list" {{if .OutOfBand}}hx-swap-oob="outerHTML"{{end}}>
|
||||
{{range .Items}}
|
||||
<article class="subscription-item">
|
||||
<h2>{{.Title}}</h2>
|
||||
|
||||
Reference in New Issue
Block a user