Files
tver/internal/web/newer.go
T

241 lines
6.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package web
import (
"bytes"
"context"
"fmt"
"net/http"
"net/url"
"regexp"
"time"
"gitea.kazu634.com/kazu634/tver/internal/store"
"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)
FetchSeriesEpisodes(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 {
ID string
SeriesID string
SubscriptionAction subscriptionActionView
Genre string
Status string
StatusText string
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
ErrorText string
}
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
view.ErrorText = "新着を取得できませんでした"
return view
}
visible := make([]tver.Episode, 0, len(episodes))
for _, episode := range episodes {
if options.hidePreviews && episode.Duration <= previewMaxSeconds {
continue
}
visible = append(visible, episode)
}
ids := make([]string, 0, len(visible))
for _, episode := range visible {
ids = append(ids, episode.ID)
}
statuses, err := s.jobs.EpisodeStatuses(ctx, ids)
if err != nil {
s.logger.Printf("新着のジョブ状態取得に失敗: %v", err)
view.Failed = true
view.ErrorText = "状態を取得できませんでした"
return view
}
seriesIDs := make([]string, 0, len(visible))
seenSeries := make(map[string]bool)
for _, episode := range visible {
if episode.SeriesID != "" && !seenSeries[episode.SeriesID] {
seriesIDs = append(seriesIDs, episode.SeriesID)
seenSeries[episode.SeriesID] = true
}
}
subscribed, err := s.jobs.SubscribedSeries(ctx, seriesIDs)
if err != nil {
s.logger.Printf("登録番組の状態取得に失敗: %v", err)
view.Failed = true
view.ErrorText = "登録番組の状態を取得できませんでした"
return view
}
for _, episode := range visible {
endAt := time.Unix(episode.EndAt, 0).In(s.zone)
view.Episodes = append(view.Episodes, episodeView{
ID: episode.ID,
SeriesID: episode.SeriesID,
SubscriptionAction: subscriptionActionView{ID: episode.ID, Genre: options.genre, Subscribed: subscribed[episode.SeriesID]},
Genre: options.genre,
Status: statuses[episode.ID],
StatusText: statusText(statuses[episode.ID]),
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 (s *server) cachedEpisode(genre, id string) (tver.Episode, bool) {
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
entry, ok := s.cache[genre]
if !ok || !s.now().Before(entry.expiresAt) {
return tver.Episode{}, false
}
for _, ep := range entry.episodes {
if ep.ID == id {
return ep, true
}
}
return tver.Episode{}, false
}
func storeEpisode(ep tver.Episode) store.Episode {
var endAt *time.Time
if ep.EndAt > 0 {
end := time.Unix(ep.EndAt, 0)
endAt = &end
}
return store.Episode{ID: ep.ID, SeriesID: ep.SeriesID, SeriesTitle: ep.SeriesTitle,
Title: ep.Title, BroadcasterName: ep.BroadcasterName, DurationSeconds: int(ep.Duration),
EndAt: endAt, ThumbnailPath: ep.ThumbnailPath}
}
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
}