新着一覧の画面を追加(ジャンル切替、予告の除外、10分キャッシュ)
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user