From 2eae804dad64563fc5fce3e8a74479699e66678c Mon Sep 17 00:00:00 2001 From: mkelvers Date: Tue, 16 Jun 2026 17:25:04 +0200 Subject: [PATCH] refactor: populate watch page data before error return --- internal/playback/handler/handler.go | 40 +++--- internal/playback/handler/watch_page_test.go | 126 +++++++++++++++++++ internal/playback/watch_data.go | 19 +-- 3 files changed, 161 insertions(+), 24 deletions(-) create mode 100644 internal/playback/handler/watch_page_test.go diff --git a/internal/playback/handler/handler.go b/internal/playback/handler/handler.go index 38b3803..2c1e9e7 100644 --- a/internal/playback/handler/handler.go +++ b/internal/playback/handler/handler.go @@ -54,22 +54,32 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) { data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID) if err != nil { - anime, fetchErr := h.animeSvc.GetAnimeByID(c.Request.Context(), id) - if fetchErr != nil { - fmt.Printf("error fetching anime for error page: %v\n", fetchErr) + if data.Anime.MalID == 0 && data.WatchData.MalID == 0 && len(data.Episodes) == 0 { + anime, fetchErr := h.animeSvc.GetAnimeByID(c.Request.Context(), id) + if fetchErr != nil { + fmt.Printf("error fetching anime for error page: %v\n", fetchErr) + } + data = domain.WatchPageData{ + Anime: anime, + CurrentEpID: ep, + WatchData: domain.WatchData{ + MalID: id, + Title: anime.DisplayTitle(), + CurrentEpisode: ep, + Episodes: []domain.CanonicalEpisode{}, + Providers: []domain.ProviderData{}, + ModeSources: map[string]domain.ModeSource{}, + AvailableModes: []string{}, + Airing: anime.Airing, + }, + } } - c.HTML(http.StatusOK, "watch.gohtml", domain.WatchPageData{ - Error: err.Error(), - Anime: anime, - Episodes: []domain.CanonicalEpisode{}, - CurrentPath: c.Request.URL.Path, - User: user, - CurrentEpID: ep, - WatchData: domain.WatchData{ - Episodes: []domain.CanonicalEpisode{}, - Providers: []domain.ProviderData{}, - }, - }) + + data.Error = err.Error() + data.User = user + data.CurrentPath = c.Request.URL.Path + + c.HTML(http.StatusOK, "watch.gohtml", data) return } diff --git a/internal/playback/handler/watch_page_test.go b/internal/playback/handler/watch_page_test.go new file mode 100644 index 0000000..bd52c3e --- /dev/null +++ b/internal/playback/handler/watch_page_test.go @@ -0,0 +1,126 @@ +package handler + +import ( + "context" + "errors" + "mal/integrations/jikan" + "mal/internal/domain" + "mal/templates" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" +) + +type watchPagePlaybackService struct { + data domain.WatchPageData + err error +} + +func (s *watchPagePlaybackService) BuildWatchData(context.Context, int, []string, string, string, string) (domain.WatchPageData, error) { + return s.data, s.err +} + +func (s *watchPagePlaybackService) SaveProgress(context.Context, string, int64, int, float64) error { + return nil +} + +func (s *watchPagePlaybackService) CompleteAnime(context.Context, string, int64) error { + return nil +} + +func (s *watchPagePlaybackService) SignProxyToken(string, string, string) (string, error) { + return "", nil +} + +func (s *watchPagePlaybackService) ResolveProxyToken(string, string) (string, string, error) { + return "", "", nil +} + +func (s *watchPagePlaybackService) UpsertSkipSegmentOverride(context.Context, string, int64, int, string, float64, float64) error { + return nil +} + +type watchPageAnimeService struct{} + +func (watchPageAnimeService) GetAnimeByID(context.Context, int) (domain.Anime, error) { + return domain.Anime{}, errors.New("unexpected anime lookup") +} + +func (watchPageAnimeService) GetAllEpisodes(context.Context, int) ([]domain.EpisodeData, error) { + return nil, nil +} + +func baseWatchPageData() domain.WatchPageData { + return domain.WatchPageData{ + Anime: domain.Anime{Anime: jikan.Anime{MalID: 123, Title: "Example Anime"}}, + Episodes: []domain.CanonicalEpisode{ + {Number: 1, Title: "Episode 1", HasSub: true}, + {Number: 2, Title: "Episode 2", HasSub: true}, + }, + CurrentEpID: "1", + WatchData: domain.WatchData{ + MalID: 123, + Title: "Example Anime", + CurrentEpisode: "1", + Episodes: []domain.CanonicalEpisode{ + {Number: 1, Title: "Episode 1", HasSub: true}, + {Number: 2, Title: "Episode 2", HasSub: true}, + }, + ModeSources: map[string]domain.ModeSource{}, + AvailableModes: []string{}, + }, + } +} + +func newWatchPageRouter(t *testing.T, h *PlaybackHandler) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + renderer, err := templates.ProvideRenderer() + if err != nil { + t.Fatalf("ProvideRenderer() error = %v", err) + } + router := gin.New() + router.HTMLRender = renderer + router.GET("/anime/:id/watch", h.HandleWatchPage) + return router +} + +func TestHandleWatchPagePreservesPartialDataOnPlaybackFailure(t *testing.T) { + t.Parallel() + + router := newWatchPageRouter(t, &PlaybackHandler{ + svc: &watchPagePlaybackService{ + data: baseWatchPageData(), + err: errors.New("no streams found"), + }, + animeSvc: watchPageAnimeService{}, + }) + + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/anime/123/watch?ep=1", nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK) + } + + body := rec.Body.String() + if !strings.Contains(body, `data-mal-id="123"`) { + t.Fatalf("expected player MAL id in body, got:\n%s", body) + } + if !strings.Contains(body, `data-episode-id="1"`) { + t.Fatalf("expected episode list in body, got:\n%s", body) + } + if !strings.Contains(body, `data-playback-error="no streams found"`) { + t.Fatalf("expected playback error data attribute in body, got:\n%s", body) + } + if !strings.Contains(body, `/anime/123/watch?ep=2`) { + t.Fatalf("expected episode links to keep the anime id, got:\n%s", body) + } + if strings.Contains(body, "No episodes found") { + t.Fatalf("expected partial episode list instead of empty state, got:\n%s", body) + } +} diff --git a/internal/playback/watch_data.go b/internal/playback/watch_data.go index c5911c9..b6b8106 100644 --- a/internal/playback/watch_data.go +++ b/internal/playback/watch_data.go @@ -39,15 +39,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title if resolvedModeSwitchedFrom != "" { modeSwitchedFrom = resolvedModeSwitchedFrom } - if len(modeSources) == 0 { - return domain.WatchPageData{}, fmt.Errorf("no streams found") - } - if result == nil { - return domain.WatchPageData{}, fmt.Errorf("no streams found for mode %s", mode) - } - startTime, watchlistStatus, watchlistIDs := s.loadWatchProgress(ctx, userID, animeID, anime.Episodes, episode) - go s.warmStreamURL(result.URL, result.Referer) seasons := s.loadSeasons(ctx, animeID) segments, err := s.fetchSkipSegments(ctx, userID, animeID, episode) if err != nil { @@ -57,7 +49,16 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title ) } watchData := buildWatchDataPayload(animeData, animeID, episode, startTime, canonicalEpisodes.Episodes, modeSources, mode, modeSwitchedFrom, segments) - return buildWatchPageData(animeData, canonicalEpisodes.Episodes, episode, watchlistStatus, watchlistIDs, seasons, watchData), nil + pageData := buildWatchPageData(animeData, canonicalEpisodes.Episodes, episode, watchlistStatus, watchlistIDs, seasons, watchData) + if len(modeSources) == 0 { + return pageData, fmt.Errorf("no streams found") + } + if result == nil { + return pageData, fmt.Errorf("no streams found for mode %s", mode) + } + + go s.warmStreamURL(result.URL, result.Referer) + return pageData, nil } func buildWatchDataPayload(anime domain.Anime, animeID int, episode string, startTime float64, episodes []domain.CanonicalEpisode, modeSources map[string]domain.ModeSource, mode string, modeSwitchedFrom string, segments []domain.SkipSegment) domain.WatchData {