新着一覧の画面を追加(ステップ1) #4
+8
-5
@@ -3,7 +3,7 @@ title: tver の現状
|
|||||||
tags:
|
tags:
|
||||||
- homelab
|
- homelab
|
||||||
- tver
|
- tver
|
||||||
status: web 土台実装中
|
status: 新着一覧実装中
|
||||||
---
|
---
|
||||||
|
|
||||||
# tver の現状
|
# tver の現状
|
||||||
@@ -13,8 +13,8 @@ status: web 土台実装中
|
|||||||
|
|
||||||
## 今の状態
|
## 今の状態
|
||||||
|
|
||||||
- **web の土台を実装中。** Go の HTTP サーバー、TVer API クライアント、画面の空のページ、テスト、web コンテナの定義がある
|
- **新着一覧の画面を実装中。** Go の HTTP サーバー、TVer API クライアント、ドラマ・バラエティの新着表示、テスト、web コンテナの定義がある
|
||||||
- 新着一覧の画面表示、DB、worker は未実装
|
- DB、worker は未実装。ダウンロード済み・待ち行列中の区別は DB 導入後に実装する
|
||||||
- リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git`
|
- リポジトリ: `https://gitea.kazu634.com/kazu634/tver.git`
|
||||||
|
|
||||||
## 目的
|
## 目的
|
||||||
@@ -71,7 +71,9 @@ TVer(`https://tver.jp/`)で公開中の動画情報を取得し、選んだ
|
|||||||
| `web` | UI・API。新着一覧の取得、待ち行列への登録、番組登録、動画の配信(再生) |
|
| `web` | UI・API。新着一覧の取得、待ち行列への登録、番組登録、動画の配信(再生) |
|
||||||
| `worker` | Go + yt-dlp + ffmpeg。待ち行列からダウンロードし、登録番組の新しい回を定期的に確認する |
|
| `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/` でローカル配信する
|
- 画面は `html/template` と htmx 2.0.11 を使い、htmx は Go の `embed` から `/static/` でローカル配信する
|
||||||
- `compose.yaml` は web のみを定義し、dockage VM の LAN アドレス `192.168.10.155:8080` で公開する。待ち受けは `WEB_ADDR` で変更でき、既定は `:8080`
|
- `compose.yaml` は web のみを定義し、dockage VM の LAN アドレス `192.168.10.155:8080` で公開する。待ち受けは `WEB_ADDR` で変更でき、既定は `:8080`
|
||||||
- DB はコンテナを持たず、NAS の共有 Postgres に接続する(番組登録・ダウンロード履歴・待ち行列)
|
- 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 にはアクセスしない
|
- `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 ./...` で静的解析する
|
- `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"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
_ "time/tzdata"
|
||||||
|
|
||||||
|
"gitea.kazu634.com/kazu634/tver/internal/tver"
|
||||||
"gitea.kazu634.com/kazu634/tver/internal/web"
|
"gitea.kazu634.com/kazu634/tver/internal/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,9 +16,13 @@ func main() {
|
|||||||
if address == "" {
|
if address == "" {
|
||||||
address = ":8080"
|
address = ":8080"
|
||||||
}
|
}
|
||||||
|
client, err := tver.NewClient("", nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
server := &http.Server{
|
server := &http.Server{
|
||||||
Addr: address,
|
Addr: address,
|
||||||
Handler: web.NewHandler(),
|
Handler: web.NewHandler(client, time.Now, log.Default()),
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
ReadHeaderTimeout: 5 * time.Second,
|
||||||
}
|
}
|
||||||
log.Printf("web listening on %s", address)
|
log.Printf("web listening on %s", address)
|
||||||
|
|||||||
+23
-3
@@ -4,7 +4,10 @@ import (
|
|||||||
"embed"
|
"embed"
|
||||||
"html/template"
|
"html/template"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed templates/*.html static/*
|
//go:embed templates/*.html static/*
|
||||||
@@ -12,29 +15,46 @@ var assets embed.FS
|
|||||||
|
|
||||||
type page struct {
|
type page struct {
|
||||||
Title string
|
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"))
|
tmpl := template.Must(template.ParseFS(assets, "templates/*.html"))
|
||||||
staticFiles, err := fs.Sub(assets, "static")
|
staticFiles, err := fs.Sub(assets, "static")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
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 := http.NewServeMux()
|
||||||
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFiles)))
|
mux.Handle("GET /static/", http.StripPrefix("/static/", http.FileServerFS(staticFiles)))
|
||||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||||
_, _ = w.Write([]byte("ok\n"))
|
_, _ = w.Write([]byte("ok\n"))
|
||||||
})
|
})
|
||||||
|
mux.HandleFunc("GET /{$}", s.home)
|
||||||
|
mux.HandleFunc("GET /newer", s.newerFragment)
|
||||||
for path, title := range map[string]string{
|
for path, title := range map[string]string{
|
||||||
"GET /{$}": "新着",
|
|
||||||
"GET /queue": "待ち行列",
|
"GET /queue": "待ち行列",
|
||||||
"GET /subscriptions": "登録番組",
|
"GET /subscriptions": "登録番組",
|
||||||
"GET /library": "ライブラリ",
|
"GET /library": "ライブラリ",
|
||||||
} {
|
} {
|
||||||
mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) {
|
mux.HandleFunc(path, func(w http.ResponseWriter, _ *http.Request) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
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)
|
http.Error(w, "template error", http.StatusInternalServerError)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,14 +9,14 @@ import (
|
|||||||
|
|
||||||
func TestHealthz(t *testing.T) {
|
func TestHealthz(t *testing.T) {
|
||||||
response := httptest.NewRecorder()
|
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" {
|
if response.Code != http.StatusOK || response.Body.String() != "ok\n" {
|
||||||
t.Errorf("healthz: status=%d body=%q", response.Code, response.Body.String())
|
t.Errorf("healthz: status=%d body=%q", response.Code, response.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPagesAndEmbeddedAssets(t *testing.T) {
|
func TestPagesAndEmbeddedAssets(t *testing.T) {
|
||||||
handler := NewHandler()
|
handler := newTestHandler(&fakeFetcher{}, nil)
|
||||||
for _, tt := range []struct{ path, title string }{
|
for _, tt := range []struct{ path, title string }{
|
||||||
{"/", "新着"}, {"/queue", "待ち行列"}, {"/subscriptions", "登録番組"}, {"/library", "ライブラリ"},
|
{"/", "新着"}, {"/queue", "待ち行列"}, {"/subscriptions", "登録番組"}, {"/library", "ライブラリ"},
|
||||||
} {
|
} {
|
||||||
@@ -42,7 +42,7 @@ func TestPagesAndEmbeddedAssets(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestUnknownPathsAndMethods(t *testing.T) {
|
func TestUnknownPathsAndMethods(t *testing.T) {
|
||||||
handler := NewHandler()
|
handler := newTestHandler(&fakeFetcher{}, nil)
|
||||||
for _, tt := range []struct {
|
for _, tt := range []struct {
|
||||||
name, method, path string
|
name, method, path string
|
||||||
wantStatus int
|
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 { display: flex; flex-wrap: wrap; gap: 1.5rem; }
|
||||||
nav a { color: #145a86; }
|
nav a { color: #145a86; }
|
||||||
main { max-width: 72rem; margin: 2rem auto; padding: 0 2rem; }
|
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>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
<main>
|
<main>
|
||||||
<h1>{{.Title}}</h1>
|
{{if .Newer}}
|
||||||
|
{{template "newer_page" .Newer}}
|
||||||
|
{{else}}
|
||||||
|
<h1>{{.Title}}</h1>
|
||||||
|
{{end}}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>{{end}}
|
</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