Merge pull request '新着一覧の画面を追加(ステップ1)' (#4) from newer-list into main
Reviewed-on: #4
This commit was merged in pull request #4.
This commit is contained in:
+8
-5
@@ -3,7 +3,7 @@ title: tver の現状
|
||||
tags:
|
||||
- homelab
|
||||
- tver
|
||||
status: web 土台実装中
|
||||
status: 新着一覧実装中
|
||||
---
|
||||
|
||||
# tver の現状
|
||||
@@ -13,8 +13,8 @@ status: web 土台実装中
|
||||
|
||||
## 今の状態
|
||||
|
||||
- **web の土台を実装中。** Go の HTTP サーバー、TVer API クライアント、画面の空のページ、テスト、web コンテナの定義がある
|
||||
- 新着一覧の画面表示、DB、worker は未実装
|
||||
- **新着一覧の画面を実装中。** Go の HTTP サーバー、TVer API クライアント、ドラマ・バラエティの新着表示、テスト、web コンテナの定義がある
|
||||
- DB、worker は未実装。ダウンロード済み・待ち行列中の区別は DB 導入後に実装する
|
||||
- リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git`
|
||||
|
||||
## 目的
|
||||
@@ -71,7 +71,9 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
|
||||
| `web` | UI・API。新着一覧の取得、待ち行列への登録、番組登録、動画の配信(再生) |
|
||||
| `worker` | Go + yt-dlp + ffmpeg。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する |
|
||||
|
||||
- 現在の `web` は `/healthz` と「新着/待ち行列/登録番組/ライブラリ」の空ページを提供する。`internal/tver` はセッション発行とドラマ・バラエティの新着取得を実装済みだが、画面からはまだ呼び出さない
|
||||
- 現在の `web` は `/healthz`、ドラマ・バラエティの新着ページ(`/`)、新着一覧の HTML 断片(`/newer`)を提供する。「待ち行列/登録番組/ライブラリ」のページは空のまま
|
||||
- 新着はジャンルごとにメモリへ 10 分間キャッシュする。キャッシュ中は TVer セッションの再発行と一覧の再取得を行わない。TVer の取得に失敗した場合、一覧の場所にエラーを表示して詳細をログへ記録する
|
||||
- 「予告を隠す」は既定でオンとし、180 秒以下の件を非表示にする。長さは秒から分へ切り上げて表示し、配信終了日時は Asia/Tokyo で表示する。ジャンルと予告表示の状態は URL に残す
|
||||
- 画面は `html/template` と htmx 2.0.11 を使い、htmx は Go の `embed` から `/static/` でローカル配信する
|
||||
- `compose.yaml` は web のみを定義し、dockage VM の LAN アドレス `192.168.10.155:8080` で公開する。待ち受けは `WEB_ADDR` で変更でき、既定は `:8080`
|
||||
- DB はコンテナを持たず、NAS の共有 Postgres に接続する(番組登録・ダウンロード履歴・待ち行列)
|
||||
@@ -106,5 +108,6 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
|
||||
## 確かめ方
|
||||
|
||||
- `env GOCACHE=/private/tmp/tver-go-cache GOPROXY=off go test -count=1 ./...` で Go のテストを実行する。テストの TVer 応答は `internal/tver/testdata/newer_drama.json`(2026-10-03 の実応答を 3 件に縮小)と `httptest` を使い、テストから本物の TVer にはアクセスしない
|
||||
- 新着画面のテストは偽物の TVer クライアントと差し替え可能な時計を使い、表示項目、絞り込みの境界、10 分キャッシュ、取得失敗、JST 表示を確認する
|
||||
- `env GOCACHE=/private/tmp/tver-go-cache GOPROXY=off go vet ./...` で静的解析する
|
||||
- `WEB_ADDR=127.0.0.1:18080 go run ./cmd/web` で起動し、`/healthz` と `/` を確認できる。Dockerfile と Compose のビルド・実行は Docker が使える環境で別途確認が必要
|
||||
- `WEB_ADDR=127.0.0.1:18080 go run ./cmd/web` で起動し、`/healthz`、`/`、`/newer?genre=variety` を確認できる。TVer への実アクセスを伴うため手動確認は少数回に留める
|
||||
|
||||
+7
-1
@@ -5,7 +5,9 @@ import (
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
_ "time/tzdata"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/tver"
|
||||
"gitea.kazu634.com/kazu634/tver/internal/web"
|
||||
)
|
||||
|
||||
@@ -14,9 +16,13 @@ func main() {
|
||||
if address == "" {
|
||||
address = ":8080"
|
||||
}
|
||||
client, err := tver.NewClient("", nil)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
server := &http.Server{
|
||||
Addr: address,
|
||||
Handler: web.NewHandler(),
|
||||
Handler: web.NewHandler(client, time.Now, log.Default()),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
log.Printf("web listening on %s", address)
|
||||
|
||||
+23
-3
@@ -4,7 +4,10 @@ import (
|
||||
"embed"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
"log"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
//go:embed templates/*.html static/*
|
||||
@@ -12,29 +15,46 @@ var assets embed.FS
|
||||
|
||||
type page struct {
|
||||
Title string
|
||||
Newer *newerView
|
||||
}
|
||||
|
||||
func NewHandler() http.Handler {
|
||||
type server struct {
|
||||
fetcher NewerFetcher
|
||||
now func() time.Time
|
||||
logger *log.Logger
|
||||
zone *time.Location
|
||||
tmpl *template.Template
|
||||
cacheMu sync.Mutex
|
||||
cache map[string]cacheEntry
|
||||
}
|
||||
|
||||
func NewHandler(fetcher NewerFetcher, now func() time.Time, logger *log.Logger) http.Handler {
|
||||
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
|
||||
staticFiles, err := fs.Sub(assets, "static")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
zone, err := time.LoadLocation("Asia/Tokyo")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
s := &server{fetcher: fetcher, now: now, logger: logger, zone: zone, tmpl: tmpl, cache: make(map[string]cacheEntry)}
|
||||
mux := http.NewServeMux()
|
||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFiles)))
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
_, _ = w.Write([]byte("ok\n"))
|
||||
})
|
||||
mux.HandleFunc("GET /{$}", s.home)
|
||||
mux.HandleFunc("GET /newer", s.newerFragment)
|
||||
for path, title := range map[string]string{
|
||||
"GET /{$}": "新着",
|
||||
"GET /queue": "待ち行列",
|
||||
"GET /subscriptions": "登録番組",
|
||||
"GET /library": "ライブラリ",
|
||||
} {
|
||||
mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
if err := tmpl.ExecuteTemplate(w, "layout", page{Title: title}); err != nil {
|
||||
if err := s.tmpl.ExecuteTemplate(w, "layout", page{Title: title}); err != nil {
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,14 +9,14 @@ import (
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
response := httptest.NewRecorder()
|
||||
NewHandler().ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
newTestHandler(&fakeFetcher{}, nil).ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/healthz", nil))
|
||||
if response.Code != http.StatusOK || response.Body.String() != "ok\n" {
|
||||
t.Errorf("healthz: status=%d body=%q", response.Code, response.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPagesAndEmbeddedAssets(t *testing.T) {
|
||||
handler := NewHandler()
|
||||
handler := newTestHandler(&fakeFetcher{}, nil)
|
||||
for _, tt := range []struct{ path, title string }{
|
||||
{"/", "新着"}, {"/queue", "待ち行列"}, {"/subscriptions", "登録番組"}, {"/library", "ライブラリ"},
|
||||
} {
|
||||
@@ -42,7 +42,7 @@ func TestPagesAndEmbeddedAssets(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestUnknownPathsAndMethods(t *testing.T) {
|
||||
handler := NewHandler()
|
||||
handler := newTestHandler(&fakeFetcher{}, nil)
|
||||
for _, tt := range []struct {
|
||||
name, method, path string
|
||||
wantStatus int
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/tver"
|
||||
)
|
||||
|
||||
const (
|
||||
previewMaxSeconds = 180
|
||||
newerCacheTTL = 10 * time.Minute
|
||||
)
|
||||
|
||||
var platformTokenPattern = regexp.MustCompile(`(?i)(platform_token=)[^&\s]+`)
|
||||
|
||||
type NewerFetcher interface {
|
||||
FetchNewer(context.Context, string) ([]tver.Episode, error)
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
episodes []tver.Episode
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type newerOptions struct {
|
||||
genre string
|
||||
hidePreviews bool
|
||||
}
|
||||
|
||||
type episodeView struct {
|
||||
URL string
|
||||
ThumbnailURL string
|
||||
SeriesTitle string
|
||||
Title string
|
||||
BroadcasterName string
|
||||
DurationMinutes int64
|
||||
BroadcastDateLabel string
|
||||
EndAtText string
|
||||
EndAtISO string
|
||||
}
|
||||
|
||||
type newerView struct {
|
||||
Genre string
|
||||
HidePreviews bool
|
||||
Episodes []episodeView
|
||||
Failed bool
|
||||
}
|
||||
|
||||
func parseNewerOptions(r *http.Request) (newerOptions, error) {
|
||||
options := newerOptions{genre: "drama", hidePreviews: true}
|
||||
query := r.URL.Query()
|
||||
if values, ok := query["genre"]; ok {
|
||||
options.genre = values[len(values)-1]
|
||||
}
|
||||
if options.genre != "drama" && options.genre != "variety" {
|
||||
return newerOptions{}, fmt.Errorf("invalid genre")
|
||||
}
|
||||
if values, ok := query["hide_previews"]; ok {
|
||||
switch values[len(values)-1] {
|
||||
case "0":
|
||||
options.hidePreviews = false
|
||||
case "1":
|
||||
options.hidePreviews = true
|
||||
default:
|
||||
return newerOptions{}, fmt.Errorf("invalid hide_previews")
|
||||
}
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
func (options newerOptions) pageURL() string {
|
||||
query := url.Values{}
|
||||
query.Set("genre", options.genre)
|
||||
if options.hidePreviews {
|
||||
query.Set("hide_previews", "1")
|
||||
} else {
|
||||
query.Set("hide_previews", "0")
|
||||
}
|
||||
return "/?" + query.Encode()
|
||||
}
|
||||
|
||||
func (s *server) home(w http.ResponseWriter, r *http.Request) {
|
||||
options, err := parseNewerOptions(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
view := s.newerView(r.Context(), options)
|
||||
s.render(w, "layout", page{Title: "新着", Newer: &view})
|
||||
}
|
||||
|
||||
func (s *server) newerFragment(w http.ResponseWriter, r *http.Request) {
|
||||
options, err := parseNewerOptions(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
view := s.newerView(r.Context(), options)
|
||||
w.Header().Set("HX-Push-Url", options.pageURL())
|
||||
s.render(w, "newer_results", view)
|
||||
}
|
||||
|
||||
func (s *server) render(w http.ResponseWriter, name string, value any) {
|
||||
var body bytes.Buffer
|
||||
if err := s.tmpl.ExecuteTemplate(&body, name, value); err != nil {
|
||||
s.logger.Printf("テンプレートの描画に失敗: %v", err)
|
||||
http.Error(w, "template error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = body.WriteTo(w)
|
||||
}
|
||||
|
||||
func (s *server) newerView(ctx context.Context, options newerOptions) newerView {
|
||||
view := newerView{Genre: options.genre, HidePreviews: options.hidePreviews}
|
||||
episodes, err := s.cachedNewer(ctx, options.genre)
|
||||
if err != nil {
|
||||
s.logger.Printf("TVer の新着取得に失敗(genre=%s): %s", options.genre, platformTokenPattern.ReplaceAllString(err.Error(), "${1}[REDACTED]"))
|
||||
view.Failed = true
|
||||
return view
|
||||
}
|
||||
for _, episode := range episodes {
|
||||
if options.hidePreviews && episode.Duration <= previewMaxSeconds {
|
||||
continue
|
||||
}
|
||||
endAt := time.Unix(episode.EndAt, 0).In(s.zone)
|
||||
view.Episodes = append(view.Episodes, episodeView{
|
||||
URL: "https://tver.jp/episodes/" + url.PathEscape(episode.ID),
|
||||
ThumbnailURL: "https://statics.tver.jp" + episode.ThumbnailPath,
|
||||
SeriesTitle: episode.SeriesTitle,
|
||||
Title: episode.Title,
|
||||
BroadcasterName: episode.BroadcasterName,
|
||||
DurationMinutes: durationMinutes(episode.Duration),
|
||||
BroadcastDateLabel: episode.BroadcastDateLabel,
|
||||
EndAtText: endAt.Format("2006-01-02 15:04 MST"),
|
||||
EndAtISO: endAt.Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
return view
|
||||
}
|
||||
|
||||
func durationMinutes(seconds int64) int64 {
|
||||
minutes := seconds / 60
|
||||
if seconds%60 != 0 {
|
||||
minutes++
|
||||
}
|
||||
return max(1, minutes)
|
||||
}
|
||||
|
||||
func (s *server) cachedNewer(ctx context.Context, genre string) ([]tver.Episode, error) {
|
||||
s.cacheMu.Lock()
|
||||
defer s.cacheMu.Unlock()
|
||||
if cached, ok := s.cache[genre]; ok && s.now().Before(cached.expiresAt) {
|
||||
return cached.episodes, nil
|
||||
}
|
||||
episodes, err := s.fetcher.FetchNewer(ctx, genre)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.cache[genre] = cacheEntry{episodes: episodes, expiresAt: s.now().Add(newerCacheTTL)}
|
||||
return episodes, nil
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.kazu634.com/kazu634/tver/internal/tver"
|
||||
)
|
||||
|
||||
type fakeFetcher struct {
|
||||
episodes []tver.Episode
|
||||
episodesByGenre map[string][]tver.Episode
|
||||
err error
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeFetcher) FetchNewer(_ context.Context, genre string) ([]tver.Episode, error) {
|
||||
f.calls = append(f.calls, genre)
|
||||
if f.episodesByGenre != nil {
|
||||
return f.episodesByGenre[genre], f.err
|
||||
}
|
||||
return f.episodes, f.err
|
||||
}
|
||||
|
||||
func newTestHandler(fetcher *fakeFetcher, now func() time.Time) http.Handler {
|
||||
if now == nil {
|
||||
now = func() time.Time { return time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC) }
|
||||
}
|
||||
return NewHandler(fetcher, now, log.New(io.Discard, "", 0))
|
||||
}
|
||||
|
||||
func getPage(handler http.Handler, path string) *httptest.ResponseRecorder {
|
||||
response := httptest.NewRecorder()
|
||||
handler.ServeHTTP(response, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
return response
|
||||
}
|
||||
|
||||
func TestNewerPageShowsEpisodeFieldsAndJST(t *testing.T) {
|
||||
endAt := time.Date(2026, 10, 3, 21, 34, 0, 0, time.UTC).Unix()
|
||||
fetcher := &fakeFetcher{episodes: []tver.Episode{{
|
||||
ID: "ep123", SeriesTitle: "番組名", Title: "第1話", BroadcasterName: "放送局",
|
||||
Duration: 2712, EndAt: endAt, ThumbnailPath: "/images/ep123.jpg",
|
||||
BroadcastDateLabel: "10月3日放送分",
|
||||
}}}
|
||||
response := getPage(newTestHandler(fetcher, nil), "/")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, want := range []string{
|
||||
"番組名", "第1話", "放送局", "10月3日放送分",
|
||||
"長さ: 46分",
|
||||
"https://statics.tver.jp/images/ep123.jpg",
|
||||
"https://tver.jp/episodes/ep123",
|
||||
"2026-10-04 06:34 JST", `datetime="2026-10-04T06:34:00+09:00"`,
|
||||
`value="drama" selected`, `name="hide_previews" value="1" checked`,
|
||||
`hx-get="/newer"`, `hx-target="#newer-results"`,
|
||||
} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("page missing %q", want)
|
||||
}
|
||||
}
|
||||
if len(fetcher.calls) != 1 || fetcher.calls[0] != "drama" {
|
||||
t.Errorf("calls = %v", fetcher.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDurationMinutesRoundsUp(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
seconds int64
|
||||
minutes int64
|
||||
}{
|
||||
{0, 1}, {1, 1}, {59, 1}, {60, 1}, {61, 2}, {2712, 46},
|
||||
} {
|
||||
if got := durationMinutes(tt.seconds); got != tt.minutes {
|
||||
t.Errorf("%d seconds = %d minutes; want %d", tt.seconds, got, tt.minutes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewerFragmentUpdatesURLAndFullPageRestoresState(t *testing.T) {
|
||||
fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "variety-1", Duration: 200}}}
|
||||
handler := newTestHandler(fetcher, nil)
|
||||
response := getPage(handler, "/newer?genre=variety&hide_previews=0")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("fragment status = %d", response.Code)
|
||||
}
|
||||
if got := response.Header().Get("HX-Push-Url"); got != "/?genre=variety&hide_previews=0" {
|
||||
t.Errorf("HX-Push-Url = %q", got)
|
||||
}
|
||||
if body := response.Body.String(); !strings.Contains(body, `id="newer-results"`) || !strings.Contains(body, "variety-1") || strings.Contains(body, "<html") {
|
||||
t.Errorf("not an episode fragment: %s", body)
|
||||
}
|
||||
reload := getPage(handler, "/?genre=variety&hide_previews=0")
|
||||
if reload.Code != http.StatusOK || !strings.Contains(reload.Body.String(), `value="variety" selected`) || strings.Contains(reload.Body.String(), `value="1" checked`) || !strings.Contains(reload.Body.String(), "variety-1") {
|
||||
t.Errorf("reload did not restore state: status=%d body=%s", reload.Code, reload.Body.String())
|
||||
}
|
||||
if len(fetcher.calls) != 1 || fetcher.calls[0] != "variety" {
|
||||
t.Errorf("calls = %v", fetcher.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewerInvalidGenreReturns400WithoutFetch(t *testing.T) {
|
||||
fetcher := &fakeFetcher{}
|
||||
handler := newTestHandler(fetcher, nil)
|
||||
for _, path := range []string{"/?genre=news", "/newer?genre=news", "/?genre=", "/?hide_previews=invalid", "/newer?hide_previews="} {
|
||||
response := getPage(handler, path)
|
||||
if response.Code != http.StatusBadRequest {
|
||||
t.Errorf("%s: status = %d", path, response.Code)
|
||||
}
|
||||
}
|
||||
if len(fetcher.calls) != 0 {
|
||||
t.Errorf("unexpected fetch calls: %v", fetcher.calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewFilterBoundaryAndToggle(t *testing.T) {
|
||||
fetcher := &fakeFetcher{episodes: []tver.Episode{
|
||||
{ID: "preview-180", Duration: 180},
|
||||
{ID: "episode-181", Duration: 181},
|
||||
}}
|
||||
handler := newTestHandler(fetcher, nil)
|
||||
hidden := getPage(handler, "/")
|
||||
if strings.Contains(hidden.Body.String(), "preview-180") || !strings.Contains(hidden.Body.String(), "episode-181") {
|
||||
t.Errorf("default filter: %s", hidden.Body.String())
|
||||
}
|
||||
shown := getPage(handler, "/newer?genre=drama&hide_previews=0")
|
||||
if !strings.Contains(shown.Body.String(), "preview-180") || !strings.Contains(shown.Body.String(), "episode-181") {
|
||||
t.Errorf("show previews: %s", shown.Body.String())
|
||||
}
|
||||
checked := getPage(handler, "/newer?genre=drama&hide_previews=0&hide_previews=1")
|
||||
if strings.Contains(checked.Body.String(), "preview-180") || checked.Header().Get("HX-Push-Url") != "/?genre=drama&hide_previews=1" {
|
||||
t.Errorf("checked form state: %s", checked.Body.String())
|
||||
}
|
||||
if len(fetcher.calls) != 1 {
|
||||
t.Errorf("filter caused %d TVer fetches; want 1", len(fetcher.calls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewerCacheExpiresPerGenre(t *testing.T) {
|
||||
now := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
|
||||
fetcher := &fakeFetcher{episodes: []tver.Episode{{ID: "ep", Duration: 200}}}
|
||||
handler := newTestHandler(fetcher, func() time.Time { return now })
|
||||
getPage(handler, "/")
|
||||
now = now.Add(9*time.Minute + 59*time.Second)
|
||||
getPage(handler, "/?genre=drama&hide_previews=0")
|
||||
getPage(handler, "/?genre=variety")
|
||||
if got := strings.Join(fetcher.calls, ","); got != "drama,variety" {
|
||||
t.Fatalf("before expiry calls = %q", got)
|
||||
}
|
||||
now = now.Add(2 * time.Second)
|
||||
getPage(handler, "/")
|
||||
getPage(handler, "/?genre=variety")
|
||||
if got := strings.Join(fetcher.calls, ","); got != "drama,variety,drama" {
|
||||
t.Errorf("after expiry calls = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewerCacheKeepsGenreResultsSeparateAndExpiresAtTenMinutes(t *testing.T) {
|
||||
now := time.Date(2026, 10, 3, 0, 0, 0, 0, time.UTC)
|
||||
fetcher := &fakeFetcher{episodesByGenre: map[string][]tver.Episode{
|
||||
"drama": {{ID: "drama-only", Duration: 200}},
|
||||
"variety": {{ID: "variety-only", Duration: 200}},
|
||||
}}
|
||||
handler := newTestHandler(fetcher, func() time.Time { return now })
|
||||
checkPage := func(path, wantID, absentID string) {
|
||||
t.Helper()
|
||||
response := getPage(handler, path)
|
||||
body := response.Body.String()
|
||||
if response.Code != http.StatusOK || !strings.Contains(body, "/episodes/"+wantID) || strings.Contains(body, "/episodes/"+absentID) {
|
||||
t.Errorf("%s at %s: status=%d, wrong genre contents: %s", path, now.Format(time.RFC3339), response.Code, body)
|
||||
}
|
||||
}
|
||||
checkCalls := func(want string) {
|
||||
t.Helper()
|
||||
if got := strings.Join(fetcher.calls, ","); got != want {
|
||||
t.Errorf("fetch calls = %q; want %q", got, want)
|
||||
}
|
||||
}
|
||||
checkPage("/", "drama-only", "variety-only")
|
||||
now = now.Add(5 * time.Minute)
|
||||
checkPage("/?genre=variety", "variety-only", "drama-only")
|
||||
now = now.Add(4*time.Minute + 59*time.Second)
|
||||
checkPage("/", "drama-only", "variety-only")
|
||||
checkCalls("drama,variety")
|
||||
now = now.Add(time.Second)
|
||||
checkPage("/", "drama-only", "variety-only")
|
||||
checkPage("/?genre=variety", "variety-only", "drama-only")
|
||||
checkCalls("drama,variety,drama")
|
||||
now = now.Add(5 * time.Minute)
|
||||
checkPage("/?genre=variety", "variety-only", "drama-only")
|
||||
checkCalls("drama,variety,drama,variety")
|
||||
}
|
||||
|
||||
func TestNewerPageEscapesUntrustedEpisodeFields(t *testing.T) {
|
||||
fetcher := &fakeFetcher{episodes: []tver.Episode{{
|
||||
ID: "safe-id", SeriesTitle: `<script>alert(1)</script>`, Title: `<b>title</b>`,
|
||||
BroadcasterName: `<img src=x onerror=alert(1)>`, BroadcastDateLabel: `<svg onload=alert(1)>`,
|
||||
ThumbnailPath: `/image.jpg" onerror="alert(1)`, Duration: 200,
|
||||
}}}
|
||||
response := getPage(newTestHandler(fetcher, nil), "/")
|
||||
if response.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d", response.Code)
|
||||
}
|
||||
body := response.Body.String()
|
||||
for _, unsafe := range []string{"<script>alert(1)</script>", "<b>title</b>", "<img src=x", "<svg onload", `onerror="alert(1)"`} {
|
||||
if strings.Contains(body, unsafe) {
|
||||
t.Errorf("untrusted markup was not escaped: %q", unsafe)
|
||||
}
|
||||
}
|
||||
for _, escaped := range []string{"<script>", "<b>title</b>", "<img src=x onerror=alert(1)>", "<svg onload=alert(1)>"} {
|
||||
if !strings.Contains(body, escaped) {
|
||||
t.Errorf("escaped field missing: %q", escaped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewerFailureShowsFriendlyMessageAndRedactsToken(t *testing.T) {
|
||||
fetcher := &fakeFetcher{err: errors.New("GET https://platform-api.tver.jp/?platform_token=secret-123&x=1: upstream unavailable")}
|
||||
var logs bytes.Buffer
|
||||
handler := NewHandler(fetcher, time.Now, log.New(&logs, "", 0))
|
||||
response := getPage(handler, "/")
|
||||
if response.Code != http.StatusOK || !strings.Contains(response.Body.String(), "新着を取得できませんでした") {
|
||||
t.Errorf("error page: status=%d body=%s", response.Code, response.Body.String())
|
||||
}
|
||||
if strings.Contains(response.Body.String(), "upstream") || strings.Contains(response.Body.String(), "secret-123") {
|
||||
t.Errorf("error detail leaked into page: %s", response.Body.String())
|
||||
}
|
||||
fragment := getPage(handler, "/newer?genre=variety&hide_previews=0")
|
||||
if fragment.Code != http.StatusOK || !strings.Contains(fragment.Body.String(), `id="newer-results"`) || !strings.Contains(fragment.Body.String(), "新着を取得できませんでした") || strings.Contains(fragment.Body.String(), "<html") {
|
||||
t.Errorf("error fragment: status=%d body=%s", fragment.Code, fragment.Body.String())
|
||||
}
|
||||
if fragment.Header().Get("HX-Push-Url") != "/?genre=variety&hide_previews=0" || strings.Contains(fragment.Body.String(), "secret-123") {
|
||||
t.Errorf("error fragment URL or token leak: header=%q body=%s", fragment.Header().Get("HX-Push-Url"), fragment.Body.String())
|
||||
}
|
||||
if !strings.Contains(logs.String(), "upstream unavailable") || strings.Contains(logs.String(), "secret-123") {
|
||||
t.Errorf("log detail or token redaction failed: %s", logs.String())
|
||||
}
|
||||
}
|
||||
@@ -3,3 +3,13 @@ header { display: flex; align-items: center; gap: 2rem; padding: 1rem 2rem; bord
|
||||
nav { display: flex; flex-wrap: wrap; gap: 1.5rem; }
|
||||
nav a { color: #145a86; }
|
||||
main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; }
|
||||
#newer-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 1rem; margin-bottom: 2rem; }
|
||||
#newer-controls select, #newer-controls button { font: inherit; padding: .35rem .6rem; }
|
||||
.episode-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr)); gap: 1.25rem; }
|
||||
.episode-card { border: 1px solid #d6dee4; border-radius: .5rem; overflow: hidden; }
|
||||
.episode-card a { color: inherit; text-decoration: none; }
|
||||
.episode-card a:hover h2 { text-decoration: underline; }
|
||||
.episode-card img { display: block; width: 100%; aspect-ratio: 16 / 9; object-fit: cover; background: #e5ebef; }
|
||||
.episode-copy { padding: .8rem 1rem; }
|
||||
.episode-copy h2 { font-size: 1.1rem; margin: 0 0 .4rem; }
|
||||
.episode-copy p { margin: .3rem 0; }
|
||||
|
||||
@@ -18,7 +18,11 @@
|
||||
</nav>
|
||||
</header>
|
||||
<main>
|
||||
<h1>{{.Title}}</h1>
|
||||
{{if .Newer}}
|
||||
{{template "newer_page" .Newer}}
|
||||
{{else}}
|
||||
<h1>{{.Title}}</h1>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
</html>{{end}}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
{{define "newer_page"}}
|
||||
<h1>新着</h1>
|
||||
<form id="newer-controls" action="/" method="get"
|
||||
hx-get="/newer" hx-trigger="change, submit"
|
||||
hx-target="#newer-results" hx-swap="outerHTML">
|
||||
<label for="genre">ジャンル</label>
|
||||
<select id="genre" name="genre">
|
||||
<option value="drama" {{if eq .Genre "drama"}}selected{{end}}>ドラマ</option>
|
||||
<option value="variety" {{if eq .Genre "variety"}}selected{{end}}>バラエティ</option>
|
||||
</select>
|
||||
<input type="hidden" name="hide_previews" value="0">
|
||||
<label for="hide-previews">
|
||||
<input id="hide-previews" type="checkbox" name="hide_previews" value="1" {{if .HidePreviews}}checked{{end}}>
|
||||
予告を隠す
|
||||
</label>
|
||||
<button type="submit">表示</button>
|
||||
</form>
|
||||
{{template "newer_results" .}}
|
||||
{{end}}
|
||||
|
||||
{{define "newer_results"}}
|
||||
<section id="newer-results" aria-label="新着一覧" aria-live="polite">
|
||||
{{if .Failed}}
|
||||
<p role="status">新着を取得できませんでした</p>
|
||||
{{else if not .Episodes}}
|
||||
<p>表示する新着はありません</p>
|
||||
{{else}}
|
||||
<div class="episode-grid">
|
||||
{{range .Episodes}}
|
||||
<article class="episode-card">
|
||||
<a href="{{.URL}}">
|
||||
<img src="{{.ThumbnailURL}}" alt="" loading="lazy">
|
||||
<div class="episode-copy">
|
||||
<h2>{{.SeriesTitle}}</h2>
|
||||
<p>{{.Title}}</p>
|
||||
<p>放送局: {{.BroadcasterName}}</p>
|
||||
<p>長さ: {{.DurationMinutes}}分</p>
|
||||
<p>放送日: {{.BroadcastDateLabel}}</p>
|
||||
<p>配信終了: <time datetime="{{.EndAtISO}}">{{.EndAtText}}</time></p>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
{{end}}
|
||||
</div>
|
||||
{{end}}
|
||||
</section>
|
||||
{{end}}
|
||||
Reference in New Issue
Block a user