From f0d73a14e1b4b8c905e0348604b68bbc7fd3e73c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=AD=A6=E8=94=B5=E4=B8=80=E5=BC=98?= Date: Sat, 3 Oct 2026 17:20:44 +0900 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E7=9D=80=E4=B8=80=E8=A6=A7=E3=81=AE?= =?UTF-8?q?=E7=94=BB=E9=9D=A2=E3=82=92=E8=BF=BD=E5=8A=A0=EF=BC=88=E3=82=B8?= =?UTF-8?q?=E3=83=A3=E3=83=B3=E3=83=AB=E5=88=87=E6=9B=BF=E3=80=81=E4=BA=88?= =?UTF-8?q?=E5=91=8A=E3=81=AE=E9=99=A4=E5=A4=96=E3=80=8110=E5=88=86?= =?UTF-8?q?=E3=82=AD=E3=83=A3=E3=83=83=E3=82=B7=E3=83=A5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PROJECT.md | 13 +- cmd/web/main.go | 8 +- internal/web/handler.go | 26 ++- internal/web/handler_test.go | 6 +- internal/web/newer.go | 168 ++++++++++++++++++++ internal/web/newer_test.go | 247 +++++++++++++++++++++++++++++ internal/web/static/app.css | 10 ++ internal/web/templates/layout.html | 6 +- internal/web/templates/newer.html | 47 ++++++ 9 files changed, 518 insertions(+), 13 deletions(-) create mode 100644 internal/web/newer.go create mode 100644 internal/web/newer_test.go create mode 100644 internal/web/templates/newer.html diff --git a/PROJECT.md b/PROJECT.md index f91b6db..8b4be8e 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -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 への実アクセスを伴うため手動確認は少数回に留める diff --git a/cmd/web/main.go b/cmd/web/main.go index c7f48e8..a02fed3 100644 --- a/cmd/web/main.go +++ b/cmd/web/main.go @@ -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) diff --git a/internal/web/handler.go b/internal/web/handler.go index 5aba47f..3fd1abe 100644 --- a/internal/web/handler.go +++ b/internal/web/handler.go @@ -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) } }) diff --git a/internal/web/handler_test.go b/internal/web/handler_test.go index de4681f..8ca6f20 100644 --- a/internal/web/handler_test.go +++ b/internal/web/handler_test.go @@ -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 diff --git a/internal/web/newer.go b/internal/web/newer.go new file mode 100644 index 0000000..e5c4836 --- /dev/null +++ b/internal/web/newer.go @@ -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 +} diff --git a/internal/web/newer_test.go b/internal/web/newer_test.go new file mode 100644 index 0000000..7a22a6a --- /dev/null +++ b/internal/web/newer_test.go @@ -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, "