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, "alert(1)`, Title: `title`,
+ BroadcasterName: `
`, BroadcastDateLabel: `