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 }