From b648afa47165b955babcf8fe2b4fec2b19d822bb Mon Sep 17 00:00:00 2001 From: mkelvers Date: Sat, 27 Jun 2026 19:04:13 +0200 Subject: [PATCH] feat: warn on uncertain episode availability --- internal/domain/playback.go | 21 ++++---- internal/playback/handler/watch_page_test.go | 29 ++++++++++ internal/playback/watch_data.go | 21 ++++++++ internal/playback/watch_data_test.go | 56 ++++++++++++++++++++ static/app.ts | 1 + static/episode_availability_warning.ts | 23 ++++++++ templates/renderer_test.go | 24 +++++++++ templates/watch.gohtml | 17 ++++++ 8 files changed, 182 insertions(+), 10 deletions(-) create mode 100644 static/episode_availability_warning.ts diff --git a/internal/domain/playback.go b/internal/domain/playback.go index 97360a8e..f819a10f 100644 --- a/internal/domain/playback.go +++ b/internal/domain/playback.go @@ -15,16 +15,17 @@ type PlaybackService interface { } type WatchPageData struct { - WatchData WatchData - Anime Anime - Episodes []CanonicalEpisode - CurrentEpID string - WatchlistStatus string - WatchlistIDs []int64 - Seasons []SeasonEntry - User *User - CurrentPath string - Error string + WatchData WatchData + Anime Anime + Episodes []CanonicalEpisode + CurrentEpID string + WatchlistStatus string + WatchlistIDs []int64 + Seasons []SeasonEntry + User *User + CurrentPath string + Error string + EpisodeAvailabilityWarning string } type WatchData struct { diff --git a/internal/playback/handler/watch_page_test.go b/internal/playback/handler/watch_page_test.go index 03c5a12e..e622c417 100644 --- a/internal/playback/handler/watch_page_test.go +++ b/internal/playback/handler/watch_page_test.go @@ -106,3 +106,32 @@ func TestHandleWatchPagePreservesPartialDataOnPlaybackFailure(t *testing.T) { t.Fatalf("expected partial episode list instead of empty state, got:\n%s", body) } } + +func TestHandleWatchPageRendersEpisodeAvailabilityWarningModal(t *testing.T) { + t.Parallel() + + data := baseWatchPageData() + data.EpisodeAvailabilityWarning = "Episode availability may be incomplete or out of date. Continue only if you understand that the episode list and audio availability may be uncertain." + router := newWatchPageRouter(t, &PlaybackHandler{ + svc: &watchPagePlaybackService{data: data}, + }) + + 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() + for _, want := range []string{ + `data-episode-availability-warning`, + `Episode availability is uncertain`, + `Continue anyway`, + data.EpisodeAvailabilityWarning, + } { + if !strings.Contains(body, want) { + t.Fatalf("watch page missing %q in body:\n%s", want, body) + } + } +} diff --git a/internal/playback/watch_data.go b/internal/playback/watch_data.go index 2ae495b2..4f66ed9d 100644 --- a/internal/playback/watch_data.go +++ b/internal/playback/watch_data.go @@ -6,12 +6,15 @@ import ( "sort" "strconv" "strings" + "time" "mal/integrations/jikan" "mal/internal/domain" "mal/internal/observability" ) +const episodeAvailabilityUncertainWarning = "Episode availability may be incomplete or out of date. Continue only if you understand that the episode list and audio availability may be uncertain." + func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string, userID string) (domain.WatchPageData, error) { anime, err := s.jikan.GetAnimeByID(ctx, animeID) if err != nil { @@ -51,6 +54,7 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title } watchData := buildWatchDataPayload(animeData, animeID, episode, startTime, eps.Episodes, modeSources, mode, from, segments) pageData := buildWatchPageData(animeData, eps.Episodes, episode, watchlistStatus, watchlistIDs, seasons, watchData) + pageData.EpisodeAvailabilityWarning = episodeAvailabilityWarning(eps, time.Now()) if len(modeSources) == 0 { return pageData, fmt.Errorf("no streams found") } @@ -96,6 +100,23 @@ func buildWatchPageData(anime domain.Anime, episodes []domain.CanonicalEpisode, } } +func episodeAvailabilityWarning(episodeList domain.CanonicalEpisodeList, now time.Time) string { + if episodeList.FailureCount > 0 { + return episodeAvailabilityUncertainWarning + } + if episodeList.NextRefreshAt == "" { + return "" + } + nextRefresh, err := time.Parse(time.RFC3339, episodeList.NextRefreshAt) + if err != nil { + return "" + } + if nextRefresh.After(now) { + return "" + } + return episodeAvailabilityUncertainWarning +} + func buildSearchTitles(anime domain.Anime, titleCandidates []string) []string { seen := map[string]struct{}{} out := make([]string, 0, 3+len(anime.TitleSynonyms)+len(titleCandidates)) diff --git a/internal/playback/watch_data_test.go b/internal/playback/watch_data_test.go index bb2ec4f6..06289b90 100644 --- a/internal/playback/watch_data_test.go +++ b/internal/playback/watch_data_test.go @@ -1,7 +1,9 @@ package playback import ( + "mal/internal/domain" "testing" + "time" ) func TestFallbackModes(t *testing.T) { @@ -33,3 +35,57 @@ func TestFallbackModes(t *testing.T) { }) } } + +func TestEpisodeAvailabilityWarning(t *testing.T) { + now := time.Date(2026, time.June, 27, 11, 0, 0, 0, time.UTC) + tests := []struct { + name string + list domain.CanonicalEpisodeList + want string + }{ + { + name: "fresh availability does not warn", + list: domain.CanonicalEpisodeList{ + Episodes: []domain.CanonicalEpisode{{Number: 1, HasSub: true}}, + LastSuccessAt: "2026-06-27T10:00:00Z", + NextRefreshAt: "2026-06-27T12:00:00Z", + }, + }, + { + name: "stale availability warns", + list: domain.CanonicalEpisodeList{ + Episodes: []domain.CanonicalEpisode{{Number: 1, HasSub: true}}, + LastSuccessAt: "2026-06-27T09:00:00Z", + NextRefreshAt: "2026-06-27T10:00:00Z", + }, + want: episodeAvailabilityUncertainWarning, + }, + { + name: "retrying availability warns", + list: domain.CanonicalEpisodeList{ + Episodes: []domain.CanonicalEpisode{{Number: 1, HasSub: true}}, + NextRefreshAt: "2026-06-27T11:05:00Z", + RetryUntilAt: "2026-06-27T11:30:00Z", + FailureCount: 1, + }, + want: episodeAvailabilityUncertainWarning, + }, + { + name: "failed availability warns", + list: domain.CanonicalEpisodeList{ + Episodes: []domain.CanonicalEpisode{{Number: 1, HasSub: true}}, + FailureCount: 3, + }, + want: episodeAvailabilityUncertainWarning, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := episodeAvailabilityWarning(tt.list, now) + if got != tt.want { + t.Fatalf("episodeAvailabilityWarning() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/static/app.ts b/static/app.ts index dd41a8cf..d30551e2 100644 --- a/static/app.ts +++ b/static/app.ts @@ -3,6 +3,7 @@ import "./toast"; import "./htmx"; import "./dropdown"; import "./anime"; +import "./episode_availability_warning"; import "./search"; import "./dedupe"; import "./watchlist"; diff --git a/static/episode_availability_warning.ts b/static/episode_availability_warning.ts new file mode 100644 index 00000000..be66bf98 --- /dev/null +++ b/static/episode_availability_warning.ts @@ -0,0 +1,23 @@ +import { closestFocusable, onReady } from "./utils"; + +const initEpisodeAvailabilityWarning = (): void => { + onReady(() => { + const dialog = document.querySelector("[data-episode-availability-warning]"); + const continueButton = document.querySelector( + "[data-episode-availability-warning-continue]", + ); + if (!dialog || !continueButton) { + return; + } + + document.body.classList.add("overflow-hidden"); + closestFocusable(dialog)?.focus(); + + continueButton.addEventListener("click", () => { + dialog.remove(); + document.body.classList.remove("overflow-hidden"); + }); + }); +}; + +initEpisodeAvailabilityWarning(); diff --git a/templates/renderer_test.go b/templates/renderer_test.go index 0b08432c..e32cdc3e 100644 --- a/templates/renderer_test.go +++ b/templates/renderer_test.go @@ -138,6 +138,30 @@ func TestTopPicksTemplateRendersRecommendationRationale(t *testing.T) { } } +func TestAnimeEpisodeCountTemplateDoesNotRenderAvailabilityStatus(t *testing.T) { + r, err := ProvideRenderer() + if err != nil { + t.Fatal(err) + } + + var buf bytes.Buffer + err = r.ExecuteFragment(&buf, "anime.gohtml", "anime_episode_count", map[string]any{ + "Items": map[string]any{ + "Count": 3, + "Label": "Available episodes", + "Status": "Retrying soon", + "StatusTone": "warning", + }, + }) + if err != nil { + t.Fatalf("ExecuteFragment error: %v", err) + } + body := buf.String() + if strings.Contains(body, "Retrying soon") || strings.Contains(body, "data-episode-availability-status") { + t.Fatalf("anime episode count should not expose availability status:\n%s", body) + } +} + func TestWatchTemplateEscapesJSONDataAttributes(t *testing.T) { r, err := ProvideRenderer() if err != nil { diff --git a/templates/watch.gohtml b/templates/watch.gohtml index 8c1b5ec4..474e6bec 100644 --- a/templates/watch.gohtml +++ b/templates/watch.gohtml @@ -16,6 +16,23 @@ {{$rangeStart := episodeRangeStart $currentEpNum 100}} {{$rangeEnd := min (add $rangeStart 99) $totalEpisodes}} +{{if .EpisodeAvailabilityWarning}} + +{{end}} +