Merge upstream/main
This commit is contained in:
@@ -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),
|
||||
|
||||
64
internal/anime/catalog_handler_test.go
Normal file
64
internal/anime/catalog_handler_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
22
internal/anime/discovery.go
Normal file
22
internal/anime/discovery.go
Normal 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}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type recommendationCandidate struct {
|
||||
themeMatches int
|
||||
studioMatches int
|
||||
demographicMatches int
|
||||
rationale []string
|
||||
}
|
||||
|
||||
type userTasteProfile struct {
|
||||
|
||||
164
internal/anime/recommendations_cache_test.go
Normal file
164
internal/anime/recommendations_cache_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
56
internal/anime/service_test.go
Normal file
56
internal/anime/service_test.go
Normal 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
144
internal/anime/simulcast.go
Normal 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
|
||||
}
|
||||
88
internal/anime/simulcast_test.go
Normal file
88
internal/anime/simulcast_test.go
Normal 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
58
internal/auth/cookie.go
Normal 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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
110
internal/config/config_test.go
Normal file
110
internal/config/config_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
142
internal/database/data_fixes_test.go
Normal file
142
internal/database/data_fixes_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
183
internal/database/fixes/fixes_test.go
Normal file
183
internal/database/fixes/fixes_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
93
internal/db/continue_watching_test.go
Normal file
93
internal/db/continue_watching_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
81
internal/episodes/service/classifications.go
Normal file
81
internal/episodes/service/classifications.go
Normal 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
|
||||
}
|
||||
}
|
||||
32
internal/episodes/service/classifications_test.go
Normal file
32
internal/episodes/service/classifications_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
140
internal/episodes/service/titles.go
Normal file
140
internal/episodes/service/titles.go
Normal 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)
|
||||
}
|
||||
145
internal/episodes/service/titles_test.go
Normal file
145
internal/episodes/service/titles_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
29
internal/observability/bytes_test.go
Normal file
29
internal/observability/bytes_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
29
internal/observability/clone_test.go
Normal file
29
internal/observability/clone_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
23
internal/observability/duration_test.go
Normal file
23
internal/observability/duration_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
52
internal/observability/field_test.go
Normal file
52
internal/observability/field_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
119
internal/observability/fx_test.go
Normal file
119
internal/observability/fx_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
27
internal/observability/localip_test.go
Normal file
27
internal/observability/localip_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
38
internal/observability/quote_test.go
Normal file
38
internal/observability/quote_test.go
Normal 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"`)
|
||||
}
|
||||
}
|
||||
19
internal/observability/status_test.go
Normal file
19
internal/observability/status_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
52
internal/playback/handler/episode_metadata_test.go
Normal file
52
internal/playback/handler/episode_metadata_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
109
internal/playback/handler/manifest_cache.go
Normal file
109
internal/playback/handler/manifest_cache.go
Normal 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)
|
||||
}
|
||||
122
internal/playback/handler/manifest_cache_test.go
Normal file
122
internal/playback/handler/manifest_cache_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
78
internal/playback/handler/proxy_target_test.go
Normal file
78
internal/playback/handler/proxy_target_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
125
internal/playback/proxytarget/target.go
Normal file
125
internal/playback/proxytarget/target.go
Normal 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()
|
||||
}
|
||||
68
internal/playback/proxytarget/target_test.go
Normal file
68
internal/playback/proxytarget/target_test.go
Normal 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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
152
internal/playback/source_cache.go
Normal file
152
internal/playback/source_cache.go
Normal 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
|
||||
}
|
||||
147
internal/playback/source_cache_test.go
Normal file
147
internal/playback/source_cache_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
113
internal/server/cors_test.go
Normal file
113
internal/server/cors_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
83
internal/server/respond_test.go
Normal file
83
internal/server/respond_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"}`)
|
||||
}
|
||||
}
|
||||
|
||||
414
internal/server/static_delivery.go
Normal file
414
internal/server/static_delivery.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
394
internal/server/static_delivery_test.go
Normal file
394
internal/server/static_delivery_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
152
internal/watchlist/export.go
Normal file
152
internal/watchlist/export.go
Normal 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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user