Merge upstream/main

This commit is contained in:
2026-07-07 22:29:06 +02:00
157 changed files with 11390 additions and 1128 deletions

View File

@@ -4,10 +4,64 @@ import (
"mal/internal/observability"
"mal/internal/server"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
)
func (h *AnimeHandler) HandleSimulcast(c *gin.Context) {
c.HTML(http.StatusOK, "simulcast.gohtml", gin.H{
"CurrentPath": "/simulcast",
"User": server.CurrentUser(c),
"SimulcastURL": simulcastURL(c),
})
}
func (h *AnimeHandler) HandleSimulcastContent(c *gin.Context) {
now := time.Now()
current := calendarSeason(now.Year(), int(now.Month()))
latest := h.discoverySvc.LatestAvailableSeason(c.Request.Context(), current)
selected := seasonSelection(c.Query("season"), c.Query("year"), current, latest)
data, err := h.discoverySvc.GetSimulcast(c.Request.Context(), selected)
if err != nil {
observability.WarnContext(c.Request.Context(), "simulcast_fetch_failed", "anime", "", nil, err)
c.AbortWithStatus(http.StatusInternalServerError)
return
}
watchlistMap := h.watchlistMapForAnimes(c.Request.Context(), server.CurrentUserID(c), data.Animes)
previous, next := seasonNavigation(selected, 2018, latest)
c.HTML(http.StatusOK, "simulcast.gohtml", gin.H{
"_fragment": "simulcast_content",
"CurrentPath": "/simulcast",
"User": server.CurrentUser(c),
"Animes": data.Animes,
"Season": data.Season,
"SeasonLabel": strings.ToUpper(data.Season[:1]) + data.Season[1:],
"Year": data.Year,
"SeasonOptions": seasonOptions(2018, latest),
"Previous": previous,
"Next": next,
"WatchlistMap": watchlistMap,
})
}
func simulcastURL(c *gin.Context) string {
query := url.Values{}
if season := c.Query("season"); season != "" {
query.Set("season", season)
}
if year := c.Query("year"); year != "" {
query.Set("year", year)
}
if encoded := query.Encode(); encoded != "" {
return "/api/simulcast?" + encoded
}
return "/api/simulcast"
}
func (h *AnimeHandler) HandleSearch(c *gin.Context) {
c.HTML(http.StatusOK, "search.gohtml", gin.H{
"User": server.CurrentUser(c),

View File

@@ -0,0 +1,64 @@
package anime
import (
"context"
"mal/integrations/playback/allanime"
"mal/templates"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
type countingSeasonalProvider struct {
calls int
}
func (p *countingSeasonalProvider) SeasonalShows(context.Context, string, int) ([]allanime.ProviderShow, error) {
p.calls++
return nil, nil
}
func TestSimulcastPageDefersProviderFetch(t *testing.T) {
gin.SetMode(gin.TestMode)
provider := &countingSeasonalProvider{}
handler := NewAnimeHandler(nil, nil, nil, newSeasonDiscoveryService(provider))
renderer, err := templates.ProvideRenderer()
if err != nil {
t.Fatalf("ProvideRenderer: %v", err)
}
router := gin.New()
router.HTMLRender = renderer
router.GET("/simulcast", handler.HandleSimulcast)
router.GET("/api/simulcast", handler.HandleSimulcastContent)
page := httptest.NewRecorder()
router.ServeHTTP(page, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/simulcast?season=winter&year=2024", nil))
if page.Code != http.StatusOK {
t.Fatalf("page status = %d, want %d", page.Code, http.StatusOK)
}
if provider.calls != 0 {
t.Fatalf("provider calls during page render = %d, want 0", provider.calls)
}
if !strings.Contains(page.Body.String(), `hx-get="/api/simulcast?season=winter&year=2024"`) {
t.Fatalf("page did not preserve the selected season in the deferred request:\n%s", page.Body.String())
}
fragment := httptest.NewRecorder()
router.ServeHTTP(fragment, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/simulcast?season=winter&year=2024", nil))
if fragment.Code != http.StatusOK {
t.Fatalf("fragment status = %d, want %d", fragment.Code, http.StatusOK)
}
if provider.calls != 2 {
t.Fatalf("provider calls after fragment render = %d, want 2", provider.calls)
}
if strings.Contains(fragment.Body.String(), "<!DOCTYPE html>") {
t.Fatal("fragment response unexpectedly rendered the full page")
}
if !strings.Contains(fragment.Body.String(), `id="simulcast-content"`) {
t.Fatalf("fragment response is missing its replacement root:\n%s", fragment.Body.String())
}
}

View File

@@ -10,6 +10,7 @@ import (
"mal/internal/server"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -27,15 +28,10 @@ type animeEpisodeCountDisplay struct {
Label string
}
func listedEpisodeCount(episodes []domain.EpisodeData) int {
count := 0
for _, episode := range episodes {
if episode.MalID <= 0 || episode.IsRecap {
continue
}
count++
}
return count
type animeReleaseInfoDisplay struct {
Count int
Label string
Status string
}
func releasedEpisodeCount(anime domain.Anime, now time.Time) int {
@@ -56,15 +52,18 @@ func releasedEpisodeCount(anime domain.Anime, now time.Time) int {
}
func (h *AnimeHandler) animeEpisodeCount(ctx context.Context, anime domain.Anime, now time.Time) animeEpisodeCountDisplay {
info := h.animeReleaseInfo(ctx, anime, now)
return animeEpisodeCountDisplay{Count: info.Count, Label: info.Label}
}
func (h *AnimeHandler) animeReleaseInfo(ctx context.Context, anime domain.Anime, now time.Time) animeReleaseInfoDisplay {
if h.episodeSvc != nil {
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout)
defer cancel()
episodeList, err := h.episodeSvc.GetCanonicalEpisodes(episodeCtx, anime, false)
if err == nil {
if count := len(episodeList.Episodes); count > 0 {
return animeEpisodeCountDisplay{Count: count, Label: "Available episodes"}
}
return releaseInfoFromCanonical(anime, episodeList)
} else {
observability.Warn(
"anime_episode_availability_count_fetch_failed",
@@ -78,45 +77,63 @@ func (h *AnimeHandler) animeEpisodeCount(ctx context.Context, anime domain.Anime
}
}
if h.svc != nil && anime.Airing {
episodeCtx, cancel := context.WithTimeout(ctx, episodeCountTimeout)
defer cancel()
return animeInitialReleaseInfo(anime, now)
}
episodes, err := h.svc.GetAllEpisodes(episodeCtx, anime.MalID)
if err == nil {
if count := listedEpisodeCount(episodes); count > 0 {
return animeEpisodeCountDisplay{Count: count, Label: "Listed episodes"}
}
} else {
observability.Warn(
"anime_episode_count_fetch_failed",
"anime",
"",
map[string]any{
"anime_id": anime.MalID,
},
err,
)
}
func releaseInfoFromCanonical(anime domain.Anime, episodeList domain.CanonicalEpisodeList) animeReleaseInfoDisplay {
info := animeReleaseInfoDisplay{Status: trustedAnimeStatus(anime, len(episodeList.Episodes))}
if count := len(episodeList.Episodes); count > 0 {
info.Count = count
info.Label = canonicalEpisodeCountLabel(episodeList.Source)
}
return info
}
func canonicalEpisodeCountLabel(source string) string {
if source == "jikan_fallback" || source == "legacy_disabled" {
return "Estimated aired episodes"
}
return "Available episodes"
}
func animeInitialReleaseInfo(anime domain.Anime, now time.Time) animeReleaseInfoDisplay {
if isCurrentlyAiring(anime) {
return animeReleaseInfoDisplay{}
}
info := animeReleaseInfoDisplay{Status: strings.TrimSpace(anime.Status)}
if anime.Episodes > 0 {
return animeEpisodeCountDisplay{Count: anime.Episodes, Label: "Total episodes"}
info.Count = anime.Episodes
info.Label = "Total episodes"
return info
}
if count := releasedEpisodeCount(anime, now); count > 0 {
return animeEpisodeCountDisplay{Count: count, Label: "Estimated aired episodes"}
info.Count = count
info.Label = "Estimated aired episodes"
}
return animeEpisodeCountDisplay{}
return info
}
func animeInitialEpisodeCount(anime domain.Anime, now time.Time) animeEpisodeCountDisplay {
if anime.Episodes > 0 {
return animeEpisodeCountDisplay{Count: anime.Episodes, Label: "Total episodes"}
info := animeInitialReleaseInfo(anime, now)
return animeEpisodeCountDisplay{Count: info.Count, Label: info.Label}
}
func trustedAnimeStatus(anime domain.Anime, canonicalEpisodes int) string {
if canonicalEpisodes == 0 && isCurrentlyAiring(anime) {
return "Not yet aired"
}
if count := releasedEpisodeCount(anime, now); count > 0 {
return animeEpisodeCountDisplay{Count: count, Label: "Estimated aired episodes"}
if status := strings.TrimSpace(anime.Status); status != "" {
return status
}
return animeEpisodeCountDisplay{}
if anime.Airing {
return "Currently Airing"
}
return ""
}
func isCurrentlyAiring(anime domain.Anime) bool {
return anime.Airing || strings.EqualFold(strings.TrimSpace(anime.Status), "Currently Airing")
}
func animeAudioAvailabilityLabel(episodes []domain.CanonicalEpisode) string {
@@ -203,7 +220,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
}
}
episodesCount := animeInitialEpisodeCount(anime, time.Now())
releaseInfo := animeInitialReleaseInfo(anime, time.Now())
c.HTML(http.StatusOK, "anime.gohtml", gin.H{
"Anime": anime,
@@ -213,8 +230,7 @@ func (h *AnimeHandler) HandleAnimeDetails(c *gin.Context) {
"WatchlistIDs": watchlistIDs,
"ContinueWatchingEp": ep,
"ContinueWatchingTime": cwSeconds,
"EpisodesCount": episodesCount.Count,
"EpisodesCountLabel": episodesCount.Label,
"ReleaseInfo": releaseInfo,
})
}
@@ -265,12 +281,12 @@ func (h *AnimeHandler) loadAnimeDetailsSection(ctx context.Context, id int, sect
case "statistics":
data, err := h.svc.GetStatistics(ctx, id)
return data, "anime_statistics", err
case "episode-count":
case "episode-count", "release-info":
anime, err := h.svc.GetAnimeByID(ctx, id)
if err != nil {
return nil, "", err
}
return h.animeEpisodeCount(ctx, anime, time.Now()), "anime_episode_count", nil
return h.animeReleaseInfo(ctx, anime, time.Now()), "anime_release_info", nil
case "audio-availability":
anime, err := h.svc.GetAnimeByID(ctx, id)
if err != nil {

View File

@@ -0,0 +1,22 @@
package anime
import (
"context"
"mal/integrations/playback/allanime"
)
type seasonProvider interface {
SeasonalShows(context.Context, string, int) ([]allanime.ProviderShow, error)
}
type SeasonDiscoveryService struct {
provider seasonProvider
}
func NewSeasonDiscoveryService(provider *allanime.AllAnimeProvider) *SeasonDiscoveryService {
return newSeasonDiscoveryService(provider)
}
func newSeasonDiscoveryService(provider seasonProvider) *SeasonDiscoveryService {
return &SeasonDiscoveryService{provider: provider}
}

View File

@@ -3,17 +3,15 @@ package anime
import (
"context"
"mal/internal/domain"
"sync"
"github.com/gin-gonic/gin"
)
type AnimeHandler struct {
svc Service
watchlistSvc domain.WatchlistService
episodeSvc domain.EpisodeService
scheduleCache map[string]cachedWeekSchedule
sync.Mutex
svc Service
watchlistSvc domain.WatchlistService
episodeSvc domain.EpisodeService
discoverySvc *SeasonDiscoveryService
}
type Service interface {
@@ -23,12 +21,12 @@ type Service interface {
WarmDetailSections(id int)
}
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService) *AnimeHandler {
func NewAnimeHandler(svc Service, watchlistSvc domain.WatchlistService, episodeSvc domain.EpisodeService, discoverySvc *SeasonDiscoveryService) *AnimeHandler {
return &AnimeHandler{
svc: svc,
watchlistSvc: watchlistSvc,
episodeSvc: episodeSvc,
scheduleCache: make(map[string]cachedWeekSchedule),
svc: svc,
watchlistSvc: watchlistSvc,
episodeSvc: episodeSvc,
discoverySvc: discoverySvc,
}
}
@@ -63,6 +61,8 @@ func (h *AnimeHandler) Register(r *gin.Engine) {
r.GET("/search", h.HandleSearch)
r.GET("/top-picks", h.HandleTopPicks)
r.GET("/browse", h.HandleBrowse)
r.GET("/simulcast", h.HandleSimulcast)
r.GET("/api/simulcast", h.HandleSimulcastContent)
r.GET("/anime/:id", h.HandleAnimeDetails)
r.GET("/anime/:id/reviews", h.HandleAnimeReviews)
r.GET("/api/watch-order", h.HandleHTMLWatchOrder)

View File

@@ -115,20 +115,6 @@ func TestReleasedEpisodeCount(t *testing.T) {
}
}
func TestListedEpisodeCount(t *testing.T) {
episodes := []domain.EpisodeData{
{MalID: 1, Title: "Episode 1"},
{MalID: 2, Title: "Episode 2"},
{MalID: 3, Title: "Recap", IsRecap: true},
{Title: "missing id"},
}
got := listedEpisodeCount(episodes)
if got != 2 {
t.Fatalf("listedEpisodeCount() = %d, want 2", got)
}
}
func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) {
episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{
@@ -140,7 +126,7 @@ func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) {
},
},
}
handler := NewAnimeHandler(nil, nil, episodeSvc)
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
MalID: 59970,
@@ -160,9 +146,120 @@ func TestAnimeEpisodeCountUsesCanonicalEpisodes(t *testing.T) {
}
}
func TestAnimeReleaseInfoUsesCanonicalEpisodes(t *testing.T) {
episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{
Source: "AllAnime",
Episodes: []domain.CanonicalEpisode{
{Number: 1},
{Number: 2},
{Number: 3},
},
},
}
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeReleaseInfo(context.Background(), domain.Anime{Anime: jikan.Anime{
MalID: 59970,
Status: "Currently Airing",
Airing: true,
Episodes: 12,
}}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC))
if got.Count != 3 || got.Label != "Available episodes" || got.Status != "Currently Airing" {
t.Fatalf("animeReleaseInfo() = %+v, want 3 available episodes and current status", got)
}
if episodeSvc.called != 1 {
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 1", episodeSvc.called)
}
}
func TestAnimeReleaseInfoDoesNotCallJikanFallbackAvailable(t *testing.T) {
episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{
Source: "jikan_fallback",
Episodes: []domain.CanonicalEpisode{
{Number: 1},
},
},
}
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeReleaseInfo(context.Background(), domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Status: "Currently Airing",
Airing: true,
}}, time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC))
if got.Count != 1 || got.Label != "Estimated aired episodes" {
t.Fatalf("animeReleaseInfo() = %+v, want estimated aired episode count", got)
}
}
func TestAnimeReleaseInfoMarksAiringAnimeWithoutCanonicalEpisodesAsNotYetAired(t *testing.T) {
episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{
Source: "jikan_fallback",
ReleaseChecked: true,
Episodes: []domain.CanonicalEpisode{},
},
}
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeReleaseInfo(context.Background(), domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Status: "Currently Airing",
Airing: true,
Episodes: 6,
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
}}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC))
if got.Count != 0 || got.Label != "" || got.Status != "Not yet aired" {
t.Fatalf("animeReleaseInfo() = %+v, want not-yet-aired status without count", got)
}
}
func TestAnimeEpisodeCountStopsWhenCanonicalEpisodesAreEmpty(t *testing.T) {
episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{
Source: "AllAnime",
Episodes: []domain.CanonicalEpisode{},
},
}
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
Episodes: 12,
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
}}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC))
if got.Count != 0 || got.Label != "" {
t.Fatalf("animeEpisodeCount() = %+v, want empty display", got)
}
if episodeSvc.called != 1 {
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 1", episodeSvc.called)
}
}
func TestAnimeInitialReleaseInfoDoesNotTrustCurrentlyAiringMetadata(t *testing.T) {
got := animeInitialReleaseInfo(domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Status: "Currently Airing",
Airing: true,
Episodes: 6,
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
}}, time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC))
if got.Count != 0 || got.Label != "" || got.Status != "" {
t.Fatalf("animeInitialReleaseInfo() = %+v, want empty unverified airing display", got)
}
}
func TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) {
episodeSvc := &stubEpisodeService{err: errors.New("provider unavailable")}
handler := NewAnimeHandler(nil, nil, episodeSvc)
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeEpisodeCount(context.Background(), domain.Anime{Anime: jikan.Anime{
MalID: 59970,
@@ -175,7 +272,7 @@ func TestAnimeEpisodeCountFallsBackToMetadata(t *testing.T) {
}
}
func TestAnimeInitialEpisodeCountDoesNotCallEpisodeService(t *testing.T) {
func TestAnimeInitialEpisodeCountDoesNotTrustCurrentlyAiringMetadata(t *testing.T) {
episodeSvc := &stubEpisodeService{
episodes: domain.CanonicalEpisodeList{
Episodes: []domain.CanonicalEpisode{{Number: 1}, {Number: 2}, {Number: 3}},
@@ -185,12 +282,13 @@ func TestAnimeInitialEpisodeCountDoesNotCallEpisodeService(t *testing.T) {
got := animeInitialEpisodeCount(domain.Anime{Anime: jikan.Anime{
MalID: 59970,
Airing: true,
Status: "Currently Airing",
Episodes: 12,
Aired: jikan.Aired{From: "2026-04-03T00:00:00+00:00"},
}}, time.Date(2026, time.June, 21, 0, 0, 0, 0, time.UTC))
if got.Count != 12 || got.Label != "Total episodes" {
t.Fatalf("animeInitialEpisodeCount() = %+v, want count=12 label=%q", got, "Total episodes")
if got.Count != 0 || got.Label != "" {
t.Fatalf("animeInitialEpisodeCount() = %+v, want empty display", got)
}
if episodeSvc.called != 0 {
t.Fatalf("GetCanonicalEpisodes() calls = %d, want 0", episodeSvc.called)
@@ -279,7 +377,7 @@ func TestAnimeAudioAvailabilityRequiresAllAnimeSource(t *testing.T) {
},
err: tt.err,
}
handler := NewAnimeHandler(nil, nil, episodeSvc)
handler := NewAnimeHandler(nil, nil, episodeSvc, nil)
got := handler.animeAudioAvailability(context.Background(), domain.Anime{
Anime: jikan.Anime{MalID: 52991},

View File

@@ -10,6 +10,7 @@ import (
var Module = fx.Options(
fx.Provide(
NewAnimeRepository,
NewSeasonDiscoveryService,
fx.Annotate(
NewAnimeService,
fx.As(new(Service)),
@@ -17,6 +18,7 @@ var Module = fx.Options(
fx.As(new(domain.AnimeSearchService)),
fx.As(new(domain.AnimeDetailsService)),
fx.As(new(domain.AnimePlaybackService)),
fx.As(new(domain.RecommendationInvalidator)),
),
NewAnimeHandler,
),

View File

@@ -4,12 +4,136 @@ import (
"context"
"mal/internal/anime/recommendations"
"mal/internal/domain"
"mal/internal/observability"
"maps"
"strings"
"sync"
"time"
)
func (s *animeService) GetTopPickForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, recommendations.TopPickLimit)
type recommendationComputeFunc func(context.Context, string, int) (domain.CatalogSectionData, error)
type topPicksCacheKey struct {
userID string
limit int
}
func (s *animeService) GetTopPicksForYou(ctx context.Context, userID string) (domain.CatalogSectionData, error) {
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, recommendations.TopPicksLimit)
type topPicksCacheEntry struct {
data domain.CatalogSectionData
updatedAt time.Time
hasData bool
refreshing bool
}
type topPicksCache struct {
mu sync.Mutex
entries map[topPicksCacheKey]*topPicksCacheEntry
}
const topPicksRefreshTimeout = 30 * time.Second
func (s *animeService) GetTopPickForYou(_ context.Context, userID string) (domain.CatalogSectionData, error) {
data := s.getCachedTopPicksForYou(userID, recommendations.TopPicksLimit)
if len(data.Animes) > recommendations.TopPickLimit {
data.Animes = data.Animes[:recommendations.TopPickLimit]
}
return data, nil
}
func (s *animeService) GetTopPicksForYou(_ context.Context, userID string) (domain.CatalogSectionData, error) {
return s.getCachedTopPicksForYou(userID, recommendations.TopPicksLimit), nil
}
func (s *animeService) fetchTopPicksForYou(ctx context.Context, userID string, limit int) (domain.CatalogSectionData, error) {
return recommendations.GetTopPicksForYou(ctx, s.jikan, s.repo, userID, limit)
}
func (s *animeService) getCachedTopPicksForYou(userID string, limit int) domain.CatalogSectionData {
userID = strings.TrimSpace(userID)
if userID == "" {
return domain.CatalogSectionData{Animes: []domain.Anime{}}
}
key := topPicksCacheKey{userID: userID, limit: limit}
now := time.Now()
s.topPicksCache.mu.Lock()
entry := s.topPicksCache.entries[key]
if entry != nil && entry.hasData {
data := cloneCatalogSectionData(entry.data)
if now.Sub(entry.updatedAt) >= s.topPicksCacheTTL && !entry.refreshing {
entry.refreshing = true
go s.refreshTopPicksForYou(key)
}
s.topPicksCache.mu.Unlock()
return data
}
if entry == nil {
entry = &topPicksCacheEntry{}
s.topPicksCache.entries[key] = entry
}
if !entry.refreshing {
entry.refreshing = true
go s.refreshTopPicksForYou(key)
}
s.topPicksCache.mu.Unlock()
return domain.CatalogSectionData{Animes: []domain.Anime{}}
}
func (s *animeService) refreshTopPicksForYou(key topPicksCacheKey) {
ctx, cancel := context.WithTimeout(context.Background(), topPicksRefreshTimeout)
defer cancel()
data, err := s.computeTopPicks(ctx, key.userID, key.limit)
if err != nil {
observability.WarnContext(ctx,
"top_picks_refresh_failed",
"anime",
"",
map[string]any{"user_id": key.userID, "limit": key.limit},
err,
)
s.topPicksCache.mu.Lock()
if entry := s.topPicksCache.entries[key]; entry != nil {
entry.refreshing = false
}
s.topPicksCache.mu.Unlock()
return
}
s.topPicksCache.mu.Lock()
s.topPicksCache.entries[key] = &topPicksCacheEntry{
data: cloneCatalogSectionData(data),
updatedAt: time.Now(),
hasData: true,
}
s.topPicksCache.mu.Unlock()
}
func (s *animeService) InvalidateTopPicksForUser(userID string) {
userID = strings.TrimSpace(userID)
if userID == "" {
return
}
s.topPicksCache.mu.Lock()
defer s.topPicksCache.mu.Unlock()
for key := range s.topPicksCache.entries {
if key.userID == userID {
delete(s.topPicksCache.entries, key)
}
}
}
func cloneCatalogSectionData(data domain.CatalogSectionData) domain.CatalogSectionData {
data.Animes = append([]domain.Anime(nil), data.Animes...)
data.ContinueWatching = append(data.ContinueWatching[:0:0], data.ContinueWatching...)
if data.WatchlistMap != nil {
watchlistMap := make(map[int64]bool, len(data.WatchlistMap))
maps.Copy(watchlistMap, data.WatchlistMap)
data.WatchlistMap = watchlistMap
}
return data
}

View File

@@ -74,6 +74,47 @@ func TestScoreRecommendationCandidateRewardsProfileOverlap(t *testing.T) {
}
}
func TestScoreRecommendationCandidateBuildsRationaleFromProfileMatches(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
profile := userTasteProfile{
genres: map[int]float64{1: 2.0},
themes: map[int]float64{10: 1.5},
studios: map[int]float64{20: 1.0},
demographics: map[int]float64{30: 1.0},
}
candidate := scoreRecommendationCandidate(now, profile, jikan.Anime{
MalID: 10,
Genres: []jikan.NamedEntity{{MalID: 1, Name: "Action"}},
Themes: []jikan.NamedEntity{{MalID: 10, Name: "School"}},
Studios: []jikan.NamedEntity{{MalID: 20, Name: "Production I.G"}},
Demographics: []jikan.NamedEntity{{MalID: 30, Name: "Shounen"}},
}, 5.0, 0)
want := []string{"Action", "School", "Production I.G", "Shounen"}
if !slices.Equal(candidate.rationale, want) {
t.Fatalf("expected profile match rationale %v, got %v", want, candidate.rationale)
}
}
func TestScoreRecommendationCandidateOmitsRationaleWhenSignalsAreWeak(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)
profile := userTasteProfile{
genres: map[int]float64{1: 2.0},
themes: map[int]float64{},
studios: map[int]float64{},
demographics: map[int]float64{},
}
candidate := scoreRecommendationCandidate(now, profile, jikan.Anime{
MalID: 10,
}, 5.0, 0)
if len(candidate.rationale) != 0 {
t.Fatalf("expected no rationale for weak signals, got %v", candidate.rationale)
}
}
func TestBuildTasteProfileUsesSeedWeights(t *testing.T) {
now := time.Date(2026, time.June, 4, 12, 0, 0, 0, time.UTC)

View File

@@ -24,7 +24,7 @@ func rerankRecommendationCandidates(candidates []recommendationCandidate, limit
continue
}
selected = append(selected, domain.Anime{Anime: candidate.anime})
selected = append(selected, domain.Anime{Anime: candidate.anime, RecommendationRationale: candidate.rationale})
features := diversityFeatures(candidate.anime)
seen.add(features)
recent = append(recent, features)

View File

@@ -48,9 +48,23 @@ func scoreRecommendationCandidate(
themeMatches: themes,
studioMatches: studios,
demographicMatches: demos,
rationale: buildRecommendationRationale(profile, candidate),
}
}
func buildRecommendationRationale(profile userTasteProfile, candidate jikan.Anime) []string {
rationale := make([]string, 0, 4)
rationale = append(rationale, matchedEntityNames(profile.genres, candidate.Genres)...)
rationale = append(rationale, matchedEntityNames(profile.themes, candidate.Themes)...)
rationale = append(rationale, matchedEntityNames(profile.studios, candidate.Studios)...)
rationale = append(rationale, matchedEntityNames(profile.demographics, candidate.Demographics)...)
if len(rationale) > 4 {
return rationale[:4]
}
return rationale
}
func recommendationCandidateScoreAdjustments(now time.Time, profile userTasteProfile, candidate jikan.Anime) float64 {
var score float64
@@ -115,3 +129,22 @@ func weightedEntityMatch(weights map[int]float64, entities []jikan.NamedEntity)
return matches, score
}
func matchedEntityNames(weights map[int]float64, entities []jikan.NamedEntity) []string {
if len(weights) == 0 {
return []string{}
}
names := make([]string, 0, 1)
for _, entity := range entities {
if entity.Name == "" || weights[entity.MalID] <= 0 {
continue
}
names = append(names, entity.Name)
if len(names) >= 1 {
break
}
}
return names
}

View File

@@ -25,6 +25,7 @@ type recommendationCandidate struct {
themeMatches int
studioMatches int
demographicMatches int
rationale []string
}
type userTasteProfile struct {

View File

@@ -0,0 +1,164 @@
package anime
import (
"context"
"errors"
"testing"
"time"
"mal/integrations/jikan"
"mal/internal/anime/recommendations"
"mal/internal/domain"
)
func TestGetTopPicksForYouReturnsEmptyOnCacheMissAndRefreshesInBackground(t *testing.T) {
refreshed := make(chan struct{}, 1)
svc := NewAnimeService(nil, nil)
svc.computeTopPicks = func(context.Context, string, int) (domain.CatalogSectionData, error) {
refreshed <- struct{}{}
return domain.CatalogSectionData{Animes: []domain.Anime{{Anime: jikan.Anime{MalID: 7}}}}, nil
}
got, err := svc.GetTopPicksForYou(context.Background(), "user-1")
if err != nil {
t.Fatalf("GetTopPicksForYou cache miss: %v", err)
}
if len(got.Animes) != 0 {
t.Fatalf("cache miss animes = %+v, want empty while refresh runs", got.Animes)
}
waitForRefresh(t, refreshed)
got, err = svc.GetTopPicksForYou(context.Background(), "user-1")
if err != nil {
t.Fatalf("GetTopPicksForYou cache hit: %v", err)
}
if len(got.Animes) != 1 || got.Animes[0].MalID != 7 {
t.Fatalf("cache hit animes = %+v, want anime 7", got.Animes)
}
}
func TestTopPickAndTopPicksShareCache(t *testing.T) {
refreshed := make(chan struct{}, 1)
limits := make(chan int, 1)
svc := NewAnimeService(nil, nil)
svc.computeTopPicks = func(_ context.Context, _ string, limit int) (domain.CatalogSectionData, error) {
limits <- limit
animes := make([]domain.Anime, recommendations.TopPickLimit+1)
for i := range animes {
animes[i].MalID = i + 1
}
refreshed <- struct{}{}
return domain.CatalogSectionData{Animes: animes}, nil
}
if _, err := svc.GetTopPickForYou(context.Background(), "user-1"); err != nil {
t.Fatalf("GetTopPickForYou cache miss: %v", err)
}
waitForRefresh(t, refreshed)
carousel, err := svc.GetTopPickForYou(context.Background(), "user-1")
if err != nil {
t.Fatalf("GetTopPickForYou cache hit: %v", err)
}
if len(carousel.Animes) != recommendations.TopPickLimit {
t.Fatalf("carousel animes = %d, want %d", len(carousel.Animes), recommendations.TopPickLimit)
}
all, err := svc.GetTopPicksForYou(context.Background(), "user-1")
if err != nil {
t.Fatalf("GetTopPicksForYou shared cache: %v", err)
}
if len(all.Animes) != recommendations.TopPickLimit+1 {
t.Fatalf("all animes = %d, want %d", len(all.Animes), recommendations.TopPickLimit+1)
}
if limit := <-limits; limit != recommendations.TopPicksLimit {
t.Fatalf("computed limit = %d, want %d", limit, recommendations.TopPicksLimit)
}
}
func TestGetTopPicksForYouReturnsStaleDataWhenRefreshFails(t *testing.T) {
svc := NewAnimeService(nil, nil)
svc.topPicksCacheTTL = time.Nanosecond
refreshed := make(chan struct{}, 2)
svc.computeTopPicks = func(context.Context, string, int) (domain.CatalogSectionData, error) {
refreshed <- struct{}{}
return domain.CatalogSectionData{Animes: []domain.Anime{{Anime: jikan.Anime{MalID: 11}}}}, nil
}
if _, err := svc.GetTopPicksForYou(context.Background(), "user-1"); err != nil {
t.Fatalf("prime cache: %v", err)
}
waitForRefresh(t, refreshed)
svc.computeTopPicks = func(context.Context, string, int) (domain.CatalogSectionData, error) {
refreshed <- struct{}{}
return domain.CatalogSectionData{}, errors.New("provider unavailable")
}
time.Sleep(time.Nanosecond)
got, err := svc.GetTopPicksForYou(context.Background(), "user-1")
if err != nil {
t.Fatalf("stale GetTopPicksForYou: %v", err)
}
if len(got.Animes) != 1 || got.Animes[0].MalID != 11 {
t.Fatalf("stale animes = %+v, want anime 11", got.Animes)
}
}
func TestInvalidateTopPicksForUserRefreshesNextRequest(t *testing.T) {
refreshed := make(chan struct{}, 2)
results := []int{3, 4}
svc := newPickSvc(refreshed, &results)
primePickCache(t, svc, refreshed)
svc.InvalidateTopPicksForUser("user-1")
got, err := svc.GetTopPicksForYou(context.Background(), "user-1")
if err != nil {
t.Fatalf("GetTopPicksForYou after invalidation: %v", err)
}
if len(got.Animes) != 0 {
t.Fatalf("invalidated cache animes = %+v, want empty while refresh runs", got.Animes)
}
waitForRefresh(t, refreshed)
got, err = svc.GetTopPicksForYou(context.Background(), "user-1")
if err != nil {
t.Fatalf("GetTopPicksForYou refreshed cache: %v", err)
}
if len(got.Animes) != 1 || got.Animes[0].MalID != 4 {
t.Fatalf("refreshed animes = %+v, want anime 4", got.Animes)
}
}
func newPickSvc(refreshed chan struct{}, results *[]int) *animeService {
svc := NewAnimeService(nil, nil)
svc.computeTopPicks = func(context.Context, string, int) (domain.CatalogSectionData, error) {
id := (*results)[0]
*results = (*results)[1:]
refreshed <- struct{}{}
return domain.CatalogSectionData{Animes: []domain.Anime{{Anime: jikan.Anime{MalID: id}}}}, nil
}
return svc
}
func primePickCache(t *testing.T, svc *animeService, refreshed chan struct{}) {
t.Helper()
if _, err := svc.GetTopPicksForYou(context.Background(), "user-1"); err != nil {
t.Fatalf("prime cache: %v", err)
}
waitForRefresh(t, refreshed)
}
func waitForRefresh(t *testing.T, refreshed chan struct{}) {
t.Helper()
select {
case <-refreshed:
case <-time.After(time.Second):
t.Fatal("background refresh did not run")
}
}

View File

@@ -25,3 +25,10 @@ func (r *animeRepository) GetWatchListEntry(ctx context.Context, params db.GetWa
func (r *animeRepository) GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) {
return r.queries.GetContinueWatchingEntries(ctx, userID)
}
func (r *animeRepository) GetContinueWatchingCarouselEntries(ctx context.Context, userID string, limit int64) ([]db.GetContinueWatchingEntriesRow, error) {
return r.queries.GetContinueWatchingCarouselEntries(ctx, db.GetContinueWatchingCarouselEntriesParams{
UserID: userID,
Limit: limit,
})
}

View File

@@ -1,121 +0,0 @@
package anime
import (
"context"
"fmt"
"mal/integrations/animeschedule"
"sort"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
type cachedWeekSchedule struct {
fetchedAt time.Time
value animeschedule.WeekSchedule
}
func parseYearWeek(c *gin.Context) (int, int) {
year, _ := strconv.Atoi(c.Query("year"))
week, _ := strconv.Atoi(c.Query("week"))
if year <= 0 || week <= 0 {
now := time.Now()
y, w := now.ISOWeek()
if year <= 0 {
year = y
}
if week <= 0 {
week = w
}
}
return year, week
}
func scheduleTimezone(c *gin.Context) string {
timezone := strings.TrimSpace(c.Query("timezone"))
if timezone == "" {
return "UTC"
}
return timezone
}
func (h *AnimeHandler) getCachedAnimeScheduleWeek(ctx context.Context, year int, week int, timezone string) (animeschedule.WeekSchedule, error) {
cacheKey := fmt.Sprintf("%d-%02d-%s", year, week, timezone)
const ttl = 10 * time.Minute
h.Lock()
cached, ok := h.scheduleCache[cacheKey]
h.Unlock()
if ok && time.Since(cached.fetchedAt) < ttl {
return cached.value, nil
}
value, err := animeschedule.FetchWeek(ctx, nil, year, week, timezone)
if err != nil {
return animeschedule.WeekSchedule{}, err
}
h.Lock()
h.scheduleCache[cacheKey] = cachedWeekSchedule{fetchedAt: time.Now(), value: value}
h.Unlock()
return value, nil
}
type scheduleDayView struct {
DateLabel string
WeekdayLabel string
Entries []animeschedule.Entry
}
func buildScheduleDays(schedule animeschedule.WeekSchedule, year int, week int) []scheduleDayView {
start := isoWeekStartMonday(year, week)
order := []time.Weekday{time.Monday, time.Tuesday, time.Wednesday, time.Thursday, time.Friday, time.Saturday, time.Sunday}
out := make([]scheduleDayView, 0, 7)
for i, wd := range order {
date := start.AddDate(0, 0, i)
entries := schedule.Days[wd]
sort.SliceStable(entries, func(i, j int) bool {
if !entries[i].AirsAt.IsZero() && !entries[j].AirsAt.IsZero() {
return entries[i].AirsAt.Before(entries[j].AirsAt)
}
return localTimeMinutes(entries[i].LocalTime) < localTimeMinutes(entries[j].LocalTime)
})
out = append(out, scheduleDayView{
DateLabel: strings.ToUpper(date.Format("02 Jan")),
WeekdayLabel: wd.String(),
Entries: entries,
})
}
return out
}
func localTimeMinutes(localTime string) int {
for _, layout := range []string{"15:04", "03:04 PM"} {
t, err := time.Parse(layout, localTime)
if err == nil {
return t.Hour()*60 + t.Minute()
}
}
return 0
}
func isoWeekStartMonday(year int, week int) time.Time {
// ISO week 1 is the week with the year's first Thursday in it.
jan4 := time.Date(year, 1, 4, 12, 0, 0, 0, time.Local)
// Move back to Monday
offset := int(time.Monday - jan4.Weekday())
if offset > 0 {
offset -= 7
}
week1Monday := jan4.AddDate(0, 0, offset)
return week1Monday.AddDate(0, 0, (week-1)*7)
}
func adjacentISOWeek(year int, week int, deltaWeeks int) (int, int) {
target := isoWeekStartMonday(year, week).AddDate(0, 0, deltaWeeks*7)
return target.ISOWeek()
}

View File

@@ -15,10 +15,15 @@ import (
)
type animeService struct {
jikan *jikan.Client
repo domain.AnimeRepository
jikan *jikan.Client
repo domain.AnimeRepository
topPicksCache *topPicksCache
topPicksCacheTTL time.Duration
computeTopPicks recommendationComputeFunc
}
const continueWatchingCarouselLimit int64 = 24
func wrapAnimes(in []jikan.Anime) []domain.Anime {
out := make([]domain.Anime, 0, len(in))
for _, a := range in {
@@ -28,7 +33,14 @@ func wrapAnimes(in []jikan.Anime) []domain.Anime {
}
func NewAnimeService(jikan *jikan.Client, repo domain.AnimeRepository) *animeService {
return &animeService{jikan: jikan, repo: repo}
svc := &animeService{
jikan: jikan,
repo: repo,
topPicksCache: &topPicksCache{entries: map[topPicksCacheKey]*topPicksCacheEntry{}},
topPicksCacheTTL: 15 * time.Minute,
}
svc.computeTopPicks = svc.fetchTopPicksForYou
return svc
}
func (s *animeService) GetCatalogSection(ctx context.Context, userID string, section string) (domain.CatalogSectionData, error) {
@@ -56,7 +68,7 @@ func (s *animeService) GetCatalogSection(ctx context.Context, userID string, sec
if userID != "" && section == "Continue" {
g.Go(func() error {
var err error
cw, err = s.repo.GetContinueWatchingEntries(gCtx, userID)
cw, err = s.repo.GetContinueWatchingCarouselEntries(gCtx, userID, continueWatchingCarouselLimit)
if err != nil {
return fmt.Errorf("get continue watching entries for %q: %w", userID, err)
}

View File

@@ -0,0 +1,56 @@
package anime
import (
"context"
"mal/internal/db"
"testing"
)
type catalogRepoStub struct {
watchlist []db.GetUserWatchListRow
allContinueRows []db.GetContinueWatchingEntriesRow
carouselContinueRows []db.GetContinueWatchingEntriesRow
carouselLimit int64
}
func (r *catalogRepoStub) GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error) {
return r.watchlist, nil
}
func (r *catalogRepoStub) GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error) {
return db.WatchListEntry{}, nil
}
func (r *catalogRepoStub) GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error) {
return r.allContinueRows, nil
}
func (r *catalogRepoStub) GetContinueWatchingCarouselEntries(ctx context.Context, userID string, limit int64) ([]db.GetContinueWatchingEntriesRow, error) {
r.carouselLimit = limit
return r.carouselContinueRows, nil
}
func TestGetCatalogSectionLimitsContinueWatchingCarousel(t *testing.T) {
repo := &catalogRepoStub{
allContinueRows: make([]db.GetContinueWatchingEntriesRow, 30),
carouselContinueRows: []db.GetContinueWatchingEntriesRow{
{AnimeID: 30},
{AnimeID: 29},
},
}
svc := NewAnimeService(nil, repo)
got, err := svc.GetCatalogSection(context.Background(), "user-1", "Continue")
if err != nil {
t.Fatalf("GetCatalogSection: %v", err)
}
if repo.carouselLimit != continueWatchingCarouselLimit {
t.Fatalf("carousel limit = %d, want %d", repo.carouselLimit, continueWatchingCarouselLimit)
}
if len(got.ContinueWatching) != len(repo.carouselContinueRows) {
t.Fatalf("len(ContinueWatching) = %d, want %d", len(got.ContinueWatching), len(repo.carouselContinueRows))
}
if got.ContinueWatching[0].AnimeID != 30 || got.ContinueWatching[1].AnimeID != 29 {
t.Fatalf("ContinueWatching = %+v, want anime IDs [30 29]", got.ContinueWatching)
}
}

144
internal/anime/simulcast.go Normal file
View File

@@ -0,0 +1,144 @@
package anime
import (
"context"
"mal/integrations/jikan"
"mal/internal/domain"
"slices"
"strconv"
"strings"
)
type animeSeason struct {
Season string
Year int
}
type seasonOption struct {
Season string
Year int
Label string
}
var seasons = []string{"winter", "spring", "summer", "fall"}
func seasonOptions(firstYear int, latest animeSeason) []seasonOption {
options := make([]seasonOption, 0, (latest.Year-firstYear+1)*len(seasons))
for year := latest.Year; year >= firstYear; year-- {
start := len(seasons) - 1
if year == latest.Year {
start = seasonIndex(latest.Season)
}
for i := start; i >= 0; i-- {
season := seasons[i]
options = append(options, seasonOption{
Season: season,
Year: year,
Label: strings.ToUpper(season[:1]) + season[1:] + " " + strconv.Itoa(year),
})
}
}
return options
}
func seasonIndex(season string) int {
for i, candidate := range seasons {
if strings.EqualFold(candidate, season) {
return i
}
}
return 0
}
func calendarSeason(year, month int) animeSeason {
index := (month - 1) / 3
if index < 0 || index >= len(seasons) {
index = 0
}
return animeSeason{Season: seasons[index], Year: year}
}
func adjacentSeason(season string, year, direction int) animeSeason {
index := 0
for i, candidate := range seasons {
if strings.EqualFold(candidate, season) {
index = i
break
}
}
index += direction
if index < 0 {
index = len(seasons) - 1
year--
} else if index >= len(seasons) {
index = 0
year++
}
return animeSeason{Season: seasons[index], Year: year}
}
func seasonNavigation(selected animeSeason, firstYear int, latest animeSeason) (*animeSeason, *animeSeason) {
var previous *animeSeason
var next *animeSeason
if selected.Year > firstYear || selected.Season != "winter" {
value := adjacentSeason(selected.Season, selected.Year, -1)
previous = &value
}
value := adjacentSeason(selected.Season, selected.Year, 1)
if value.Year < latest.Year || value.Year == latest.Year && seasonIndex(value.Season) <= seasonIndex(latest.Season) {
next = &value
}
return previous, next
}
func seasonSelection(rawSeason, rawYear string, current, latest animeSeason) animeSeason {
season := strings.ToLower(strings.TrimSpace(rawSeason))
validSeason := slices.Contains(seasons, season)
year, err := strconv.Atoi(rawYear)
if !validSeason || err != nil || year < 2000 || year > latest.Year || year == latest.Year && seasonIndex(season) > seasonIndex(latest.Season) {
return current
}
return animeSeason{Season: season, Year: year}
}
func (s *SeasonDiscoveryService) LatestAvailableSeason(ctx context.Context, current animeSeason) animeSeason {
next := adjacentSeason(current.Season, current.Year, 1)
shows, err := s.provider.SeasonalShows(ctx, next.Season, next.Year)
if err == nil && len(shows) > 0 {
return next
}
return current
}
type SimulcastData struct {
Animes []domain.Anime
Season string
Year int
}
func (s *SeasonDiscoveryService) GetSimulcast(ctx context.Context, selected animeSeason) (SimulcastData, error) {
shows, err := s.provider.SeasonalShows(ctx, selected.Season, selected.Year)
if err != nil {
return SimulcastData{}, err
}
animes := make([]domain.Anime, 0, len(shows))
for _, show := range shows {
anime := jikan.Anime{
MalID: show.MalID,
Title: show.Name,
TitleEnglish: show.EnglishName,
Synopsis: show.Description,
Status: show.Status,
Type: show.Type,
Year: show.Year,
Episodes: show.EpisodeCount,
}
anime.Images.Webp.LargeImageURL = show.Thumbnail
animes = append(animes, domain.Anime{Anime: anime})
}
return SimulcastData{
Animes: animes,
Season: selected.Season,
Year: selected.Year,
}, nil
}

View File

@@ -0,0 +1,88 @@
package anime
import (
"context"
"fmt"
"mal/integrations/playback/allanime"
"testing"
"time"
)
type seasonalProviderStub struct {
shows map[string][]allanime.ProviderShow
}
func (p seasonalProviderStub) SeasonalShows(_ context.Context, season string, year int) ([]allanime.ProviderShow, error) {
return p.shows[fmt.Sprintf("%s-%d", season, year)], nil
}
func TestAdjacentSeasonCrossesYearBoundary(t *testing.T) {
previous := adjacentSeason("winter", 2026, -1)
if previous.Season != "fall" || previous.Year != 2025 {
t.Fatalf("previous = %+v", previous)
}
next := adjacentSeason("fall", 2026, 1)
if next.Season != "winter" || next.Year != 2027 {
t.Fatalf("next = %+v", next)
}
}
func TestSeasonSelectionRejectsInvalidValues(t *testing.T) {
now := time.Date(2026, time.August, 1, 0, 0, 0, 0, time.UTC)
current := calendarSeason(now.Year(), int(now.Month()))
got := seasonSelection("monsoon", "nope", current, current)
if got.Season != "summer" || got.Year != 2026 {
t.Fatalf("seasonSelection = %+v", got)
}
}
func TestSeasonSelectionDefaultsToCalendarSeasonWhenNewerSeasonIsAvailable(t *testing.T) {
current := animeSeason{Season: "spring", Year: 2026}
latest := animeSeason{Season: "summer", Year: 2026}
got := seasonSelection("", "", current, latest)
if got != current {
t.Fatalf("seasonSelection = %+v, want %+v", got, current)
}
}
func TestLatestAvailableSeasonIncludesPlayableNextSeason(t *testing.T) {
provider := seasonalProviderStub{shows: map[string][]allanime.ProviderShow{
"summer-2026": {{MalID: 1}},
}}
svc := newSeasonDiscoveryService(provider)
got := svc.LatestAvailableSeason(context.Background(), animeSeason{Season: "spring", Year: 2026})
if got.Season != "summer" || got.Year != 2026 {
t.Fatalf("LatestAvailableSeason = %+v", got)
}
}
func TestSeasonOptionsRunNewestFirstWithoutFutureSeasons(t *testing.T) {
got := seasonOptions(2025, animeSeason{Season: "summer", Year: 2026})
want := []string{"Summer 2026", "Spring 2026", "Winter 2026", "Fall 2025", "Summer 2025", "Spring 2025", "Winter 2025"}
if len(got) != len(want) {
t.Fatalf("len(seasonOptions) = %d, want %d", len(got), len(want))
}
for i := range want {
if got[i].Label != want[i] {
t.Fatalf("seasonOptions[%d] = %q, want %q", i, got[i].Label, want[i])
}
}
}
func TestSeasonNavigationStopsAtSupportedRange(t *testing.T) {
current := animeSeason{Season: "summer", Year: 2026}
if previous, _ := seasonNavigation(animeSeason{Season: "winter", Year: 2018}, 2018, current); previous != nil {
t.Fatalf("previous = %+v, want nil", previous)
}
if _, next := seasonNavigation(current, 2018, current); next != nil {
t.Fatalf("next = %+v, want nil", next)
}
}
func TestParseSeasonDefaultsToCalendarSeason(t *testing.T) {
got := calendarSeason(2026, 7)
if got.Season != "summer" || got.Year != 2026 {
t.Fatalf("calendarSeason = %+v", got)
}
}

58
internal/auth/cookie.go Normal file
View File

@@ -0,0 +1,58 @@
package auth
import (
"net/http"
"strings"
"mal/internal/domain"
"github.com/gin-gonic/gin"
)
const sessionCookieName = "session_id"
func setSessionCookie(c *gin.Context, value string, maxAge int) {
http.SetCookie(c.Writer, &http.Cookie{
Name: sessionCookieName,
Value: value,
Path: "/",
MaxAge: maxAge,
Secure: isHTTPSRequest(c.Request),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func setPersistentSessionCookie(c *gin.Context, value string) {
setSessionCookie(c, value, int(domain.SessionLifetime.Seconds()))
}
func clearSessionCookie(c *gin.Context) {
setSessionCookie(c, "", -1)
}
func isHTTPSRequest(r *http.Request) bool {
if r.TLS != nil {
return true
}
proto := r.Header.Get("X-Forwarded-Proto")
if i := strings.IndexByte(proto, ','); i >= 0 {
proto = proto[:i]
}
if strings.EqualFold(strings.TrimSpace(proto), "https") {
return true
}
forwarded := r.Header.Values("Forwarded")
for _, value := range forwarded {
for part := range strings.SplitSeq(value, ";") {
key, val, ok := strings.Cut(strings.TrimSpace(part), "=")
if ok && strings.EqualFold(key, "proto") && strings.EqualFold(strings.Trim(val, `"`), "https") {
return true
}
}
}
return false
}

View File

@@ -20,7 +20,7 @@ func NewAuthHandler(svc domain.AuthService) *AuthHandler {
func (h *AuthHandler) Register(r *gin.Engine) {
r.GET("/login", h.HandleLoginPage)
r.POST("/login", h.HandleLogin)
r.GET("/logout", h.HandleLogout)
r.POST("/logout", h.HandleLogout)
r.POST("/api/auth/login", h.HandleAPILogin)
}
@@ -43,7 +43,7 @@ func (h *AuthHandler) HandleLogin(c *gin.Context) {
return
}
c.SetCookie("session_id", session.ID, int(domain.SessionLifetime.Seconds()), "/", "", false, true)
setPersistentSessionCookie(c, session.ID)
if c.GetHeader("HX-Request") == "true" {
c.Header("HX-Redirect", "/")
c.Status(http.StatusOK)
@@ -60,7 +60,7 @@ func (h *AuthHandler) HandleLogout(c *gin.Context) {
}
}
c.SetCookie("session_id", "", -1, "/", "", false, true)
clearSessionCookie(c)
c.Redirect(http.StatusSeeOther, "/login")
}

View File

@@ -2,6 +2,7 @@ package auth
import (
"context"
"crypto/tls"
"errors"
"net/http"
"net/http/httptest"
@@ -72,6 +73,85 @@ func TestHandleAPILoginRejectsInvalidRequests(t *testing.T) {
}
}
func TestHandleLoginSetsHardenedSessionCookie(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
configure func(*http.Request)
wantSecure bool
}{
{name: "local http", wantSecure: false},
{name: "direct https", configure: func(req *http.Request) { req.TLS = &tls.ConnectionState{} }, wantSecure: true},
{name: "forwarded https", configure: func(req *http.Request) { req.Header.Set("X-Forwarded-Proto", "https") }, wantSecure: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
svc := &fakeAuthService{}
router := gin.New()
NewAuthHandler(svc).Register(router)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/login", strings.NewReader("username=alice&password=correct"))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
if tt.configure != nil {
tt.configure(req)
}
router.ServeHTTP(rec, req)
cookie := findSetCookie(t, rec, "session_id")
if cookie.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want %v", cookie.SameSite, http.SameSiteLaxMode)
}
if cookie.Secure != tt.wantSecure {
t.Fatalf("Secure = %v, want %v", cookie.Secure, tt.wantSecure)
}
if !cookie.HttpOnly {
t.Fatalf("expected HttpOnly cookie")
}
})
}
}
func TestHandleLogoutRequiresPostAndClearsHardenedSessionCookie(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &fakeAuthService{}
router := gin.New()
NewAuthHandler(svc).Register(router)
getRec := httptest.NewRecorder()
getReq := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/logout", nil)
router.ServeHTTP(getRec, getReq)
if getRec.Code == http.StatusSeeOther {
t.Fatalf("GET /logout must not perform logout redirect")
}
postRec := httptest.NewRecorder()
postReq := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/logout", nil)
postReq.Header.Set("X-Forwarded-Proto", "https")
postReq.AddCookie(&http.Cookie{Name: "session_id", Value: "session-1"})
router.ServeHTTP(postRec, postReq)
if postRec.Code != http.StatusSeeOther {
t.Fatalf("POST /logout status = %d, want %d", postRec.Code, http.StatusSeeOther)
}
if svc.loggedOutSessionID != "session-1" {
t.Fatalf("logged out session = %q, want session-1", svc.loggedOutSessionID)
}
cookie := findSetCookie(t, postRec, "session_id")
if cookie.MaxAge >= 0 {
t.Fatalf("MaxAge = %d, want deletion cookie", cookie.MaxAge)
}
if cookie.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want %v", cookie.SameSite, http.SameSiteLaxMode)
}
if !cookie.Secure {
t.Fatalf("expected Secure cookie for forwarded https")
}
}
func TestAuthMiddlewareAllowsPublicRoutes(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -133,6 +213,7 @@ func TestAuthMiddlewareAuthenticatesCookieSessionAndRefreshes(t *testing.T) {
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
req.Header.Set("X-Forwarded-Proto", "https")
req.AddCookie(&http.Cookie{Name: "session_id", Value: "session-1"})
router.ServeHTTP(rec, req)
@@ -148,6 +229,13 @@ func TestAuthMiddlewareAuthenticatesCookieSessionAndRefreshes(t *testing.T) {
if got := rec.Header().Values("Set-Cookie"); len(got) == 0 || !strings.Contains(got[0], "session_id=session-1") {
t.Fatalf("Set-Cookie = %v, want refreshed session cookie", got)
}
cookie := findSetCookie(t, rec, "session_id")
if cookie.SameSite != http.SameSiteLaxMode {
t.Fatalf("SameSite = %v, want %v", cookie.SameSite, http.SameSiteLaxMode)
}
if !cookie.Secure {
t.Fatalf("expected Secure cookie for forwarded https")
}
}
func TestAuthMiddlewareRejectsUnauthenticatedRequests(t *testing.T) {
@@ -184,6 +272,26 @@ func TestAuthMiddlewareRejectsUnauthenticatedRequests(t *testing.T) {
}
}
func TestAuthMiddlewareAllowsPublicWatchlistAPI(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &fakeAuthService{validateErr: errors.New("no auth")}
router := gin.New()
router.Use(AuthMiddleware(svc))
router.GET("/api/public/users/:userID/watchlist", func(c *gin.Context) { c.Status(http.StatusOK) })
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/public/users/user-42/watchlist", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if svc.validateSessionCalled || svc.validateAPITokenCalled {
t.Fatalf("public watchlist endpoint should not authenticate")
}
}
type fakeAuthService struct {
user *domain.User
@@ -253,3 +361,14 @@ func (s *fakeAuthService) RevokeAllAPITokensForUser(_ context.Context, userID st
s.revokedAPITokensForUser = userID
return nil
}
func findSetCookie(t *testing.T, rec *httptest.ResponseRecorder, name string) *http.Cookie {
t.Helper()
for _, cookie := range rec.Result().Cookies() {
if cookie.Name == name {
return cookie
}
}
t.Fatalf("missing Set-Cookie %q in %v", name, rec.Header().Values("Set-Cookie"))
return nil
}

View File

@@ -18,7 +18,11 @@ var publicRoutes = []publicRoute{
// Pages.
{method: http.MethodGet, path: "/login"},
{method: http.MethodPost, path: "/login"},
{method: http.MethodGet, path: "/logout"},
{method: http.MethodPost, path: "/logout"},
// Crawler noise.
{method: http.MethodGet, path: "/robots.txt"},
{method: http.MethodGet, path: "/sitemap.xml"},
// Static assets.
{path: "/static", prefix: true},
@@ -26,6 +30,9 @@ var publicRoutes = []publicRoute{
// Auth API.
{method: http.MethodPost, path: "/api/auth/login"},
// Explicitly public, read-only API resources.
{method: http.MethodGet, path: "/api/public/", prefix: true},
}
func isPublicRequest(method string, path string) bool {
@@ -108,7 +115,7 @@ func AuthMiddleware(svc domain.AuthService) gin.HandlerFunc {
if usesCookieSession {
if refreshErr := svc.RefreshSession(c.Request.Context(), sessionID); refreshErr == nil {
c.SetCookie("session_id", sessionID, int(domain.SessionLifetime.Seconds()), "/", "", false, true)
setPersistentSessionCookie(c, sessionID)
}
}

View File

@@ -0,0 +1,110 @@
package config
import (
"os"
"testing"
)
func TestFirstNonEmptyReturnsFirstNonBlank(t *testing.T) {
if got := firstNonEmpty("", "b", "c"); got != "b" {
t.Fatalf("got %q, want %q", got, "b")
}
}
func TestFirstNonEmptyReturnsEmptyWhenAllBlank(t *testing.T) {
if got := firstNonEmpty("", "", ""); got != "" {
t.Fatalf("got %q, want empty", got)
}
}
func TestFirstNonEmptySkipsWhitespaceOnly(t *testing.T) {
if got := firstNonEmpty(" ", "x"); got != "x" {
t.Fatalf("got %q, want %q", got, "x")
}
}
func TestTruthyRecognises1TrueYesYOn(t *testing.T) {
for _, v := range []string{"1", "true", "True", "TRUE", "yes", "Yes", "y", "Y", "on", "ON"} {
if !truthy(v) {
t.Fatalf("truthy(%q) = false, want true", v)
}
}
}
func TestTruthyReturnsFalseForEmptyAndGarbage(t *testing.T) {
for _, v := range []string{"", "0", "false", "no", "off", "maybe"} {
if truthy(v) {
t.Fatalf("truthy(%q) = true, want false", v)
}
}
}
func TestLoadDefaults(t *testing.T) {
cfg, err := Load()
if err != nil {
t.Fatalf("Load(): %v", err)
}
if cfg.Port != "3000" {
t.Fatalf("Port = %q, want %q", cfg.Port, "3000")
}
if cfg.DatabaseFile != "mal.db" {
t.Fatalf("DatabaseFile = %q, want %q", cfg.DatabaseFile, "mal.db")
}
if cfg.EpisodeAvailabilityMode != EpisodeAvailabilityModeAuto {
t.Fatalf("EpisodeAvailabilityMode = %q, want %q", cfg.EpisodeAvailabilityMode, EpisodeAvailabilityModeAuto)
}
}
func TestLoadReadsEnv(t *testing.T) {
os.Setenv("PORT", "8080")
os.Setenv("DATABASE_FILE", "/tmp/test.db")
os.Setenv("MAL_CORS_ALLOW_ALL", "1")
os.Setenv("MAL_JIKAN_TRACE", "true")
defer func() {
os.Unsetenv("PORT")
os.Unsetenv("DATABASE_FILE")
os.Unsetenv("MAL_CORS_ALLOW_ALL")
os.Unsetenv("MAL_JIKAN_TRACE")
}()
cfg, err := Load()
if err != nil {
t.Fatalf("Load(): %v", err)
}
if cfg.Port != "8080" {
t.Fatalf("Port = %q, want %q", cfg.Port, "8080")
}
if cfg.DatabaseFile != "/tmp/test.db" {
t.Fatalf("DatabaseFile = %q, want %q", cfg.DatabaseFile, "/tmp/test.db")
}
if !cfg.CORSAllowAll {
t.Fatal("CORSAllowAll = false, want true")
}
if !cfg.JikanTrace {
t.Fatal("JikanTrace = false, want true")
}
}
func TestLoadInvalidEpisodeAvailabilityMode(t *testing.T) {
os.Setenv("EPISODE_AVAILABILITY_MODE", "invalid")
defer os.Unsetenv("EPISODE_AVAILABILITY_MODE")
_, err := Load()
if err == nil {
t.Fatal("expected error for invalid EPISODE_AVAILABILITY_MODE")
}
}
func TestLoadEmptyPortDefaultsTo3000(t *testing.T) {
os.Setenv("PORT", "")
os.Setenv("DATABASE_FILE", "mal.db")
defer os.Unsetenv("PORT")
cfg, err := Load()
if err != nil {
t.Fatalf("Load(): %v", err)
}
if cfg.Port != "3000" {
t.Fatalf("Port = %q, want %q", cfg.Port, "3000")
}
}

View File

@@ -0,0 +1,142 @@
package database
import (
"context"
"database/sql"
"errors"
"reflect"
"testing"
dbfixes "mal/internal/database/fixes"
_ "github.com/mattn/go-sqlite3"
)
func TestRunDataFixListRunsUnappliedFixesAndRecordsSuccesses(t *testing.T) {
sqlDB := newEmptyTestDB(t)
defer closeTestDB(t, sqlDB)
var applied []string
fixes := []dbfixes.Fix{
{
ID: "first",
Apply: func(ctx context.Context, sqlDB *sql.DB, deps dbfixes.Dependencies) error {
applied = append(applied, "first")
return nil
},
},
{
ID: "second",
Apply: func(ctx context.Context, sqlDB *sql.DB, deps dbfixes.Dependencies) error {
applied = append(applied, "second")
return nil
},
},
}
if err := runDataFixList(sqlDB, dbfixes.Dependencies{}, fixes); err != nil {
t.Fatalf("runDataFixList: %v", err)
}
if !reflect.DeepEqual(applied, []string{"first", "second"}) {
t.Fatalf("applied fixes = %v, want [first second]", applied)
}
assertAppliedDataFixes(context.Background(), t, sqlDB, []string{"first", "second"})
}
func TestRunDataFixListSkipsAlreadyAppliedFixes(t *testing.T) {
sqlDB := newEmptyTestDB(t)
defer closeTestDB(t, sqlDB)
ctx := context.Background()
if _, err := sqlDB.ExecContext(ctx, `CREATE TABLE data_fixes (id TEXT PRIMARY KEY, applied_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`); err != nil {
t.Fatalf("create data_fixes: %v", err)
}
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO data_fixes (id) VALUES ('already-applied')`); err != nil {
t.Fatalf("insert applied fix: %v", err)
}
var ran bool
fixes := []dbfixes.Fix{
{
ID: "already-applied",
Apply: func(ctx context.Context, sqlDB *sql.DB, deps dbfixes.Dependencies) error {
ran = true
return nil
},
},
}
if err := runDataFixList(sqlDB, dbfixes.Dependencies{}, fixes); err != nil {
t.Fatalf("runDataFixList: %v", err)
}
if ran {
t.Fatal("already applied fix ran")
}
assertAppliedDataFixes(context.Background(), t, sqlDB, []string{"already-applied"})
}
func TestRunDataFixListDoesNotRecordFailedFix(t *testing.T) {
sqlDB := newEmptyTestDB(t)
defer closeTestDB(t, sqlDB)
fixErr := errors.New("boom")
fixes := []dbfixes.Fix{
{
ID: "fails",
Apply: func(ctx context.Context, sqlDB *sql.DB, deps dbfixes.Dependencies) error {
return fixErr
},
},
}
err := runDataFixList(sqlDB, dbfixes.Dependencies{}, fixes)
if !errors.Is(err, fixErr) {
t.Fatalf("runDataFixList error = %v, want wrapped %v", err, fixErr)
}
assertAppliedDataFixes(context.Background(), t, sqlDB, nil)
}
func newEmptyTestDB(t *testing.T) *sql.DB {
t.Helper()
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlDB.SetMaxOpenConns(1)
return sqlDB
}
func assertAppliedDataFixes(ctx context.Context, t *testing.T, sqlDB *sql.DB, want []string) {
t.Helper()
rows, err := sqlDB.QueryContext(ctx, `SELECT id FROM data_fixes ORDER BY id`)
if err != nil {
t.Fatalf("query data_fixes: %v", err)
}
defer func() {
if err := rows.Close(); err != nil {
t.Errorf("close rows: %v", err)
}
}()
var got []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
t.Fatalf("scan data_fix id: %v", err)
}
got = append(got, id)
}
if err := rows.Err(); err != nil {
t.Fatalf("iterate data_fixes: %v", err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("applied data fixes = %v, want %v", got, want)
}
}

View File

@@ -7,6 +7,7 @@ import (
"testing"
_ "github.com/mattn/go-sqlite3"
"github.com/pressly/goose/v3"
)
func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
@@ -45,6 +46,116 @@ func TestRunMigrationsCreatesHotPathIndexes(t *testing.T) {
}
}
func TestWatchlistCompletionDateFollowsCompletedStatus(t *testing.T) {
sqlDB := newMigratedTestDB(t)
defer closeTestDB(t, sqlDB)
ctx := context.Background()
mustExecTestSQL(t, sqlDB, `INSERT INTO user (id, username, password_hash) VALUES ('user-1', 'alice', 'hash')`)
mustExecTestSQL(t, sqlDB, `INSERT INTO anime (id, title_original, image_url) VALUES (1, 'Anime', 'image.jpg')`)
queries := db.New(sqlDB)
upsertTestWatchlistStatus(ctx, t, queries, "completed")
completedAt, estimated := testWatchlistCompletion(ctx, t, sqlDB)
if !completedAt.Valid {
t.Fatalf("completed status should record completed_at")
}
if estimated {
t.Fatalf("new completion date should be exact")
}
upsertTestWatchlistStatus(ctx, t, queries, "watching")
completedAt, estimated = testWatchlistCompletion(ctx, t, sqlDB)
if completedAt.Valid || estimated {
t.Fatalf("watching status completion = %v estimated=%v, want empty", completedAt, estimated)
}
upsertTestWatchlistStatus(ctx, t, queries, "completed")
completedAt, estimated = testWatchlistCompletion(ctx, t, sqlDB)
if !completedAt.Valid || estimated {
t.Fatalf("re-completed status completion = %v estimated=%v, want exact date", completedAt, estimated)
}
}
func TestCompletionDateMigrationMarksHistoricalEstimates(t *testing.T) {
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
defer closeTestDB(t, sqlDB)
sqlDB.SetMaxOpenConns(1)
goose.SetBaseFS(migrationsFS)
goose.SetLogger(goose.NopLogger())
if err := goose.SetDialect("sqlite3"); err != nil {
t.Fatalf("set goose dialect: %v", err)
}
if err := goose.UpTo(sqlDB, "migrations", 25); err != nil {
t.Fatalf("migrate to version 25: %v", err)
}
ctx := context.Background()
mustExecTestSQL(t, sqlDB, `INSERT INTO user (id, username, password_hash) VALUES ('user-1', 'alice', 'hash')`)
mustExecTestSQL(t, sqlDB, `INSERT INTO anime (id, title_original, image_url) VALUES (1, 'Audited', '1.jpg'), (2, 'Estimated', '2.jpg')`)
mustExecTestSQL(t, sqlDB, `
INSERT INTO watch_list_entry (id, user_id, anime_id, status, updated_at)
VALUES
('entry-1', 'user-1', 1, 'completed', '2026-06-20 20:00:00'),
('entry-2', 'user-1', 2, 'completed', '2026-06-21 21:00:00')`)
mustExecTestSQL(t, sqlDB, `
INSERT INTO audit_log (id, occurred_at, user_id, action, resource_type, resource_id)
VALUES ('audit-1', '2026-06-19 19:00:00', 'user-1', 'watch_completed', 'anime', '1')`)
if err := goose.UpTo(sqlDB, "migrations", 26); err != nil {
t.Fatalf("migrate to version 26: %v", err)
}
assertHistoricalCompletion(ctx, t, sqlDB, 1, "2026-06-19T19:00:00Z", false)
assertHistoricalCompletion(ctx, t, sqlDB, 2, "2026-06-21T21:00:00Z", true)
}
func mustExecTestSQL(t *testing.T, sqlDB *sql.DB, query string) {
t.Helper()
if _, err := sqlDB.ExecContext(context.Background(), query); err != nil {
t.Fatalf("execute test SQL: %v", err)
}
}
func upsertTestWatchlistStatus(ctx context.Context, t *testing.T, queries *db.Queries, status string) {
t.Helper()
_, err := queries.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{
ID: "entry-1",
UserID: "user-1",
AnimeID: 1,
Status: status,
})
if err != nil {
t.Fatalf("upsert %s watchlist entry: %v", status, err)
}
}
func testWatchlistCompletion(ctx context.Context, t *testing.T, sqlDB *sql.DB) (sql.NullTime, bool) {
t.Helper()
var completedAt sql.NullTime
var estimated bool
if err := sqlDB.QueryRowContext(ctx, `SELECT completed_at, completed_at_estimated FROM watch_list_entry WHERE user_id = 'user-1' AND anime_id = 1`).Scan(&completedAt, &estimated); err != nil {
t.Fatalf("query completion date: %v", err)
}
return completedAt, estimated
}
func assertHistoricalCompletion(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64, wantTime string, wantEstimated bool) {
t.Helper()
var completedAt string
var estimated bool
if err := sqlDB.QueryRowContext(ctx, `SELECT completed_at, completed_at_estimated FROM watch_list_entry WHERE anime_id = ?`, animeID).Scan(&completedAt, &estimated); err != nil {
t.Fatalf("query completion for anime %d: %v", animeID, err)
}
if completedAt != wantTime || estimated != wantEstimated {
t.Fatalf("anime %d completion = %q estimated=%v, want %q estimated=%v", animeID, completedAt, estimated, wantTime, wantEstimated)
}
}
func TestCleanupExpiredJikanCache(t *testing.T) {
sqlDB := newMigratedTestDB(t)
defer closeTestDB(t, sqlDB)

View File

@@ -12,11 +12,13 @@ import (
)
func RunDataFixes(sqlDB *sql.DB, deps dbfixes.Dependencies) error {
return runDataFixList(sqlDB, deps, dbfixes.All())
}
func runDataFixList(sqlDB *sql.DB, deps dbfixes.Dependencies, fixes []dbfixes.Fix) error {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
fixes := dbfixes.All()
if len(fixes) == 0 {
return nil
}

View File

@@ -0,0 +1,183 @@
package fixes
import (
"context"
"database/sql"
"testing"
_ "github.com/mattn/go-sqlite3"
)
func TestEpisodeAvailabilityBackfillSetsMissingNextRefreshAt(t *testing.T) {
sqlDB := newFixTestDB(t)
defer closeTestDB(t, sqlDB)
ctx := context.Background()
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO episode_availability_cache (anime_id, data, next_refresh_at, updated_at) VALUES (1, '{}', NULL, '2000-01-01T00:00:00Z')`); err != nil {
t.Fatalf("insert missing next_refresh row: %v", err)
}
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO episode_availability_cache (anime_id, data, next_refresh_at, updated_at) VALUES (2, '{}', '2999-01-01T00:00:00Z', '2000-01-01T00:00:00Z')`); err != nil {
t.Fatalf("insert populated next_refresh row: %v", err)
}
runFix(ctx, t, sqlDB, "20260526_episode_availability_backfill_next_refresh_at", Dependencies{})
assertNextRefreshAtBackfilled(ctx, t, sqlDB, 1)
assertUpdatedAtChanged(ctx, t, sqlDB, 1, "2000-01-01T00:00:00Z")
assertNextRefreshAtEquals(ctx, t, sqlDB, 2, "2999-01-01T00:00:00Z")
backfilledNextRefreshAt := queryNextRefreshAt(ctx, t, sqlDB, 1)
runFix(ctx, t, sqlDB, "20260526_episode_availability_backfill_next_refresh_at", Dependencies{})
assertNextRefreshAtEquals(ctx, t, sqlDB, 1, backfilledNextRefreshAt)
}
func TestAvatarURLBackfillUsesDefaultAvatarForBlankURLs(t *testing.T) {
sqlDB := newFixTestDB(t)
defer closeTestDB(t, sqlDB)
ctx := context.Background()
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO user (id, username, password_hash, avatar_url) VALUES ('blank', 'alice', 'hash', '')`); err != nil {
t.Fatalf("insert blank avatar user: %v", err)
}
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO user (id, username, password_hash, avatar_url) VALUES ('existing', 'bob', 'hash', 'custom.png')`); err != nil {
t.Fatalf("insert existing avatar user: %v", err)
}
deps := Dependencies{DefaultAvatarURL: func(username string) string { return "avatar/" + username + ".png" }}
runFix(ctx, t, sqlDB, "20260528_backfill_avatar_url", deps)
assertUserAvatarURL(ctx, t, sqlDB, "blank", "avatar/alice.png")
assertUserAvatarURL(ctx, t, sqlDB, "existing", "custom.png")
runFix(ctx, t, sqlDB, "20260528_backfill_avatar_url", deps)
assertUserAvatarURL(ctx, t, sqlDB, "blank", "avatar/alice.png")
}
func TestListAnimeMissingDurationSecondsOnlyReturnsRowsWithNullDuration(t *testing.T) {
sqlDB := newFixTestDB(t)
defer closeTestDB(t, sqlDB)
ctx := context.Background()
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO anime (id, title_original, image_url, duration_seconds) VALUES (1, 'Missing', 'missing.png', NULL)`); err != nil {
t.Fatalf("insert missing duration anime: %v", err)
}
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO anime (id, title_original, image_url, duration_seconds) VALUES (2, 'Present', 'present.png', 1440)`); err != nil {
t.Fatalf("insert present duration anime: %v", err)
}
rows, err := listAnimeMissingDurationSeconds(ctx, sqlDB)
if err != nil {
t.Fatalf("listAnimeMissingDurationSeconds: %v", err)
}
if len(rows) != 1 {
t.Fatalf("missing duration rows = %v, want 1 row", rows)
}
if rows[0].id != 1 || rows[0].titleOriginal != "Missing" {
t.Fatalf("missing duration row = %+v, want anime 1", rows[0])
}
}
func runFix(ctx context.Context, t *testing.T, sqlDB *sql.DB, id string, deps Dependencies) {
t.Helper()
for _, fix := range All() {
if fix.ID != id {
continue
}
if err := fix.Apply(ctx, sqlDB, deps); err != nil {
t.Fatalf("apply fix %s: %v", id, err)
}
return
}
t.Fatalf("fix %s not registered", id)
}
func queryNextRefreshAt(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64) string {
t.Helper()
var v sql.NullString
if err := sqlDB.QueryRowContext(ctx, `SELECT next_refresh_at FROM episode_availability_cache WHERE anime_id = ?`, animeID).Scan(&v); err != nil {
t.Fatalf("query next_refresh_at for %d: %v", animeID, err)
}
if !v.Valid || v.String == "" {
t.Fatalf("next_refresh_at for %d = %q, valid=%v; want populated", animeID, v.String, v.Valid)
}
return v.String
}
func assertNextRefreshAtBackfilled(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64) {
t.Helper()
queryNextRefreshAt(ctx, t, sqlDB, animeID)
}
func assertUpdatedAtChanged(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64, original string) {
t.Helper()
var updatedAt string
if err := sqlDB.QueryRowContext(ctx, `SELECT updated_at FROM episode_availability_cache WHERE anime_id = ?`, animeID).Scan(&updatedAt); err != nil {
t.Fatalf("query updated_at for %d: %v", animeID, err)
}
if updatedAt == original {
t.Fatalf("updated_at for %d = %q, expected change", animeID, updatedAt)
}
}
func assertNextRefreshAtEquals(ctx context.Context, t *testing.T, sqlDB *sql.DB, animeID int64, want string) {
t.Helper()
var v sql.NullString
if err := sqlDB.QueryRowContext(ctx, `SELECT next_refresh_at FROM episode_availability_cache WHERE anime_id = ?`, animeID).Scan(&v); err != nil {
t.Fatalf("query next_refresh_at for %d: %v", animeID, err)
}
if !v.Valid {
t.Fatalf("next_refresh_at for %d is NULL", animeID)
}
if v.String != want {
t.Fatalf("next_refresh_at for %d = %q, want %q", animeID, v.String, want)
}
}
func assertUserAvatarURL(ctx context.Context, t *testing.T, sqlDB *sql.DB, id string, want string) {
t.Helper()
var got string
if err := sqlDB.QueryRowContext(ctx, `SELECT avatar_url FROM user WHERE id = ?`, id).Scan(&got); err != nil {
t.Fatalf("query avatar_url for %s: %v", id, err)
}
if got != want {
t.Fatalf("avatar_url for %s = %q, want %q", id, got, want)
}
}
func newFixTestDB(t *testing.T) *sql.DB {
t.Helper()
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlDB.SetMaxOpenConns(1)
ctx := context.Background()
for _, statement := range []string{
`CREATE TABLE user (id TEXT PRIMARY KEY, username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, avatar_url TEXT NOT NULL DEFAULT '')`,
`CREATE TABLE anime (id INTEGER PRIMARY KEY, title_original TEXT NOT NULL, title_english TEXT, title_japanese TEXT, image_url TEXT NOT NULL, airing BOOLEAN, duration_seconds REAL)`,
`CREATE TABLE episode_availability_cache (anime_id INTEGER PRIMARY KEY, data TEXT NOT NULL, next_refresh_at DATETIME, retry_until_at DATETIME, last_attempt_at DATETIME, last_success_at DATETIME, failure_count INTEGER NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
} {
if _, err := sqlDB.ExecContext(ctx, statement); err != nil {
closeTestDB(t, sqlDB)
t.Fatalf("create test schema: %v", err)
}
}
return sqlDB
}
func closeTestDB(t *testing.T, sqlDB *sql.DB) {
t.Helper()
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
}

View File

@@ -0,0 +1,32 @@
-- +goose Up
ALTER TABLE watch_list_entry ADD COLUMN completed_at DATETIME;
ALTER TABLE watch_list_entry ADD COLUMN completed_at_estimated BOOLEAN NOT NULL DEFAULT 0;
UPDATE watch_list_entry
SET completed_at = COALESCE(
(
SELECT MAX(a.occurred_at)
FROM audit_log a
WHERE a.user_id = watch_list_entry.user_id
AND a.action = 'watch_completed'
AND a.resource_type = 'anime'
AND a.resource_id = CAST(watch_list_entry.anime_id AS TEXT)
),
watch_list_entry.updated_at
),
completed_at_estimated = CASE
WHEN EXISTS (
SELECT 1
FROM audit_log a
WHERE a.user_id = watch_list_entry.user_id
AND a.action = 'watch_completed'
AND a.resource_type = 'anime'
AND a.resource_id = CAST(watch_list_entry.anime_id AS TEXT)
) THEN 0
ELSE 1
END
WHERE status = 'completed';
-- +goose Down
ALTER TABLE watch_list_entry DROP COLUMN completed_at_estimated;
ALTER TABLE watch_list_entry DROP COLUMN completed_at;

View File

@@ -0,0 +1,93 @@
package db
import (
"context"
"database/sql"
"fmt"
"testing"
_ "github.com/mattn/go-sqlite3"
)
func TestGetContinueWatchingCarouselEntriesLimitsNewestFirst(t *testing.T) {
ctx := context.Background()
sqlDB := newContinueWatchingTestDB(ctx, t)
defer closeContinueWatchingTestDB(t, sqlDB)
seedContinueWatchingRows(ctx, t, sqlDB, 30)
rows, err := New(sqlDB).GetContinueWatchingCarouselEntries(ctx, GetContinueWatchingCarouselEntriesParams{
UserID: "user-1",
Limit: 24,
})
if err != nil {
t.Fatalf("GetContinueWatchingCarouselEntries: %v", err)
}
if len(rows) != 24 {
t.Fatalf("len(rows) = %d, want 24", len(rows))
}
if rows[0].AnimeID != 30 {
t.Fatalf("first AnimeID = %d, want 30", rows[0].AnimeID)
}
if rows[len(rows)-1].AnimeID != 7 {
t.Fatalf("last AnimeID = %d, want 7", rows[len(rows)-1].AnimeID)
}
}
func newContinueWatchingTestDB(ctx context.Context, t *testing.T) *sql.DB {
t.Helper()
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
_, err = sqlDB.ExecContext(ctx, `
CREATE TABLE anime (
id INTEGER PRIMARY KEY,
title_original TEXT NOT NULL,
title_english TEXT,
title_japanese TEXT,
image_url TEXT NOT NULL,
duration_seconds REAL
);
CREATE TABLE continue_watching_entry (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
anime_id INTEGER NOT NULL REFERENCES anime(id),
current_episode INTEGER,
current_time_seconds REAL NOT NULL DEFAULT 0,
duration_seconds REAL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE(user_id, anime_id)
);`)
if err != nil {
closeContinueWatchingTestDB(t, sqlDB)
t.Fatalf("create schema: %v", err)
}
return sqlDB
}
func closeContinueWatchingTestDB(t *testing.T, sqlDB *sql.DB) {
t.Helper()
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
}
func seedContinueWatchingRows(ctx context.Context, t *testing.T, sqlDB *sql.DB, totalRows int) {
t.Helper()
for i := 1; i <= totalRows; i++ {
if _, err := sqlDB.ExecContext(ctx, `INSERT INTO anime (id, title_original, image_url) VALUES (?, ?, ?)`, i, fmt.Sprintf("Anime %02d", i), "image.jpg"); err != nil {
t.Fatalf("insert anime %d: %v", i, err)
}
if _, err := sqlDB.ExecContext(ctx, `
INSERT INTO continue_watching_entry (id, user_id, anime_id, current_episode, current_time_seconds, updated_at)
VALUES (?, ?, ?, ?, ?, datetime('2026-01-01', printf('+%d minutes', ?)))`, fmt.Sprintf("cw-%02d", i), "user-1", i, i, 0, i); err != nil {
t.Fatalf("insert continue watching %d: %v", i, err)
}
}
}

View File

@@ -103,6 +103,38 @@ type JikanCache struct {
CreatedAt time.Time `json:"created_at"`
}
type RecommendationEvent struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID sql.NullInt64 `json:"anime_id"`
EventType string `json:"event_type"`
Source sql.NullString `json:"source"`
MetadataJson sql.NullString `json:"metadata_json"`
OccurredAt time.Time `json:"occurred_at"`
CreatedAt time.Time `json:"created_at"`
}
type RecommendationImpression struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Rail string `json:"rail"`
Position int64 `json:"position"`
RequestID sql.NullString `json:"request_id"`
MetadataJson sql.NullString `json:"metadata_json"`
OccurredAt time.Time `json:"occurred_at"`
CreatedAt time.Time `json:"created_at"`
}
type RecommendationProfileSnapshot struct {
UserID string `json:"user_id"`
ProfileJson string `json:"profile_json"`
SourceWindowStart sql.NullTime `json:"source_window_start"`
SourceWindowEnd sql.NullTime `json:"source_window_end"`
ComputedAt time.Time `json:"computed_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Session struct {
ID string `json:"id"`
UserID string `json:"user_id"`
@@ -131,13 +163,15 @@ type User struct {
}
type WatchListEntry struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
CurrentTimeSeconds float64 `json:"current_time_seconds"`
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
CurrentTimeSeconds float64 `json:"current_time_seconds"`
CompletedAt sql.NullTime `json:"completed_at"`
CompletedAtEstimated bool `json:"completed_at_estimated"`
}

View File

@@ -25,6 +25,7 @@ type Querier interface {
GetAnime(ctx context.Context, id int64) (Anime, error)
GetAnimeNeedingRelationSync(ctx context.Context) ([]GetAnimeNeedingRelationSyncRow, error)
GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUserParams) ([]AuditLog, error)
GetContinueWatchingCarouselEntries(ctx context.Context, arg GetContinueWatchingCarouselEntriesParams) ([]GetContinueWatchingEntriesRow, error)
GetContinueWatchingEntries(ctx context.Context, userID string) ([]GetContinueWatchingEntriesRow, error)
GetContinueWatchingEntry(ctx context.Context, arg GetContinueWatchingEntryParams) (ContinueWatchingEntry, error)
GetDueAnimeFetchRetries(ctx context.Context, limit int64) ([]AnimeFetchRetry, error)

View File

@@ -68,12 +68,32 @@ RETURNING *;
SELECT * FROM anime WHERE id = ? LIMIT 1;
-- name: UpsertWatchListEntry :one
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, completed_at, completed_at_estimated, updated_at)
VALUES (
sqlc.arg(id),
sqlc.arg(user_id),
sqlc.arg(anime_id),
sqlc.arg(status),
sqlc.arg(current_episode),
sqlc.arg(current_time_seconds),
CASE WHEN sqlc.arg(status) = 'completed' THEN CURRENT_TIMESTAMP END,
0,
CURRENT_TIMESTAMP
)
ON CONFLICT (user_id, anime_id) DO UPDATE SET
status = excluded.status,
current_episode = excluded.current_episode,
current_time_seconds = excluded.current_time_seconds,
completed_at = CASE
WHEN excluded.status != 'completed' THEN NULL
WHEN watch_list_entry.status = 'completed' THEN COALESCE(watch_list_entry.completed_at, CURRENT_TIMESTAMP)
ELSE CURRENT_TIMESTAMP
END,
completed_at_estimated = CASE
WHEN excluded.status = 'completed' AND watch_list_entry.status = 'completed'
THEN watch_list_entry.completed_at_estimated
ELSE 0
END,
updated_at = CURRENT_TIMESTAMP
RETURNING *;
@@ -117,6 +137,27 @@ JOIN anime a ON c.anime_id = a.id
WHERE c.user_id = ?
ORDER BY c.updated_at DESC;
-- name: GetContinueWatchingCarouselEntries :many
SELECT
c.id,
c.user_id,
c.anime_id,
c.current_episode,
c.current_time_seconds,
c.duration_seconds,
c.created_at,
c.updated_at,
a.title_original,
a.title_english,
a.title_japanese,
a.image_url,
a.duration_seconds as anime_duration_seconds
FROM continue_watching_entry c
JOIN anime a ON c.anime_id = a.id
WHERE c.user_id = ?
ORDER BY c.updated_at DESC
LIMIT ?;
-- name: DeleteContinueWatchingEntry :exec
DELETE FROM continue_watching_entry
WHERE user_id = ? AND anime_id = ?;
@@ -127,7 +168,20 @@ WHERE user_id = ? AND anime_id = ? LIMIT 1;
-- name: GetUserWatchList :many
SELECT
e.*,
e.id,
e.user_id,
e.anime_id,
e.status,
e.created_at,
e.updated_at,
e.current_episode,
e.last_episode_at,
e.current_time_seconds,
e.completed_at,
e.completed_at_estimated,
c.current_episode AS playback_current_episode,
c.current_time_seconds AS playback_current_time_seconds,
c.updated_at AS playback_updated_at,
a.title_original,
a.title_english,
a.title_japanese,
@@ -135,6 +189,7 @@ SELECT
a.airing
FROM watch_list_entry e
JOIN anime a ON e.anime_id = a.id
LEFT JOIN continue_watching_entry c ON c.user_id = e.user_id AND c.anime_id = e.anime_id
WHERE e.user_id = ?
ORDER BY e.updated_at DESC;

View File

@@ -380,6 +380,70 @@ func (q *Queries) GetAuditLogsForUser(ctx context.Context, arg GetAuditLogsForUs
return items, nil
}
const getContinueWatchingCarouselEntries = `-- name: GetContinueWatchingCarouselEntries :many
SELECT
c.id,
c.user_id,
c.anime_id,
c.current_episode,
c.current_time_seconds,
c.duration_seconds,
c.created_at,
c.updated_at,
a.title_original,
a.title_english,
a.title_japanese,
a.image_url,
a.duration_seconds as anime_duration_seconds
FROM continue_watching_entry c
JOIN anime a ON c.anime_id = a.id
WHERE c.user_id = ?
ORDER BY c.updated_at DESC
LIMIT ?
`
type GetContinueWatchingCarouselEntriesParams struct {
UserID string `json:"user_id"`
Limit int64 `json:"limit"`
}
func (q *Queries) GetContinueWatchingCarouselEntries(ctx context.Context, arg GetContinueWatchingCarouselEntriesParams) ([]GetContinueWatchingEntriesRow, error) {
rows, err := q.db.QueryContext(ctx, getContinueWatchingCarouselEntries, arg.UserID, arg.Limit)
if err != nil {
return nil, err
}
defer rows.Close()
var items []GetContinueWatchingEntriesRow
for rows.Next() {
var i GetContinueWatchingEntriesRow
if err := rows.Scan(
&i.ID,
&i.UserID,
&i.AnimeID,
&i.CurrentEpisode,
&i.CurrentTimeSeconds,
&i.DurationSeconds,
&i.CreatedAt,
&i.UpdatedAt,
&i.TitleOriginal,
&i.TitleEnglish,
&i.TitleJapanese,
&i.ImageUrl,
&i.AnimeDurationSeconds,
); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const getContinueWatchingEntries = `-- name: GetContinueWatchingEntries :many
SELECT
c.id,
@@ -609,6 +673,7 @@ func (q *Queries) GetJikanCacheStale(ctx context.Context, key string) (string, e
return data, err
}
const getSession = `-- name: GetSession :one
SELECT id, user_id, expires_at, created_at FROM session WHERE id = ? LIMIT 1
`
@@ -794,7 +859,20 @@ func (q *Queries) GetUserByUsername(ctx context.Context, username string) (User,
const getUserWatchList = `-- name: GetUserWatchList :many
SELECT
e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, e.current_time_seconds,
e.id,
e.user_id,
e.anime_id,
e.status,
e.created_at,
e.updated_at,
e.current_episode,
e.last_episode_at,
e.current_time_seconds,
e.completed_at,
e.completed_at_estimated,
c.current_episode AS playback_current_episode,
c.current_time_seconds AS playback_current_time_seconds,
c.updated_at AS playback_updated_at,
a.title_original,
a.title_english,
a.title_japanese,
@@ -802,25 +880,31 @@ SELECT
a.airing
FROM watch_list_entry e
JOIN anime a ON e.anime_id = a.id
LEFT JOIN continue_watching_entry c ON c.user_id = e.user_id AND c.anime_id = e.anime_id
WHERE e.user_id = ?
ORDER BY e.updated_at DESC
`
type GetUserWatchListRow struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
CurrentTimeSeconds float64 `json:"current_time_seconds"`
TitleOriginal string `json:"title_original"`
TitleEnglish sql.NullString `json:"title_english"`
TitleJapanese sql.NullString `json:"title_japanese"`
ImageUrl string `json:"image_url"`
Airing sql.NullBool `json:"airing"`
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
CurrentTimeSeconds float64 `json:"current_time_seconds"`
CompletedAt sql.NullTime `json:"completed_at"`
CompletedAtEstimated bool `json:"completed_at_estimated"`
PlaybackCurrentEpisode sql.NullInt64 `json:"playback_current_episode"`
PlaybackCurrentTimeSeconds sql.NullFloat64 `json:"playback_current_time_seconds"`
PlaybackUpdatedAt sql.NullTime `json:"playback_updated_at"`
TitleOriginal string `json:"title_original"`
TitleEnglish sql.NullString `json:"title_english"`
TitleJapanese sql.NullString `json:"title_japanese"`
ImageUrl string `json:"image_url"`
Airing sql.NullBool `json:"airing"`
}
func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUserWatchListRow, error) {
@@ -841,6 +925,11 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
&i.CurrentEpisode,
&i.LastEpisodeAt,
&i.CurrentTimeSeconds,
&i.CompletedAt,
&i.CompletedAtEstimated,
&i.PlaybackCurrentEpisode,
&i.PlaybackCurrentTimeSeconds,
&i.PlaybackUpdatedAt,
&i.TitleOriginal,
&i.TitleEnglish,
&i.TitleJapanese,
@@ -861,7 +950,7 @@ func (q *Queries) GetUserWatchList(ctx context.Context, userID string) ([]GetUse
}
const getWatchListEntry = `-- name: GetWatchListEntry :one
SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds FROM watch_list_entry
SELECT id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds, completed_at, completed_at_estimated FROM watch_list_entry
WHERE user_id = ? AND anime_id = ? LIMIT 1
`
@@ -883,13 +972,15 @@ func (q *Queries) GetWatchListEntry(ctx context.Context, arg GetWatchListEntryPa
&i.CurrentEpisode,
&i.LastEpisodeAt,
&i.CurrentTimeSeconds,
&i.CompletedAt,
&i.CompletedAtEstimated,
)
return i, err
}
const getWatchingAnime = `-- name: GetWatchingAnime :many
SELECT
e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, e.current_time_seconds,
e.id, e.user_id, e.anime_id, e.status, e.created_at, e.updated_at, e.current_episode, e.last_episode_at, e.current_time_seconds, e.completed_at, e.completed_at_estimated,
a.title_original,
a.title_english,
a.title_japanese,
@@ -902,20 +993,22 @@ ORDER BY e.updated_at DESC
`
type GetWatchingAnimeRow struct {
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
CurrentTimeSeconds float64 `json:"current_time_seconds"`
TitleOriginal string `json:"title_original"`
TitleEnglish sql.NullString `json:"title_english"`
TitleJapanese sql.NullString `json:"title_japanese"`
ImageUrl string `json:"image_url"`
Airing sql.NullBool `json:"airing"`
ID string `json:"id"`
UserID string `json:"user_id"`
AnimeID int64 `json:"anime_id"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CurrentEpisode sql.NullInt64 `json:"current_episode"`
LastEpisodeAt sql.NullTime `json:"last_episode_at"`
CurrentTimeSeconds float64 `json:"current_time_seconds"`
CompletedAt sql.NullTime `json:"completed_at"`
CompletedAtEstimated bool `json:"completed_at_estimated"`
TitleOriginal string `json:"title_original"`
TitleEnglish sql.NullString `json:"title_english"`
TitleJapanese sql.NullString `json:"title_japanese"`
ImageUrl string `json:"image_url"`
Airing sql.NullBool `json:"airing"`
}
func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWatchingAnimeRow, error) {
@@ -936,6 +1029,8 @@ func (q *Queries) GetWatchingAnime(ctx context.Context, userID string) ([]GetWat
&i.CurrentEpisode,
&i.LastEpisodeAt,
&i.CurrentTimeSeconds,
&i.CompletedAt,
&i.CompletedAtEstimated,
&i.TitleOriginal,
&i.TitleEnglish,
&i.TitleJapanese,
@@ -1299,14 +1394,34 @@ func (q *Queries) UpsertEpisodeProviderMapping(ctx context.Context, arg UpsertEp
}
const upsertWatchListEntry = `-- name: UpsertWatchListEntry :one
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, updated_at)
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
INSERT INTO watch_list_entry (id, user_id, anime_id, status, current_episode, current_time_seconds, completed_at, completed_at_estimated, updated_at)
VALUES (
?1,
?2,
?3,
?4,
?5,
?6,
CASE WHEN ?4 = 'completed' THEN CURRENT_TIMESTAMP END,
0,
CURRENT_TIMESTAMP
)
ON CONFLICT (user_id, anime_id) DO UPDATE SET
status = excluded.status,
current_episode = excluded.current_episode,
current_time_seconds = excluded.current_time_seconds,
completed_at = CASE
WHEN excluded.status != 'completed' THEN NULL
WHEN watch_list_entry.status = 'completed' THEN COALESCE(watch_list_entry.completed_at, CURRENT_TIMESTAMP)
ELSE CURRENT_TIMESTAMP
END,
completed_at_estimated = CASE
WHEN excluded.status = 'completed' AND watch_list_entry.status = 'completed'
THEN watch_list_entry.completed_at_estimated
ELSE 0
END,
updated_at = CURRENT_TIMESTAMP
RETURNING id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds
RETURNING id, user_id, anime_id, status, created_at, updated_at, current_episode, last_episode_at, current_time_seconds, completed_at, completed_at_estimated
`
type UpsertWatchListEntryParams struct {
@@ -1338,6 +1453,8 @@ func (q *Queries) UpsertWatchListEntry(ctx context.Context, arg UpsertWatchListE
&i.CurrentEpisode,
&i.LastEpisodeAt,
&i.CurrentTimeSeconds,
&i.CompletedAt,
&i.CompletedAtEstimated,
)
return i, err
}

View File

@@ -9,6 +9,7 @@ import (
type Anime struct {
jikan.Anime
RecommendationRationale []string
}
type Genre struct {
@@ -137,6 +138,10 @@ type AnimeCatalogService interface {
GetTopPicksForYou(ctx context.Context, userID string) (CatalogSectionData, error)
}
type RecommendationInvalidator interface {
InvalidateTopPicksForUser(userID string)
}
type AnimeSearchService interface {
SearchAdvanced(ctx context.Context, q, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (jikan.SearchResult, error)
GetProducerNameByID(ctx context.Context, id int) (string, error)
@@ -179,4 +184,5 @@ type AnimeRepository interface {
GetUserWatchList(ctx context.Context, userID string) ([]db.GetUserWatchListRow, error)
GetWatchListEntry(ctx context.Context, params db.GetWatchListEntryParams) (db.WatchListEntry, error)
GetContinueWatchingEntries(ctx context.Context, userID string) ([]db.GetContinueWatchingEntriesRow, error)
GetContinueWatchingCarouselEntries(ctx context.Context, userID string, limit int64) ([]db.GetContinueWatchingEntriesRow, error)
}

View File

@@ -3,16 +3,26 @@ package domain
import "context"
type EpisodeAvailability struct {
Sub []int
Dub []int
Sub []int
Dub []int
Titles map[int]string
}
type EpisodeProvider interface {
Name() string
ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error)
}
type EpisodeAvailabilityProvider interface {
Name() string
ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error)
EpisodeProvider
GetEpisodeAvailabilityByProviderID(ctx context.Context, providerID string) (EpisodeAvailability, error)
}
type EpisodeTitleProvider interface {
EpisodeProvider
GetEpisodeTitlesByProviderID(ctx context.Context, providerID string, anime Anime, episodeCount int) (map[int]string, error)
}
type CanonicalEpisode struct {
Number int `json:"number"`
Title string `json:"title"`
@@ -24,13 +34,28 @@ type CanonicalEpisode struct {
}
type CanonicalEpisodeList struct {
AnimeID int `json:"anime_id"`
Episodes []CanonicalEpisode `json:"episodes"`
Source string `json:"source"`
NextRefreshAt string `json:"next_refresh_at,omitempty"`
AnimeID int `json:"anime_id"`
Episodes []CanonicalEpisode `json:"episodes"`
Source string `json:"source"`
AvailabilityVersion int `json:"availability_version,omitempty"`
ClassificationChecked bool `json:"classification_checked,omitempty"`
ReleaseChecked bool `json:"release_checked,omitempty"`
NextRefreshAt string `json:"next_refresh_at,omitempty"`
RetryUntilAt string `json:"retry_until_at,omitempty"`
LastAttemptAt string `json:"last_attempt_at,omitempty"`
LastSuccessAt string `json:"last_success_at,omitempty"`
FailureCount int64 `json:"failure_count,omitempty"`
}
type EpisodeService interface {
GetCanonicalEpisodes(ctx context.Context, anime Anime, forceRefresh bool) (CanonicalEpisodeList, error)
RefreshTrackedDue(ctx context.Context, limit int) error
}
type EpisodeTitleService interface {
EnrichEpisodeTitles(ctx context.Context, anime Anime) (CanonicalEpisodeList, error)
}
type EpisodeClassificationService interface {
EnrichEpisodeClassifications(ctx context.Context, anime Anime) (CanonicalEpisodeList, error)
}

View File

@@ -5,6 +5,27 @@ import (
"mal/internal/db"
)
type playbackSourceRefreshKey struct{}
type deferredPlaybackDataKey struct{}
func WithDeferredPlaybackData(ctx context.Context) context.Context {
return context.WithValue(ctx, deferredPlaybackDataKey{}, true)
}
func PlaybackDataDeferred(ctx context.Context) bool {
deferred, _ := ctx.Value(deferredPlaybackDataKey{}).(bool)
return deferred
}
func WithPlaybackSourceRefresh(ctx context.Context) context.Context {
return context.WithValue(ctx, playbackSourceRefreshKey{}, true)
}
func PlaybackSourceRefreshRequested(ctx context.Context) bool {
refresh, _ := ctx.Value(playbackSourceRefreshKey{}).(bool)
return refresh
}
type PlaybackService interface {
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
@@ -14,17 +35,26 @@ type PlaybackService interface {
UpsertSkipSegmentOverride(ctx context.Context, userID string, animeID int64, episode int, skipType string, startTime, endTime float64) error
}
type PlaybackEpisodeTitleService interface {
EnrichEpisodeTitles(ctx context.Context, animeID int) ([]CanonicalEpisode, error)
}
type PlaybackEpisodeClassificationService interface {
EnrichEpisodeClassifications(ctx context.Context, animeID int) ([]CanonicalEpisode, error)
}
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 {

View File

@@ -3,6 +3,7 @@ package episodes
import (
"mal/integrations/playback/allanime"
"mal/integrations/tvmaze"
"mal/internal/config"
"mal/internal/domain"
episodeService "mal/internal/episodes/service"
@@ -17,6 +18,7 @@ func episodeAvailabilityEnabled(cfg config.Config) bool {
var Module = fx.Options(
fx.Provide(
episodeAvailabilityEnabled,
tvmaze.NewClient,
fx.Annotate(
episodeService.NewEpisodeService,
),
@@ -24,5 +26,8 @@ var Module = fx.Options(
fx.Provide(func(p *allanime.AllAnimeProvider) []domain.EpisodeAvailabilityProvider {
return []domain.EpisodeAvailabilityProvider{p}
}),
fx.Provide(func(p *tvmaze.Client) domain.EpisodeTitleProvider {
return p
}),
fx.Invoke(RegisterWorker),
)

View File

@@ -6,20 +6,26 @@ import (
"encoding/json"
"time"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"mal/internal/observability"
)
func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, source string, now time.Time, providerSuccess bool) (domain.CanonicalEpisodeList, error) {
const episodeAvailabilityPayloadVersion = 4
func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, availability domain.EpisodeAvailability, source string, now time.Time) (domain.CanonicalEpisodeList, error) {
nextRefreshSQL := nextRefreshAt(anime, now)
episodes := mergeEpisodes(jikanEpisodes, availability, anime.Episodes)
episodes := mergeEpisodes(nil, availability, 0)
payload := domain.CanonicalEpisodeList{
AnimeID: anime.MalID,
Episodes: episodes,
Source: source,
AnimeID: anime.MalID,
Episodes: episodes,
Source: source,
AvailabilityVersion: episodeAvailabilityPayloadVersion,
ReleaseChecked: false,
LastAttemptAt: now.Format(time.RFC3339),
FailureCount: 0,
}
payload.LastSuccessAt = now.Format(time.RFC3339)
if nextRefreshSQL.Valid {
payload.NextRefreshAt = nextRefreshSQL.Time.Format(time.RFC3339)
}
@@ -29,7 +35,7 @@ func (s *EpisodeService) store(ctx context.Context, anime domain.Anime, jikanEpi
return domain.CanonicalEpisodeList{}, err
}
if !s.writeEpisodeAvailabilityCache(ctx, anime, source, body, now, providerSuccess, nextRefreshSQL) {
if !s.writeEpisodeAvailabilityCache(ctx, anime, source, body, now, true, nextRefreshSQL) {
return payload, nil
}
@@ -146,6 +152,7 @@ func (s *EpisodeService) getFreshCached(ctx context.Context, anime domain.Anime)
if !ok {
return domain.CanonicalEpisodeList{}, false
}
payload = enrichCachedPayload(payload, row)
observability.Info(
"episodes_cache_served",
"episodes",
@@ -168,9 +175,27 @@ func (s *EpisodeService) getDecodedCached(ctx context.Context, anime domain.Anim
if !ok {
return domain.CanonicalEpisodeList{}, false
}
payload = enrichCachedPayload(payload, row)
return payload, true
}
func enrichCachedPayload(payload domain.CanonicalEpisodeList, row db.EpisodeAvailabilityCache) domain.CanonicalEpisodeList {
if row.NextRefreshAt.Valid {
payload.NextRefreshAt = row.NextRefreshAt.Time.Format(time.RFC3339)
}
if row.RetryUntilAt.Valid {
payload.RetryUntilAt = row.RetryUntilAt.Time.Format(time.RFC3339)
}
if row.LastAttemptAt.Valid {
payload.LastAttemptAt = row.LastAttemptAt.Time.Format(time.RFC3339)
}
if row.LastSuccessAt.Valid {
payload.LastSuccessAt = row.LastSuccessAt.Time.Format(time.RFC3339)
}
payload.FailureCount = row.FailureCount
return payload
}
func (s *EpisodeService) isFreshEpisodeCache(anime domain.Anime, row db.EpisodeAvailabilityCache, now time.Time) bool {
if row.NextRefreshAt.Valid && !row.NextRefreshAt.Time.After(now) {
observability.Info(
@@ -213,6 +238,24 @@ func (s *EpisodeService) decodeCachedPayload(anime domain.Anime, raw string) (do
)
return domain.CanonicalEpisodeList{}, false
}
if payload.Source == "jikan_fallback" || payload.Source == "legacy_disabled" {
return domain.CanonicalEpisodeList{}, false
}
if isStaleProviderEpisodePayload(payload) {
observability.Info(
"episodes_cached_payload_rejected_stale_version",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"cached_episodes": len(payload.Episodes),
"source": payload.Source,
"availability_version": payload.AvailabilityVersion,
},
)
return domain.CanonicalEpisodeList{}, false
}
if !isCanonicalEpisodePayloadValid(payload, anime.Episodes) {
observability.Info(
@@ -230,3 +273,7 @@ func (s *EpisodeService) decodeCachedPayload(anime domain.Anime, raw string) (do
return payload, true
}
func isStaleProviderEpisodePayload(payload domain.CanonicalEpisodeList) bool {
return payload.Source != "" && payload.Source != "jikan_fallback" && payload.Source != "legacy_disabled" && payload.AvailabilityVersion < episodeAvailabilityPayloadVersion
}

View File

@@ -0,0 +1,81 @@
package service
import (
"context"
"errors"
"strconv"
"mal/integrations/jikan"
"mal/internal/domain"
"mal/internal/observability"
)
func (s *EpisodeService) EnrichEpisodeClassifications(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
payload, _, ok := s.cachedEpisodePayload(ctx, anime)
if !ok {
return domain.CanonicalEpisodeList{}, errors.New("episode classifications: episode availability is not cached")
}
if payload.ClassificationChecked {
return payload, nil
}
if s.jikan == nil {
return payload, errors.New("episode classifications: provider is not configured")
}
value, err, _ := s.classificationLoad.Do(strconv.Itoa(anime.MalID), func() (any, error) {
return s.loadEpisodeClassifications(ctx, anime)
})
if err != nil {
return payload, err
}
return value.(domain.CanonicalEpisodeList), nil
}
func (s *EpisodeService) loadEpisodeClassifications(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
episodes, err := s.jikan.GetAllEpisodes(ctx, anime.MalID)
if err != nil {
return domain.CanonicalEpisodeList{}, err
}
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
payload, row, ok := s.cachedEpisodePayload(ctx, anime)
if !ok {
return domain.CanonicalEpisodeList{}, errors.New("episode classifications: episode availability cache disappeared")
}
mergeEpisodeClassifications(payload.Episodes, episodes)
payload.ClassificationChecked = true
if err := s.storeEnrichedPayload(ctx, row, payload); err != nil {
return domain.CanonicalEpisodeList{}, err
}
observability.Info(
"episode_classifications_enriched",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"episodes": len(episodes),
},
)
return payload, nil
}
func mergeEpisodeClassifications(canonical []domain.CanonicalEpisode, episodes []jikan.Episode) {
byNumber := make(map[int]jikan.Episode, len(episodes))
for i, episode := range episodes {
number, ok := jikanEpisodeNumber(episode, i)
if ok {
byNumber[number] = episode
}
}
for i := range canonical {
classification, ok := byNumber[canonical[i].Number]
if !ok {
continue
}
canonical[i].Filler = classification.Filler
canonical[i].Recap = classification.Recap
}
}

View File

@@ -0,0 +1,32 @@
package service
import (
"testing"
"mal/integrations/jikan"
"mal/internal/domain"
)
func TestMergeEpisodeClassificationsRestoresFillerAndRecap(t *testing.T) {
episodes := []domain.CanonicalEpisode{
{Number: 1, Title: "First", HasSub: true},
{Number: 2, Title: "Second", HasSub: true},
{Number: 3, Title: "Third", HasSub: true},
}
mergeEpisodeClassifications(episodes, []jikan.Episode{
{Episode: "1"},
{Episode: "2", Filler: true},
{Episode: "3", Recap: true},
})
if episodes[0].Filler || episodes[0].Recap {
t.Fatalf("episode 1 = %#v", episodes[0])
}
if !episodes[1].Filler || episodes[1].Recap {
t.Fatalf("episode 2 = %#v", episodes[1])
}
if episodes[2].Filler || !episodes[2].Recap {
t.Fatalf("episode 3 = %#v", episodes[2])
}
}

View File

@@ -5,6 +5,7 @@ import (
"sort"
"strconv"
"strings"
"time"
"mal/integrations/jikan"
"mal/internal/domain"
@@ -35,6 +36,9 @@ func titleCandidates(anime domain.Anime) []string {
}
func isCanonicalEpisodePayloadValid(payload domain.CanonicalEpisodeList, expectedCount int) bool {
if payload.Source != "" && payload.Source != "jikan_fallback" && payload.Source != "legacy_disabled" {
return providerBackedPayloadHasAvailability(payload)
}
if expectedCount <= 0 {
return providerBackedPayloadHasAvailability(payload)
}
@@ -62,15 +66,25 @@ func providerBackedPayloadHasAvailability(payload domain.CanonicalEpisodeList) b
}
func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, expectedCount int) []domain.CanonicalEpisode {
return mergeEpisodeData(jikanEpisodes, availability, expectedCount, time.Now(), false, "", false)
}
func mergeEpisodesForAnime(anime domain.Anime, jikanEpisodes []jikan.Episode, now time.Time, providerVerified bool) []domain.CanonicalEpisode {
return mergeEpisodeData(jikanEpisodes, domain.EpisodeAvailability{}, anime.Episodes, now, providerVerified, anime.Aired.From, anime.Airing)
}
func mergeEpisodeData(jikanEpisodes []jikan.Episode, availability domain.EpisodeAvailability, expectedCount int, now time.Time, providerVerified bool, firstAired string, requireJikanAiredDates bool) []domain.CanonicalEpisode {
byNumber := map[int]episodePartial{}
providerNumbers := availableEpisodeNumbers(availability, expectedCount)
providerBacked := len(providerNumbers) > 0
providerBacked := providerVerified || len(providerNumbers) > 0
for number := range providerNumbers {
mergeEpisode(&byNumber, number, func(item *episodePartial) {})
mergeEpisode(&byNumber, number, func(item *episodePartial) {
item.title = availability.Titles[number]
})
}
mergeJikanEpisodes(&byNumber, jikanEpisodes, providerNumbers, providerBacked, expectedCount)
mergeJikanEpisodes(&byNumber, jikanEpisodes, providerNumbers, providerBacked, expectedCount, now, firstAired, requireJikanAiredDates)
mergeAvailability(&byNumber, availability.Sub, expectedCount, func(item *episodePartial) { item.sub = true })
mergeAvailability(&byNumber, availability.Dub, expectedCount, func(item *episodePartial) { item.dub = true })
@@ -100,13 +114,26 @@ func mergeEpisodes(jikanEpisodes []jikan.Episode, availability domain.EpisodeAva
return episodes
}
func mergeJikanEpisodes(byNumber *map[int]episodePartial, episodes []jikan.Episode, providerNumbers map[int]bool, providerBacked bool, expectedCount int) {
func mergeJikanEpisodes(byNumber *map[int]episodePartial, episodes []jikan.Episode, providerNumbers map[int]bool, providerBacked bool, expectedCount int, now time.Time, firstAired string, requireAiredDates bool) {
if shouldSkipJikanMerge(providerBacked, firstAired, now) {
return
}
for i, ep := range episodes {
if exceedsExpectedCount(i+1, expectedCount) {
break
}
number, ok := jikanEpisodeNumber(ep, i)
if !ok || exceedsExpectedCount(number, expectedCount) || (providerBacked && !providerNumbers[number]) {
if !ok {
continue
}
if exceedsExpectedCount(number, expectedCount) {
continue
}
if providerBacked && !providerNumbers[number] {
continue
}
if !providerBacked && !hasEpisodeAired(ep, now, requireAiredDates) {
continue
}
mergeEpisode(byNumber, number, func(item *episodePartial) {
@@ -117,6 +144,10 @@ func mergeJikanEpisodes(byNumber *map[int]episodePartial, episodes []jikan.Episo
}
}
func shouldSkipJikanMerge(providerBacked bool, firstAired string, now time.Time) bool {
return !providerBacked && !hasStartedAiring(firstAired, now)
}
func availableEpisodeNumbers(availability domain.EpisodeAvailability, expectedCount int) map[int]bool {
numbers := map[int]bool{}
for _, number := range availability.Sub {
@@ -158,6 +189,28 @@ func jikanEpisodeNumber(ep jikan.Episode, index int) (int, bool) {
return index + 1, true
}
func hasStartedAiring(firstAired string, now time.Time) bool {
if strings.TrimSpace(firstAired) == "" {
return true
}
startedAt, err := time.Parse(time.RFC3339, firstAired)
if err != nil {
return true
}
return !now.Before(startedAt)
}
func hasEpisodeAired(ep jikan.Episode, now time.Time, requireAiredDate bool) bool {
if strings.TrimSpace(ep.Aired) == "" {
return !requireAiredDate
}
airedAt, err := time.Parse(time.RFC3339, ep.Aired)
if err != nil {
return !requireAiredDate
}
return !now.Before(airedAt)
}
func exceedsExpectedCount(number int, expectedCount int) bool {
return expectedCount > 0 && number > expectedCount
}

View File

@@ -13,7 +13,7 @@ import (
"mal/internal/observability"
)
func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, titles []string) (string, error) {
func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, provider domain.EpisodeProvider, titles []string) (string, error) {
providerID, found, err := s.cachedProviderID(ctx, anime, provider)
if found || err != nil {
return providerID, err
@@ -39,7 +39,7 @@ func (s *EpisodeService) providerID(ctx context.Context, anime domain.Anime, pro
return providerID, nil
}
func (s *EpisodeService) cachedProviderID(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider) (string, bool, error) {
func (s *EpisodeService) cachedProviderID(ctx context.Context, anime domain.Anime, provider domain.EpisodeProvider) (string, bool, error) {
row, err := s.queries.GetEpisodeProviderMapping(ctx, db.GetEpisodeProviderMappingParams{
AnimeID: int64(anime.MalID),
Provider: provider.Name(),
@@ -81,7 +81,7 @@ func (s *EpisodeService) cachedProviderID(ctx context.Context, anime domain.Anim
return row.ProviderShowID, true, nil
}
func (s *EpisodeService) cacheProviderIDFailure(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, resolveErr error) {
func (s *EpisodeService) cacheProviderIDFailure(ctx context.Context, anime domain.Anime, provider domain.EpisodeProvider, resolveErr error) {
err := s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{
AnimeID: int64(anime.MalID),
Provider: provider.Name(),
@@ -105,7 +105,7 @@ func (s *EpisodeService) cacheProviderIDFailure(ctx context.Context, anime domai
)
}
func (s *EpisodeService) cacheProviderIDSuccess(ctx context.Context, anime domain.Anime, provider domain.EpisodeAvailabilityProvider, providerID string) {
func (s *EpisodeService) cacheProviderIDSuccess(ctx context.Context, anime domain.Anime, provider domain.EpisodeProvider, providerID string) {
err := s.queries.UpsertEpisodeProviderMapping(ctx, db.UpsertEpisodeProviderMappingParams{
AnimeID: int64(anime.MalID),
Provider: provider.Name(),

View File

@@ -4,12 +4,15 @@ package service
import (
"context"
"fmt"
"sync"
"time"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"mal/internal/observability"
"golang.org/x/sync/singleflight"
)
type Clock interface {
@@ -21,32 +24,33 @@ type realClock struct{}
func (realClock) Now() time.Time { return time.Now() }
type EpisodeService struct {
queries *db.Queries
jikan *jikan.Client
providers []domain.EpisodeAvailabilityProvider
clock Clock
enabled bool
queries *db.Queries
jikan *jikan.Client
providers []domain.EpisodeAvailabilityProvider
titles domain.EpisodeTitleProvider
clock Clock
enabled bool
titleLoad singleflight.Group
classificationLoad singleflight.Group
cacheMu sync.Mutex
}
func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool) domain.EpisodeService {
return NewEpisodeServiceWithClock(queries, jikanClient, providers, enabled, realClock{})
func NewEpisodeService(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, titles domain.EpisodeTitleProvider, enabled bool) domain.EpisodeService {
return NewEpisodeServiceWithClock(queries, jikanClient, providers, titles, enabled, realClock{})
}
func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, enabled bool, clock Clock) *EpisodeService {
func NewEpisodeServiceWithClock(queries *db.Queries, jikanClient *jikan.Client, providers []domain.EpisodeAvailabilityProvider, titles domain.EpisodeTitleProvider, enabled bool, clock Clock) *EpisodeService {
return &EpisodeService{
queries: queries,
jikan: jikanClient,
providers: providers,
titles: titles,
clock: clock,
enabled: enabled,
}
}
func (s *EpisodeService) GetCanonicalEpisodes(ctx context.Context, anime domain.Anime, forceRefresh bool) (domain.CanonicalEpisodeList, error) {
if !s.enabled {
return s.jikanOnly(ctx, anime, "legacy_disabled")
}
if !forceRefresh {
if cached, ok := s.getFreshCached(ctx, anime); ok {
return cached, nil
@@ -125,19 +129,6 @@ func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domai
},
)
jikanEpisodes, jikanErr := s.jikan.GetAllEpisodes(ctx, anime.MalID)
if jikanErr != nil {
observability.Warn(
"episodes_jikan_metadata_failed",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
},
jikanErr,
)
}
availability, source, providerErr := s.fetchProviderAvailability(ctx, anime)
if providerErr != nil {
s.markFailure(ctx, anime, providerErr)
@@ -153,19 +144,10 @@ func (s *EpisodeService) refresh(ctx context.Context, anime domain.Anime) (domai
)
return cached, nil
}
if jikanErr == nil {
storeCtx := ctx
if ctx.Err() != nil {
var cancel context.CancelFunc
storeCtx, cancel = context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
}
return s.store(storeCtx, anime, jikanEpisodes, domain.EpisodeAvailability{}, "jikan_fallback", now, false)
}
return domain.CanonicalEpisodeList{}, providerErr
}
return s.store(ctx, anime, jikanEpisodes, availability, source, now, true)
return s.store(ctx, anime, availability, source, now)
}
func (s *EpisodeService) fetchProviderAvailability(ctx context.Context, anime domain.Anime) (domain.EpisodeAvailability, string, error) {
@@ -215,15 +197,3 @@ func (s *EpisodeService) fetchProviderAvailability(ctx context.Context, anime do
}
return domain.EpisodeAvailability{}, "", fmt.Errorf("no episode availability provider matched anime_id=%d", anime.MalID)
}
func (s *EpisodeService) jikanOnly(ctx context.Context, anime domain.Anime, source string) (domain.CanonicalEpisodeList, error) {
episodes, err := s.jikan.GetAllEpisodes(ctx, anime.MalID)
if err != nil {
return domain.CanonicalEpisodeList{}, err
}
return domain.CanonicalEpisodeList{
AnimeID: anime.MalID,
Episodes: mergeEpisodes(episodes, domain.EpisodeAvailability{}, anime.Episodes),
Source: source,
}, nil
}

View File

@@ -1,7 +1,10 @@
package service
import (
"database/sql"
"encoding/json"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
"testing"
"time"
@@ -27,6 +30,19 @@ func TestMergeEpisodesUsesProviderAvailabilityAsSourceOfTruth(t *testing.T) {
assertEpisode(t, episodes[3], 6, "Episode 6", true, false, true, false)
}
func TestMergeEpisodesUsesProviderTitles(t *testing.T) {
episodes := mergeEpisodes(nil, domain.EpisodeAvailability{
Sub: []int{1, 2},
Titles: map[int]string{1: "The Apocalypse", 2: "The Battle of Loka"},
}, 0)
if len(episodes) != 2 {
t.Fatalf("len(episodes) = %d, want 2", len(episodes))
}
assertEpisode(t, episodes[0], 1, "The Apocalypse", true, false, true, false)
assertEpisode(t, episodes[1], 2, "The Battle of Loka", true, false, true, false)
}
func TestMergeEpisodesUsesJikanWhenProviderAvailabilityMissing(t *testing.T) {
episodes := mergeEpisodes([]jikan.Episode{
{MalID: 101, Episode: "1", Title: "Start"},
@@ -41,6 +57,74 @@ func TestMergeEpisodesUsesJikanWhenProviderAvailabilityMissing(t *testing.T) {
assertEpisode(t, episodes[1], 2, "Second", false, false, false, false)
}
func TestMergeEpisodesSkipsFutureJikanEpisodesWithoutProviderAvailability(t *testing.T) {
now := time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC)
anime := domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
}}
episodes := mergeEpisodesForAnime(anime, decodeJikanEpisodes(t, `[
{"mal_id":1,"title":"Episode 1","episode":null,"aired":"2026-07-09T00:00:00+00:00"},
{"mal_id":2,"title":"Episode 2","episode":null,"aired":"2026-07-16T00:00:00+00:00"}
]`), now, false)
if len(episodes) != 0 {
t.Fatalf("len(episodes) = %d, want 0", len(episodes))
}
}
func TestMergeEpisodesSkipsUndatedJikanEpisodesForAiringAnimeWithoutProviderAvailability(t *testing.T) {
now := time.Date(2026, time.July, 1, 12, 0, 0, 0, time.UTC)
anime := domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
}}
episodes := mergeEpisodesForAnime(anime, []jikan.Episode{
{MalID: 1, Title: "Episode 1"},
{MalID: 2, Title: "Episode 2"},
}, now, false)
if len(episodes) != 0 {
t.Fatalf("len(episodes) = %d, want 0", len(episodes))
}
}
func TestMergeEpisodesKeepsReleasedJikanEpisodesWithoutProviderAvailability(t *testing.T) {
now := time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC)
anime := domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
}}
episodes := mergeEpisodesForAnime(anime, decodeJikanEpisodes(t, `[
{"mal_id":1,"title":"Episode 1","episode":null,"aired":"2026-07-09T00:00:00+00:00"},
{"mal_id":2,"title":"Episode 2","episode":null,"aired":"2026-07-16T00:00:00+00:00"}
]`), now, false)
if len(episodes) != 1 {
t.Fatalf("len(episodes) = %d, want 1", len(episodes))
}
assertEpisode(t, episodes[0], 1, "Episode 1", false, false, false, false)
}
func TestMergeEpisodesTreatsEmptyProviderAvailabilityAsAuthoritative(t *testing.T) {
now := time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC)
anime := domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
Aired: jikan.Aired{From: "2026-06-03T00:00:00+00:00"},
}}
episodes := mergeEpisodesForAnime(anime, decodeJikanEpisodes(t, `[
{"mal_id":1,"title":"Episode 1","episode":null,"aired":"2026-07-09T00:00:00+00:00"}
]`), now, true)
if len(episodes) != 0 {
t.Fatalf("len(episodes) = %d, want 0", len(episodes))
}
}
func TestMergeEpisodesIgnoresInvalidJikanEpisodeNumbers(t *testing.T) {
episodes := mergeEpisodes([]jikan.Episode{
{MalID: 201, Episode: "", Title: "Missing"},
@@ -113,6 +197,19 @@ func TestIsCanonicalEpisodePayloadValidRejectsProviderEpisodesWithoutAvailabilit
}
}
func TestIsCanonicalEpisodePayloadValidTrustsProviderCount(t *testing.T) {
payload := domain.CanonicalEpisodeList{
Source: "AllAnime",
Episodes: []domain.CanonicalEpisode{
{Number: 13, Title: "Episode 13", HasSub: true},
},
}
if !isCanonicalEpisodePayloadValid(payload, 12) {
t.Fatal("expected provider episode count to override Jikan metadata")
}
}
func TestIsCanonicalEpisodePayloadValidAllowsJikanFallbackWithoutAvailability(t *testing.T) {
payload := domain.CanonicalEpisodeList{
Source: "jikan_fallback",
@@ -127,6 +224,89 @@ func TestIsCanonicalEpisodePayloadValidAllowsJikanFallbackWithoutAvailability(t
}
}
func TestDecodeCachedPayloadRejectsUncheckedAiringJikanFallback(t *testing.T) {
svc := &EpisodeService{}
anime := domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
}}
raw := `{"anime_id":62076,"source":"jikan_fallback","episodes":[{"number":1,"title":"Episode 1"}]}`
if _, ok := svc.decodeCachedPayload(anime, raw); ok {
t.Fatal("expected unchecked airing jikan fallback cache to be rejected")
}
}
func TestDecodeCachedPayloadRejectsOldReleaseCheckedAiringFallback(t *testing.T) {
svc := &EpisodeService{}
anime := domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
}}
raw := `{"anime_id":62076,"source":"jikan_fallback","release_checked":true,"episodes":[{"number":1,"title":"Episode 1"}]}`
if _, ok := svc.decodeCachedPayload(anime, raw); ok {
t.Fatal("expected old release-checked jikan fallback cache to be rejected")
}
}
func TestDecodeCachedPayloadRejectsOldProviderPayload(t *testing.T) {
svc := &EpisodeService{}
anime := domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
}}
raw := `{"anime_id":62076,"source":"AllAnime","availability_version":2,"episodes":[{"number":1,"title":"Episode 1","has_sub":true}]}`
if _, ok := svc.decodeCachedPayload(anime, raw); ok {
t.Fatal("expected old airing provider cache to be rejected")
}
}
func TestDecodeCachedPayloadRejectsCurrentJikanFallback(t *testing.T) {
svc := &EpisodeService{}
anime := domain.Anime{Anime: jikan.Anime{
MalID: 62076,
Airing: true,
}}
raw := `{"anime_id":62076,"source":"jikan_fallback","availability_version":2,"release_checked":true,"episodes":[{"number":1,"title":"Episode 1"}]}`
if _, ok := svc.decodeCachedPayload(anime, raw); ok {
t.Fatal("expected Jikan fallback cache to be rejected")
}
}
func TestEnrichCachedPayloadAddsRefreshMetadata(t *testing.T) {
now := time.Date(2026, time.June, 27, 11, 0, 0, 0, time.UTC)
payload := enrichCachedPayload(domain.CanonicalEpisodeList{
AnimeID: 59970,
Episodes: []domain.CanonicalEpisode{{Number: 1}},
Source: "AllAnime",
}, db.EpisodeAvailabilityCache{
NextRefreshAt: sql.NullTime{Time: now.Add(time.Hour), Valid: true},
RetryUntilAt: sql.NullTime{Time: now.Add(30 * time.Minute), Valid: true},
LastAttemptAt: sql.NullTime{Time: now.Add(-5 * time.Minute), Valid: true},
LastSuccessAt: sql.NullTime{Time: now.Add(-time.Hour), Valid: true},
FailureCount: 2,
})
if payload.NextRefreshAt != "2026-06-27T12:00:00Z" {
t.Fatalf("NextRefreshAt = %q, want RFC3339 timestamp", payload.NextRefreshAt)
}
if payload.RetryUntilAt != "2026-06-27T11:30:00Z" {
t.Fatalf("RetryUntilAt = %q, want RFC3339 timestamp", payload.RetryUntilAt)
}
if payload.LastAttemptAt != "2026-06-27T10:55:00Z" {
t.Fatalf("LastAttemptAt = %q, want RFC3339 timestamp", payload.LastAttemptAt)
}
if payload.LastSuccessAt != "2026-06-27T10:00:00Z" {
t.Fatalf("LastSuccessAt = %q, want RFC3339 timestamp", payload.LastSuccessAt)
}
if payload.FailureCount != 2 {
t.Fatalf("FailureCount = %d, want 2", payload.FailureCount)
}
}
func TestNextBroadcastAfterUsesJikanTimezone(t *testing.T) {
anime := domain.Anime{Anime: jikan.Anime{MalID: 1}}
anime.Broadcast.Day = "Saturdays"
@@ -160,6 +340,16 @@ func TestNextRetryTimeWithinAndAfterRetryWindow(t *testing.T) {
}
}
func decodeJikanEpisodes(t *testing.T, raw string) []jikan.Episode {
t.Helper()
var episodes []jikan.Episode
if err := json.Unmarshal([]byte(raw), &episodes); err != nil {
t.Fatalf("json.Unmarshal episodes: %v", err)
}
return episodes
}
func assertEpisode(t *testing.T, got domain.CanonicalEpisode, number int, title string, sub bool, dub bool, subOnly bool, filler bool) {
t.Helper()
if got.Number != number || got.Title != title || got.HasSub != sub || got.HasDub != dub || got.SubOnly != subOnly || got.Filler != filler || got.Recap {

View File

@@ -0,0 +1,140 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"mal/internal/db"
"mal/internal/domain"
"mal/internal/observability"
)
func (s *EpisodeService) EnrichEpisodeTitles(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
payload, _, ok := s.cachedEpisodePayload(ctx, anime)
if !ok {
return domain.CanonicalEpisodeList{}, errors.New("episode titles: episode availability is not cached")
}
if !hasPlaceholderTitles(payload.Episodes) {
return payload, nil
}
if s.titles == nil {
return payload, errors.New("episode titles: provider is not configured")
}
value, err, _ := s.titleLoad.Do(strconv.Itoa(anime.MalID), func() (any, error) {
return s.loadEpisodeTitles(ctx, anime)
})
if err != nil {
return payload, err
}
return value.(domain.CanonicalEpisodeList), nil
}
func (s *EpisodeService) loadEpisodeTitles(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, error) {
payload, _, ok := s.cachedEpisodePayload(ctx, anime)
if !ok {
return domain.CanonicalEpisodeList{}, errors.New("episode titles: episode availability cache disappeared")
}
providerID, err := s.providerID(ctx, anime, s.titles, titleCandidates(anime))
if err != nil {
return domain.CanonicalEpisodeList{}, err
}
titles, err := s.titles.GetEpisodeTitlesByProviderID(ctx, providerID, anime, len(payload.Episodes))
if err != nil {
return domain.CanonicalEpisodeList{}, err
}
s.cacheMu.Lock()
defer s.cacheMu.Unlock()
payload, row, ok := s.cachedEpisodePayload(ctx, anime)
if !ok {
return domain.CanonicalEpisodeList{}, errors.New("episode titles: episode availability cache disappeared")
}
changed := mergeMissingTitles(payload.Episodes, titles)
if !changed {
return payload, nil
}
if err := s.storeEnrichedPayload(ctx, row, payload); err != nil {
return domain.CanonicalEpisodeList{}, err
}
observability.Info(
"episode_titles_enriched",
"episodes",
"",
map[string]any{
"anime_id": anime.MalID,
"provider": s.titles.Name(),
"titles": len(titles),
},
)
return payload, nil
}
func (s *EpisodeService) cachedEpisodePayload(ctx context.Context, anime domain.Anime) (domain.CanonicalEpisodeList, db.EpisodeAvailabilityCache, bool) {
row, err := s.queries.GetEpisodeAvailabilityCache(ctx, int64(anime.MalID))
if err != nil {
return domain.CanonicalEpisodeList{}, db.EpisodeAvailabilityCache{}, false
}
payload, ok := s.decodeCachedPayload(anime, row.Data)
if !ok {
return domain.CanonicalEpisodeList{}, db.EpisodeAvailabilityCache{}, false
}
return enrichCachedPayload(payload, row), row, true
}
func (s *EpisodeService) storeEnrichedPayload(ctx context.Context, row db.EpisodeAvailabilityCache, payload domain.CanonicalEpisodeList) error {
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("episode metadata: encode cache: %w", err)
}
err = s.queries.UpsertEpisodeAvailabilityCache(ctx, db.UpsertEpisodeAvailabilityCacheParams{
AnimeID: row.AnimeID,
Data: string(body),
NextRefreshAt: row.NextRefreshAt,
RetryUntilAt: row.RetryUntilAt,
LastAttemptAt: row.LastAttemptAt,
LastSuccessAt: row.LastSuccessAt,
FailureCount: row.FailureCount,
LastError: row.LastError,
})
if err != nil {
return fmt.Errorf("episode metadata: update cache: %w", err)
}
return nil
}
func hasPlaceholderTitles(episodes []domain.CanonicalEpisode) bool {
for _, episode := range episodes {
if isPlaceholderTitle(episode.Number, episode.Title) {
return true
}
}
return false
}
func mergeMissingTitles(episodes []domain.CanonicalEpisode, titles map[int]string) bool {
changed := false
for i := range episodes {
episode := &episodes[i]
title := strings.TrimSpace(titles[episode.Number])
if title == "" || !isPlaceholderTitle(episode.Number, episode.Title) || title == episode.Title {
continue
}
episode.Title = title
changed = true
}
return changed
}
func isPlaceholderTitle(number int, title string) bool {
return strings.TrimSpace(title) == fmt.Sprintf("Episode %d", number)
}

View File

@@ -0,0 +1,145 @@
package service
import (
"context"
"database/sql"
"encoding/json"
"testing"
"mal/integrations/jikan"
"mal/internal/db"
"mal/internal/domain"
_ "github.com/mattn/go-sqlite3"
)
type titleProviderStub struct {
loads int
resolves int
}
func (p *titleProviderStub) Name() string { return "TVmaze" }
func (p *titleProviderStub) ResolveEpisodeProviderID(context.Context, int, []string) (string, error) {
p.resolves++
return "80712", nil
}
func (p *titleProviderStub) GetEpisodeTitlesByProviderID(context.Context, string, domain.Anime, int) (map[int]string, error) {
p.loads++
return map[int]string{1: "TVmaze title one", 2: "TVmaze title two"}, nil
}
func TestMergeMissingTitlesPreservesAllAnimeTitles(t *testing.T) {
episodes := []domain.CanonicalEpisode{
{Number: 1, Title: "AllAnime title"},
{Number: 2, Title: "Episode 2"},
{Number: 3, Title: "Episode 3"},
}
changed := mergeMissingTitles(episodes, map[int]string{
1: "TVmaze title one",
2: "TVmaze title two",
})
if !changed {
t.Fatal("expected missing title to be enriched")
}
if episodes[0].Title != "AllAnime title" {
t.Fatalf("AllAnime title was overwritten: %q", episodes[0].Title)
}
if episodes[1].Title != "TVmaze title two" {
t.Fatalf("episode 2 title = %q", episodes[1].Title)
}
if episodes[2].Title != "Episode 3" {
t.Fatalf("episode 3 title = %q", episodes[2].Title)
}
}
func TestEnrichEpisodeTitlesCachesTVmazeTitles(t *testing.T) {
ctx := context.Background()
sqlDB := newTitleTestDB(ctx, t)
provider := &titleProviderStub{}
svc := &EpisodeService{queries: db.New(sqlDB), titles: provider, clock: realClock{}}
anime := domain.Anime{Anime: jikan.Anime{
MalID: 59846,
Title: "Saigo ni Hitotsu dake Onegai shitemo Yoroshii deshou ka",
TitleEnglish: "May I Ask for One Final Thing?",
}}
for range 2 {
assertEnrichedTitleList(t, svc, anime)
}
if provider.resolves != 1 || provider.loads != 1 {
t.Fatalf("provider calls = resolves:%d loads:%d, want 1 each", provider.resolves, provider.loads)
}
assertCachedTitles(ctx, t, sqlDB)
}
func newTitleTestDB(ctx context.Context, t *testing.T) *sql.DB {
t.Helper()
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := sqlDB.Close(); err != nil {
t.Errorf("close sqlite: %v", err)
}
})
sqlDB.SetMaxOpenConns(1)
for _, statement := range []string{
`CREATE TABLE episode_availability_cache (
anime_id INTEGER PRIMARY KEY, data TEXT NOT NULL, next_refresh_at DATETIME,
retry_until_at DATETIME, last_attempt_at DATETIME, last_success_at DATETIME,
failure_count INTEGER NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
`CREATE TABLE episode_provider_mapping (
anime_id INTEGER NOT NULL, provider TEXT NOT NULL, provider_show_id TEXT NOT NULL,
failed_until DATETIME, last_error TEXT NOT NULL DEFAULT '',
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (anime_id, provider)
)`,
`INSERT INTO episode_availability_cache (
anime_id, data, next_refresh_at, failure_count, last_error
) VALUES (
59846,
'{"anime_id":59846,"source":"AllAnime","availability_version":4,"episodes":[{"number":1,"title":"AllAnime title","has_sub":true},{"number":2,"title":"Episode 2","has_sub":true}]}',
'2999-01-01T00:00:00Z', 2, 'preserve me'
)`,
} {
if _, err := sqlDB.ExecContext(ctx, statement); err != nil {
t.Fatal(err)
}
}
return sqlDB
}
func assertEnrichedTitleList(t *testing.T, svc *EpisodeService, anime domain.Anime) {
t.Helper()
list, err := svc.EnrichEpisodeTitles(context.Background(), anime)
if err != nil {
t.Fatal(err)
}
if list.Episodes[0].Title != "AllAnime title" || list.Episodes[1].Title != "TVmaze title two" {
t.Fatalf("episodes = %#v", list.Episodes)
}
}
func assertCachedTitles(ctx context.Context, t *testing.T, sqlDB *sql.DB) {
t.Helper()
var raw, lastError string
var failureCount int
if err := sqlDB.QueryRowContext(ctx, `SELECT data, failure_count, last_error FROM episode_availability_cache WHERE anime_id = 59846`).Scan(&raw, &failureCount, &lastError); err != nil {
t.Fatal(err)
}
var cached domain.CanonicalEpisodeList
if err := json.Unmarshal([]byte(raw), &cached); err != nil {
t.Fatal(err)
}
if cached.Episodes[1].Title != "TVmaze title two" || failureCount != 2 || lastError != "preserve me" {
t.Fatalf("cached = %#v failure_count=%d last_error=%q", cached.Episodes, failureCount, lastError)
}
}

View File

@@ -0,0 +1,29 @@
package observability
import (
"testing"
)
func TestFormatBytesBelow1KB(t *testing.T) {
if got := formatBytes(512.0); got != "512B" {
t.Fatalf("got %q, want %q", got, "512B")
}
}
func TestFormatBytesInKB(t *testing.T) {
if got := formatBytes(2048.0); got != "2.0KB" {
t.Fatalf("got %q, want %q", got, "2.0KB")
}
}
func TestFormatBytesInMB(t *testing.T) {
if got := formatBytes(5_242_880.0); got != "5.0MB" {
t.Fatalf("got %q, want %q", got, "5.0MB")
}
}
func TestFormatBytesReturnsStringAsIs(t *testing.T) {
if got := formatBytes("unknown"); got != "unknown" {
t.Fatalf("got %q, want %q", got, "unknown")
}
}

View File

@@ -0,0 +1,29 @@
package observability
import (
"testing"
)
func TestCloneFieldsReturnsNilForNil(t *testing.T) {
if got := cloneFields(nil); got != nil {
t.Fatalf("expected nil, got %#v", got)
}
}
func TestCloneFieldsReturnsNilForEmpty(t *testing.T) {
if got := cloneFields(map[string]any{}); got != nil {
t.Fatalf("expected nil, got %#v", got)
}
}
func TestCloneFieldsCopiesAllEntries(t *testing.T) {
original := map[string]any{"a": 1, "b": "two"}
cp := cloneFields(original)
if cp["a"] != 1 || cp["b"] != "two" {
t.Fatalf("clone = %#v, want %#v", cp, original)
}
cp["c"] = 3
if _, exists := original["c"]; exists {
t.Fatal("clone modified original")
}
}

View File

@@ -0,0 +1,23 @@
package observability
import (
"testing"
)
func TestFormatDurationMillisFormatsFloat(t *testing.T) {
if got := formatDurationMillis(123.456); got != "123.456ms" {
t.Fatalf("got %q, want %q", got, "123.456ms")
}
}
func TestFormatDurationMillisFormatsInt(t *testing.T) {
if got := formatDurationMillis(42); got != "42ms" {
t.Fatalf("got %q, want %q", got, "42ms")
}
}
func TestFormatDurationMillisReturnsStringAsIs(t *testing.T) {
if got := formatDurationMillis("fast"); got != "fast" {
t.Fatalf("got %q, want %q", got, "fast")
}
}

View File

@@ -0,0 +1,52 @@
package observability
import (
"testing"
)
func TestPopFieldReturnsEmptyForNil(t *testing.T) {
if got := popField(nil, "key"); got != "" {
t.Fatalf("got %q, want empty", got)
}
}
func TestPopFieldReturnsEmptyForMissingKey(t *testing.T) {
if got := popField(map[string]any{"a": 1}, "b"); got != "" {
t.Fatalf("got %q, want empty", got)
}
}
func TestPopFieldReturnsAndDeletes(t *testing.T) {
m := map[string]any{"status": 200, "other": "x"}
got := popField(m, "status")
if got != "200" {
t.Fatalf("got %q, want %q", got, "200")
}
if _, exists := m["status"]; exists {
t.Fatal("popField did not delete key")
}
if m["other"] != "x" {
t.Fatal("popField deleted other key")
}
}
func TestPopFieldFormatsDuration(t *testing.T) {
got := popField(map[string]any{"duration_ms": 123.4}, "duration_ms")
if got != "123.4ms" {
t.Fatalf("got %q, want %q", got, "123.4ms")
}
}
func TestPopFieldFormatsBytes(t *testing.T) {
got := popField(map[string]any{"bytes": 2048.0}, "bytes")
if got != "2.0KB" {
t.Fatalf("got %q, want %q", got, "2.0KB")
}
}
func TestPopFieldPassthroughString(t *testing.T) {
got := popField(map[string]any{"method": "GET"}, "method")
if got != "GET" {
t.Fatalf("got %q, want %q", got, "GET")
}
}

View File

@@ -20,13 +20,28 @@ func (fxLogger) LogEvent(event fxevent.Event) {
}
func describeFXEventError(event fxevent.Event) (string, map[string]any, error) {
if eventName, fields, ok, err := describeFXBuildEventError(event); ok {
return eventName, fields, err
}
return describeFXLifecycleEventError(event)
}
func describeFXBuildEventError(event fxevent.Event) (string, map[string]any, bool, error) {
switch e := event.(type) {
case *fxevent.Provided:
return "fx_provide_failed", map[string]any{"constructor": e.ConstructorName}, e.Err
return "fx_provide_failed", map[string]any{"constructor": e.ConstructorName}, true, e.Err
case *fxevent.Invoked:
return "fx_invoke_failed", map[string]any{"function": e.FunctionName}, e.Err
return "fx_invoke_failed", map[string]any{"function": e.FunctionName}, true, e.Err
case *fxevent.Run:
return "fx_run_failed", map[string]any{"function": e.Name, "kind": e.Kind}, e.Err
return "fx_run_failed", map[string]any{"function": e.Name, "kind": e.Kind}, true, e.Err
default:
return "", nil, false, nil
}
}
func describeFXLifecycleEventError(event fxevent.Event) (string, map[string]any, error) {
switch e := event.(type) {
case *fxevent.OnStartExecuted:
return "fx_on_start_failed", map[string]any{"caller": e.CallerName, "function": e.FunctionName, "runtime": e.Runtime}, e.Err
case *fxevent.OnStopExecuted:

View File

@@ -0,0 +1,119 @@
package observability
import (
"errors"
"reflect"
"testing"
"time"
"go.uber.org/fx/fxevent"
)
func TestDescribeFXEventErrorDescribesFailedBuildEvents(t *testing.T) {
err := errors.New("fx failed")
tests := map[string]struct {
event fxevent.Event
wantName string
wantFields map[string]any
}{
"provided": {
event: &fxevent.Provided{ConstructorName: "newServer", Err: err},
wantName: "fx_provide_failed",
wantFields: map[string]any{"constructor": "newServer"},
},
"invoked": {
event: &fxevent.Invoked{FunctionName: "startServer", Err: err},
wantName: "fx_invoke_failed",
wantFields: map[string]any{"function": "startServer"},
},
"run": {
event: &fxevent.Run{Name: "newRepo", Kind: "provide", Err: err},
wantName: "fx_run_failed",
wantFields: map[string]any{"function": "newRepo", "kind": "provide"},
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
assertFXEventError(t, tt.event, tt.wantName, tt.wantFields, err)
})
}
}
func TestDescribeFXEventErrorDescribesFailedLifecycleEvents(t *testing.T) {
err := errors.New("fx failed")
runtime := 12 * time.Millisecond
tests := map[string]struct {
event fxevent.Event
wantName string
wantFields map[string]any
}{
"on start executed": {
event: &fxevent.OnStartExecuted{CallerName: "server", FunctionName: "listen", Runtime: runtime, Err: err},
wantName: "fx_on_start_failed",
wantFields: map[string]any{"caller": "server", "function": "listen", "runtime": runtime},
},
"on stop executed": {
event: &fxevent.OnStopExecuted{CallerName: "server", FunctionName: "close", Runtime: runtime, Err: err},
wantName: "fx_on_stop_failed",
wantFields: map[string]any{"caller": "server", "function": "close", "runtime": runtime},
},
"started": {
event: &fxevent.Started{Err: err},
wantName: "fx_start_failed",
},
"stopped": {
event: &fxevent.Stopped{Err: err},
wantName: "fx_stop_failed",
},
"rolling back": {
event: &fxevent.RollingBack{StartErr: err},
wantName: "fx_rollback_start",
},
"rolled back": {
event: &fxevent.RolledBack{Err: err},
wantName: "fx_rollback_failed",
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
assertFXEventError(t, tt.event, tt.wantName, tt.wantFields, err)
})
}
}
func assertFXEventError(t *testing.T, event fxevent.Event, wantName string, wantFields map[string]any, wantErr error) {
t.Helper()
gotName, gotFields, gotErr := describeFXEventError(event)
if gotName != wantName {
t.Fatalf("event name = %q, want %q", gotName, wantName)
}
if !reflect.DeepEqual(gotFields, wantFields) {
t.Fatalf("fields = %#v, want %#v", gotFields, wantFields)
}
if !errors.Is(gotErr, wantErr) {
t.Fatalf("error = %v, want %v", gotErr, wantErr)
}
}
func TestDescribeFXEventErrorIgnoresEventsWithoutErrors(t *testing.T) {
tests := map[string]fxevent.Event{
"provided": &fxevent.Provided{ConstructorName: "newServer"},
"unknown": &fxevent.Supplied{},
}
for name, event := range tests {
t.Run(name, func(t *testing.T) {
_, _, gotErr := describeFXEventError(event)
if gotErr != nil {
t.Fatalf("error = %v, want nil", gotErr)
}
})
}
}

View File

@@ -0,0 +1,27 @@
package observability
import (
"testing"
)
func TestIsLocalClientIPReturnsTrueForLoopback(t *testing.T) {
for _, ip := range []string{"127.0.0.1", "::1"} {
if !isLocalClientIP(ip) {
t.Fatalf("isLocalClientIP(%q) = false, want true", ip)
}
}
}
func TestIsLocalClientIPReturnsFalseForExternal(t *testing.T) {
for _, ip := range []string{"8.8.8.8", "192.168.1.1", ""} {
if isLocalClientIP(ip) {
t.Fatalf("isLocalClientIP(%q) = true, want false", ip)
}
}
}
func TestIsLocalClientIPReturnsFalseForInvalid(t *testing.T) {
if isLocalClientIP("not-an-ip") {
t.Fatal("isLocalClientIP('not-an-ip') = true, want false")
}
}

View File

@@ -0,0 +1,38 @@
package observability
import (
"testing"
)
func TestQuoteIfNeededReturnsEmptyQuoted(t *testing.T) {
if got := quoteIfNeeded(""); got != `""` {
t.Fatalf("got %q, want %q", got, `""`)
}
}
func TestQuoteIfNeededReturnsPlainString(t *testing.T) {
if got := quoteIfNeeded("hello"); got != "hello" {
t.Fatalf("got %q, want %q", got, "hello")
}
}
func TestQuoteIfNeededQuotesStringWithEquals(t *testing.T) {
got := quoteIfNeeded("key=value")
if got != `"key=value"` {
t.Fatalf("got %q, want %q", got, `"key=value"`)
}
}
func TestQuoteIfNeededQuotesStringWithSpace(t *testing.T) {
got := quoteIfNeeded("hello world")
if got != `"hello world"` {
t.Fatalf("got %q, want %q", got, `"hello world"`)
}
}
func TestQuoteIfNeededQuotesStringWithNewline(t *testing.T) {
got := quoteIfNeeded("line1\nline2")
if got != `"line1\nline2"` {
t.Fatalf("got %q, want %q", got, `"line1\nline2"`)
}
}

View File

@@ -0,0 +1,19 @@
package observability
import (
"testing"
)
func TestFormatHTTPStatusReturnsPlainWhenNoColor(t *testing.T) {
colorLogs = false
if got := formatHTTPStatus(200); got != "200" {
t.Fatalf("got %q, want %q", got, "200")
}
}
func TestFormatHTTPStatusReturnsFallback(t *testing.T) {
colorLogs = false
if got := formatHTTPStatus("ok"); got != "ok" {
t.Fatalf("got %q, want %q", got, "ok")
}
}

View File

@@ -0,0 +1,52 @@
package handler
import (
"bytes"
"context"
"errors"
"mal/internal/db"
"mal/internal/domain"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
type failingSkipSegmentPlaybackService struct {
domain.PlaybackService
}
func (s failingSkipSegmentPlaybackService) UpsertSkipSegmentOverride(context.Context, string, int64, int, string, float64, float64) error {
return errors.New("database constraint skip_segments_user_id_fkey failed")
}
func TestUpsertSkipSegmentRouteHidesServiceError(t *testing.T) {
t.Parallel()
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set("User", &domain.User{User: db.User{ID: "user-1"}})
})
h := NewPlaybackHandler(failingSkipSegmentPlaybackService{}, nil)
h.Register(router)
body := bytes.NewBufferString(`{"mal_id":123,"episode":1,"skip_type":"op","start_time":10,"end_time":90}`)
req := httptest.NewRequestWithContext(context.Background(), http.MethodPost, "/api/watch/segments", body)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
}
responseBody := rec.Body.String()
if !strings.Contains(responseBody, `"error":"invalid skip segment"`) {
t.Fatalf("expected stable public error, got %s", responseBody)
}
if strings.Contains(responseBody, "database constraint") {
t.Fatalf("expected private service error to stay out of response, got %s", responseBody)
}
}

View File

@@ -2,14 +2,14 @@
package handler
import (
"fmt"
"net/http"
"strconv"
"time"
"mal/internal/domain"
"mal/internal/observability"
"mal/internal/playback/proxytarget"
"mal/internal/server"
netutil "mal/pkg/net"
"github.com/gin-gonic/gin"
)
@@ -21,15 +21,17 @@ type PlaybackHandler struct {
proxyClient *http.Client
streamingClient *http.Client
subtitleCache *subtitleCache
manifestCache *manifestCache
}
func NewPlaybackHandler(svc domain.PlaybackService, animeSvc domain.AnimePlaybackService) *PlaybackHandler {
return &PlaybackHandler{
svc: svc,
animeSvc: animeSvc,
proxyClient: netutil.NewClient(),
streamingClient: netutil.NewStreamingClient(),
proxyClient: proxytarget.NewClient(60 * time.Second),
streamingClient: proxytarget.NewStreamingClient(),
subtitleCache: newSubtitleCache(10*time.Minute, 256),
manifestCache: newManifestCache(manifestCacheMaxEntries),
}
}
@@ -37,13 +39,84 @@ func (h *PlaybackHandler) Register(r *gin.Engine) {
r.GET("/anime/:id/watch", h.HandleWatchPage)
r.POST("/api/watch-progress", h.HandleSaveProgress)
r.POST("/api/watch-complete", h.HandleWatchComplete)
r.GET("/api/watch/episodes/:animeId/titles", h.HandleEpisodeTitles)
r.GET("/api/watch/episodes/:animeId/classifications", h.HandleEpisodeClassifications)
r.GET("/api/watch/episode/:animeId/:episode", h.HandleEpisodeData)
r.POST("/api/watch/segments", h.HandleUpsertSkipSegment)
r.GET("/api/watch/thumbnails/:animeId", h.HandleEpisodeThumbnails)
r.GET("/watch/proxy/stream", h.HandleProxyStream)
r.GET("/watch/proxy/subtitle", h.HandleProxySubtitle)
}
func (h *PlaybackHandler) HandleEpisodeClassifications(c *gin.Context) {
animeID, err := strconv.Atoi(c.Param("animeId"))
if err != nil || animeID <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
return
}
enricher, ok := h.svc.(domain.PlaybackEpisodeClassificationService)
if !ok {
server.RespondHTMLOrJSONError(c, http.StatusServiceUnavailable, "episode classifications are unavailable")
return
}
episodes, err := enricher.EnrichEpisodeClassifications(c.Request.Context(), animeID)
if err != nil {
server.RespondError(
c,
http.StatusBadGateway,
"watch_episode_classifications_failed",
"playback",
"episode classifications are unavailable",
map[string]any{"anime_id": animeID},
err,
)
return
}
classifications := make([]gin.H, 0, len(episodes))
for _, episode := range episodes {
classifications = append(classifications, gin.H{
"number": episode.Number,
"filler": episode.Filler,
"recap": episode.Recap,
})
}
c.JSON(http.StatusOK, classifications)
}
func (h *PlaybackHandler) HandleEpisodeTitles(c *gin.Context) {
animeID, err := strconv.Atoi(c.Param("animeId"))
if err != nil || animeID <= 0 {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid anime id")
return
}
enricher, ok := h.svc.(domain.PlaybackEpisodeTitleService)
if !ok {
server.RespondHTMLOrJSONError(c, http.StatusServiceUnavailable, "episode titles are unavailable")
return
}
episodes, err := enricher.EnrichEpisodeTitles(c.Request.Context(), animeID)
if err != nil {
server.RespondError(
c,
http.StatusBadGateway,
"watch_episode_titles_failed",
"playback",
"episode titles are unavailable",
map[string]any{"anime_id": animeID},
err,
)
return
}
titles := make([]gin.H, 0, len(episodes))
for _, episode := range episodes {
titles = append(titles, gin.H{"number": episode.Number, "title": episode.Title})
}
c.JSON(http.StatusOK, titles)
}
func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
@@ -56,12 +129,13 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
user := server.CurrentUser(c)
userID := server.CurrentUserID(c)
data, err := h.svc.BuildWatchData(c.Request.Context(), id, []string{}, ep, mode, userID)
ctx := domain.WithDeferredPlaybackData(c.Request.Context())
data, err := h.svc.BuildWatchData(ctx, id, []string{}, ep, mode, userID)
if err != nil {
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)
observability.Warn("error_page_anime_fetch_failed", "playback", "", map[string]any{"anime_id": id}, fetchErr)
}
data = domain.WatchPageData{
Anime: anime,
@@ -79,7 +153,17 @@ func (h *PlaybackHandler) HandleWatchPage(c *gin.Context) {
}
}
data.Error = err.Error()
observability.LogContext(
c.Request.Context(),
observability.LogLevelError,
"watch_page_build_failed",
"playback",
"",
map[string]any{"anime_id": id, "episode": ep, "mode": mode, "user_id": userID, "request_path": c.Request.URL.Path},
err,
)
data.Error = "failed to load playback data"
data.User = user
data.CurrentPath = c.Request.URL.Path
@@ -109,10 +193,14 @@ func (h *PlaybackHandler) HandleEpisodeData(c *gin.Context) {
}
mode := c.DefaultQuery("mode", "sub")
ctx := c.Request.Context()
if c.Query("refresh") == "1" {
ctx = domain.WithPlaybackSourceRefresh(ctx)
}
userID := server.CurrentUserID(c)
data, err := h.svc.BuildWatchData(c.Request.Context(), animeID, []string{}, episode, mode, userID)
data, err := h.svc.BuildWatchData(ctx, animeID, []string{}, episode, mode, userID)
if err != nil {
server.RespondError(
c,
@@ -243,61 +331,17 @@ func (h *PlaybackHandler) HandleUpsertSkipSegment(c *gin.Context) {
}
if err := h.svc.UpsertSkipSegmentOverride(c.Request.Context(), userID, req.MalID, req.Episode, req.SkipType, req.StartTime, req.EndTime); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
server.RespondError(
c,
http.StatusBadRequest,
"skip_segment_upsert_failed",
"playback",
"invalid skip segment",
map[string]any{"mal_id": req.MalID, "episode": req.Episode, "skip_type": req.SkipType, "user_id": userID},
err,
)
return
}
c.Status(http.StatusOK)
}
func (h *PlaybackHandler) HandleEpisodeThumbnails(c *gin.Context) {
id, err := strconv.Atoi(c.Param("animeId"))
if err != nil {
c.Status(http.StatusBadRequest)
return
}
allEpisodes, err := h.animeSvc.GetAllEpisodes(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
anime, fetchErr := h.animeSvc.GetAnimeByID(c.Request.Context(), id)
if fetchErr != nil {
fmt.Printf("error fetching anime for thumbnail fill: %v\n", fetchErr)
}
if anime.Episodes > 0 && anime.Episodes > len(allEpisodes) {
epMap := make(map[int]domain.EpisodeData)
for _, ep := range allEpisodes {
epMap[ep.MalID] = ep
}
var filled []domain.EpisodeData
for i := 1; i <= anime.Episodes; i++ {
if ep, ok := epMap[i]; ok {
filled = append(filled, ep)
} else {
filled = append(filled, domain.EpisodeData{
MalID: i,
Title: fmt.Sprintf("Episode %d", i),
})
}
}
allEpisodes = filled
}
type Result struct {
MalID int `json:"mal_id"`
Title string `json:"title"`
}
results := make([]Result, len(allEpisodes))
for i, ep := range allEpisodes {
results[i] = Result{
MalID: ep.MalID,
Title: ep.Title,
}
}
c.JSON(http.StatusOK, results)
}

View File

@@ -0,0 +1,109 @@
package handler
import (
"container/list"
"net/http"
"strings"
"sync"
"time"
)
const (
manifestCacheVODTTL = 45 * time.Second
manifestCacheLiveTTL = 3 * time.Second
manifestCacheMaxEntries = 256
manifestCacheMaxBodySize = 2 << 20
)
type manifestCacheEntry struct {
key string
body []byte
headers http.Header
status int
expiresAt time.Time
}
type manifestCache struct {
mu sync.Mutex
maxEntries int
entries map[string]*list.Element
lru *list.List
}
func newManifestCache(maxEntries int) *manifestCache {
if maxEntries <= 0 {
maxEntries = manifestCacheMaxEntries
}
return &manifestCache{
maxEntries: maxEntries,
entries: make(map[string]*list.Element, maxEntries),
lru: list.New(),
}
}
func manifestCacheKey(targetURL string, referer string) string {
return targetURL + "\x00" + referer
}
func (c *manifestCache) get(key string, now time.Time) (manifestCacheEntry, bool) {
c.mu.Lock()
defer c.mu.Unlock()
el := c.entries[key]
if el == nil {
return manifestCacheEntry{}, false
}
entry, ok := el.Value.(manifestCacheEntry)
if !ok || !now.Before(entry.expiresAt) {
c.remove(el)
return manifestCacheEntry{}, false
}
c.lru.MoveToFront(el)
entry.body = append([]byte(nil), entry.body...)
entry.headers = entry.headers.Clone()
return entry, true
}
func (c *manifestCache) set(key string, status int, headers http.Header, body []byte, now time.Time) bool {
if status < http.StatusOK || status >= http.StatusMultipleChoices || len(body) > manifestCacheMaxBodySize {
return false
}
ttl := manifestCacheVODTTL
bodyText := string(body)
if strings.Contains(bodyText, "#EXTINF") && !strings.Contains(bodyText, "#EXT-X-ENDLIST") {
ttl = manifestCacheLiveTTL
}
entry := manifestCacheEntry{
key: key,
body: append([]byte(nil), body...),
headers: headers.Clone(),
status: status,
expiresAt: now.Add(ttl),
}
c.mu.Lock()
defer c.mu.Unlock()
if el := c.entries[key]; el != nil {
el.Value = entry
c.lru.MoveToFront(el)
return true
}
c.entries[key] = c.lru.PushFront(entry)
for len(c.entries) > c.maxEntries {
back := c.lru.Back()
if back == nil {
break
}
c.remove(back)
}
return true
}
func (c *manifestCache) remove(el *list.Element) {
entry, ok := el.Value.(manifestCacheEntry)
if ok {
delete(c.entries, entry.key)
}
c.lru.Remove(el)
}

View File

@@ -0,0 +1,122 @@
package handler
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
)
type manifestRoundTripper struct {
calls int
body string
}
func (rt *manifestRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
rt.calls++
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": {"application/vnd.apple.mpegurl"},
"Content-Length": {"48"},
},
Body: io.NopCloser(strings.NewReader(rt.body)),
}, nil
}
func TestManifestCacheTTLSizeAndLRU(t *testing.T) {
now := time.Unix(100, 0)
cache := newManifestCache(2)
headers := http.Header{"Content-Type": {"application/vnd.apple.mpegurl"}}
vod := []byte("#EXTM3U\n#EXTINF:4,\nsegment.ts\n#EXT-X-ENDLIST\n")
live := []byte("#EXTM3U\n#EXTINF:4,\nsegment.ts\n")
if !cache.set("vod", http.StatusOK, headers, vod, now) {
t.Fatal("VOD manifest was not cached")
}
if !cache.set("live", http.StatusOK, headers, live, now) {
t.Fatal("live manifest was not cached")
}
cache.get("vod", now)
cache.set("new", http.StatusOK, headers, vod, now)
if _, ok := cache.get("live", now); ok {
t.Fatal("least recently used manifest was not evicted")
}
if _, ok := cache.get("vod", now.Add(manifestCacheLiveTTL+time.Second)); !ok {
t.Fatal("VOD manifest expired at the live TTL")
}
if cache.set("large", http.StatusOK, headers, make([]byte, manifestCacheMaxBodySize+1), now) {
t.Fatal("oversized manifest was cached")
}
}
func TestReadBoundedPlaylistRejectsOversizedBody(t *testing.T) {
_, err := readBoundedPlaylist(bytes.NewReader(make([]byte, manifestCacheMaxBodySize+1)))
if err == nil {
t.Fatal("readBoundedPlaylist accepted an oversized body")
}
}
func TestCachedRawPlaylistIsRewrittenWithResponseTokens(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &rewritePlaybackService{}
h := &PlaybackHandler{svc: svc}
body := []byte("#EXTM3U\n#EXTINF:4,\nsegment.ts\n#EXT-X-ENDLIST\n")
headers := http.Header{"Content-Type": {"application/vnd.apple.mpegurl"}, "Content-Length": {"48"}}
responses := make([]string, 0, 2)
for range 2 {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
h.writeProxyPlaylist(ctx, http.StatusOK, headers, body, "https://cdn.example.test/master.m3u8", "")
responses = append(responses, recorder.Body.String())
if recorder.Header().Get("Content-Length") == "48" {
t.Fatal("rewritten playlist retained upstream Content-Length")
}
}
if responses[0] == responses[1] {
t.Fatalf("rewritten responses reused tokenized output: %q", responses[0])
}
if !strings.Contains(responses[0], "token=token-1") || !strings.Contains(responses[1], "token=token-2") {
t.Fatalf("responses did not contain separately issued tokens: %#v", responses)
}
}
func TestHandleProxyStreamServesCachedRawManifest(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &rewritePlaybackService{targetURL: "https://203.0.113.10/master.m3u8"}
rt := &manifestRoundTripper{body: "#EXTM3U\n#EXTINF:4,\nsegment.ts\n#EXT-X-ENDLIST\n"}
h := &PlaybackHandler{
svc: svc,
streamingClient: &http.Client{Transport: rt},
manifestCache: newManifestCache(2),
}
router := gin.New()
router.GET("/watch/proxy/stream", h.HandleProxyStream)
responses := make([]string, 0, 2)
for range 2 {
recorder := httptest.NewRecorder()
request := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/watch/proxy/stream?token=opaque", nil)
router.ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK)
}
responses = append(responses, recorder.Body.String())
}
if rt.calls != 1 {
t.Fatalf("upstream calls = %d, want 1", rt.calls)
}
if responses[0] == responses[1] {
t.Fatalf("cached raw manifest reused rewritten tokens: %#v", responses)
}
}

View File

@@ -10,7 +10,8 @@ import (
)
type rewritePlaybackService struct {
targets []string
targets []string
targetURL string
}
func (s *rewritePlaybackService) BuildWatchData(context.Context, int, []string, string, string, string) (domain.WatchPageData, error) {
@@ -31,7 +32,7 @@ func (s *rewritePlaybackService) SignProxyToken(targetURL, _ string, _ string) (
}
func (s *rewritePlaybackService) ResolveProxyToken(string, string) (string, string, error) {
return "", "", nil
return s.targetURL, "", nil
}
func (s *rewritePlaybackService) UpsertSkipSegmentOverride(context.Context, string, int64, int, string, float64, float64) error {

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"mal/internal/observability"
"mal/internal/playback/proxytarget"
netutil "mal/pkg/net"
"net/http"
@@ -11,9 +12,13 @@ import (
)
func newProxyRequest(ctx context.Context, targetURL string, referer string) (*http.Request, error) {
if err := proxytarget.Validate(targetURL); err != nil {
return nil, fmt.Errorf("validate proxy target: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
return nil, fmt.Errorf("build proxy request for %q: %w", targetURL, err)
return nil, fmt.Errorf("build proxy request: %w", err)
}
if referer != "" {

View File

@@ -8,6 +8,7 @@ import (
errlog "mal/pkg"
netutil "mal/pkg/net"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
@@ -17,68 +18,111 @@ func (h *PlaybackHandler) HandleProxyStream(c *gin.Context) {
if !ok {
return
}
req, err := newProxyRequest(c.Request.Context(), targetURL, referer)
if err != nil {
c.Status(http.StatusBadGateway)
return
}
if rangeHeader := c.GetHeader("Range"); rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
if ifRangeHeader := c.GetHeader("If-Range"); ifRangeHeader != "" {
req.Header.Set("If-Range", ifRangeHeader)
cacheKey := manifestCacheKey(targetURL, referer)
if h.writeCachedPlaylist(c, cacheKey, targetURL, referer) {
return
}
copyProxyRangeHeaders(req, c)
resp, err := h.streamingClient.Do(req)
if err != nil {
if !errors.Is(err, context.Canceled) {
observability.ErrorContext(c.Request.Context(), "proxy_stream_upstream_failed", "playback", "", map[string]any{"target_url": targetURL}, err)
recordPrivateGinError(c, err)
}
c.Status(http.StatusBadGateway)
h.handleStreamRequestError(c, err)
return
}
defer func() {
errlog.Log("failed to close proxy stream response body", resp.Body.Close())
}()
h.writeStreamResponse(c, resp, cacheKey, targetURL, referer)
}
func (h *PlaybackHandler) writeCachedPlaylist(c *gin.Context, cacheKey string, targetURL string, referer string) bool {
if c.GetHeader("Range") != "" {
return false
}
cached, found := h.manifestCache.get(cacheKey, time.Now())
if !found {
return false
}
observability.Info("playback_manifest_cache_hit", "playback", "", nil)
h.writeProxyPlaylist(c, cached.status, cached.headers, cached.body, targetURL, referer)
return true
}
func copyProxyRangeHeaders(req *http.Request, c *gin.Context) {
if rangeHeader := c.GetHeader("Range"); rangeHeader != "" {
req.Header.Set("Range", rangeHeader)
}
if ifRangeHeader := c.GetHeader("If-Range"); ifRangeHeader != "" {
req.Header.Set("If-Range", ifRangeHeader)
}
}
func (h *PlaybackHandler) handleStreamRequestError(c *gin.Context, err error) {
if !errors.Is(err, context.Canceled) {
safeErr := errors.New("stream upstream request failed")
observability.ErrorContext(c.Request.Context(), "proxy_stream_upstream_failed", "playback", "", nil, safeErr)
recordPrivateGinError(c, safeErr)
}
c.Status(http.StatusBadGateway)
}
func (h *PlaybackHandler) writeStreamResponse(c *gin.Context, resp *http.Response, cacheKey string, targetURL string, referer string) {
if isHLSPlaylistResponse(targetURL, resp.Header) {
h.writeProxyPlaylist(c, resp, targetURL, referer)
observability.Info("playback_manifest_cache_miss", "playback", "", nil)
body, readErr := readBoundedPlaylist(resp.Body)
if readErr != nil {
safeErr := errors.New("stream playlist read failed")
observability.ErrorContext(c.Request.Context(), "proxy_stream_playlist_read_failed", "playback", "", nil, safeErr)
recordPrivateGinError(c, safeErr)
c.Status(http.StatusBadGateway)
return
}
h.manifestCache.set(cacheKey, resp.StatusCode, resp.Header, body, time.Now())
h.writeProxyPlaylist(c, resp.StatusCode, resp.Header, body, targetURL, referer)
return
}
copyProxyHeaders(c.Writer.Header(), resp.Header)
c.Status(resp.StatusCode)
copyProxyResponseBody(c, resp.Body, targetURL)
copyProxyResponseBody(c, resp.Body)
}
func copyProxyResponseBody(c *gin.Context, body io.Reader, targetURL string) {
func copyProxyResponseBody(c *gin.Context, body io.Reader) {
n, err := io.Copy(c.Writer, body)
if err == nil || errors.Is(err, context.Canceled) || c.Request.Context().Err() != nil {
return
}
observability.WarnContext(c.Request.Context(), "proxy_stream_copy_failed", "playback", "", map[string]any{"target_url": targetURL, "bytes_copied": n}, err)
observability.WarnContext(c.Request.Context(), "proxy_stream_copy_failed", "playback", "", map[string]any{"bytes_copied": n}, err)
}
func (h *PlaybackHandler) writeProxyPlaylist(c *gin.Context, resp *http.Response, targetURL string, referer string) {
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
func readBoundedPlaylist(body io.Reader) ([]byte, error) {
data, err := io.ReadAll(io.LimitReader(body, netutil.MiB2+1))
if err != nil {
observability.ErrorContext(c.Request.Context(), "proxy_stream_playlist_read_failed", "playback", "", map[string]any{"target_url": targetURL}, err)
recordPrivateGinError(c, err)
c.Status(http.StatusBadGateway)
return
return nil, err
}
if len(data) > manifestCacheMaxBodySize {
return nil, errors.New("upstream playlist exceeds size limit")
}
return data, nil
}
func (h *PlaybackHandler) writeProxyPlaylist(c *gin.Context, status int, headers http.Header, body []byte, targetURL string, referer string) {
rewritten, err := h.rewriteHLSPlaylist(string(body), targetURL, referer)
if err != nil {
observability.ErrorContext(c.Request.Context(), "proxy_stream_playlist_rewrite_failed", "playback", "", map[string]any{"target_url": targetURL}, err)
recordPrivateGinError(c, err)
safeErr := errors.New("stream playlist rewrite failed")
observability.ErrorContext(c.Request.Context(), "proxy_stream_playlist_rewrite_failed", "playback", "", nil, safeErr)
recordPrivateGinError(c, safeErr)
c.Status(http.StatusBadGateway)
return
}
copyProxyHeaders(c.Writer.Header(), resp.Header)
copyProxyHeaders(c.Writer.Header(), headers)
c.Writer.Header().Del("Content-Length")
c.Data(resp.StatusCode, "application/vnd.apple.mpegurl", []byte(rewritten))
c.Data(status, "application/vnd.apple.mpegurl", []byte(rewritten))
}

View File

@@ -34,8 +34,9 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
resp, err := h.proxyClient.Do(req)
if err != nil {
if !errors.Is(err, context.Canceled) {
observability.ErrorContext(c.Request.Context(), "proxy_subtitle_upstream_failed", "playback", "", map[string]any{"target_url": targetURL}, err)
recordPrivateGinError(c, err)
safeErr := errors.New("subtitle upstream request failed")
observability.ErrorContext(c.Request.Context(), "proxy_subtitle_upstream_failed", "playback", "", nil, safeErr)
recordPrivateGinError(c, safeErr)
}
c.Status(http.StatusBadGateway)
return
@@ -46,8 +47,9 @@ func (h *PlaybackHandler) HandleProxySubtitle(c *gin.Context) {
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
if err != nil {
observability.ErrorContext(c.Request.Context(), "proxy_subtitle_read_failed", "playback", "", map[string]any{"target_url": targetURL}, err)
recordPrivateGinError(c, err)
safeErr := errors.New("subtitle response read failed")
observability.ErrorContext(c.Request.Context(), "proxy_subtitle_read_failed", "playback", "", nil, safeErr)
recordPrivateGinError(c, safeErr)
c.Status(http.StatusBadGateway)
return
}

View File

@@ -0,0 +1,78 @@
package handler
import (
"context"
"io"
"mal/internal/domain"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
type proxyTargetPlaybackService struct {
targetURL string
referer string
}
func (s proxyTargetPlaybackService) BuildWatchData(context.Context, int, []string, string, string, string) (domain.WatchPageData, error) {
return domain.WatchPageData{}, nil
}
func (s proxyTargetPlaybackService) SaveProgress(context.Context, string, int64, int, float64) error {
return nil
}
func (s proxyTargetPlaybackService) CompleteAnime(context.Context, string, int64) error {
return nil
}
func (s proxyTargetPlaybackService) SignProxyToken(string, string, string) (string, error) {
return "token", nil
}
func (s proxyTargetPlaybackService) ResolveProxyToken(string, string) (string, string, error) {
return s.targetURL, s.referer, nil
}
func (s proxyTargetPlaybackService) UpsertSkipSegmentOverride(context.Context, string, int64, int, string, float64, float64) error {
return nil
}
type recordingRoundTripper struct {
called bool
}
func (rt *recordingRoundTripper) RoundTrip(*http.Request) (*http.Response, error) {
rt.called = true
return &http.Response{
StatusCode: http.StatusOK,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("WEBVTT\n")),
}, nil
}
func TestHandleProxySubtitleRejectsUnsafeTargetBeforeFetch(t *testing.T) {
rt := &recordingRoundTripper{}
h := &PlaybackHandler{
svc: proxyTargetPlaybackService{targetURL: "http://127.0.0.1/subtitle.vtt"},
proxyClient: &http.Client{Transport: rt},
subtitleCache: newSubtitleCache(0, 1),
}
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/watch/proxy/subtitle?token=token", nil)
rec := httptest.NewRecorder()
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/watch/proxy/subtitle", h.HandleProxySubtitle)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadGateway {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadGateway)
}
if rt.called {
t.Fatal("proxy client was called for unsafe target")
}
}

View File

@@ -17,14 +17,96 @@ import (
type watchPagePlaybackService struct {
domain.PlaybackService
data domain.WatchPageData
err error
data domain.WatchPageData
deferred bool
err error
refreshRequested bool
titles []domain.CanonicalEpisode
classifications []domain.CanonicalEpisode
}
func (s *watchPagePlaybackService) BuildWatchData(context.Context, int, []string, string, string, string) (domain.WatchPageData, error) {
func (s *watchPagePlaybackService) BuildWatchData(ctx context.Context, _ int, _ []string, _ string, _ string, _ string) (domain.WatchPageData, error) {
s.deferred = domain.PlaybackDataDeferred(ctx)
s.refreshRequested = domain.PlaybackSourceRefreshRequested(ctx)
return s.data, s.err
}
func (s *watchPagePlaybackService) EnrichEpisodeTitles(context.Context, int) ([]domain.CanonicalEpisode, error) {
return s.titles, s.err
}
func (s *watchPagePlaybackService) EnrichEpisodeClassifications(context.Context, int) ([]domain.CanonicalEpisode, error) {
return s.classifications, s.err
}
func TestHandleEpisodeDataRequestsForcedSourceRefresh(t *testing.T) {
data := baseWatchPageData()
data.WatchData.ModeSources = map[string]domain.ModeSource{
"sub": {Token: "opaque", Type: "m3u8"},
}
svc := &watchPagePlaybackService{data: data}
h := &PlaybackHandler{svc: svc}
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/watch/episode/:animeId/:episode", h.HandleEpisodeData)
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/watch/episode/123/1?mode=sub&refresh=1", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if !svc.refreshRequested {
t.Fatal("episode handler did not request a forced source refresh")
}
}
func TestHandleEpisodeTitlesReturnsMinimalEnrichedList(t *testing.T) {
svc := &watchPagePlaybackService{titles: []domain.CanonicalEpisode{
{Number: 1, Title: "The First Title", HasSub: true},
{Number: 2, Title: "The Second Title", HasDub: true},
}}
h := &PlaybackHandler{svc: svc}
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/watch/episodes/:animeId/titles", h.HandleEpisodeTitles)
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/watch/episodes/123/titles", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if got := strings.TrimSpace(rec.Body.String()); got != `[{"number":1,"title":"The First Title"},{"number":2,"title":"The Second Title"}]` {
t.Fatalf("body = %s", got)
}
}
func TestHandleEpisodeClassificationsReturnsMinimalList(t *testing.T) {
svc := &watchPagePlaybackService{classifications: []domain.CanonicalEpisode{
{Number: 1},
{Number: 2, Filler: true},
{Number: 3, Recap: true},
}}
h := &PlaybackHandler{svc: svc}
gin.SetMode(gin.TestMode)
router := gin.New()
router.GET("/api/watch/episodes/:animeId/classifications", h.HandleEpisodeClassifications)
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/watch/episodes/123/classifications", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if got := strings.TrimSpace(rec.Body.String()); got != `[{"filler":false,"number":1,"recap":false},{"filler":true,"number":2,"recap":false},{"filler":false,"number":3,"recap":true}]` {
t.Fatalf("body = %s", got)
}
}
type watchPageAnimeService struct {
domain.AnimePlaybackService
t *testing.T
@@ -96,8 +178,11 @@ func TestHandleWatchPagePreservesPartialDataOnPlaybackFailure(t *testing.T) {
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, `data-playback-error="failed to load playback data"`) {
t.Fatalf("expected stable playback error data attribute in body, got:\n%s", body)
}
if strings.Contains(body, "no streams found") {
t.Fatalf("expected private playback error to stay out of 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)
@@ -106,3 +191,49 @@ func TestHandleWatchPagePreservesPartialDataOnPlaybackFailure(t *testing.T) {
t.Fatalf("expected partial episode list instead of empty state, got:\n%s", body)
}
}
func TestHandleWatchPageDefersPlaybackData(t *testing.T) {
t.Parallel()
svc := &watchPagePlaybackService{data: baseWatchPageData()}
router := newWatchPageRouter(t, &PlaybackHandler{svc: svc})
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)
}
if !svc.deferred {
t.Fatal("watch page did not defer playback data")
}
}
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)
}
}
}

View File

@@ -189,6 +189,33 @@ func TestPlaybackServiceProxyTokenDisabled(t *testing.T) {
}
}
func TestPlaybackServiceSignProxyTokenRejectsUnsafeTargets(t *testing.T) {
svc := &playbackService{proxyTokenKey: "secret", proxyTokens: newProxyTokenStore()}
tests := []struct {
name string
targetURL string
}{
{name: "loopback ipv4", targetURL: "http://127.0.0.1/video.m3u8"},
{name: "private ipv4", targetURL: "https://10.0.0.4/segment.ts"},
{name: "loopback ipv6", targetURL: "http://[::1]/subtitle.vtt"},
{name: "unsupported scheme", targetURL: "file:///tmp/video.m3u8"},
{name: "missing host", targetURL: "https:///segment.ts"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
token, err := svc.SignProxyToken(tt.targetURL, "", "stream")
if err == nil {
t.Fatalf("SignProxyToken(%q) error = nil, token = %q", tt.targetURL, token)
}
if token != "" {
t.Fatalf("SignProxyToken(%q) token = %q, want empty", tt.targetURL, token)
}
})
}
}
type fakePlaybackRepository struct {
inTxCalled bool
getAnimeErr error

View File

@@ -0,0 +1,125 @@
package proxytarget
import (
"context"
"fmt"
"net"
"net/http"
"net/netip"
"net/url"
"strings"
"time"
)
type ipResolver interface {
LookupIPAddr(context.Context, string) ([]net.IPAddr, error)
}
func Validate(rawURL string) error {
parsed, err := url.Parse(rawURL)
if err != nil {
return fmt.Errorf("parse proxy target: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return fmt.Errorf("proxy target scheme must be http or https")
}
host := parsed.Hostname()
if host == "" {
return fmt.Errorf("proxy target host is required")
}
if isLocalhostName(host) {
return fmt.Errorf("proxy target host is local")
}
if addr, err := netip.ParseAddr(host); err == nil && !isAllowedAddr(addr) {
return fmt.Errorf("proxy target address is not allowed")
}
return nil
}
func NewClient(timeout time.Duration) *http.Client {
return &http.Client{
Transport: newTransport(10*time.Second, 10*time.Second, 30*time.Second),
Timeout: timeout,
CheckRedirect: checkRedirect,
}
}
func NewStreamingClient() *http.Client {
return &http.Client{
Transport: newTransport(10*time.Second, 10*time.Second, 15*time.Second),
CheckRedirect: checkRedirect,
}
}
func checkRedirect(req *http.Request, _ []*http.Request) error {
return Validate(req.URL.String())
}
func newTransport(dialTimeout, tlsTimeout, headerTimeout time.Duration) *http.Transport {
dialer := &net.Dialer{Timeout: dialTimeout}
return &http.Transport{
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: tlsTimeout,
ResponseHeaderTimeout: headerTimeout,
ExpectContinueTimeout: 1 * time.Second,
DialContext: validatingDialContext(dialer, net.DefaultResolver),
}
}
func validatingDialContext(dialer *net.Dialer, resolver ipResolver) func(context.Context, string, string) (net.Conn, error) {
return func(ctx context.Context, network string, address string) (net.Conn, error) {
host, port, err := net.SplitHostPort(address)
if err != nil {
return nil, fmt.Errorf("parse proxy target address: %w", err)
}
if isLocalhostName(host) {
return nil, fmt.Errorf("proxy target host is local")
}
if addr, err := netip.ParseAddr(host); err == nil {
if !isAllowedAddr(addr) {
return nil, fmt.Errorf("proxy target address is not allowed")
}
return dialer.DialContext(ctx, network, net.JoinHostPort(addr.String(), port))
}
ips, err := resolver.LookupIPAddr(ctx, host)
if err != nil {
return nil, fmt.Errorf("resolve proxy target host: %w", err)
}
for _, ip := range ips {
addr, ok := addrFromIP(ip.IP)
if !ok || !isAllowedAddr(addr) {
continue
}
return dialer.DialContext(ctx, network, net.JoinHostPort(addr.String(), port))
}
return nil, fmt.Errorf("proxy target resolved to no allowed addresses")
}
}
func isLocalhostName(host string) bool {
lower := strings.TrimSuffix(strings.ToLower(host), ".")
return lower == "localhost" || strings.HasSuffix(lower, ".localhost")
}
func addrFromIP(ip net.IP) (netip.Addr, bool) {
if v4 := ip.To4(); v4 != nil {
return netip.AddrFrom4([4]byte{v4[0], v4[1], v4[2], v4[3]}), true
}
if v6 := ip.To16(); v6 != nil {
return netip.AddrFrom16([16]byte{v6[0], v6[1], v6[2], v6[3], v6[4], v6[5], v6[6], v6[7], v6[8], v6[9], v6[10], v6[11], v6[12], v6[13], v6[14], v6[15]}), true
}
return netip.Addr{}, false
}
func isAllowedAddr(addr netip.Addr) bool {
return addr.IsValid() &&
addr.IsGlobalUnicast() &&
!addr.IsLoopback() &&
!addr.IsPrivate() &&
!addr.IsLinkLocalUnicast() &&
!addr.IsUnspecified()
}

View File

@@ -0,0 +1,68 @@
package proxytarget
import (
"context"
"net"
"net/http"
"net/url"
"strings"
"testing"
"time"
)
func TestValidateAllowsHTTPSTargetWithHostname(t *testing.T) {
if err := Validate("https://cdn.example.test/video/segment.ts"); err != nil {
t.Fatalf("Validate() error = %v, want nil", err)
}
}
func TestValidateRejectsUnsafeTargets(t *testing.T) {
tests := []string{
"ftp://cdn.example.test/video.ts",
"https:///video.ts",
"http://localhost/video.ts",
"http://127.0.0.1/video.ts",
"http://[::ffff:127.0.0.1]/video.ts",
"http://169.254.169.254/video.ts",
"https://[::1]/subtitle.vtt",
}
for _, targetURL := range tests {
t.Run(targetURL, func(t *testing.T) {
if err := Validate(targetURL); err == nil {
t.Fatal("Validate() error = nil, want error")
}
})
}
}
func TestCheckRedirectRejectsUnsafeLocation(t *testing.T) {
req := &http.Request{URL: &url.URL{Scheme: "http", Host: "127.0.0.1", Path: "/video.ts"}}
if err := checkRedirect(req, nil); err == nil {
t.Fatal("checkRedirect() error = nil, want error")
}
}
func TestValidatingDialContextRejectsPrivateResolvedAddress(t *testing.T) {
dial := validatingDialContext(&net.Dialer{Timeout: time.Millisecond}, fakeResolver{
ips: []net.IPAddr{{IP: net.ParseIP("192.168.1.20")}},
})
_, err := dial(context.Background(), "tcp", "cdn.example.test:443")
if err == nil {
t.Fatal("dial error = nil, want error")
}
if !strings.Contains(err.Error(), "no allowed addresses") {
t.Fatalf("dial error = %v, want no allowed addresses", err)
}
}
type fakeResolver struct {
ips []net.IPAddr
err error
}
func (r fakeResolver) LookupIPAddr(context.Context, string) ([]net.IPAddr, error) {
return r.ips, r.err
}

View File

@@ -2,15 +2,15 @@
package playback
import (
"context"
"fmt"
"mal/integrations/jikan"
"mal/internal/domain"
"mal/internal/observability"
errlog "mal/pkg"
"mal/internal/playback/proxytarget"
netutil "mal/pkg/net"
"net/http"
"time"
"golang.org/x/sync/singleflight"
)
type playbackService struct {
@@ -21,6 +21,8 @@ type playbackService struct {
httpClient *http.Client
proxyTokenKey string
proxyTokens *proxyTokenStore
sourceCache *sourceCache
sourceFlight singleflight.Group
auditSvc domain.AuditService
}
@@ -36,6 +38,7 @@ func NewPlaybackService(repo domain.PlaybackRepository, providers []domain.Provi
httpClient: netutil.NewClient(),
proxyTokenKey: string(proxyTokenKey),
proxyTokens: newProxyTokenStore(),
sourceCache: newSourceCache(defaultSourceCacheTTL, defaultSourceCacheStaleTTL, defaultSourceCacheMaxEntries),
}
}
@@ -43,6 +46,9 @@ func (s *playbackService) SignProxyToken(targetURL, referer, scope string) (stri
if s.proxyTokenKey == "" {
return "", nil
}
if err := proxytarget.Validate(targetURL); err != nil {
return "", err
}
return s.proxyTokens.create(targetURL, referer, scope, 2*time.Hour, time.Now())
}
@@ -59,27 +65,3 @@ func (s *playbackService) ResolveProxyToken(token string, scope string) (string,
}
return target.targetURL, target.referer, nil
}
func (s *playbackService) warmStreamURL(targetURL, referer string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, targetURL, nil)
if err != nil {
return
}
if referer != "" {
req.Header.Set("Referer", referer)
}
req.Header.Set("User-Agent", netutil.Firefox121)
resp, err := s.httpClient.Do(req)
if err != nil {
if resp != nil {
errlog.Close(resp.Body, "failed to close warm stream error response body")
}
observability.LogJSON(observability.LogLevelWarn, "warm_stream_failed", "playback", err.Error(), map[string]any{"url": targetURL}, nil)
return
}
errlog.Log("failed to close warm stream response body", resp.Body.Close())
}

View File

@@ -0,0 +1,152 @@
package playback
import (
"container/list"
"fmt"
"strings"
"sync"
"time"
"mal/internal/domain"
)
const (
defaultSourceCacheTTL = 5 * time.Minute
defaultSourceCacheStaleTTL = 10 * time.Minute
defaultSourceCacheMaxEntries = 512
)
type sourceCacheKey struct {
animeID int
episode string
mode string
}
func newSourceCacheKey(animeID int, episode string, mode string) sourceCacheKey {
return sourceCacheKey{
animeID: animeID,
episode: strings.TrimSpace(episode),
mode: normalizeSourceMode(mode),
}
}
func (k sourceCacheKey) flightKey() string {
return fmt.Sprintf("%d|%q|%q", k.animeID, k.episode, k.mode)
}
func normalizeSourceMode(mode string) string {
mode = strings.ToLower(strings.TrimSpace(mode))
if mode == "" {
return "sub"
}
return mode
}
type sourceCacheEntry struct {
key sourceCacheKey
result *domain.StreamResult
freshUntil time.Time
staleUntil time.Time
}
type sourceCacheState uint8
const (
sourceCacheMiss sourceCacheState = iota
sourceCacheFresh
sourceCacheStale
)
type sourceCache struct {
mu sync.Mutex
freshTTL time.Duration
staleTTL time.Duration
maxEntries int
entries map[sourceCacheKey]*list.Element
lru *list.List
}
func newSourceCache(freshTTL, staleTTL time.Duration, maxEntries int) *sourceCache {
if freshTTL <= 0 {
freshTTL = defaultSourceCacheTTL
}
if staleTTL < 0 {
staleTTL = 0
}
if maxEntries <= 0 {
maxEntries = defaultSourceCacheMaxEntries
}
return &sourceCache{
freshTTL: freshTTL,
staleTTL: staleTTL,
maxEntries: maxEntries,
entries: make(map[sourceCacheKey]*list.Element, maxEntries),
lru: list.New(),
}
}
func (c *sourceCache) get(key sourceCacheKey, now time.Time) (*domain.StreamResult, sourceCacheState) {
c.mu.Lock()
defer c.mu.Unlock()
el := c.entries[key]
if el == nil {
return nil, sourceCacheMiss
}
entry, ok := el.Value.(sourceCacheEntry)
if !ok || !now.Before(entry.staleUntil) {
c.remove(el)
return nil, sourceCacheMiss
}
c.lru.MoveToFront(el)
if now.Before(entry.freshUntil) {
return cloneStreamResult(entry.result), sourceCacheFresh
}
return cloneStreamResult(entry.result), sourceCacheStale
}
func (c *sourceCache) set(key sourceCacheKey, result *domain.StreamResult, now time.Time) (evicted bool) {
c.mu.Lock()
defer c.mu.Unlock()
entry := sourceCacheEntry{
key: key,
result: cloneStreamResult(result),
freshUntil: now.Add(c.freshTTL),
staleUntil: now.Add(c.freshTTL + c.staleTTL),
}
if el := c.entries[key]; el != nil {
el.Value = entry
c.lru.MoveToFront(el)
return false
}
c.entries[key] = c.lru.PushFront(entry)
for len(c.entries) > c.maxEntries {
back := c.lru.Back()
if back == nil {
break
}
c.remove(back)
evicted = true
}
return evicted
}
func (c *sourceCache) remove(el *list.Element) {
entry, ok := el.Value.(sourceCacheEntry)
if ok {
delete(c.entries, entry.key)
}
c.lru.Remove(el)
}
func cloneStreamResult(result *domain.StreamResult) *domain.StreamResult {
if result == nil {
return nil
}
cloned := *result
cloned.Subtitles = append([]domain.Subtitle(nil), result.Subtitles...)
cloned.Qualities = append([]domain.StreamSource(nil), result.Qualities...)
return &cloned
}

View File

@@ -0,0 +1,147 @@
package playback
import (
"context"
"errors"
"sync"
"sync/atomic"
"testing"
"time"
"mal/internal/domain"
)
type sourceCacheProvider struct {
calls atomic.Int32
get func(context.Context, int, []string, string, string) (*domain.StreamResult, error)
}
func (p *sourceCacheProvider) Name() string { return "test" }
func (p *sourceCacheProvider) GetStreams(ctx context.Context, animeID int, titles []string, episode string, mode string) (*domain.StreamResult, error) {
p.calls.Add(1)
return p.get(ctx, animeID, titles, episode, mode)
}
func newSourceCacheService(provider domain.Provider) *playbackService {
return &playbackService{
providers: []domain.Provider{provider},
sourceCache: newSourceCache(time.Minute, time.Minute, 8),
}
}
func TestResolveStreamResultCachesClonedProviderResult(t *testing.T) {
provider := &sourceCacheProvider{get: func(context.Context, int, []string, string, string) (*domain.StreamResult, error) {
return &domain.StreamResult{
URL: "https://cdn.example.test/episode.m3u8",
Subtitles: []domain.Subtitle{{URL: "https://cdn.example.test/sub.vtt", Label: "English"}},
}, nil
}}
svc := newSourceCacheService(provider)
first := svc.resolveStreamResult(context.Background(), 42, []string{"Title"}, "3", "SUB", false, false)
first.Subtitles[0].Label = "changed"
second := svc.resolveStreamResult(context.Background(), 42, []string{"Different title"}, "3", "sub", false, false)
if provider.calls.Load() != 1 {
t.Fatalf("provider calls = %d, want 1", provider.calls.Load())
}
if second == nil || second.Subtitles[0].Label != "English" {
t.Fatalf("cached result was not cloned: %#v", second)
}
}
func TestResolveStreamResultCollapsesConcurrentMisses(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
provider := &sourceCacheProvider{get: func(context.Context, int, []string, string, string) (*domain.StreamResult, error) {
select {
case <-started:
default:
close(started)
}
<-release
return &domain.StreamResult{URL: "https://cdn.example.test/episode.m3u8"}, nil
}}
svc := newSourceCacheService(provider)
const requests = 8
results := make(chan *domain.StreamResult, requests)
var wg sync.WaitGroup
for range requests {
wg.Go(func() {
results <- svc.resolveStreamResult(context.Background(), 42, nil, "3", "sub", false, false)
})
}
<-started
close(release)
wg.Wait()
close(results)
for result := range results {
if result == nil {
t.Fatal("concurrent lookup returned nil")
}
}
if provider.calls.Load() != 1 {
t.Fatalf("provider calls = %d, want 1", provider.calls.Load())
}
}
func TestResolveStreamResultSeparatesKeysAndForcesRefresh(t *testing.T) {
provider := &sourceCacheProvider{get: func(_ context.Context, _ int, _ []string, episode string, mode string) (*domain.StreamResult, error) {
return &domain.StreamResult{URL: "https://cdn.example.test/" + episode + "/" + mode}, nil
}}
svc := newSourceCacheService(provider)
svc.resolveStreamResult(context.Background(), 42, nil, "1", "sub", false, false)
svc.resolveStreamResult(context.Background(), 42, nil, "2", "sub", false, false)
svc.resolveStreamResult(context.Background(), 42, nil, "2", "dub", false, false)
svc.resolveStreamResult(context.Background(), 42, nil, "2", "dub", false, true)
if provider.calls.Load() != 4 {
t.Fatalf("provider calls = %d, want 4", provider.calls.Load())
}
}
func TestResolveStreamResultUsesStaleCompletedMediaOnProviderError(t *testing.T) {
provider := &sourceCacheProvider{get: func(context.Context, int, []string, string, string) (*domain.StreamResult, error) {
return nil, errors.New("provider unavailable")
}}
svc := newSourceCacheService(provider)
svc.sourceCache.set(
newSourceCacheKey(42, "3", "sub"),
&domain.StreamResult{URL: "https://cdn.example.test/stale.m3u8"},
time.Now().Add(-90*time.Second),
)
result := svc.resolveStreamResult(context.Background(), 42, nil, "3", "sub", true, false)
if result == nil || result.URL != "https://cdn.example.test/stale.m3u8" {
t.Fatalf("result = %#v, want stale source", result)
}
if airing := svc.resolveStreamResult(context.Background(), 42, nil, "3", "sub", false, false); airing != nil {
t.Fatalf("airing result = %#v, want nil after provider failure", airing)
}
}
func TestSourceCacheExpiresAndEvictsLeastRecentlyUsed(t *testing.T) {
now := time.Unix(100, 0)
cache := newSourceCache(time.Minute, time.Minute, 2)
keyA := newSourceCacheKey(1, "1", "sub")
keyB := newSourceCacheKey(1, "2", "sub")
keyC := newSourceCacheKey(1, "3", "sub")
cache.set(keyA, &domain.StreamResult{URL: "a"}, now)
cache.set(keyB, &domain.StreamResult{URL: "b"}, now)
cache.get(keyA, now)
cache.set(keyC, &domain.StreamResult{URL: "c"}, now)
if _, state := cache.get(keyB, now); state != sourceCacheMiss {
t.Fatalf("evicted cache state = %d, want miss", state)
}
if _, state := cache.get(keyA, now.Add(90*time.Second)); state != sourceCacheStale {
t.Fatalf("stale cache state = %d, want stale", state)
}
if _, state := cache.get(keyA, now.Add(2*time.Minute)); state != sourceCacheMiss {
t.Fatalf("expired cache state = %d, want miss", state)
}
}

View File

@@ -2,29 +2,43 @@ package playback
import (
"context"
"errors"
"fmt"
"sort"
"strconv"
"strings"
"time"
"mal/integrations/jikan"
"mal/internal/domain"
"mal/internal/observability"
)
const sourceResolutionTimeout = 15 * time.Second
type sourceResolutionResult struct {
result *domain.StreamResult
err error
duration time.Duration
shared bool
completed bool
}
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)
animeData, err := s.watchAnime(ctx, animeID)
if err != nil {
return domain.WatchPageData{}, fmt.Errorf("failed to fetch anime: %w", err)
}
animeData := domain.Anime{Anime: anime}
if err := s.ensureAnimeRow(ctx, animeData); err != nil {
observability.Warn("upsert_anime_failed", "playback", "",
map[string]any{"anime_id": animeID},
err,
)
}
anime := animeData.Anime
searchTitles := buildSearchTitles(animeData, titleCandidates)
eps, err := s.episodes.GetCanonicalEpisodes(ctx, animeData, false)
if err != nil {
@@ -33,24 +47,18 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
// mode fallback
mode, from := resolveMode(episode, mode, eps.Episodes)
modeSources, result, resolvedMode, switchedFrom := s.resolveModeSources(ctx, animeID, searchTitles, episode, mode)
if resolvedMode != "" {
mode = resolvedMode
}
if switchedFrom != "" {
from = switchedFrom
}
deferred := domain.PlaybackDataDeferred(ctx)
modeSources, result, mode, from := s.watchModeSources(
ctx, animeID, searchTitles, episode, mode, from, !anime.Airing, deferred,
)
startTime, watchlistStatus, watchlistIDs := s.loadWatchProgress(ctx, userID, animeID, anime.Episodes, episode)
seasons := s.loadSeasons(ctx, animeID)
segments, err := s.fetchSkipSegments(ctx, userID, animeID, episode)
if err != nil {
observability.Warn("fetch_skip_segments_failed", "playback", "",
map[string]any{"anime_id": animeID, "episode": episode},
err,
)
}
segments := s.watchSegments(ctx, userID, animeID, episode, deferred)
watchData := buildWatchDataPayload(animeData, animeID, episode, startTime, eps.Episodes, modeSources, mode, from, segments)
pageData := buildWatchPageData(animeData, eps.Episodes, episode, watchlistStatus, watchlistIDs, seasons, watchData)
pageData := buildWatchPageData(animeData, eps.Episodes, episode, watchlistStatus, watchlistIDs, nil, watchData)
pageData.EpisodeAvailabilityWarning = episodeAvailabilityWarning(eps, time.Now())
if deferred {
return pageData, nil
}
if len(modeSources) == 0 {
return pageData, fmt.Errorf("no streams found")
}
@@ -58,10 +66,99 @@ func (s *playbackService) BuildWatchData(ctx context.Context, animeID int, title
return pageData, fmt.Errorf("no streams found for mode %s", mode)
}
go s.warmStreamURL(result.URL, result.Referer)
return pageData, nil
}
func (s *playbackService) EnrichEpisodeTitles(ctx context.Context, animeID int) ([]domain.CanonicalEpisode, error) {
anime, err := s.watchAnime(ctx, animeID)
if err != nil {
return nil, fmt.Errorf("failed to fetch anime for episode titles: %w", err)
}
enricher, ok := s.episodes.(domain.EpisodeTitleService)
if !ok {
return nil, errors.New("episode title enrichment is unavailable")
}
episodes, err := enricher.EnrichEpisodeTitles(ctx, anime)
if err != nil {
return nil, err
}
return episodes.Episodes, nil
}
func (s *playbackService) EnrichEpisodeClassifications(ctx context.Context, animeID int) ([]domain.CanonicalEpisode, error) {
anime, err := s.watchAnime(ctx, animeID)
if err != nil {
return nil, fmt.Errorf("failed to fetch anime for episode classifications: %w", err)
}
enricher, ok := s.episodes.(domain.EpisodeClassificationService)
if !ok {
return nil, errors.New("episode classification enrichment is unavailable")
}
episodes, err := enricher.EnrichEpisodeClassifications(ctx, anime)
if err != nil {
return nil, err
}
return episodes.Episodes, nil
}
func (s *playbackService) watchAnime(ctx context.Context, animeID int) (domain.Anime, error) {
row, err := s.repo.GetAnime(ctx, int64(animeID))
if err == nil && row.ID > 0 && strings.TrimSpace(row.TitleOriginal) != "" {
anime := jikan.Anime{
MalID: int(row.ID),
Title: row.TitleOriginal,
TitleEnglish: row.TitleEnglish.String,
TitleJapanese: row.TitleJapanese.String,
Airing: row.Airing.Valid && row.Airing.Bool,
Status: row.Status.String,
}
return domain.Anime{Anime: anime}, nil
}
anime, err := s.jikan.GetAnimeByID(ctx, animeID)
if err != nil {
return domain.Anime{}, err
}
return domain.Anime{Anime: anime}, nil
}
func (s *playbackService) watchModeSources(ctx context.Context, animeID int, searchTitles []string, episode, mode, from string, allowStale, deferred bool) (map[string]domain.ModeSource, *domain.StreamResult, string, string) {
if deferred {
return map[string]domain.ModeSource{}, nil, mode, from
}
modeSources, result, resolvedMode, switchedFrom := s.resolveModeSources(
ctx,
animeID,
searchTitles,
episode,
mode,
allowStale,
domain.PlaybackSourceRefreshRequested(ctx),
)
if resolvedMode != "" {
mode = resolvedMode
}
if switchedFrom != "" {
from = switchedFrom
}
return modeSources, result, mode, from
}
func (s *playbackService) watchSegments(ctx context.Context, userID string, animeID int, episode string, deferred bool) []domain.SkipSegment {
if deferred {
return []domain.SkipSegment{}
}
segments, err := s.fetchSkipSegments(ctx, userID, animeID, episode)
if err != nil {
observability.Warn("fetch_skip_segments_failed", "playback", "",
map[string]any{"anime_id": animeID, "episode": episode},
err,
)
}
return segments
}
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 {
return domain.WatchData{
MalID: animeID,
@@ -96,6 +193,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))
@@ -144,15 +258,16 @@ func resolveMode(episode string, requestedMode string, episodes []domain.Canonic
return requestedMode, ""
}
func (s *playbackService) resolveModeSources(ctx context.Context, animeID int, searchTitles []string, episode string, requestedMode string) (map[string]domain.ModeSource, *domain.StreamResult, string, string) {
if res := s.resolveStreamResult(ctx, animeID, searchTitles, episode, requestedMode); res != nil {
func (s *playbackService) resolveModeSources(ctx context.Context, animeID int, searchTitles []string, episode string, requestedMode string, allowStale bool, forceRefresh bool) (map[string]domain.ModeSource, *domain.StreamResult, string, string) {
requestedMode = normalizeSourceMode(requestedMode)
if res := s.resolveStreamResult(ctx, animeID, searchTitles, episode, requestedMode, allowStale, forceRefresh); res != nil {
return map[string]domain.ModeSource{
requestedMode: s.buildModeSource(res),
}, res, requestedMode, ""
}
for _, fallbackMode := range fallbackModes(requestedMode) {
res := s.resolveStreamResult(ctx, animeID, searchTitles, episode, fallbackMode)
res := s.resolveStreamResult(ctx, animeID, searchTitles, episode, fallbackMode, allowStale, forceRefresh)
if res == nil {
continue
}
@@ -164,23 +279,85 @@ func (s *playbackService) resolveModeSources(ctx context.Context, animeID int, s
return map[string]domain.ModeSource{}, nil, requestedMode, ""
}
func (s *playbackService) resolveStreamResult(ctx context.Context, animeID int, searchTitles []string, episode string, mode string) *domain.StreamResult {
for _, p := range s.providers {
res, err := p.GetStreams(ctx, animeID, searchTitles, episode, mode)
if err == nil && res != nil {
return res
}
func (s *playbackService) resolveStreamResult(ctx context.Context, animeID int, searchTitles []string, episode string, mode string, allowStale bool, forceRefresh bool) *domain.StreamResult {
key := newSourceCacheKey(animeID, episode, mode)
stale, state := s.sourceCache.get(key, time.Now())
if !forceRefresh && state == sourceCacheFresh {
observability.Info("playback_source_cache_hit", "playback", "", map[string]any{"anime_id": animeID, "episode": episode, "mode": key.mode})
return stale
}
observability.Info("playback_source_cache_miss", "playback", "", map[string]any{"anime_id": animeID, "episode": episode, "mode": key.mode, "forced": forceRefresh})
resolved := s.waitForSourceResult(ctx, key, animeID, searchTitles, forceRefresh)
if !resolved.completed {
return nil
}
observability.Info("playback_source_resolution", "playback", "", map[string]any{
"anime_id": animeID, "episode": episode, "mode": key.mode,
"duration_ms": resolved.duration.Milliseconds(), "shared": resolved.shared,
})
if resolved.err == nil {
return cloneStreamResult(resolved.result)
}
if allowStale && state == sourceCacheStale && stale != nil {
observability.Warn("playback_source_cache_stale_hit", "playback", "", map[string]any{"anime_id": animeID, "episode": episode, "mode": key.mode}, errors.New("provider source refresh failed"))
return stale
}
observability.Warn("playback_source_resolution_failed", "playback", "", map[string]any{"anime_id": animeID, "episode": episode, "mode": key.mode}, resolved.err)
return nil
}
func (s *playbackService) waitForSourceResult(ctx context.Context, key sourceCacheKey, animeID int, searchTitles []string, forceRefresh bool) sourceResolutionResult {
startedAt := time.Now()
resultCh := s.sourceFlight.DoChan(key.flightKey(), func() (any, error) {
return s.resolveSource(key, animeID, searchTitles, forceRefresh)
})
select {
case <-ctx.Done():
return sourceResolutionResult{err: ctx.Err(), duration: time.Since(startedAt)}
case resolved := <-resultCh:
result, _ := resolved.Val.(*domain.StreamResult)
return sourceResolutionResult{
result: result, err: resolved.Err, shared: resolved.Shared,
duration: time.Since(startedAt), completed: true,
}
}
}
func (s *playbackService) resolveSource(key sourceCacheKey, animeID int, searchTitles []string, forceRefresh bool) (*domain.StreamResult, error) {
if !forceRefresh {
if cached, state := s.sourceCache.get(key, time.Now()); state == sourceCacheFresh {
return cached, nil
}
}
resolveCtx, cancel := context.WithTimeout(context.Background(), sourceResolutionTimeout)
defer cancel()
var lastErr error
for _, provider := range s.providers {
result, err := provider.GetStreams(resolveCtx, animeID, searchTitles, key.episode, key.mode)
if err == nil && result != nil {
if s.sourceCache.set(key, result, time.Now()) {
observability.Info("playback_source_cache_eviction", "playback", "", nil)
}
return cloneStreamResult(result), nil
}
if err != nil {
lastErr = err
}
}
if lastErr == nil {
lastErr = errors.New("no provider returned a stream")
}
return nil, lastErr
}
func (s *playbackService) buildModeSource(res *domain.StreamResult) domain.ModeSource {
subtitles := make([]domain.SubtitleItem, 0, len(res.Subtitles))
for _, sub := range res.Subtitles {
token, err := s.SignProxyToken(sub.URL, res.Referer, "subtitle")
if err != nil {
observability.LogJSON(observability.LogLevelWarn, "sign_subtitle_token_failed", "playback", err.Error(), map[string]any{"url": sub.URL}, nil)
observability.Warn("sign_subtitle_token_failed", "playback", "", nil, err)
}
subtitles = append(subtitles, domain.SubtitleItem{
Lang: sub.Label,
@@ -190,7 +367,7 @@ func (s *playbackService) buildModeSource(res *domain.StreamResult) domain.ModeS
streamToken, err := s.SignProxyToken(res.URL, res.Referer, "stream")
if err != nil {
observability.LogJSON(observability.LogLevelWarn, "sign_stream_token_failed", "playback", err.Error(), map[string]any{"url": res.URL}, nil)
observability.Warn("sign_stream_token_failed", "playback", "", nil, err)
}
return domain.ModeSource{
Token: streamToken,
@@ -199,40 +376,6 @@ func (s *playbackService) buildModeSource(res *domain.StreamResult) domain.ModeS
}
}
func (s *playbackService) loadSeasons(ctx context.Context, animeID int) []domain.SeasonEntry {
relations, err := s.jikan.GetFullRelations(ctx, animeID, jikan.WatchOrderModeMain)
if err != nil {
observability.Warn("fetch_relations_failed", "playback", "",
map[string]any{"anime_id": animeID},
err,
)
}
seasons := make([]domain.SeasonEntry, 0, len(relations))
tvCounter := 1
for _, rel := range relations {
animeType := strings.ToLower(rel.Anime.Type)
if animeType != "tv" && animeType != "movie" {
continue
}
season := domain.SeasonEntry{
MalID: rel.Anime.MalID,
Title: rel.Anime.DisplayTitle(),
Prefix: rel.Relation,
IsCurrent: rel.IsCurrent,
}
if rel.Relation == "TV" {
season.Prefix = fmt.Sprintf("S%d", tvCounter)
tvCounter++
}
seasons = append(seasons, season)
}
return seasons
}
func availableModes(modeSources map[string]domain.ModeSource) []string {
modes := make([]string, 0, len(modeSources))
for mode := range modeSources {

View File

@@ -1,9 +1,43 @@
package playback
import (
"context"
"mal/internal/domain"
"testing"
"time"
)
func TestWatchModeSourcesDefersProviderResolution(t *testing.T) {
provider := &sourceCacheProvider{get: func(context.Context, int, []string, string, string) (*domain.StreamResult, error) {
t.Fatal("deferred watch page resolved a provider source")
return nil, nil
}}
svc := newSourceCacheService(provider)
sources, result, mode, from := svc.watchModeSources(
context.Background(), 42, nil, "1", "sub", "", false, true,
)
if len(sources) != 0 || result != nil || mode != "sub" || from != "" {
t.Fatalf("deferred result = (%v, %v, %q, %q)", sources, result, mode, from)
}
if provider.calls.Load() != 0 {
t.Fatalf("provider calls = %d, want 0", provider.calls.Load())
}
}
func TestWatchAnimeUsesLocalRow(t *testing.T) {
svc := &playbackService{repo: &fakePlaybackRepository{}}
anime, err := svc.watchAnime(context.Background(), 12)
if err != nil {
t.Fatalf("watchAnime() error = %v", err)
}
if anime.MalID != 12 || anime.Title != "Anime 12" {
t.Fatalf("watchAnime() = %+v", anime.Anime)
}
}
func TestFallbackModes(t *testing.T) {
t.Parallel()
@@ -33,3 +67,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)
}
})
}
}

View File

@@ -0,0 +1,113 @@
package server
import (
"context"
"mal/internal/config"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestCORSAllowAllSetsOriginHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := config.Config{CORSAllowAll: true}
router := gin.New()
router.Use(CORSMiddlewareWithConfig(cfg))
router.GET("/api/test", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/test", nil)
req.Header.Set("Origin", "https://example.com")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://example.com" {
t.Fatalf("Access-Control-Allow-Origin = %q, want %q", got, "https://example.com")
}
if got := rec.Header().Get("Vary"); got != "Origin" {
t.Fatalf("Vary = %q, want %q", got, "Origin")
}
}
func TestCORSDisallowNonLocalOrigin(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := config.Config{CORSAllowAll: false}
router := gin.New()
router.Use(CORSMiddlewareWithConfig(cfg))
router.GET("/api/test", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/test", nil)
req.Header.Set("Origin", "https://example.com")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Fatalf("Access-Control-Allow-Origin = %q, want empty", got)
}
}
func TestCORSAllowsLocalOrigin(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := config.Config{CORSAllowAll: false}
router := gin.New()
router.Use(CORSMiddlewareWithConfig(cfg))
router.GET("/api/test", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
for _, origin := range []string{"http://localhost:3000", "https://localhost:5173", "http://127.0.0.1:8080"} {
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/test", nil)
req.Header.Set("Origin", origin)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != origin {
t.Fatalf("origin %q: Access-Control-Allow-Origin = %q, want %q", origin, got, origin)
}
}
}
func TestCORSPreflightReturnsNoContent(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := config.Config{CORSAllowAll: true}
router := gin.New()
router.Use(CORSMiddlewareWithConfig(cfg))
req := httptest.NewRequestWithContext(context.Background(), http.MethodOptions, "/api/test", nil)
req.Header.Set("Origin", "http://localhost:3000")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNoContent)
}
}
func TestCORSPreflightSkipsNonAPI(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := config.Config{CORSAllowAll: true}
router := gin.New()
router.Use(CORSMiddlewareWithConfig(cfg))
router.GET("/test", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequestWithContext(context.Background(), http.MethodOptions, "/test", nil)
req.Header.Set("Origin", "http://localhost:3000")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
}

View File

@@ -2,6 +2,7 @@ package server
import (
"mal/internal/observability"
"net/http"
"time"
"github.com/gin-gonic/gin"
@@ -12,6 +13,9 @@ func RequestLogger() gin.HandlerFunc {
start := time.Now()
path := c.Request.URL.Path
query := c.Request.URL.RawQuery
if c.Request.URL.Path == "/watch/proxy/stream" || c.Request.URL.Path == "/watch/proxy/subtitle" {
query = ""
}
c.Next()
@@ -22,15 +26,8 @@ func RequestLogger() gin.HandlerFunc {
duration := time.Since(start)
status := c.Writer.Status()
fields := map[string]any{
"client_ip": c.ClientIP(),
"duration_ms": float64(duration.Microseconds()) / 1000,
"method": c.Request.Method,
"path": path,
"request_id": c.Writer.Header().Get(requestIDHeader),
"status": status,
}
privateErrors := c.Errors.ByType(gin.ErrorTypePrivate)
privateErrorText := privateErrors.String()
var logErr error
if len(privateErrors) > 0 {
logErr = privateErrors.Last().Err
@@ -38,17 +35,8 @@ func RequestLogger() gin.HandlerFunc {
if route == "/watch/proxy/stream" && status < 400 && len(privateErrors) == 0 {
return
}
if route != path {
fields["route"] = route
}
if query != "" {
fields["query"] = query
}
if size := c.Writer.Size(); size >= 0 {
fields["bytes"] = size
}
if errors := privateErrors.String(); errors != "" {
fields["errors"] = errors
if c.FullPath() == "" && status == http.StatusSeeOther {
return
}
observability.LogContext(
@@ -57,12 +45,37 @@ func RequestLogger() gin.HandlerFunc {
"http_request",
"http",
c.Request.Method+" "+path,
fields,
requestLogFields(c, path, query, route, duration, status, privateErrorText),
logErr,
)
}
}
func requestLogFields(c *gin.Context, path, query, route string, duration time.Duration, status int, privateErrorText string) map[string]any {
fields := map[string]any{
"client_ip": c.ClientIP(),
"duration_ms": float64(duration.Microseconds()) / 1000,
"method": c.Request.Method,
"path": path,
"request_id": c.Writer.Header().Get(requestIDHeader),
"status": status,
}
if route != path {
fields["route"] = route
}
if query != "" {
fields["query"] = query
}
if size := c.Writer.Size(); size >= 0 {
fields["bytes"] = size
}
if privateErrorText != "" {
fields["errors"] = privateErrorText
}
return fields
}
func requestLogLevel(status int) observability.LogLevel {
if status >= 500 {
return observability.LogLevelError

View File

@@ -0,0 +1,83 @@
package server
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestAcceptsHTMLFromAcceptHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
c.Request.Header.Set("Accept", "text/html,application/xhtml+xml")
if !acceptsHTML(c) {
t.Fatal("acceptsHTML = false, want true")
}
}
func TestAcceptsHTMLFromHXRequest(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
c.Request.Header.Set("HX-Request", "true")
if !acceptsHTML(c) {
t.Fatal("acceptsHTML = false, want true")
}
}
func TestAcceptsHTMLReturnsFalseForJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
c.Request.Header.Set("Accept", "application/json")
if acceptsHTML(c) {
t.Fatal("acceptsHTML = true, want false")
}
}
func TestRespondHTMLOrJSONErrorReturnsHTML(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
c.Request.Header.Set("Accept", "text/html")
RespondHTMLOrJSONError(c, http.StatusNotFound, "not found")
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", w.Code, http.StatusNotFound)
}
if w.Body.String() != "not found" {
t.Fatalf("body = %q, want %q", w.Body.String(), "not found")
}
}
func TestRespondHTMLOrJSONErrorReturnsJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/", nil)
c.Request.Header.Set("Accept", "application/json")
RespondHTMLOrJSONError(c, http.StatusBadRequest, "bad request")
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d", w.Code, http.StatusBadRequest)
}
if !strings.Contains(w.Body.String(), `"error":"bad request"`) {
t.Fatalf("body = %q, want json error", w.Body.String())
}
}

View File

@@ -7,7 +7,6 @@ import (
"mal/internal/config"
"mal/internal/observability"
"net/http"
_ "net/http/pprof" // register pprof handlers on http.DefaultServeMux
"time"
"github.com/gin-gonic/gin"
@@ -27,11 +26,27 @@ func ProvideRouter(cfg config.Config, htmlRender render.HTMLRender) *gin.Engine
gin.SetMode(cfg.GinMode)
}
r := gin.New()
r.Use(CORSMiddlewareWithConfig(cfg), RequestContextMiddleware(), audit.ContextMiddleware(), RequestLogger(), gin.Recovery())
r.Use(CORSMiddlewareWithConfig(cfg), RequestContextMiddleware(), audit.ContextMiddleware(), RequestLogger(), CompressionMiddleware(), StaticCacheMiddleware(), gin.Recovery())
r.NoRoute(func(c *gin.Context) {
if acceptsHTML(c) {
c.HTML(http.StatusNotFound, "not_found.gohtml", gin.H{
"CurrentPath": c.Request.URL.Path,
})
return
}
c.JSON(http.StatusNotFound, ErrorResponse{Error: "Not found"})
})
r.GET("/robots.txt", func(c *gin.Context) {
c.String(http.StatusOK, "User-agent: *\nDisallow: /\n")
})
r.GET("/sitemap.xml", func(c *gin.Context) {
c.String(http.StatusOK, `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
</urlset>
`)
})
r.Static("/static", "./static")
r.Static("/dist", "./dist")
r.GET("/debug/pprof", gin.WrapH(http.DefaultServeMux))
r.GET("/debug/pprof/*action", gin.WrapH(http.DefaultServeMux))
r.HTMLRender = htmlRender
return r
}

View File

@@ -6,6 +6,7 @@ import (
"io"
"log"
"mal/internal/config"
"mal/templates"
"net/http"
"net/http/httptest"
"strings"
@@ -35,7 +36,7 @@ func TestNewHTTPServer_TimeoutsAndAddr(t *testing.T) {
}
}
func TestProvideRouterRegistersPprof(t *testing.T) {
func TestProvideRouterDoesNotRegisterPprof(t *testing.T) {
gin.SetMode(gin.TestMode)
router := ProvideRouter(config.Config{GinMode: gin.TestMode}, nil)
@@ -44,11 +45,11 @@ func TestProvideRouterRegistersPprof(t *testing.T) {
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("pprof status = %d, want %d", rec.Code, http.StatusOK)
if rec.Code != http.StatusNotFound {
t.Fatalf("pprof status = %d, want %d", rec.Code, http.StatusNotFound)
}
if !strings.Contains(rec.Body.String(), "Types of profiles available") {
t.Fatalf("pprof index missing profile list: %s", rec.Body.String())
if strings.Contains(rec.Body.String(), "Types of profiles available") {
t.Fatalf("pprof index should not be exposed: %s", rec.Body.String())
}
}
@@ -148,6 +149,34 @@ func TestRequestLoggerLogsFailedStreamProxy(t *testing.T) {
}
}
func TestRequestLoggerRedactsProxyTokenQuery(t *testing.T) {
gin.SetMode(gin.TestMode)
var logs bytes.Buffer
previousOutput := log.Writer()
log.SetOutput(&logs)
defer log.SetOutput(previousOutput)
router := gin.New()
router.Use(RequestContextMiddleware())
router.Use(RequestLogger())
router.GET("/watch/proxy/subtitle", func(c *gin.Context) {
c.Status(http.StatusForbidden)
})
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/watch/proxy/subtitle?lang=en&token=secret-token", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusForbidden)
}
got := logs.String()
if strings.Contains(got, "secret-token") || strings.Contains(got, "query=") {
t.Fatalf("log leaked proxy query: %s", got)
}
}
func TestRespondErrorIncludesRequestContext(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -182,3 +211,50 @@ func TestRespondErrorIncludesRequestContext(t *testing.T) {
t.Fatalf("log line missing request route: %s", logLine)
}
}
func TestProvideRouterRendersNotFoundPageForHTMLRequests(t *testing.T) {
gin.SetMode(gin.TestMode)
renderer, err := templates.ProvideRenderer()
if err != nil {
t.Fatalf("ProvideRenderer: %v", err)
}
router := ProvideRouter(config.Config{GinMode: gin.TestMode}, renderer)
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/missing", nil)
req.Header.Set("Accept", "text/html")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
for _, want := range []string{"You got a little lost", "This page slipped off the map for a minute", "Head back home"} {
if !strings.Contains(rec.Body.String(), want) {
t.Fatalf("not found page missing %q in body:\n%s", want, rec.Body.String())
}
}
}
func TestProvideRouterReturnsJSONForAPINotFound(t *testing.T) {
gin.SetMode(gin.TestMode)
renderer, err := templates.ProvideRenderer()
if err != nil {
t.Fatalf("ProvideRenderer: %v", err)
}
router := ProvideRouter(config.Config{GinMode: gin.TestMode}, renderer)
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/missing", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusNotFound)
}
if got := rec.Body.String(); got != `{"error":"Not found"}` {
t.Fatalf("body = %q, want %q", got, `{"error":"Not found"}`)
}
}

View File

@@ -0,0 +1,414 @@
package server
import (
"bufio"
"bytes"
"compress/gzip"
"errors"
"mime"
"net"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/gin-gonic/gin"
)
func addVary(header http.Header, value string) {
for _, line := range header.Values("Vary") {
for existing := range strings.SplitSeq(line, ",") {
if strings.EqualFold(strings.TrimSpace(existing), value) {
return
}
}
}
header.Add("Vary", value)
}
func isCompressibleContentType(value string) bool {
mediaType, _, err := mime.ParseMediaType(value)
if err != nil {
mediaType = strings.TrimSpace(strings.Split(value, ";")[0])
}
mediaType = strings.ToLower(mediaType)
if strings.HasPrefix(mediaType, "text/") || strings.HasSuffix(mediaType, "+json") || strings.HasSuffix(mediaType, "+xml") {
return true
}
switch mediaType {
case "application/javascript", "application/json", "application/manifest+json", "application/x-javascript", "application/xhtml+xml", "application/xml", "image/svg+xml":
return true
default:
return false
}
}
func isCompressedPath(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".7z", ".avif", ".br", ".gif", ".gz", ".ico", ".jpeg", ".jpg", ".m4a", ".m4v", ".mov", ".mp3", ".mp4", ".oga", ".ogg", ".ogv", ".pdf", ".png", ".rar", ".webm", ".webp", ".woff", ".woff2", ".zip":
return true
default:
return false
}
}
func isImagePath(path string) bool {
switch strings.ToLower(filepath.Ext(path)) {
case ".avif", ".gif", ".ico", ".jpeg", ".jpg", ".png", ".svg", ".webp":
return true
default:
return false
}
}
func staticCachePolicy(r *http.Request) string {
path := r.URL.Path
switch {
case strings.HasPrefix(path, "/dist/"):
if r.URL.Query().Get("v") != "" {
return "public, max-age=31536000, immutable"
}
return "public, max-age=0, must-revalidate"
case strings.HasPrefix(path, "/static/assets/") && r.URL.Query().Get("v") != "":
return "public, max-age=31536000, immutable"
case strings.HasPrefix(path, "/static/assets/") && isImagePath(path):
return "public, max-age=86400"
default:
return ""
}
}
type cacheControlWriter struct {
gin.ResponseWriter
policy string
}
func (w *cacheControlWriter) WriteHeader(status int) {
w.apply(status)
w.ResponseWriter.WriteHeader(status)
}
func (w *cacheControlWriter) WriteHeaderNow() {
w.apply(w.Status())
w.ResponseWriter.WriteHeaderNow()
}
func (w *cacheControlWriter) Write(data []byte) (int, error) {
if !w.Written() {
w.apply(w.Status())
}
return w.ResponseWriter.Write(data)
}
func (w *cacheControlWriter) WriteString(data string) (int, error) {
if !w.Written() {
w.apply(w.Status())
}
return w.ResponseWriter.WriteString(data)
}
func (w *cacheControlWriter) Flush() {
if !w.Written() {
w.apply(w.Status())
}
w.ResponseWriter.Flush()
}
func (w *cacheControlWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
return w.ResponseWriter.Hijack()
}
func (w *cacheControlWriter) apply(status int) {
if status >= http.StatusOK && status < http.StatusMultipleChoices || status == http.StatusNotModified {
w.Header().Set("Cache-Control", w.policy)
} else {
w.Header().Del("Cache-Control")
}
}
func StaticCacheMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
return
}
policy := staticCachePolicy(c.Request)
if policy == "" {
return
}
c.Writer = &cacheControlWriter{ResponseWriter: c.Writer, policy: policy}
c.Next()
}
}
type compressionMode uint8
const (
compressionUndecided compressionMode = iota
compressionPlain
compressionGzip
)
const compressionMinLength = 1024
func acceptsGzip(value string) bool {
wildcard := false
for part := range strings.SplitSeq(value, ",") {
params := strings.Split(part, ";")
encoding := strings.TrimSpace(strings.ToLower(params[0]))
quality := 1.0
for _, param := range params[1:] {
name, raw, ok := strings.Cut(strings.TrimSpace(param), "=")
if !ok || !strings.EqualFold(name, "q") {
continue
}
parsed, err := strconv.ParseFloat(raw, 64)
if err != nil {
quality = 0
} else {
quality = parsed
}
}
if encoding == "gzip" {
return quality > 0
}
if encoding == "*" && quality > 0 {
wildcard = true
}
}
return wildcard
}
func canNegotiateCompression(r *http.Request) bool {
if r.Method == http.MethodHead || r.Header.Get("Range") != "" {
return false
}
if strings.EqualFold(r.Header.Get("Connection"), "Upgrade") {
return false
}
if r.URL.Path == "/watch/proxy/stream" || r.URL.Path == "/watch/proxy/subtitle" {
return false
}
return !isCompressedPath(r.URL.Path)
}
type compressionWriter struct {
gin.ResponseWriter
buffer bytes.Buffer
gzip *gzip.Writer
mode compressionMode
status int
wroteBody bool
acceptsGzip bool
}
func (w *compressionWriter) WriteHeader(status int) {
if status > 0 && w.mode == compressionUndecided && !w.wroteBody {
w.status = status
}
}
func (w *compressionWriter) WriteHeaderNow() {
if w.mode == compressionUndecided {
w.startPlain()
_ = w.flushBuffer()
}
}
func (w *compressionWriter) WriteString(data string) (int, error) {
return w.Write([]byte(data))
}
func (w *compressionWriter) Status() int {
if w.status != 0 {
return w.status
}
return w.ResponseWriter.Status()
}
func (w *compressionWriter) Size() int {
if size := w.ResponseWriter.Size(); size >= 0 {
return size
}
if w.buffer.Len() > 0 {
return w.buffer.Len()
}
return -1
}
func (w *compressionWriter) Written() bool {
return w.wroteBody || w.ResponseWriter.Written()
}
func (w *compressionWriter) startPlain() {
if w.mode != compressionUndecided {
return
}
w.mode = compressionPlain
w.ResponseWriter.WriteHeader(w.Status())
}
func (w *compressionWriter) flushBuffer() error {
if w.buffer.Len() == 0 {
return nil
}
data := w.buffer.Bytes()
w.buffer.Reset()
var err error
if w.mode == compressionGzip {
_, err = w.gzip.Write(data)
} else {
//nolint:staticcheck
_, err = w.ResponseWriter.Write(data)
}
return err
}
func (w *compressionWriter) writePlain(data []byte) (int, error) {
w.startPlain()
if err := w.flushBuffer(); err != nil {
return 0, err
}
//nolint:staticcheck
return w.ResponseWriter.Write(data)
}
func (w *compressionWriter) startGzip() {
if w.mode != compressionUndecided {
return
}
w.mode = compressionGzip
w.Header().Del("Content-Length")
if etag := w.Header().Get("ETag"); etag != "" && !strings.HasPrefix(etag, "W/") {
w.Header().Set("ETag", "W/"+etag)
}
addVary(w.Header(), "Accept-Encoding")
w.Header().Set("Content-Encoding", "gzip")
w.ResponseWriter.WriteHeader(w.Status())
w.gzip = gzip.NewWriter(w.ResponseWriter)
}
func (w *compressionWriter) knownContentLength() (compress, known bool) {
contentLength := w.Header().Get("Content-Length")
if contentLength == "" {
return false, false
}
length, err := strconv.ParseInt(contentLength, 10, 64)
if err != nil {
return false, false
}
return length >= compressionMinLength, true
}
func (w *compressionWriter) canCompress(data []byte) bool {
status := w.Status()
if status < http.StatusOK || status == http.StatusNoContent || status == http.StatusPartialContent || status == http.StatusNotModified {
return false
}
if w.Header().Get("Content-Encoding") != "" {
return false
}
contentType := w.Header().Get("Content-Type")
if contentType == "" && len(data) > 0 {
contentType = http.DetectContentType(data)
w.Header().Set("Content-Type", contentType)
}
if !isCompressibleContentType(contentType) {
return false
}
addVary(w.Header(), "Accept-Encoding")
return w.acceptsGzip
}
func (w *compressionWriter) Write(data []byte) (int, error) {
w.wroteBody = true
switch w.mode {
case compressionPlain:
//nolint:staticcheck
return w.ResponseWriter.Write(data)
case compressionGzip:
return w.gzip.Write(data)
}
if !w.canCompress(data) {
return w.writePlain(data)
}
if compress, known := w.knownContentLength(); known {
if compress {
w.startGzip()
return w.gzip.Write(data)
}
return w.writePlain(data)
}
if w.buffer.Len()+len(data) < compressionMinLength {
return w.buffer.Write(data)
}
w.startGzip()
if err := w.flushBuffer(); err != nil {
return 0, err
}
return w.gzip.Write(data)
}
func (w *compressionWriter) Flush() {
if w.mode == compressionUndecided {
if w.canCompress(w.buffer.Bytes()) {
w.startGzip()
_ = w.flushBuffer()
} else {
w.startPlain()
_ = w.flushBuffer()
}
}
if w.mode == compressionGzip {
_ = w.gzip.Flush()
}
w.ResponseWriter.Flush()
}
func (w *compressionWriter) finish() error {
if w.mode == compressionUndecided {
if w.Status() == http.StatusNotModified {
addVary(w.Header(), "Accept-Encoding")
}
if w.buffer.Len() > 0 || w.status != 0 {
w.startPlain()
if err := w.flushBuffer(); err != nil {
return err
}
w.ResponseWriter.WriteHeaderNow()
}
return nil
}
if w.mode == compressionPlain {
return w.flushBuffer()
}
if w.gzip == nil {
return errors.New("gzip writer was not initialized")
}
return w.gzip.Close()
}
func CompressionMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
if !canNegotiateCompression(c.Request) {
return
}
writer := &compressionWriter{
ResponseWriter: c.Writer,
acceptsGzip: acceptsGzip(c.Request.Header.Get("Accept-Encoding")),
}
c.Writer = writer
c.Next()
if err := writer.finish(); err != nil {
_ = c.Error(err)
}
}
}

View File

@@ -0,0 +1,394 @@
package server
import (
"compress/gzip"
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestStaticCacheMiddleware(t *testing.T) {
tests := []struct {
name string
path string
want string
}{
{name: "versioned dist", path: "/dist/static/app.js?v=test", want: "public, max-age=31536000, immutable"},
{name: "unversioned dist", path: "/dist/static/app.js", want: "public, max-age=0, must-revalidate"},
{name: "versioned static asset", path: "/static/assets/logo-128.png?v=test", want: "public, max-age=31536000, immutable"},
{name: "unversioned static image", path: "/static/assets/logo-128.png", want: "public, max-age=86400"},
{name: "html", path: "/anime/1", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := gin.New()
router.Use(StaticCacheMiddleware())
path, _, _ := strings.Cut(tt.path, "?")
router.GET(path, func(c *gin.Context) {
c.String(http.StatusOK, "response")
})
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, tt.path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Cache-Control"); got != tt.want {
t.Fatalf("Cache-Control = %q, want %q", got, tt.want)
}
})
}
}
type compressionTestCase struct {
name string
method string
path string
acceptEncoding string
rangeHeader string
contentEncoding string
status int
contentType string
wantEncoding string
wantVary bool
}
var compressionCases = []compressionTestCase{
{
name: "gzip text",
method: http.MethodGet,
path: "/dist/static/app.js",
acceptEncoding: "gzip",
status: http.StatusOK,
contentType: "application/javascript",
wantEncoding: "gzip",
wantVary: true,
},
{
name: "plain without negotiation",
method: http.MethodGet,
path: "/dist/static/app.js",
status: http.StatusOK,
contentType: "application/javascript",
wantEncoding: "",
wantVary: true,
},
{
name: "playback stream",
method: http.MethodGet,
path: "/watch/proxy/stream",
acceptEncoding: "gzip",
status: http.StatusOK,
contentType: "application/vnd.apple.mpegurl",
wantEncoding: "",
},
{
name: "playback subtitle",
method: http.MethodGet,
path: "/watch/proxy/subtitle",
acceptEncoding: "gzip",
status: http.StatusOK,
contentType: "text/vtt",
wantEncoding: "",
},
{
name: "range response",
method: http.MethodGet,
path: "/media",
acceptEncoding: "gzip",
rangeHeader: "bytes=0-99",
status: http.StatusPartialContent,
contentType: "text/plain",
wantEncoding: "",
},
{
name: "compressed image",
method: http.MethodGet,
path: "/image",
acceptEncoding: "gzip",
status: http.StatusOK,
contentType: "image/png",
wantEncoding: "",
},
{
name: "already encoded",
method: http.MethodGet,
path: "/encoded",
acceptEncoding: "gzip",
contentEncoding: "br",
status: http.StatusOK,
contentType: "text/plain",
wantEncoding: "br",
},
{
name: "gzip refused",
method: http.MethodGet,
path: "/text",
acceptEncoding: "br, gzip;q=0",
status: http.StatusOK,
contentType: "text/plain",
wantEncoding: "",
wantVary: true,
},
}
func headerContains(header http.Header, name, want string) bool {
for _, line := range header.Values(name) {
for value := range strings.SplitSeq(line, ",") {
if strings.EqualFold(strings.TrimSpace(value), want) {
return true
}
}
}
return false
}
func TestCompressionMiddleware(t *testing.T) {
body := strings.Repeat("compressible javascript;", 100)
for _, tt := range compressionCases {
t.Run(tt.name, func(t *testing.T) {
testCompressionResponse(t, tt, body)
})
}
}
func testCompressionResponse(t *testing.T, tt compressionTestCase, body string) {
t.Helper()
router := compressionRouter(tt, body)
req := compressionRequest(tt)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
assertCompressionResponse(t, tt, rec, body)
}
func compressionRouter(tt compressionTestCase, body string) *gin.Engine {
router := gin.New()
router.Use(CompressionMiddleware())
router.Handle(tt.method, tt.path, func(c *gin.Context) {
if tt.contentEncoding != "" {
c.Header("Content-Encoding", tt.contentEncoding)
}
if tt.status == http.StatusPartialContent {
c.Header("Accept-Ranges", "bytes")
c.Header("Content-Range", "bytes 0-99/2300")
}
c.Data(tt.status, tt.contentType, []byte(body))
})
return router
}
func compressionRequest(tt compressionTestCase) *http.Request {
req := httptest.NewRequestWithContext(context.Background(), tt.method, tt.path, nil)
if tt.acceptEncoding != "" {
req.Header.Set("Accept-Encoding", tt.acceptEncoding)
}
if tt.rangeHeader != "" {
req.Header.Set("Range", tt.rangeHeader)
}
return req
}
func assertCompressionResponse(t *testing.T, tt compressionTestCase, rec *httptest.ResponseRecorder, body string) {
t.Helper()
if got := rec.Header().Get("Content-Encoding"); got != tt.wantEncoding {
t.Fatalf("Content-Encoding = %q, want %q", got, tt.wantEncoding)
}
if got := headerContains(rec.Header(), "Vary", "Accept-Encoding"); got != tt.wantVary {
t.Fatalf("Vary contains Accept-Encoding = %t, want %t", got, tt.wantVary)
}
if rec.Code != tt.status {
t.Fatalf("status = %d, want %d", rec.Code, tt.status)
}
if got := responseBody(t, rec, tt.wantEncoding); got != body {
t.Fatalf("body length = %d, want %d", len(got), len(body))
}
if tt.status == http.StatusPartialContent && rec.Header().Get("Content-Range") != "bytes 0-99/2300" {
t.Fatalf("Content-Range = %q", rec.Header().Get("Content-Range"))
}
}
func responseBody(t *testing.T, rec *httptest.ResponseRecorder, encoding string) string {
t.Helper()
if encoding != "gzip" {
return rec.Body.String()
}
reader, err := gzip.NewReader(rec.Body)
if err != nil {
t.Fatalf("open gzip response: %v", err)
}
decompressed, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("read gzip response: %v", err)
}
if err := reader.Close(); err != nil {
t.Fatalf("close gzip response: %v", err)
}
return string(decompressed)
}
func TestCompressionMiddlewareSkipsSmallResponses(t *testing.T) {
router := gin.New()
router.Use(CompressionMiddleware())
router.GET("/text", func(c *gin.Context) {
c.String(http.StatusOK, "small response")
})
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/text", nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if got := rec.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty", got)
}
if got := rec.Body.String(); got != "small response" {
t.Fatalf("body = %q", got)
}
}
func TestCompressionMiddlewarePreservesNotModifiedAndHead(t *testing.T) {
tests := []struct {
name string
method string
path string
status int
wantVary bool
}{
{name: "not modified", method: http.MethodGet, path: "/not-modified", status: http.StatusNotModified, wantVary: true},
{name: "head", method: http.MethodHead, path: "/text", status: http.StatusOK},
{name: "no content", method: http.MethodGet, path: "/no-content", status: http.StatusNoContent},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
router := gin.New()
router.Use(CompressionMiddleware())
router.Handle(tt.method, tt.path, func(c *gin.Context) {
c.Status(tt.status)
})
req := httptest.NewRequestWithContext(context.Background(), tt.method, tt.path, nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != tt.status || rec.Body.Len() != 0 {
t.Fatalf("response = %d with %d body bytes", rec.Code, rec.Body.Len())
}
if got := rec.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("Content-Encoding = %q, want empty", got)
}
if got := headerContains(rec.Header(), "Vary", "Accept-Encoding"); got != tt.wantVary {
t.Fatalf("Vary contains Accept-Encoding = %t, want %t", got, tt.wantVary)
}
})
}
}
func TestCompressionMiddlewareReportsFinalBytes(t *testing.T) {
var status, size int
router := gin.New()
router.Use(func(c *gin.Context) {
c.Next()
status = c.Writer.Status()
size = c.Writer.Size()
})
router.Use(CompressionMiddleware())
router.GET("/text", func(c *gin.Context) {
c.String(http.StatusCreated, strings.Repeat("text response;", 200))
})
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/text", nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if status != http.StatusCreated {
t.Fatalf("logged status = %d, want %d", status, http.StatusCreated)
}
if size != rec.Body.Len() {
t.Fatalf("logged bytes = %d, response bytes = %d", size, rec.Body.Len())
}
}
func TestStaticHandlerUsesCacheAndCompressionMiddleware(t *testing.T) {
body := strings.Repeat("const staticAsset = true;\n", 100)
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte(body), 0o600); err != nil {
t.Fatalf("write static asset: %v", err)
}
router := gin.New()
router.Use(CompressionMiddleware(), StaticCacheMiddleware())
router.Static("/dist", dir)
lastModified := testCompressedStaticAsset(t, router, body)
testStaticNotModified(t, router, lastModified)
testMissingStaticAsset(t, router)
}
func testCompressedStaticAsset(t *testing.T, router http.Handler, body string) string {
t.Helper()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/dist/app.js?v=test", nil)
req.Header.Set("Accept-Encoding", "gzip")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if got := rec.Header().Get("Cache-Control"); got != "public, max-age=31536000, immutable" {
t.Fatalf("Cache-Control = %q", got)
}
if got := rec.Header().Get("Content-Encoding"); got != "gzip" {
t.Fatalf("Content-Encoding = %q, want gzip", got)
}
if got := responseBody(t, rec, "gzip"); got != body {
t.Fatalf("body length = %d, want %d", len(got), len(body))
}
lastModified := rec.Header().Get("Last-Modified")
if lastModified == "" {
t.Fatal("Last-Modified is empty")
}
return lastModified
}
func testStaticNotModified(t *testing.T, router http.Handler, lastModified string) {
t.Helper()
conditional := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/dist/app.js", nil)
conditional.Header.Set("Accept-Encoding", "gzip")
conditional.Header.Set("If-Modified-Since", lastModified)
conditionalRec := httptest.NewRecorder()
router.ServeHTTP(conditionalRec, conditional)
if conditionalRec.Code != http.StatusNotModified || conditionalRec.Body.Len() != 0 {
t.Fatalf("conditional response = %d with %d body bytes", conditionalRec.Code, conditionalRec.Body.Len())
}
if got := conditionalRec.Header().Get("Content-Encoding"); got != "" {
t.Fatalf("conditional Content-Encoding = %q, want empty", got)
}
if !headerContains(conditionalRec.Header(), "Vary", "Accept-Encoding") {
t.Fatal("conditional Vary does not contain Accept-Encoding")
}
}
func testMissingStaticAsset(t *testing.T, router http.Handler) {
t.Helper()
missing := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/dist/missing.js?v=test", nil)
missingRec := httptest.NewRecorder()
router.ServeHTTP(missingRec, missing)
if missingRec.Code != http.StatusNotFound {
t.Fatalf("missing status = %d, want %d", missingRec.Code, http.StatusNotFound)
}
if got := missingRec.Header().Get("Cache-Control"); got != "" {
t.Fatalf("missing Cache-Control = %q, want empty", got)
}
}

View File

@@ -0,0 +1,152 @@
package watchlist
import (
"strings"
"time"
"mal/internal/domain"
)
type publicWatchlist struct {
SchemaVersion string `json:"schema_version"`
GeneratedAt time.Time `json:"generated_at"`
UserID string `json:"user_id"`
Summary watchlistSummary `json:"summary"`
Entries []publicWatchlistEntry `json:"entries"`
}
type watchlistSummary struct {
Total int `json:"total"`
Watching int `json:"watching"`
Completed int `json:"completed"`
PlanToWatch int `json:"plan_to_watch"`
OnHold int `json:"on_hold"`
Dropped int `json:"dropped"`
}
type publicWatchlistEntry struct {
MALID int64 `json:"mal_id"`
Status string `json:"status"`
AddedAt time.Time `json:"added_at"`
CompletedAt *time.Time `json:"completed_at"`
CompletionDateAccuracy *string `json:"completion_date_accuracy"`
LastWatchedAt *time.Time `json:"last_watched_at"`
Progress watchlistProgress `json:"progress"`
Titles watchlistTitles `json:"titles"`
}
type watchlistProgress struct {
CurrentEpisode *int64 `json:"current_episode"`
CurrentTimeSeconds *float64 `json:"current_time_seconds"`
}
type watchlistTitles struct {
Preferred string `json:"preferred"`
Original string `json:"original"`
English *string `json:"english"`
Japanese *string `json:"japanese"`
}
func newPublicWatchlist(userID string, rows []domain.UserWatchListRow, generatedAt time.Time) publicWatchlist {
export := publicWatchlist{
SchemaVersion: "1.0",
GeneratedAt: generatedAt,
UserID: userID,
Entries: make([]publicWatchlistEntry, 0, len(rows)),
}
for _, row := range rows {
export.Summary.add(row.Status)
export.Entries = append(export.Entries, newPublicWatchlistEntry(row))
}
return export
}
func (s *watchlistSummary) add(status string) {
s.Total++
switch status {
case "watching":
s.Watching++
case "completed":
s.Completed++
case "plan_to_watch":
s.PlanToWatch++
case "on_hold":
s.OnHold++
case "dropped":
s.Dropped++
}
}
func newPublicWatchlistEntry(row domain.UserWatchListRow) publicWatchlistEntry {
currentEpisode := nullableInt64(row.CurrentEpisode.Int64, row.CurrentEpisode.Valid)
currentTimeSeconds := nullableFloat64(row.CurrentTimeSeconds, row.CurrentEpisode.Valid)
lastWatchedAt := nullableTime(row.LastEpisodeAt.Time, row.LastEpisodeAt.Valid)
if row.PlaybackCurrentEpisode.Valid {
currentEpisode = &row.PlaybackCurrentEpisode.Int64
currentTimeSeconds = nullableFloat64(row.PlaybackCurrentTimeSeconds.Float64, row.PlaybackCurrentTimeSeconds.Valid)
lastWatchedAt = nullableTime(row.PlaybackUpdatedAt.Time, row.PlaybackUpdatedAt.Valid)
}
entry := publicWatchlistEntry{
MALID: row.AnimeID,
Status: row.Status,
AddedAt: row.CreatedAt,
LastWatchedAt: lastWatchedAt,
Progress: watchlistProgress{
CurrentEpisode: currentEpisode,
CurrentTimeSeconds: currentTimeSeconds,
},
Titles: watchlistTitles{
Preferred: row.DisplayTitle(),
Original: row.TitleOriginal,
English: nullableString(row.TitleEnglish.String, row.TitleEnglish.Valid),
Japanese: nullableString(row.TitleJapanese.String, row.TitleJapanese.Valid),
},
}
if row.Status == "completed" {
accuracy := "exact"
if row.CompletedAt.Valid {
entry.CompletedAt = &row.CompletedAt.Time
if row.CompletedAtEstimated {
accuracy = "estimated_from_historical_update"
}
} else {
entry.CompletedAt = &row.UpdatedAt
accuracy = "estimated_from_historical_update"
}
entry.CompletionDateAccuracy = &accuracy
}
return entry
}
func nullableString(value string, valid bool) *string {
if !valid || strings.TrimSpace(value) == "" {
return nil
}
return &value
}
func nullableInt64(value int64, valid bool) *int64 {
if !valid {
return nil
}
return &value
}
func nullableFloat64(value float64, valid bool) *float64 {
if !valid {
return nil
}
return &value
}
func nullableTime(value time.Time, valid bool) *time.Time {
if !valid {
return nil
}
return &value
}

View File

@@ -6,6 +6,8 @@ import (
"mal/internal/server"
"net/http"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
)
@@ -22,6 +24,8 @@ func (h *WatchlistHandler) Register(r *gin.Engine) {
r.POST("/api/watchlist", h.HandleUpdateWatchlist)
r.DELETE("/api/watchlist/:id", h.HandleDeleteWatchlist)
r.DELETE("/api/continue-watching/:id", h.HandleDeleteContinueWatching)
r.GET("/watchlist/export", h.HandleDownloadWatchlist)
r.GET("/api/public/users/:userID/watchlist", h.HandleGetPublicWatchlist)
r.GET("/watchlist", h.HandleGetWatchlist)
}
@@ -138,3 +142,67 @@ func (h *WatchlistHandler) HandleGetWatchlist(c *gin.Context) {
"User": user,
})
}
func (h *WatchlistHandler) HandleDownloadWatchlist(c *gin.Context) {
userID := server.CurrentUserID(c)
entries, err := h.svc.GetWatchlist(c.Request.Context(), userID)
if err != nil {
server.RespondError(
c,
http.StatusInternalServerError,
"watchlist_load_failed",
"watchlist",
"failed to load watchlist",
map[string]any{"user_id": userID},
err,
)
return
}
c.Header("Cache-Control", "no-store")
c.Header("Content-Disposition", `attachment; filename="watchlist.json"`)
c.JSON(http.StatusOK, newPublicWatchlist(userID, entries, time.Now().UTC()))
}
func (h *WatchlistHandler) HandleGetPublicWatchlist(c *gin.Context) {
userID := strings.TrimSpace(c.Param("userID"))
if userID == "" {
server.RespondHTMLOrJSONError(c, http.StatusBadRequest, "invalid user id")
return
}
entries, err := h.svc.GetWatchlist(c.Request.Context(), userID)
if err != nil {
server.RespondError(
c,
http.StatusInternalServerError,
"public_watchlist_load_failed",
"watchlist",
"failed to load public watchlist",
map[string]any{"user_id": userID},
err,
)
return
}
c.Header("Cache-Control", "no-store")
if c.Query("download") == "1" {
c.Header("Content-Disposition", `attachment; filename="watchlist-`+safeFilenamePart(userID)+`.json"`)
}
c.JSON(http.StatusOK, newPublicWatchlist(userID, entries, time.Now().UTC()))
}
func safeFilenamePart(value string) string {
const allowed = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_"
var filename strings.Builder
for _, char := range value {
if strings.ContainsRune(allowed, char) {
filename.WriteRune(char)
}
}
if filename.Len() == 0 {
return "user"
}
return filename.String()
}

View File

@@ -2,11 +2,14 @@ package watchlist
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"mal/internal/db"
"mal/internal/domain"
@@ -110,6 +113,221 @@ func TestHandleDeleteContinueWatching(t *testing.T) {
}
}
func TestHandleGetPublicWatchlistReturnsMinimalJSON(t *testing.T) {
gin.SetMode(gin.TestMode)
addedAt := time.Date(2026, time.June, 1, 12, 30, 0, 0, time.UTC)
completedAt := time.Date(2026, time.June, 20, 18, 45, 0, 0, time.UTC)
lastWatchedAt := time.Date(2026, time.June, 28, 20, 15, 0, 0, time.UTC)
svc := &fakeWatchlistService{watchlist: publicWatchlistRows(addedAt, completedAt, lastWatchedAt)}
router := newWatchlistTestRouter(svc)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/public/users/user-42/watchlist", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
t.Fatalf("Content-Type = %q, want application/json", got)
}
if svc.watchlistUserID != "user-42" {
t.Fatalf("GetWatchlist user id = %q, want user-42", svc.watchlistUserID)
}
var body publicWatchlistResponse
if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response: %v; body=%s", err, rec.Body.String())
}
assertPublicWatchlistEnvelope(t, body)
if len(body.Entries) != 2 {
t.Fatalf("entries = %d, want 2", len(body.Entries))
}
assertCompletedPublicEntry(t, body.Entries[0], addedAt, completedAt)
assertWatchingPublicEntry(t, body.Entries[1], lastWatchedAt)
assertMinimalPublicWatchlistJSON(t, rec.Body.Bytes())
}
func TestHandleDownloadWatchlistReturnsAttachment(t *testing.T) {
gin.SetMode(gin.TestMode)
addedAt := time.Date(2026, time.June, 1, 12, 30, 0, 0, time.UTC)
completedAt := time.Date(2026, time.June, 20, 18, 45, 0, 0, time.UTC)
lastWatchedAt := time.Date(2026, time.June, 28, 20, 15, 0, 0, time.UTC)
svc := &fakeWatchlistService{watchlist: publicWatchlistRows(addedAt, completedAt, lastWatchedAt)}
router := newWatchlistTestRouter(svc)
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/watchlist/export", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d; body=%s", rec.Code, http.StatusOK, rec.Body.String())
}
if got := rec.Header().Get("Content-Disposition"); got != `attachment; filename="watchlist.json"` {
t.Fatalf("Content-Disposition = %q, want watchlist.json attachment", got)
}
if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
t.Fatalf("Content-Type = %q, want application/json", got)
}
assertMinimalPublicWatchlistJSON(t, rec.Body.Bytes())
}
func publicWatchlistRows(addedAt time.Time, completedAt time.Time, lastWatchedAt time.Time) []domain.UserWatchListRow {
return []domain.UserWatchListRow{
{
AnimeID: 1,
Status: "completed",
CreatedAt: addedAt,
UpdatedAt: completedAt.Add(48 * time.Hour),
CompletedAt: sql.NullTime{Time: completedAt, Valid: true},
TitleOriginal: "Sousou no Frieren",
TitleEnglish: sql.NullString{String: "Frieren: Beyond Journey's End", Valid: true},
TitleJapanese: sql.NullString{String: "葬送のフリーレン", Valid: true},
ImageUrl: "https://cdn.example/frieren.webp",
Airing: sql.NullBool{Bool: false, Valid: true},
},
{
AnimeID: 2,
Status: "watching",
CreatedAt: addedAt.Add(24 * time.Hour),
CurrentEpisode: sql.NullInt64{Int64: 3, Valid: true},
CurrentTimeSeconds: 10,
PlaybackCurrentEpisode: sql.NullInt64{Int64: 7, Valid: true},
PlaybackCurrentTimeSeconds: sql.NullFloat64{Float64: 321.5, Valid: true},
PlaybackUpdatedAt: sql.NullTime{Time: lastWatchedAt, Valid: true},
TitleOriginal: "Dungeon Meshi",
ImageUrl: "https://cdn.example/dungeon.webp",
},
}
}
type publicWatchlistResponse struct {
SchemaVersion string `json:"schema_version"`
UserID string `json:"user_id"`
Summary struct {
Total int `json:"total"`
Completed int `json:"completed"`
Watching int `json:"watching"`
} `json:"summary"`
Entries []publicWatchlistResponseEntry `json:"entries"`
}
type publicWatchlistResponseEntry struct {
MALID int64 `json:"mal_id"`
Status string `json:"status"`
AddedAt string `json:"added_at"`
CompletedAt *string `json:"completed_at"`
CompletionDateAccuracy *string `json:"completion_date_accuracy"`
LastWatchedAt *string `json:"last_watched_at"`
Progress struct {
CurrentEpisode *int64 `json:"current_episode"`
CurrentTimeSeconds *float64 `json:"current_time_seconds"`
} `json:"progress"`
Titles struct {
Preferred string `json:"preferred"`
Original string `json:"original"`
English *string `json:"english"`
Japanese *string `json:"japanese"`
} `json:"titles"`
}
func assertPublicWatchlistEnvelope(t *testing.T, body publicWatchlistResponse) {
t.Helper()
assertEqual(t, "schema version", body.SchemaVersion, "1.0")
assertEqual(t, "user id", body.UserID, "user-42")
assertEqual(t, "summary total", body.Summary.Total, 2)
assertEqual(t, "summary completed", body.Summary.Completed, 1)
assertEqual(t, "summary watching", body.Summary.Watching, 1)
}
func assertCompletedPublicEntry(t *testing.T, entry publicWatchlistResponseEntry, addedAt time.Time, completedAt time.Time) {
t.Helper()
assertEqual(t, "completed MAL id", entry.MALID, int64(1))
assertEqual(t, "completed status", entry.Status, "completed")
assertEqual(t, "added_at", entry.AddedAt, addedAt.Format(time.RFC3339))
assertPointerEqual(t, "completed_at", entry.CompletedAt, completedAt.Format(time.RFC3339))
assertPointerEqual(t, "completion date accuracy", entry.CompletionDateAccuracy, "exact")
assertEqual(t, "preferred title", entry.Titles.Preferred, "Frieren: Beyond Journey's End")
assertEqual(t, "original title", entry.Titles.Original, "Sousou no Frieren")
}
func assertWatchingPublicEntry(t *testing.T, entry publicWatchlistResponseEntry, lastWatchedAt time.Time) {
t.Helper()
assertPointerEqual(t, "watching progress episode", entry.Progress.CurrentEpisode, int64(7))
assertPointerEqual(t, "watching progress seconds", entry.Progress.CurrentTimeSeconds, 321.5)
assertPointerEqual(t, "last_watched_at", entry.LastWatchedAt, lastWatchedAt.Format(time.RFC3339))
assertNilPointer(t, "watching completed_at", entry.CompletedAt)
assertNilPointer(t, "watching completion date accuracy", entry.CompletionDateAccuracy)
}
func assertEqual[T comparable](t *testing.T, label string, got T, want T) {
t.Helper()
if got != want {
t.Fatalf("%s = %v, want %v", label, got, want)
}
}
func assertPointerEqual[T comparable](t *testing.T, label string, got *T, want T) {
t.Helper()
if got == nil || *got != want {
t.Fatalf("%s = %v, want %v", label, got, want)
}
}
func assertNilPointer[T any](t *testing.T, label string, got *T) {
t.Helper()
if got != nil {
t.Fatalf("%s = %v, want null", label, got)
}
}
func assertMinimalPublicWatchlistJSON(t *testing.T, data []byte) {
t.Helper()
var raw struct {
RecommendationGuidance json.RawMessage `json:"recommendation_guidance"`
Entries []map[string]json.RawMessage `json:"entries"`
}
if err := json.Unmarshal(data, &raw); err != nil {
t.Fatalf("decode raw response: %v", err)
}
if raw.RecommendationGuidance != nil {
t.Fatalf("recommendation_guidance should not be part of the public contract")
}
for _, key := range []string{
"updated_at",
"status_meaning",
"myanimelist_status",
"anilist_status",
"exclude_from_recommendations",
"recommendation_exclusion_reason",
"anime",
"external_ids",
"links",
} {
if _, exists := raw.Entries[0][key]; exists {
t.Fatalf("%s should not be part of the public contract", key)
}
}
}
func TestHandleGetPublicWatchlistCanDownloadJSON(t *testing.T) {
router := newWatchlistTestRouter(&fakeWatchlistService{})
rec := httptest.NewRecorder()
req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/api/public/users/user-42/watchlist?download=1", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
}
if got := rec.Header().Get("Content-Disposition"); got != `attachment; filename="watchlist-user-42.json"` {
t.Fatalf("Content-Disposition = %q", got)
}
}
func newWatchlistTestRouter(svc domain.WatchlistService) *gin.Engine {
router := gin.New()
router.Use(func(c *gin.Context) {
@@ -134,6 +352,10 @@ type fakeWatchlistService struct {
deletedContinueUserID string
deletedContinueAnimeID int64
watchlist []domain.UserWatchListRow
watchlistUserID string
watchlistErr error
}
func (s *fakeWatchlistService) UpdateEntry(_ context.Context, userID string, animeID int64, status string) error {
@@ -149,8 +371,9 @@ func (s *fakeWatchlistService) RemoveEntry(_ context.Context, userID string, ani
return s.removeErr
}
func (s *fakeWatchlistService) GetWatchlist(context.Context, string) ([]domain.UserWatchListRow, error) {
return nil, nil
func (s *fakeWatchlistService) GetWatchlist(_ context.Context, userID string) ([]domain.UserWatchListRow, error) {
s.watchlistUserID = userID
return s.watchlist, s.watchlistErr
}
func (s *fakeWatchlistService) GetWatchlistMap(context.Context, string, []int64) (map[int64]bool, error) {

View File

@@ -12,67 +12,98 @@ import (
)
type watchlistService struct {
repo domain.WatchlistRepository
jikan *jikan.Client
repo domain.WatchlistRepository
jikan *jikan.Client
invalidator domain.RecommendationInvalidator
}
func NewWatchlistService(repo domain.WatchlistRepository, jikan *jikan.Client) domain.WatchlistService {
return &watchlistService{repo: repo, jikan: jikan}
func NewWatchlistService(repo domain.WatchlistRepository, jikan *jikan.Client, invalidator domain.RecommendationInvalidator) domain.WatchlistService {
return &watchlistService{repo: repo, jikan: jikan, invalidator: invalidator}
}
func (s *watchlistService) UpdateEntry(ctx context.Context, userID string, animeID int64, status string) error {
anime, fetchErr := s.jikan.GetAnimeByID(ctx, int(animeID))
if fetchErr != nil {
// still allow status updates for already-known anime rows
anime = jikan.Anime{}
}
anime, fetchedAnime := s.fetchAnimeForWatchlist(ctx, animeID)
return s.repo.InTx(ctx, func(txCtx context.Context, repo domain.WatchlistRepository) error {
_, err := repo.GetAnime(txCtx, animeID)
if err != nil && fetchErr == nil {
durationSeconds := anime.DurationSeconds()
duration := sql.NullFloat64{Valid: durationSeconds > 0}
if duration.Valid {
duration.Float64 = durationSeconds
}
if _, err := repo.UpsertAnime(txCtx, db.UpsertAnimeParams{
ID: int64(anime.MalID),
TitleOriginal: anime.Title,
TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""},
TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""},
ImageUrl: anime.Images.Webp.LargeImageURL,
Airing: sql.NullBool{Bool: anime.Airing, Valid: true},
DurationSeconds: duration,
}); err != nil {
return err
}
}
existing, err := repo.GetWatchListEntry(txCtx, db.GetWatchListEntryParams{
UserID: userID,
AnimeID: animeID,
})
if err != nil && err != sql.ErrNoRows {
return err
}
_, err = repo.UpsertWatchListEntry(txCtx, db.UpsertWatchListEntryParams{
ID: uuid.New().String(),
UserID: userID,
AnimeID: animeID,
Status: status,
CurrentEpisode: existing.CurrentEpisode,
CurrentTimeSeconds: existing.CurrentTimeSeconds,
})
if err := s.repo.InTx(ctx, func(txCtx context.Context, repo domain.WatchlistRepository) error {
return s.updateEntryInTx(txCtx, repo, userID, animeID, status, anime, fetchedAnime)
}); err != nil {
return err
})
}
s.invalidateTopPicks(userID)
return nil
}
func (s *watchlistService) RemoveEntry(ctx context.Context, userID string, animeID int64) error {
return s.repo.DeleteWatchListEntry(ctx, db.DeleteWatchListEntryParams{
func (s *watchlistService) fetchAnimeForWatchlist(ctx context.Context, animeID int64) (jikan.Anime, bool) {
if s.jikan == nil {
return jikan.Anime{}, false
}
anime, err := s.jikan.GetAnimeByID(ctx, int(animeID))
if err != nil {
// still allow status updates for already-known anime rows
return jikan.Anime{}, false
}
return anime, anime.MalID > 0
}
func (s *watchlistService) updateEntryInTx(ctx context.Context, repo domain.WatchlistRepository, userID string, animeID int64, status string, anime jikan.Anime, fetchedAnime bool) error {
_, err := repo.GetAnime(ctx, animeID)
if err != nil && fetchedAnime {
if _, err := repo.UpsertAnime(ctx, watchlistAnimeParams(anime)); err != nil {
return err
}
}
existing, err := repo.GetWatchListEntry(ctx, db.GetWatchListEntryParams{
UserID: userID,
AnimeID: animeID,
})
if err != nil && err != sql.ErrNoRows {
return err
}
_, err = repo.UpsertWatchListEntry(ctx, db.UpsertWatchListEntryParams{
ID: uuid.New().String(),
UserID: userID,
AnimeID: animeID,
Status: status,
CurrentEpisode: existing.CurrentEpisode,
CurrentTimeSeconds: existing.CurrentTimeSeconds,
})
return err
}
func watchlistAnimeParams(anime jikan.Anime) db.UpsertAnimeParams {
durationSeconds := anime.DurationSeconds()
duration := sql.NullFloat64{Valid: durationSeconds > 0}
if duration.Valid {
duration.Float64 = durationSeconds
}
return db.UpsertAnimeParams{
ID: int64(anime.MalID),
TitleOriginal: anime.Title,
TitleEnglish: sql.NullString{String: anime.TitleEnglish, Valid: anime.TitleEnglish != ""},
TitleJapanese: sql.NullString{String: anime.TitleJapanese, Valid: anime.TitleJapanese != ""},
ImageUrl: anime.Images.Webp.LargeImageURL,
Airing: sql.NullBool{Bool: anime.Airing, Valid: true},
DurationSeconds: duration,
}
}
func (s *watchlistService) RemoveEntry(ctx context.Context, userID string, animeID int64) error {
if err := s.repo.DeleteWatchListEntry(ctx, db.DeleteWatchListEntryParams{
UserID: userID,
AnimeID: animeID,
}); err != nil {
return err
}
s.invalidateTopPicks(userID)
return nil
}
func (s *watchlistService) invalidateTopPicks(userID string) {
if s.invalidator != nil {
s.invalidator.InvalidateTopPicksForUser(userID)
}
}
func (s *watchlistService) GetWatchlist(ctx context.Context, userID string) ([]domain.UserWatchListRow, error) {

View File

@@ -12,7 +12,7 @@ import (
func TestWatchlistServiceGetWatchlistMap(t *testing.T) {
repo := &fakeWatchlistRepository{watchlistAnimeIDs: []int64{1, 3}}
svc := NewWatchlistService(repo, nil)
svc := NewWatchlistService(repo, nil, nil)
got, err := svc.GetWatchlistMap(context.Background(), "user-1", []int64{1, 2, 3})
if err != nil {
@@ -28,7 +28,7 @@ func TestWatchlistServiceGetWatchlistMap(t *testing.T) {
func TestWatchlistServiceGetWatchlistMapSkipsEmptyInputs(t *testing.T) {
repo := &fakeWatchlistRepository{}
svc := NewWatchlistService(repo, nil)
svc := NewWatchlistService(repo, nil, nil)
got, err := svc.GetWatchlistMap(context.Background(), "", []int64{1})
if err != nil {
@@ -52,7 +52,7 @@ func TestWatchlistServiceGetWatchlistMapSkipsEmptyInputs(t *testing.T) {
func TestWatchlistServiceDeleteContinueWatchingClearsProgressInTransaction(t *testing.T) {
repo := &fakeWatchlistRepository{}
svc := NewWatchlistService(repo, nil)
svc := NewWatchlistService(repo, nil, nil)
if err := svc.DeleteContinueWatching(context.Background(), "user-1", 12); err != nil {
t.Fatalf("DeleteContinueWatching: %v", err)
@@ -76,7 +76,7 @@ func TestWatchlistServiceDeleteContinueWatchingClearsProgressInTransaction(t *te
func TestWatchlistServiceDeleteContinueWatchingStopsAfterDeleteError(t *testing.T) {
repo := &fakeWatchlistRepository{deleteContinueErr: errors.New("delete failed")}
svc := NewWatchlistService(repo, nil)
svc := NewWatchlistService(repo, nil, nil)
if err := svc.DeleteContinueWatching(context.Background(), "user-1", 12); err == nil || err.Error() != "delete failed" {
t.Fatalf("DeleteContinueWatching error = %v, want delete failed", err)
@@ -88,7 +88,8 @@ func TestWatchlistServiceDeleteContinueWatchingStopsAfterDeleteError(t *testing.
func TestWatchlistServiceRemoveEntry(t *testing.T) {
repo := &fakeWatchlistRepository{}
svc := NewWatchlistService(repo, nil)
invalidator := &fakeRecommendationInvalidator{}
svc := NewWatchlistService(repo, nil, invalidator)
if err := svc.RemoveEntry(context.Background(), "user-1", 9); err != nil {
t.Fatalf("RemoveEntry: %v", err)
@@ -96,6 +97,22 @@ func TestWatchlistServiceRemoveEntry(t *testing.T) {
if repo.deletedWatchlist.UserID != "user-1" || repo.deletedWatchlist.AnimeID != 9 {
t.Fatalf("delete params = %#v", repo.deletedWatchlist)
}
if invalidator.userID != "user-1" {
t.Fatalf("invalidated user = %q, want user-1", invalidator.userID)
}
}
func TestWatchlistServiceUpdateEntryInvalidatesRecommendations(t *testing.T) {
repo := &fakeWatchlistRepository{}
invalidator := &fakeRecommendationInvalidator{}
svc := NewWatchlistService(repo, nil, invalidator)
if err := svc.UpdateEntry(context.Background(), "user-1", 9, "watching"); err != nil {
t.Fatalf("UpdateEntry: %v", err)
}
if invalidator.userID != "user-1" {
t.Fatalf("invalidated user = %q, want user-1", invalidator.userID)
}
}
type fakeWatchlistRepository struct {
@@ -110,6 +127,14 @@ type fakeWatchlistRepository struct {
deletedWatchlist db.DeleteWatchListEntryParams
}
type fakeRecommendationInvalidator struct {
userID string
}
func (i *fakeRecommendationInvalidator) InvalidateTopPicksForUser(userID string) {
i.userID = userID
}
func (r *fakeWatchlistRepository) InTx(ctx context.Context, fn func(context.Context, domain.WatchlistRepository) error) error {
r.inTxCalled = true
return fn(ctx, r)