From 812dcd24489e6ad65bbd45f072c194957a458c18 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Thu, 21 May 2026 15:07:09 +0200 Subject: [PATCH] feat: typed anime and playback payloads --- internal/anime/handler/handler.go | 22 ++--- internal/anime/service/service.go | 18 ++--- internal/domain/anime.go | 27 ++++++- internal/domain/playback.go | 57 ++++++++++++- internal/playback/handler/handler.go | 71 ++++++++--------- internal/playback/service/service.go | 115 ++++++++++----------------- internal/templates/renderer.go | 6 ++ 7 files changed, 181 insertions(+), 135 deletions(-) diff --git a/internal/anime/handler/handler.go b/internal/anime/handler/handler.go index d996948..b7ace83 100644 --- a/internal/anime/handler/handler.go +++ b/internal/anime/handler/handler.go @@ -101,14 +101,11 @@ func (h *AnimeHandler) renderCatalogSection(c *gin.Context, section string) { return } - watchlistMap := map[int64]bool{} - if animes, ok := data["Animes"].([]domain.Anime); ok { - watchlistMap = h.watchlistMapForAnimes(c.Request.Context(), userID, animes) - } + watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes) - data["Section"] = section - data["_fragment"] = "catalog_section" - data["WatchlistMap"] = watchlistMap + data.Section = section + data.Fragment = "catalog_section" + data.WatchlistMap = watchlistMap c.HTML(http.StatusOK, "index.gohtml", data) } @@ -143,14 +140,11 @@ func (h *AnimeHandler) renderDiscoverSection(c *gin.Context, section string) { return } - watchlistMap := map[int64]bool{} - if animes, ok := data["Animes"].([]domain.Anime); ok { - watchlistMap = h.watchlistMapForAnimes(c.Request.Context(), userID, animes) - } + watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), userID, data.Animes) - data["Section"] = section - data["_fragment"] = "discover_section" - data["WatchlistMap"] = watchlistMap + data.Section = section + data.Fragment = "discover_section" + data.WatchlistMap = watchlistMap c.HTML(http.StatusOK, "discover.gohtml", data) } diff --git a/internal/anime/service/service.go b/internal/anime/service/service.go index 72ba12d..558547f 100644 --- a/internal/anime/service/service.go +++ b/internal/anime/service/service.go @@ -20,7 +20,7 @@ func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) domain.An return &animeService{jikan: jikan, repo: repo} } -func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (map[string]any, error) { +func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (domain.CatalogSectionData, error) { var ( res jikan.TopAnimeResult cw []db.GetContinueWatchingEntriesRow @@ -48,7 +48,7 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec } if err := g.Wait(); err != nil { - return nil, err + return domain.CatalogSectionData{}, err } animes := res.Animes @@ -56,13 +56,13 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec animes = animes[:6] } - return map[string]any{ - "Animes": animes, - "ContinueWatching": cw, + return domain.CatalogSectionData{ + Animes: animes, + ContinueWatching: cw, }, nil } -func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, section string) (map[string]any, error) { +func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, section string) (domain.DiscoverSectionData, error) { var res jikan.TopAnimeResult g, gCtx := errgroup.WithContext(ctx) @@ -81,7 +81,7 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se }) if err := g.Wait(); err != nil { - return nil, err + return domain.DiscoverSectionData{}, err } animes := res.Animes @@ -89,8 +89,8 @@ func (s *animeService) GetDiscoverSection(ctx context.Context, userID string, se animes = animes[:8] } - return map[string]any{ - "Animes": animes, + return domain.DiscoverSectionData{ + Animes: animes, }, nil } diff --git a/internal/domain/anime.go b/internal/domain/anime.go index eeb1092..671b04a 100644 --- a/internal/domain/anime.go +++ b/internal/domain/anime.go @@ -17,8 +17,8 @@ type ThemesData = jikan.ThemesData type ReviewEntry = jikan.ReviewEntry type AnimeService interface { - GetCatalogSection(ctx context.Context, userID string, section string) (map[string]any, error) - GetDiscoverSection(ctx context.Context, userID string, section string) (map[string]any, error) + GetCatalogSection(ctx context.Context, userID string, section string) (CatalogSectionData, error) + GetDiscoverSection(ctx context.Context, userID string, section string) (DiscoverSectionData, error) GetAnimeByID(ctx context.Context, id int) (Anime, error) SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, sfw bool, page, limit int) (jikan.SearchResult, error) GetGenres(ctx context.Context) ([]Genre, error) @@ -34,6 +34,29 @@ type AnimeService interface { GetReviews(ctx context.Context, id int, page int) ([]ReviewEntry, bool, error) } +type CatalogSectionData struct { + Animes []Anime + ContinueWatching []db.GetContinueWatchingEntriesRow + Section string + WatchlistMap map[int64]bool + Fragment string +} + +func (d CatalogSectionData) TemplateFragment() string { + return d.Fragment +} + +type DiscoverSectionData struct { + Animes []Anime + Section string + WatchlistMap map[int64]bool + Fragment string +} + +func (d DiscoverSectionData) TemplateFragment() string { + return d.Fragment +} + type AnimeRepository interface { GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) diff --git a/internal/domain/playback.go b/internal/domain/playback.go index ab45b2d..81bad68 100644 --- a/internal/domain/playback.go +++ b/internal/domain/playback.go @@ -6,13 +6,68 @@ import ( ) type PlaybackService interface { - BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (map[string]any, error) + BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (WatchPageData, error) SaveProgress(ctx context.Context, userID string, animeID int64, episode int, timeSeconds float64) error CompleteAnime(ctx context.Context, userID string, animeID int64) error ResolveProxyToken(token string) (string, string, error) UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error } +type WatchPageData struct { + WatchData WatchData + Anime Anime + Episodes []CanonicalEpisode + CurrentEpID string + WatchlistStatus string + WatchlistIDs []int64 + Seasons []SeasonEntry + User *User + CurrentPath string + Error string +} + +type WatchData struct { + MalID int + Title string + CurrentEpisode string + StartTimeSeconds float64 + Episodes []CanonicalEpisode + Providers []ProviderData + ModeSources map[string]ModeSource + InitialMode string + ModeSwitchedFrom string + AvailableModes []string + Segments []SkipSegment +} + +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"` +} + +type SeasonEntry struct { + MalID int `json:"mal_id"` + Title string `json:"title"` + Prefix string `json:"prefix"` + IsCurrent bool `json:"is_current"` +} + +type SkipSegment struct { + Type string `json:"type"` + Start float64 `json:"start"` + End float64 `json:"end"` +} + type ProviderStream struct { Name string `json:"name"` URL string `json:"url"` diff --git a/internal/playback/handler/handler.go b/internal/playback/handler/handler.go index b4650e1..c399a57 100644 --- a/internal/playback/handler/handler.go +++ b/internal/playback/handler/handler.go @@ -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 := "" diff --git a/internal/playback/service/service.go b/internal/playback/service/service.go index 4c58fa7..27fa981 100644 --- a/internal/playback/service/service.go +++ b/internal/playback/service/service.go @@ -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 { diff --git a/internal/templates/renderer.go b/internal/templates/renderer.go index c45811c..2a72e16 100644 --- a/internal/templates/renderer.go +++ b/internal/templates/renderer.go @@ -205,6 +205,10 @@ type HTMLRender struct { Data any } +type templateFragmentData interface { + TemplateFragment() string +} + func (h HTMLRender) Render(w http.ResponseWriter) error { tmpl, ok := h.Renderer.templates[h.Name] if !ok { @@ -219,6 +223,8 @@ func (h HTMLRender) Render(w http.ResponseWriter) error { block = dataMap["_fragment"] } else if ginH, ok := h.Data.(gin.H); ok { block = ginH["_fragment"] + } else if fragmentData, ok := h.Data.(templateFragmentData); ok { + block = fragmentData.TemplateFragment() } if blockStr, ok := block.(string); ok && blockStr != "" {