feat: typed anime and playback payloads

This commit is contained in:
2026-05-21 15:07:09 +02:00
parent d94f1516ce
commit 812dcd2448
7 changed files with 181 additions and 135 deletions

View File

@@ -7,7 +7,6 @@ import (
"mal/pkg/net/limits"
"mal/pkg/net/proxytransport"
"mal/pkg/net/useragent"
"maps"
"net/http"
"strconv"
"strings"
@@ -61,26 +60,25 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID)
if err != nil {
anime, _ := h.animeSvc.GetAnimeByID(c.Request.Context(), id)
c.HTML(http.StatusOK, "watch.gohtml", gin.H{
"Error": err.Error(),
"Anime": anime,
"Episodes": []domain.EpisodeData{},
"CurrentPath": c.Request.URL.Path,
"User": user,
"CurrentEpID": ep,
"WatchData": map[string]any{"Episodes": []domain.EpisodeData{}, "Providers": []any{}},
c.HTML(http.StatusOK, "watch.gohtml", domain.WatchPageData{
Error: err.Error(),
Anime: anime,
Episodes: []domain.CanonicalEpisode{},
CurrentPath: c.Request.URL.Path,
User: currentUser(user),
CurrentEpID: ep,
WatchData: domain.WatchData{
Episodes: []domain.CanonicalEpisode{},
Providers: []domain.ProviderData{},
},
})
return
}
// Merge data from service with handler-specific context
responseData := gin.H{
"User": user,
"CurrentPath": c.Request.URL.Path,
}
maps.Copy(responseData, data)
data.User = currentUser(user)
data.CurrentPath = c.Request.URL.Path
c.HTML(http.StatusOK, "watch.gohtml", responseData)
c.HTML(http.StatusOK, "watch.gohtml", data)
}
// HandleEpisodeData returns the minimal payload needed to advance to the next
@@ -112,39 +110,36 @@ func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
return
}
watchData, _ := data["WatchData"].(map[string]any)
if watchData == nil {
c.Status(http.StatusInternalServerError)
return
}
modeSources := watchData["ModeSources"]
availableModes, _ := watchData["AvailableModes"].([]string)
segments := watchData["Segments"]
watchData := data.WatchData
// Try to resolve a title for this episode from the episode list.
episodeTitle := ""
if eps, ok := watchData["Episodes"].([]domain.CanonicalEpisode); ok {
epNum, _ := strconv.Atoi(episode)
for _, e := range eps {
if e.Number == epNum {
episodeTitle = e.Title
break
}
epNum, _ := strconv.Atoi(episode)
for _, e := range watchData.Episodes {
if e.Number == epNum {
episodeTitle = e.Title
break
}
}
c.JSON(http.StatusOK, gin.H{
"mode_sources": modeSources,
"available_modes": availableModes,
"initial_mode": watchData["InitialMode"],
"start_time_seconds": watchData["StartTimeSeconds"],
"segments": segments,
"mode_sources": watchData.ModeSources,
"available_modes": watchData.AvailableModes,
"initial_mode": watchData.InitialMode,
"start_time_seconds": watchData.StartTimeSeconds,
"segments": watchData.Segments,
"episode_title": episodeTitle,
"mode_switched_from": watchData["ModeSwitchedFrom"],
"mode_switched_from": watchData.ModeSwitchedFrom,
})
}
func currentUser(value any) *domain.User {
if user, ok := value.(*domain.User); ok {
return user
}
return nil
}
func (h *PlaybackHandler) HandleSaveProgress(c *gin.Context) {
user, _ := c.Get("User")
userID := ""

View File

@@ -33,12 +33,6 @@ type playbackService struct {
proxyTokenKey string
}
type SkipSegment struct {
Type string `json:"type"`
Start float64 `json:"start"`
End float64 `json:"end"`
}
type proxyTokenPayload struct {
TargetURL string `json:"u"`
Referer string `json:"r,omitempty"`
@@ -109,11 +103,11 @@ func (s *playbackService) ResolveProxyToken(token string) (string, string, error
return payload.TargetURL, payload.Referer, nil
}
func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (map[string]any, error) {
func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (domain.WatchPageData, error) {
// 1. Get Anime details for total episodes and titles
anime, err := s.jikan.GetAnimeByID(ctx, animeID)
if err != nil {
return nil, fmt.Errorf("failed to fetch anime: %w", err)
return domain.WatchPageData{}, fmt.Errorf("failed to fetch anime: %w", err)
}
// 2. Resolve streams from providers
@@ -132,7 +126,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
canonicalEpisodes, err := s.episodes.GetCanonicalEpisodes(ctx, anime, false)
if err != nil {
return nil, fmt.Errorf("failed to fetch episodes: %w", err)
return domain.WatchPageData{}, fmt.Errorf("failed to fetch episodes: %w", err)
}
requestedMode := mode
@@ -147,22 +141,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
}
}
type SubtitleItem struct {
Lang string `json:"lang"`
URL string `json:"url,omitempty"`
Referer string `json:"referer,omitempty"`
Token string `json:"token"`
}
type ModeSource struct {
URL string `json:"url,omitempty"`
Referer string `json:"referer,omitempty"`
Token string `json:"token"`
Subtitles []SubtitleItem `json:"subtitles"`
Qualities []string `json:"qualities,omitempty"`
}
modeSources := map[string]ModeSource{}
modeSources := map[string]domain.ModeSource{}
var result *domain.StreamResult
for _, m := range []string{"sub", "dub"} {
@@ -172,17 +151,17 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
continue
}
var subItems []SubtitleItem
var subItems []domain.SubtitleItem
for _, sub := range res.Subtitles {
subToken, _ := s.SignProxyToken(sub.URL, res.Referer, "subtitle")
subItems = append(subItems, SubtitleItem{
subItems = append(subItems, domain.SubtitleItem{
Lang: sub.Label,
Token: subToken,
})
}
streamToken, _ := s.SignProxyToken(res.URL, res.Referer, "stream")
modeSources[m] = ModeSource{
modeSources[m] = domain.ModeSource{
URL: res.URL,
Referer: res.Referer,
Token: streamToken,
@@ -197,11 +176,11 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
}
if len(modeSources) == 0 {
return nil, fmt.Errorf("no streams found")
return domain.WatchPageData{}, fmt.Errorf("no streams found")
}
if result == nil {
return nil, fmt.Errorf("no streams found for mode %s", mode)
return domain.WatchPageData{}, fmt.Errorf("no streams found for mode %s", mode)
}
// 3. Get start time from progress
@@ -248,17 +227,11 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
// 6. Resolve relations/seasons
relations, _ := s.jikan.GetFullRelations(ctx, animeID)
type SeasonEntry struct {
MalID int `json:"mal_id"`
Title string `json:"title"`
Prefix string `json:"prefix"`
IsCurrent bool `json:"is_current"`
}
var seasons []SeasonEntry
var seasons []domain.SeasonEntry
tvCounter := 1
for _, rel := range relations {
if strings.ToLower(rel.Anime.Type) == "tv" || strings.ToLower(rel.Anime.Type) == "movie" {
seasons = append(seasons, SeasonEntry{
seasons = append(seasons, domain.SeasonEntry{
MalID: rel.Anime.MalID,
Title: rel.Anime.DisplayTitle(),
Prefix: rel.Relation,
@@ -274,19 +247,19 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
// Final assembly
segments := s.fetchSkipSegments(ctx, userID, animeID, episode)
watchData := map[string]any{
"MalID": animeID,
"Title": anime.DisplayTitle(),
"CurrentEpisode": episode,
"StartTimeSeconds": startTime,
"Episodes": canonicalEpisodes.Episodes,
"Providers": []domain.ProviderData{
watchData := domain.WatchData{
MalID: animeID,
Title: anime.DisplayTitle(),
CurrentEpisode: episode,
StartTimeSeconds: startTime,
Episodes: canonicalEpisodes.Episodes,
Providers: []domain.ProviderData{
{Streams: streams},
},
"ModeSources": modeSources,
"InitialMode": mode,
"ModeSwitchedFrom": modeSwitchedFrom,
"AvailableModes": func() []string {
ModeSources: modeSources,
InitialMode: mode,
ModeSwitchedFrom: modeSwitchedFrom,
AvailableModes: func() []string {
var modes []string
for m := range modeSources {
modes = append(modes, m)
@@ -294,17 +267,17 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
sort.Strings(modes)
return modes
}(),
"Segments": segments,
Segments: segments,
}
return map[string]any{
"WatchData": watchData,
"Anime": anime,
"Episodes": canonicalEpisodes.Episodes,
"CurrentEpID": episode,
"WatchlistStatus": watchlistStatus,
"WatchlistIDs": watchlistIDs,
"Seasons": seasons,
return domain.WatchPageData{
WatchData: watchData,
Anime: anime,
Episodes: canonicalEpisodes.Episodes,
CurrentEpID: episode,
WatchlistStatus: watchlistStatus,
WatchlistIDs: watchlistIDs,
Seasons: seasons,
}, nil
}
@@ -385,31 +358,31 @@ func (s *playbackService) UpsertSkipSegmentOverride(ctx context.Context, userID
})
}
func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string, malID int, episode string) []SkipSegment {
func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string, malID int, episode string) []domain.SkipSegment {
if malID <= 0 || strings.TrimSpace(episode) == "" {
return []SkipSegment{}
return []domain.SkipSegment{}
}
endpoint := fmt.Sprintf("https://api.aniskip.com/v1/skip-times/%s/%s?types=op&types=ed", url.PathEscape(strconv.Itoa(malID)), url.PathEscape(episode))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return []SkipSegment{}
return []domain.SkipSegment{}
}
req.Header.Set("User-Agent", useragent.Generic)
resp, err := s.httpClient.Do(req)
if err != nil {
return []SkipSegment{}
return []domain.SkipSegment{}
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
return []SkipSegment{}
return []domain.SkipSegment{}
}
body, err := io.ReadAll(io.LimitReader(resp.Body, limits.KiB512))
if err != nil {
return []SkipSegment{}
return []domain.SkipSegment{}
}
type resultItem struct {
@@ -426,14 +399,14 @@ func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string,
var parsed apiResponse
if err := json.Unmarshal(body, &parsed); err != nil {
return []SkipSegment{}
return []domain.SkipSegment{}
}
if !parsed.Found || len(parsed.Result) == 0 {
return []SkipSegment{}
return []domain.SkipSegment{}
}
segments := make([]SkipSegment, 0, len(parsed.Result))
segments := make([]domain.SkipSegment, 0, len(parsed.Result))
for _, r := range parsed.Result {
skipType := strings.ToLower(r.SkipType)
switch skipType {
@@ -442,7 +415,7 @@ func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string,
case "ed":
skipType = "ending"
}
segments = append(segments, SkipSegment{
segments = append(segments, domain.SkipSegment{
Type: skipType,
Start: r.Interval.StartTime,
End: r.Interval.EndTime,
@@ -454,7 +427,7 @@ func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string,
if ok, err := s.repo.HasSkipSegmentOverrideTable(ctx); err == nil && ok {
if overrides, err := s.repo.ListSkipSegmentOverrides(ctx, userID, int64(malID), epNum); err == nil {
// Build map keyed by normalized type ("opening"/"ending")
overrideByType := make(map[string]SkipSegment, len(overrides))
overrideByType := make(map[string]domain.SkipSegment, len(overrides))
for _, o := range overrides {
t := strings.ToLower(strings.TrimSpace(o.SkipType))
switch t {
@@ -465,10 +438,10 @@ func (s *playbackService) fetchSkipSegments(ctx context.Context, userID string,
default:
continue
}
overrideByType[t] = SkipSegment{Type: t, Start: o.StartTime, End: o.EndTime}
overrideByType[t] = domain.SkipSegment{Type: t, Start: o.StartTime, End: o.EndTime}
}
if len(overrideByType) > 0 {
merged := make([]SkipSegment, 0, len(segments)+len(overrideByType))
merged := make([]domain.SkipSegment, 0, len(segments)+len(overrideByType))
seen := map[string]bool{}
for _, seg := range segments {
if o, ok := overrideByType[seg.Type]; ok {