241 lines
6.8 KiB
Go
241 lines
6.8 KiB
Go
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 (
|
||
newerCacheTTL = 10 * time.Minute
|
||
)
|
||
|
||
var platformTokenPattern = regexp.MustCompile(`(?i)(platform_token=)[^&\s]+`)
|
||
|
||
type NewerFetcher interface {
|
||
FetchNewer(context.Context, string) ([]tver.Episode, error)
|
||
FetchSeries(context.Context, string) (string, 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 <= tver.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
|
||
}
|