diff --git a/.codex/audits/frontend-performance-2026-07-03/fixes/06-player-loading-and-recovery.md b/.codex/audits/frontend-performance-2026-07-03/fixes/06-player-loading-and-recovery.md new file mode 100644 index 00000000..52ee134a --- /dev/null +++ b/.codex/audits/frontend-performance-2026-07-03/fixes/06-player-loading-and-recovery.md @@ -0,0 +1,134 @@ +# Fix 06: improve player loading and recovery states + +Priority: P1 +Risk: Low to medium +Primary benefit: Perceived performance, accessibility, and error recovery + +## Issue + +The player displays an animated spinner over a black frame while loading. During the audited cold start, this state lasted almost ten seconds. The spinner does not explain whether the application is finding a source, loading metadata, buffering media, retrying, or stuck. It also lacks a useful live status for assistive technology. + +Several player buttons also appeared without accessible names in the browser accessibility tree. + +## Goal + +Make waits understandable without adding noisy progress theater. The loading UI should expose a small state machine, announce meaningful changes, and always provide a recovery path. + +## Proposed state model + +```text +idle +resolving_source +loading_media +buffering +retrying +ready +unavailable +``` + +State meanings: + +- `resolving_source`: waiting for the episode API/provider result. +- `loading_media`: source is known; waiting for manifest/metadata. +- `buffering`: metadata exists but playback has stalled. +- `retrying`: refreshing the source after a media error. +- `unavailable`: all bounded attempts failed. +- `ready`: overlay hidden. + +Do not attempt to show a fake percentage. The application does not know enough to estimate completion accurately. + +## Visual behavior + +- Show the spinner immediately to acknowledge the action. +- Keep text visually quiet for short waits. +- After roughly 3 seconds, show a short status such as “Loading video…” or “Finding a source…”. +- After roughly 8-10 seconds, show “This is taking longer than usual” and a Retry action. +- On terminal failure, show Retry and Back to details. +- Keep episode title/context visible where possible. +- Avoid replacing the whole page; recovery stays inside the player. + +## Accessibility behavior + +Use a stable status element inside the player: + +```html +
Preparing recommendations…
+
-
+
@@ -168,6 +168,17 @@ Configuration is loaded from environment variables, and a local `.env` file is r
+### Public Watchlist JSON
+
+`GET /api/public/users/{user_id}/watchlist` returns a versioned, agent-friendly JSON view of a
+user's complete watchlist. It includes status summaries, episode progress,
+added/completed/last-watched dates, and localized titles.
+
+`GET /watchlist/export` downloads the current user's watchlist as `watchlist.json`.
+
+This endpoint is intentionally unauthenticated. A deployment that should keep watchlist and
+playback data private must restrict access to `/api/public/` at the network or reverse-proxy layer.
+
### Repository Map
| Path | Responsibility |
diff --git a/SECURITY.md b/SECURITY.md
index 5426b2de..6f957361 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -55,6 +55,10 @@ dependency monitoring.
Use a strong `PLAYBACK_PROXY_SECRET` if playback proxy token signing is enabled. Do not commit real
secrets, provider tokens, session data, or production databases to the repository.
+The read-only `/api/public/users/{user_id}/watchlist` endpoint intentionally exposes watchlist
+statuses, episode progress, and watchlist dates without authentication. Restrict `/api/public/` at
+the network or reverse-proxy layer when that data should not be public.
+
## Dependency Security
Dependencies are managed through Go modules and Bun. When updating dependencies, run the normal
diff --git a/integrations/jikan/client_test.go b/integrations/jikan/client_test.go
index 6a5ee47b..317fa969 100644
--- a/integrations/jikan/client_test.go
+++ b/integrations/jikan/client_test.go
@@ -111,6 +111,33 @@ func TestLoadCachedRandomPoolIgnoresExpiredAnimeCache(t *testing.T) {
}
}
+func TestGetJikanCacheStatsCountsRowsAndExpiry(t *testing.T) {
+ sqlDB := newTestCacheDB(t)
+ defer func() {
+ if err := sqlDB.Close(); err != nil {
+ t.Errorf("close sqlite: %v", err)
+ }
+ }()
+
+ oldest := time.Date(2026, 6, 20, 12, 0, 0, 0, time.UTC)
+ insertCachedResponse(t, sqlDB, "expired", TopAnimeResponse{Data: []Anime{{MalID: 1}}}, oldest)
+ insertCachedResponse(t, sqlDB, "fresh", TopAnimeResponse{Data: []Anime{{MalID: 2}}}, time.Now().Add(time.Hour))
+
+ stats, err := db.New(sqlDB).GetJikanCacheStats(context.Background())
+ if err != nil {
+ t.Fatalf("GetJikanCacheStats: %v", err)
+ }
+ if stats.TotalRows != 2 {
+ t.Fatalf("TotalRows = %d, want 2", stats.TotalRows)
+ }
+ if stats.ExpiredRows != 1 {
+ t.Fatalf("ExpiredRows = %d, want 1", stats.ExpiredRows)
+ }
+ if stats.OldestExpiresAtSeconds != oldest.Unix() {
+ t.Fatalf("OldestExpiresAtSeconds = %d, want %d", stats.OldestExpiresAtSeconds, oldest.Unix())
+ }
+}
+
func newTestCacheDB(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
diff --git a/integrations/jikan/types.go b/integrations/jikan/types.go
index 8eb50465..61046964 100644
--- a/integrations/jikan/types.go
+++ b/integrations/jikan/types.go
@@ -321,6 +321,7 @@ type Episode struct {
MalID int `json:"mal_id"`
Title string `json:"title"`
Episode string `json:"episode"`
+ Aired string `json:"aired"`
Filler bool `json:"filler"`
Recap bool `json:"recap"`
Images *EpisodeImages `json:"images,omitempty"`
diff --git a/integrations/playback/allanime/availability.go b/integrations/playback/allanime/availability.go
index 812ecba4..20b95b2b 100644
--- a/integrations/playback/allanime/availability.go
+++ b/integrations/playback/allanime/availability.go
@@ -9,9 +9,10 @@ import (
)
type AvailableEpisodes struct {
- Sub []string
- Dub []string
- Raw []string
+ Sub []string
+ Dub []string
+ Raw []string
+ Titles map[int]string
}
func (c *AllAnimeProvider) GetEpisodeAvailability(ctx context.Context, animeID int, titleCandidates []string) (domain.EpisodeAvailability, error) {
@@ -30,18 +31,25 @@ func (c *AllAnimeProvider) GetEpisodeAvailabilityByProviderID(ctx context.Contex
sub := episodeNums(append(available.Sub, available.Raw...))
dub := episodeNums(available.Dub)
- return domain.EpisodeAvailability{Sub: sub, Dub: dub}, nil
+ return domain.EpisodeAvailability{Sub: sub, Dub: dub, Titles: available.Titles}, nil
}
func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) {
- graphqlQuery := `query($showId: String!) {
+ graphqlQuery := `query($showId: String!, $start: Float!, $end: Float!) {
show(_id: $showId) {
availableEpisodesDetail
- lastEpisodeInfo
+ }
+ episodeInfos(showId: $showId, episodeNumStart: $start, episodeNumEnd: $end) {
+ episodeIdNum
+ notes
}
}`
- result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{"showId": showID})
+ result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{
+ "showId": showID,
+ "start": 1,
+ "end": 100000,
+ })
if err != nil {
return AvailableEpisodes{}, err
}
@@ -61,10 +69,25 @@ func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID stri
return AvailableEpisodes{}, errors.New("invalid detail")
}
+ titles := map[int]string{}
+ infos, _ := data["episodeInfos"].([]any)
+ for _, value := range infos {
+ info, ok := value.(map[string]any)
+ if !ok {
+ continue
+ }
+ number := numericInt(info["episodeIdNum"])
+ title := plainText(stringValue(info["notes"]))
+ if number > 0 && title != "" {
+ titles[number] = title
+ }
+ }
+
return AvailableEpisodes{
- Sub: stringsFrom(detail["sub"]),
- Dub: stringsFrom(detail["dub"]),
- Raw: stringsFrom(detail["raw"]),
+ Sub: stringsFrom(detail["sub"]),
+ Dub: stringsFrom(detail["dub"]),
+ Raw: stringsFrom(detail["raw"]),
+ Titles: titles,
}, nil
}
diff --git a/integrations/playback/allanime/client.go b/integrations/playback/allanime/client.go
index 1e366b80..8dc6d8c7 100644
--- a/integrations/playback/allanime/client.go
+++ b/integrations/playback/allanime/client.go
@@ -26,6 +26,7 @@ type AllAnimeProvider struct {
httpClient *http.Client
utlsClient *http.Client
extractor *providerExtractor
+ baseURL string
}
func NewAllAnimeProvider() *AllAnimeProvider {
@@ -38,6 +39,7 @@ func NewAllAnimeProvider() *AllAnimeProvider {
Timeout: 30 * time.Second,
},
extractor: newProviderExtractor(),
+ baseURL: allAnimeBaseURL,
}
}
@@ -45,9 +47,16 @@ func (c *AllAnimeProvider) Name() string {
return "AllAnime"
}
+func (c *AllAnimeProvider) apiBaseURL() string {
+ if c.baseURL != "" {
+ return c.baseURL
+ }
+ return allAnimeBaseURL
+}
+
func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string) (*domain.StreamResult, error) {
- showID := c.showID(ctx, animeID, titleCandidates, mode)
- if showID == "" {
+ showID, err := c.strictShowID(ctx, animeID, titleCandidates, mode)
+ if err != nil {
return nil, fmt.Errorf("allanime: show not found for malID %d", animeID)
}
@@ -89,7 +98,7 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
return nil, fmt.Errorf("marshal graphql payload: %w", err)
}
- req, err := http.NewRequestWithContext(ctx, http.MethodPost, allAnimeBaseURL+"/api", bytes.NewReader(body))
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.apiBaseURL()+"/api", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create graphql request: %w", err)
}
diff --git a/integrations/playback/allanime/client_test.go b/integrations/playback/allanime/client_test.go
index 6881ca89..06505935 100644
--- a/integrations/playback/allanime/client_test.go
+++ b/integrations/playback/allanime/client_test.go
@@ -8,6 +8,8 @@ import (
"crypto/sha256"
"encoding/base64"
"mal/internal/domain"
+ "net/http"
+ "strings"
"testing"
)
@@ -248,6 +250,29 @@ func TestBuildStreamSource(t *testing.T) {
})
}
+func TestGetStreamsRequiresExactMalIDMatch(t *testing.T) {
+ t.Parallel()
+
+ provider := &AllAnimeProvider{
+ httpClient: &http.Client{
+ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"wrong-show","malId":"1","name":"Wrong Anime"}]}}}`), nil
+ }),
+ },
+ utlsClient: &http.Client{
+ Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ t.Fatal("GetStreams should not fetch episode sources without an exact MAL ID match")
+ return nil, nil
+ }),
+ },
+ }
+
+ _, err := provider.GetStreams(context.Background(), 62076, []string{"Super no Ura de Yani Suu Futari"}, "1", "sub")
+ if err == nil || !strings.Contains(err.Error(), "show not found") {
+ t.Fatalf("GetStreams() error = %v, want show not found", err)
+ }
+}
+
func TestResolveDirectSourceSkipsEmbeds(t *testing.T) {
t.Parallel()
diff --git a/integrations/playback/allanime/crypto.go b/integrations/playback/allanime/crypto.go
index 35e225c6..cda6ce52 100644
--- a/integrations/playback/allanime/crypto.go
+++ b/integrations/playback/allanime/crypto.go
@@ -5,16 +5,80 @@ import (
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
+ "encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
+ "time"
+)
+
+const (
+ aaEpoch = "4128"
+ aaBuildID = "9"
+ aaKeyAHex = "b1a9a4d051988f1b1b12dbb747439d9bd64b09ea17835600a7eaa4de87c1ad87"
+ aaKeyB64 = "k7DLdv5SGiuEyGUtcncl5wQOR7r4aenLfDV3AOBKlAU="
)
var (
- aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"}
+ aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"}
+ aaCryptoVersion byte = 1
)
+func xorKey() ([]byte, error) {
+ keyA, err := hex.DecodeString(aaKeyAHex)
+ if err != nil {
+ return nil, fmt.Errorf("decode key_a: %w", err)
+ }
+
+ keyB, err := base64.StdEncoding.DecodeString(aaKeyB64)
+ if err != nil {
+ return nil, fmt.Errorf("decode key_b: %w", err)
+ }
+
+ key := make([]byte, 32)
+ for i := range key {
+ key[i] = keyA[i] ^ keyB[i]
+ }
+ return key, nil
+}
+
+func makeAALease(queryHash string) (string, error) {
+ aesKey, err := xorKey()
+ if err != nil {
+ return "", err
+ }
+
+ ts := (time.Now().UnixMilli() / 300_000) * 300_000
+
+ ivSeed := fmt.Sprintf("%s:%s:%s:%d", aaEpoch, aaBuildID, queryHash, ts)
+ ivHash := sha256.Sum256([]byte(ivSeed))
+ iv := ivHash[:12]
+
+ payload := fmt.Sprintf(`{"v":%d,"ts":%d,"epoch":%s,"buildId":"%s","qh":"%s"}`, aaCryptoVersion, ts, aaEpoch, aaBuildID, queryHash)
+
+ block, err := aes.NewCipher(aesKey)
+ if err != nil {
+ return "", fmt.Errorf("create cipher: %w", err)
+ }
+
+ gcm, err := cipher.NewGCM(block)
+ if err != nil {
+ return "", fmt.Errorf("create gcm: %w", err)
+ }
+
+ ciphertext := gcm.Seal(nil, iv, []byte(payload), nil)
+
+ envelope := append([]byte{aaCryptoVersion}, iv...)
+ envelope = append(envelope, ciphertext...)
+
+ return base64.StdEncoding.EncodeToString(envelope), nil
+}
+
+type aesCandidate struct {
+ key []byte
+}
+
func decryptTobeparsed(encoded string) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
@@ -29,30 +93,39 @@ func decryptTobeparsed(encoded string) ([]byte, error) {
iv := raw[1:13]
cipherText := raw[13 : len(raw)-16]
- for _, keyStr := range aesKeys {
- key := sha256.Sum256([]byte(keyStr))
+ candidates := make([]aesCandidate, 0, len(aesKeys)+1)
- block, err := aes.NewCipher(key[:])
+ xorK, err := xorKey()
+ if err == nil {
+ candidates = append(candidates, aesCandidate{key: xorK})
+ }
+
+ for _, keyStr := range aesKeys {
+ k := sha256.Sum256([]byte(keyStr))
+ candidates = append(candidates, aesCandidate{key: k[:]})
+ }
+
+ for _, candidate := range candidates {
+ block, err := aes.NewCipher(candidate.key)
if err != nil {
continue
}
if version == 1 {
+ gcm, err := cipher.NewGCM(block)
+ if err == nil {
+ combined := append(append([]byte{}, cipherText...), raw[len(raw)-16:]...)
+ plainText, openErr := gcm.Open(nil, iv, combined, nil)
+ if openErr == nil && json.Valid(plainText) {
+ return plainText, nil
+ }
+ }
+
plainText := tryDecryptCTR(block, iv, cipherText)
if json.Valid(plainText) {
return plainText, nil
}
}
-
- gcm, err := cipher.NewGCM(block)
- if err == nil {
- tag := raw[len(raw)-16:]
- combined := append(append([]byte{}, cipherText...), tag...)
- plainText, openErr := gcm.Open(nil, iv, combined, nil)
- if openErr == nil && json.Valid(plainText) {
- return plainText, nil
- }
- }
}
return nil, errors.New("decryption failed")
@@ -159,6 +232,10 @@ func parseGraphQLResponse(respBody []byte, decodeErrPrefix string) (map[string]a
return nil, fmt.Errorf("%s: %w", decodeErrPrefix, err)
}
+ if _, ok := parsed["data"]; ok {
+ return parsed, nil
+ }
+
if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 {
return nil, fmt.Errorf("graphql error: %v", errs[0])
}
diff --git a/integrations/playback/allanime/http_test.go b/integrations/playback/allanime/http_test.go
index 8a87732a..a42c981d 100644
--- a/integrations/playback/allanime/http_test.go
+++ b/integrations/playback/allanime/http_test.go
@@ -150,17 +150,17 @@ func TestGraphqlRequestWithHash_Plain(t *testing.T) {
t.Parallel()
provider := &AllAnimeProvider{
- utlsClient: &http.Client{
+ httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
- if req.Method != http.MethodGet {
- t.Errorf("method = %q, want GET", req.Method)
- }
- if !strings.Contains(req.URL.String(), episodeQueryHash) {
- t.Errorf("url should contain hash, got %q", req.URL.String())
+ if req.Method != http.MethodPost {
+ t.Errorf("method = %q, want POST", req.Method)
}
if req.Header.Get("Referer") != allAnimeReferer {
t.Errorf("Referer = %q", req.Header.Get("Referer"))
}
+ if req.Header.Get("x-build-id") != "9" {
+ t.Errorf("x-build-id = %q", req.Header.Get("x-build-id"))
+ }
return mockStringResponse(http.StatusOK, `{"data":{"episode":{"sourceUrls":[{"sourceUrl":"https://example.test/v.mp4","sourceName":"default"}]}}}`), nil
}),
},
@@ -190,7 +190,7 @@ func TestGraphqlRequestWithHash_Encrypted(t *testing.T) {
encryptedPayload := buildEncryptedTobeparsedPayload(t, []byte(`{"sourceUrls":[{"sourceUrl":"https://e.test/v.mp4","sourceName":"default"}]}`))
provider := &AllAnimeProvider{
- utlsClient: &http.Client{
+ httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encryptedPayload+`"}}`), nil
}),
@@ -205,7 +205,11 @@ func TestGraphqlRequestWithHash_Encrypted(t *testing.T) {
t.Fatalf("graphqlRequestWithHash: %v", err)
}
- sources := nestedSlice(result, "episode", "sourceUrls")
+ ep, ok := result["episode"].(map[string]any)
+ if !ok {
+ t.Fatal("result missing episode key")
+ }
+ sources := nestedSlice(ep, "sourceUrls")
if len(sources) != 1 {
t.Fatalf("got %d sources, want 1", len(sources))
}
@@ -215,7 +219,7 @@ func TestGraphqlRequestWithHash_Non200(t *testing.T) {
t.Parallel()
provider := &AllAnimeProvider{
- utlsClient: &http.Client{
+ httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusNotFound, `not found`), nil
}),
@@ -235,7 +239,7 @@ func TestGraphqlRequestWithHash_EmptyData(t *testing.T) {
t.Parallel()
provider := &AllAnimeProvider{
- utlsClient: &http.Client{
+ httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusOK, `{"data":{}}`), nil
}),
@@ -258,12 +262,6 @@ func TestGetEpisodeSources_EncryptedHash(t *testing.T) {
provider := &AllAnimeProvider{
httpClient: &http.Client{
- Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
- t.Error("fallback POST should not be called")
- return nil, nil
- }),
- },
- utlsClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil
}),
@@ -292,15 +290,15 @@ func TestGetEpisodeSources_FallbackPost(t *testing.T) {
provider := &AllAnimeProvider{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ body, _ := io.ReadAll(req.Body)
+ req.Body.Close()
+ if strings.Contains(string(body), `"extensions":`) {
+ return mockStringResponse(http.StatusOK, `{"data":{}}`), nil
+ }
fallbackCalled = true
return mockStringResponse(http.StatusOK, sourceResponse), nil
}),
},
- utlsClient: &http.Client{
- Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
- return mockStringResponse(http.StatusNotFound, `not found`), nil
- }),
- },
extractor: newProviderExtractor(),
}
@@ -325,11 +323,6 @@ func TestGetEpisodeSources_BothFail(t *testing.T) {
return mockStringResponse(http.StatusNotFound, `not found`), nil
}),
},
- utlsClient: &http.Client{
- Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
- return mockStringResponse(http.StatusNotFound, `not found`), nil
- }),
- },
extractor: newProviderExtractor(),
}
@@ -343,17 +336,19 @@ func TestGetAvailableEpisodes(t *testing.T) {
t.Parallel()
tests := []struct {
- name string
- body string
- wantSub int
- wantDub int
- wantErr bool
+ name string
+ body string
+ wantSub int
+ wantDub int
+ wantTitle string
+ wantErr bool
}{
{
- name: "sub and dub available",
- body: `{"data":{"show":{"availableEpisodesDetail":{"sub":["1","2","3"],"dub":["1"]},"lastEpisodeInfo":{}}}}`,
- wantSub: 3,
- wantDub: 1,
+ name: "sub and dub available",
+ body: `{"data":{"show":{"availableEpisodesDetail":{"sub":["1","2","3"],"dub":["1"]}},"episodeInfos":[{"episodeIdNum":1,"notes":"The Beginning"}]}}`,
+ wantSub: 3,
+ wantDub: 1,
+ wantTitle: "The Beginning",
},
{
name: "sub only",
@@ -394,10 +389,18 @@ func TestGetAvailableEpisodes(t *testing.T) {
if len(available.Dub) != tt.wantDub {
t.Errorf("Dub count = %d, want %d", len(available.Dub), tt.wantDub)
}
+ assertEpisodeTitle(t, available, tt.wantTitle)
})
}
}
+func assertEpisodeTitle(t *testing.T, available AvailableEpisodes, want string) {
+ t.Helper()
+ if want != "" && available.Titles[1] != want {
+ t.Errorf("episode 1 title = %q, want %q", available.Titles[1], want)
+ }
+}
+
func TestSearch(t *testing.T) {
t.Parallel()
@@ -454,11 +457,11 @@ func TestGetStreams_FullSuccess(t *testing.T) {
provider := &AllAnimeProvider{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
- return mockStringResponse(http.StatusOK, searchBody), nil
- }),
- },
- utlsClient: &http.Client{
- Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ body, _ := io.ReadAll(req.Body)
+ req.Body.Close()
+ if strings.Contains(string(body), `"search":`) {
+ return mockStringResponse(http.StatusOK, searchBody), nil
+ }
return mockStringResponse(http.StatusOK, `{"data":{"tobeparsed":"`+encrypted+`"}}`), nil
}),
},
@@ -489,12 +492,6 @@ func TestGetStreams_ShowNotFound(t *testing.T) {
return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[]}}}`), nil
}),
},
- utlsClient: &http.Client{
- Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
- t.Error("should not call episode sources when show not found")
- return nil, nil
- }),
- },
extractor: newProviderExtractor(),
}
@@ -510,11 +507,11 @@ func TestGetStreams_NoSources(t *testing.T) {
provider := &AllAnimeProvider{
httpClient: &http.Client{
Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
- return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"showX","malId":"1","name":"Anime"}]}}}`), nil
- }),
- },
- utlsClient: &http.Client{
- Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+ body, _ := io.ReadAll(req.Body)
+ req.Body.Close()
+ if strings.Contains(string(body), `"search":`) {
+ return mockStringResponse(http.StatusOK, `{"data":{"shows":{"edges":[{"_id":"showX","malId":"1","name":"Anime"}]}}}`), nil
+ }
return mockStringResponse(http.StatusNotFound, `not found`), nil
}),
},
diff --git a/integrations/playback/allanime/search.go b/integrations/playback/allanime/search.go
index 3b5dec2f..4ca72cf4 100644
--- a/integrations/playback/allanime/search.go
+++ b/integrations/playback/allanime/search.go
@@ -68,7 +68,7 @@ func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string
TranslationType: mode,
}
- data, err := graphql.Post[searchData](ctx, c.httpClient, allAnimeBaseURL+"/api", searchQuery, vars, graphql.PostOptions{
+ data, err := graphql.Post[searchData](ctx, c.httpClient, c.apiBaseURL()+"/api", searchQuery, vars, graphql.PostOptions{
Headers: map[string]string{
"Referer": allAnimeReferer,
"User-Agent": defaultUserAgent,
@@ -99,36 +99,6 @@ func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string
return out, nil
}
-func (c *AllAnimeProvider) showID(ctx context.Context, animeID int, titleCandidates []string, mode string) string {
- targetMalIDStr := strconv.Itoa(animeID)
- fallbackID := ""
-
- for _, title := range titleCandidates {
- searchResults, err := c.Search(ctx, title, mode)
- if err != nil || len(searchResults) == 0 {
- continue
- }
- if showID := exactMatchShowID(searchResults, targetMalIDStr); showID != "" {
- return showID
- }
- if fallbackID == "" {
- fallbackID = searchResults[0].ID
- }
- }
-
- return fallbackID
-}
-
-func exactMatchShowID(searchResults []searchResult, targetMalID string) string {
- for _, res := range searchResults {
- if res.MalID == targetMalID {
- return res.ID
- }
- }
-
- return ""
-}
-
func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error) {
for _, mode := range []string{"sub", "dub"} {
showID, err := c.strictShowID(ctx, animeID, titleCandidates, mode)
diff --git a/integrations/playback/allanime/sequels.go b/integrations/playback/allanime/sequels.go
new file mode 100644
index 00000000..3cfdbf13
--- /dev/null
+++ b/integrations/playback/allanime/sequels.go
@@ -0,0 +1,164 @@
+package allanime
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "fmt"
+ stdhtml "html"
+ "strconv"
+ "strings"
+
+ "golang.org/x/net/html"
+)
+
+type ProviderShow struct {
+ ID string
+ Name string
+ EnglishName string
+ Description string
+ MalID int
+ Status string
+ Thumbnail string
+ Type string
+ Year int
+ EpisodeCount int
+ SubEpisodes []int
+ DubEpisodes []int
+}
+
+func (c *AllAnimeProvider) DirectSequels(ctx context.Context, show ProviderShow) ([]string, error) {
+ query := `query($showId: String!) { show(_id: $showId) { relatedShows } }`
+ result, err := c.graphqlRequest(ctx, query, map[string]any{"showId": show.ID})
+ if err != nil {
+ return nil, err
+ }
+ data, _ := result["data"].(map[string]any)
+ rawShow, _ := data["show"].(map[string]any)
+ relations, _ := rawShow["relatedShows"].([]any)
+ ids := make([]string, 0, len(relations))
+ for _, raw := range relations {
+ relation, _ := raw.(map[string]any)
+ id, _ := relation["showId"].(string)
+ if relation["relation"] == "sequel" && id != "" {
+ ids = append(ids, id)
+ }
+ }
+ return ids, nil
+}
+
+func (c *AllAnimeProvider) GetProviderShow(ctx context.Context, showID string) (ProviderShow, error) {
+ query := `query($showId: String!) { show(_id: $showId) { _id name englishName description malId status thumbnail type season episodeCount availableEpisodesDetail } }`
+ result, err := c.graphqlRequest(ctx, query, map[string]any{"showId": showID})
+ if err != nil {
+ return ProviderShow{}, err
+ }
+ data, _ := result["data"].(map[string]any)
+ raw, _ := data["show"].(map[string]any)
+ if raw == nil {
+ return ProviderShow{}, fmt.Errorf("allanime: show %s not found", showID)
+ }
+ return providerShowFrom(raw), nil
+}
+
+func (c *AllAnimeProvider) SeasonalShows(ctx context.Context, season string, year int) ([]ProviderShow, error) {
+ const pageSize = 40
+ query := `query($search: SearchInput, $page: Int) { shows(search: $search, limit: 40, page: $page, countryOrigin: ALL) { edges { _id name englishName description malId status thumbnail type season episodeCount availableEpisodesDetail } } }`
+ if season == "" {
+ return nil, errors.New("allanime: season is required")
+ }
+ search := map[string]any{
+ "allowAdult": false,
+ "allowUnknown": false,
+ "season": strings.ToUpper(season[:1]) + strings.ToLower(season[1:]),
+ "types": []string{"TV"},
+ "includeTypes": true,
+ }
+ out := make([]ProviderShow, 0)
+ seen := make(map[int]bool)
+ for page := 1; page <= 20; page++ {
+ result, err := c.graphqlRequest(ctx, query, map[string]any{"search": search, "page": page})
+ if err != nil {
+ return nil, err
+ }
+ data, _ := result["data"].(map[string]any)
+ shows, _ := data["shows"].(map[string]any)
+ edges, _ := shows["edges"].([]any)
+ newestYear := 0
+ for _, edge := range edges {
+ raw, _ := edge.(map[string]any)
+ show := providerShowFrom(raw)
+ newestYear = max(newestYear, show.Year)
+ if seen[show.MalID] || !isPlayableSeasonShow(show, year) {
+ continue
+ }
+ seen[show.MalID] = true
+ out = append(out, show)
+ }
+ if len(edges) < pageSize || (newestYear > 0 && newestYear < year) {
+ break
+ }
+ }
+ return out, nil
+}
+
+func isPlayableSeasonShow(show ProviderShow, year int) bool {
+ return show.Year == year && show.MalID > 0 && show.Type == "TV" && max(len(show.SubEpisodes), len(show.DubEpisodes)) > 0
+}
+
+func providerShowFrom(raw map[string]any) ProviderShow {
+ detail, _ := raw["availableEpisodesDetail"].(map[string]any)
+ season, _ := raw["season"].(map[string]any)
+ return ProviderShow{
+ ID: stringValue(raw["_id"]),
+ Name: stringValue(raw["name"]),
+ EnglishName: stringValue(raw["englishName"]),
+ Description: plainText(stringValue(raw["description"])),
+ MalID: intValue(raw["malId"]),
+ Status: stringValue(raw["status"]),
+ Thumbnail: stringValue(raw["thumbnail"]),
+ Type: stringValue(raw["type"]),
+ Year: numericInt(season["year"]),
+ EpisodeCount: intValue(raw["episodeCount"]),
+ SubEpisodes: episodeNums(stringsFrom(detail["sub"])),
+ DubEpisodes: episodeNums(stringsFrom(detail["dub"])),
+ }
+}
+
+func plainText(value string) string {
+ tokenizer := html.NewTokenizer(bytes.NewBufferString(value))
+ var out strings.Builder
+ for {
+ switch tokenizer.Next() {
+ case html.ErrorToken:
+ return out.String()
+ case html.TextToken:
+ text := strings.Join(strings.Fields(stdhtml.UnescapeString(string(tokenizer.Text()))), " ")
+ if text == "" {
+ continue
+ }
+ if out.Len() > 0 {
+ out.WriteByte(' ')
+ }
+ out.WriteString(text)
+ }
+ }
+}
+
+func stringValue(value any) string {
+ valueString, _ := value.(string)
+ return valueString
+}
+
+func intValue(value any) int {
+ n, _ := strconv.Atoi(stringValue(value))
+ return n
+}
+
+func numericInt(value any) int {
+ if n := intValue(value); n > 0 {
+ return n
+ }
+ n, _ := value.(float64)
+ return int(n)
+}
diff --git a/integrations/playback/allanime/sequels_test.go b/integrations/playback/allanime/sequels_test.go
new file mode 100644
index 00000000..2d7d2386
--- /dev/null
+++ b/integrations/playback/allanime/sequels_test.go
@@ -0,0 +1,100 @@
+package allanime
+
+import (
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestDirectSequelsReturnsOnlyPlayableSequels(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":{"show":{"relatedShows":[
+ {"relation":"sequel","showId":"season-2"},
+ {"relation":"side story","showId":"ova"}
+ ]}}}`))
+ }))
+ defer server.Close()
+
+ provider := NewAllAnimeProvider()
+ provider.httpClient = server.Client()
+ provider.baseURL = server.URL
+ providerShow := ProviderShow{ID: "season-1"}
+
+ got, err := provider.DirectSequels(context.Background(), providerShow)
+ if err != nil {
+ t.Fatalf("DirectSequels: %v", err)
+ }
+ if len(got) != 1 || got[0] != "season-2" {
+ t.Fatalf("DirectSequels = %v, want [season-2]", got)
+ }
+}
+
+func TestGetProviderShowKeepsOnlyPositiveIntegerEpisodes(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":{"show":{"_id":"season-2","name":"Example Season 2","englishName":"Example Season 2","description":"A useful
synopsis & summary.","malId":"42","status":"Finished","episodeCount":"2","availableEpisodesDetail":{"sub":["2","1","0","1.5"],"dub":["1"],"raw":[]}}}}`))
+ }))
+ defer server.Close()
+
+ provider := NewAllAnimeProvider()
+ provider.httpClient = server.Client()
+ provider.baseURL = server.URL
+
+ got, err := provider.GetProviderShow(context.Background(), "season-2")
+ if err != nil {
+ t.Fatalf("GetProviderShow: %v", err)
+ }
+ if got.MalID != 42 || len(got.SubEpisodes) != 2 || len(got.DubEpisodes) != 1 {
+ t.Fatalf("GetProviderShow = %+v", got)
+ }
+ if got.Description != "A useful synopsis & summary." {
+ t.Fatalf("Description = %q", got.Description)
+ }
+}
+
+func TestSeasonalShowsReturnsPlayableTVAnime(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ var request struct {
+ Variables struct {
+ Page int `json:"page"`
+ Search map[string]any `json:"search"`
+ } `json:"variables"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
+ t.Fatalf("decode request: %v", err)
+ }
+ if _, ok := request.Variables.Search["year"]; ok {
+ t.Fatal("seasonal request must filter normalized years locally")
+ }
+ edges := make([]map[string]any, 0)
+ switch request.Variables.Page {
+ case 1:
+ for range 40 {
+ edges = append(edges, map[string]any{"_id": "empty", "malId": "12", "type": "TV", "availableEpisodesDetail": map[string]any{"sub": []string{}, "dub": []string{}}})
+ }
+ case 2:
+ edges = append(edges,
+ map[string]any{"_id": "tv", "name": "Summer Show", "malId": "10", "type": "TV", "season": map[string]any{"quarter": "Summer", "year": "2026"}, "availableEpisodesDetail": map[string]any{"sub": []string{"1"}, "dub": []string{}}},
+ map[string]any{"_id": "old", "name": "Old Summer Show", "malId": "11", "type": "TV", "season": map[string]any{"quarter": "Summer", "year": 2025}, "availableEpisodesDetail": map[string]any{"sub": []string{"1"}, "dub": []string{}}},
+ )
+ }
+ _ = json.NewEncoder(w).Encode(map[string]any{"data": map[string]any{"shows": map[string]any{"edges": edges}}})
+ }))
+ defer server.Close()
+
+ provider := NewAllAnimeProvider()
+ provider.httpClient = server.Client()
+ provider.baseURL = server.URL
+
+ got, err := provider.SeasonalShows(context.Background(), "summer", 2026)
+ if err != nil {
+ t.Fatalf("SeasonalShows: %v", err)
+ }
+ if len(got) != 1 || got[0].MalID != 10 || got[0].Year != 2026 {
+ t.Fatalf("SeasonalShows = %+v", got)
+ }
+}
diff --git a/integrations/playback/allanime/sources.go b/integrations/playback/allanime/sources.go
index 46b79cdf..5440f6d0 100644
--- a/integrations/playback/allanime/sources.go
+++ b/integrations/playback/allanime/sources.go
@@ -1,11 +1,12 @@
package allanime
import (
+ "bytes"
"context"
+ "encoding/json"
"errors"
"fmt"
"net/http"
- "net/url"
"strings"
)
@@ -172,11 +173,15 @@ func isHTTPURL(value string) bool {
}
func buildStreamSource(url, sourceType, provider string) StreamSource {
+ referer := allAnimeReferer
+ if strings.Contains(strings.ToLower(provider), "yt-mp4") {
+ referer = allAnimeSiteURL
+ }
return StreamSource{
URL: url,
Provider: provider,
Type: sourceType,
- Referer: allAnimeReferer,
+ Referer: referer,
}
}
@@ -240,28 +245,24 @@ func stringMapValue(item map[string]any, key string) (string, bool) {
}
func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) {
- req, err := newHashRequest(ctx, showID, episode, mode)
+ req, err := c.newHashRequest(ctx, showID, episode, mode)
if err != nil {
- return nil, fmt.Errorf("create GET request: %w", err)
+ return nil, fmt.Errorf("create POST request: %w", err)
}
+ req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", defaultUserAgent)
- req.Header.Set("Accept", "*/*")
- req.Header.Set("Accept-Language", "en-US,en;q=0.5")
- req.Header.Set("Accept-Encoding", "identity")
req.Header.Set("Referer", allAnimeReferer)
req.Header.Set("Origin", allAnimeOrigin)
- req.Header.Set("Sec-Fetch-Dest", "empty")
- req.Header.Set("Sec-Fetch-Mode", "cors")
- req.Header.Set("Sec-Fetch-Site", "cross-site")
+ req.Header.Set("x-build-id", "9")
- statusCode, respBody, err := executeAndReadResponse(c.utlsClient, req, "execute GET request", "read response")
+ statusCode, respBody, err := executeAndReadResponse(c.httpClient, req, "execute POST request (aaReq)", "read response")
if err != nil {
return nil, err
}
if statusCode != http.StatusOK {
- return nil, fmt.Errorf("GET status %d: %s", statusCode, string(respBody))
+ return nil, fmt.Errorf("POST status %d: %s", statusCode, string(respBody))
}
parsed, err := parseGraphQLResponse(respBody, "decode response")
@@ -289,15 +290,33 @@ func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, e
return nil, errors.New("no usable data in response")
}
-func newHashRequest(ctx context.Context, showID, episode, mode string) (*http.Request, error) {
- varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, strings.ToLower(mode), episode)
- extJSON := fmt.Sprintf(`{"persistedQuery":{"version":1,"sha256Hash":"%s"}}`, episodeQueryHash)
+func (c *AllAnimeProvider) newHashRequest(ctx context.Context, showID, episode, mode string) (*http.Request, error) {
+ aaReq, err := makeAALease(episodeQueryHash)
+ if err != nil {
+ return nil, fmt.Errorf("create aa lease: %w", err)
+ }
- params := url.Values{}
- params.Set("variables", varsJSON)
- params.Set("extensions", extJSON)
+ payload := map[string]any{
+ "variables": map[string]any{
+ "showId": showID,
+ "translationType": strings.ToLower(mode),
+ "episodeString": episode,
+ },
+ "extensions": map[string]any{
+ "persistedQuery": map[string]any{
+ "version": 1,
+ "sha256Hash": episodeQueryHash,
+ },
+ "aaReq": aaReq,
+ },
+ }
- return http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api?%s", allAnimeBaseURL, params.Encode()), nil)
+ body, err := json.Marshal(payload)
+ if err != nil {
+ return nil, fmt.Errorf("marshal payload: %w", err)
+ }
+
+ return http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("%s/api", allAnimeBaseURL), bytes.NewReader(body))
}
func detectStreamType(sourceURL string) string {
diff --git a/integrations/tvmaze/client.go b/integrations/tvmaze/client.go
new file mode 100644
index 00000000..db5ff5b0
--- /dev/null
+++ b/integrations/tvmaze/client.go
@@ -0,0 +1,272 @@
+package tvmaze
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+ "unicode"
+
+ "mal/internal/domain"
+)
+
+var (
+ seasonNumberPattern = regexp.MustCompile(`(?i)(?:\bseason\s+(\d+)|\b(\d+)(?:st|nd|rd|th)\s+season)\b`)
+ seasonSuffixPattern = regexp.MustCompile(`(?i)\s+(?:(?:season\s+\d+)|(?:\d+(?:st|nd|rd|th)\s+season))(?:\s+part\s+\d+)?\s*$`)
+ partNumberPattern = regexp.MustCompile(`(?i)\bpart\s+(\d+)\b`)
+)
+
+const baseURL = "https://api.tvmaze.com"
+
+type Client struct {
+ httpClient *http.Client
+ baseURL string
+}
+
+type searchResult struct {
+ Show show `json:"show"`
+}
+
+type show struct {
+ ID int `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+}
+
+type episode struct {
+ Name string `json:"name"`
+ Season int `json:"season"`
+ Airdate string `json:"airdate"`
+}
+
+func NewClient() *Client {
+ return &Client{
+ httpClient: &http.Client{Timeout: 5 * time.Second},
+ baseURL: baseURL,
+ }
+}
+
+func (c *Client) Name() string {
+ return "TVmaze"
+}
+
+func (c *Client) ResolveEpisodeProviderID(ctx context.Context, _ int, titleCandidates []string) (string, error) {
+ matches := map[int]struct{}{}
+ for _, candidate := range titleSearchCandidates(titleCandidates, 6) {
+ results, err := c.search(ctx, candidate)
+ if err != nil {
+ return "", err
+ }
+ addExactMatches(matches, results, normalizeTitle(candidate))
+ if len(matches) == 1 {
+ for id := range matches {
+ return strconv.Itoa(id), nil
+ }
+ }
+ }
+
+ if len(matches) > 1 {
+ return "", errors.New("tvmaze: multiple exact show matches")
+ }
+ return "", errors.New("tvmaze: no exact show match")
+}
+
+func uniqueTitleCandidates(candidates []string, limit int) []string {
+ seen := map[string]struct{}{}
+ unique := make([]string, 0, min(len(candidates), limit))
+ for _, candidate := range candidates {
+ normalized := normalizeTitle(candidate)
+ if normalized == "" {
+ continue
+ }
+ if _, ok := seen[normalized]; ok {
+ continue
+ }
+ seen[normalized] = struct{}{}
+ unique = append(unique, candidate)
+ if len(unique) == limit {
+ break
+ }
+ }
+ return unique
+}
+
+func addExactMatches(matches map[int]struct{}, results []searchResult, normalizedTitle string) {
+ for _, result := range results {
+ if result.Show.ID <= 0 || normalizeTitle(result.Show.Name) != normalizedTitle {
+ continue
+ }
+ if result.Show.Type != "" && !strings.EqualFold(result.Show.Type, "Animation") {
+ continue
+ }
+ matches[result.Show.ID] = struct{}{}
+ }
+}
+
+func (c *Client) GetEpisodeTitlesByProviderID(ctx context.Context, providerID string, anime domain.Anime, episodeCount int) (map[int]string, error) {
+ var episodes []episode
+ if err := c.getJSON(ctx, "/shows/"+url.PathEscape(providerID)+"/episodes", &episodes); err != nil {
+ return nil, err
+ }
+ episodes, err := episodesForAnimeSeason(episodes, anime, episodeCount)
+ if err != nil {
+ return nil, err
+ }
+
+ titles := make(map[int]string, len(episodes))
+ for i, item := range episodes {
+ title := strings.TrimSpace(item.Name)
+ if title != "" {
+ titles[i+1] = title
+ }
+ }
+ if len(titles) == 0 {
+ return nil, errors.New("tvmaze: show has no episode titles")
+ }
+ return titles, nil
+}
+
+func titleSearchCandidates(candidates []string, limit int) []string {
+ expanded := make([]string, 0, len(candidates)*2)
+ for _, candidate := range candidates {
+ parent := strings.TrimSpace(seasonSuffixPattern.ReplaceAllString(candidate, ""))
+ if parent != "" && parent != strings.TrimSpace(candidate) {
+ expanded = append(expanded, parent)
+ }
+ expanded = append(expanded, candidate)
+ }
+ return uniqueTitleCandidates(expanded, limit)
+}
+
+func episodesForAnimeSeason(episodes []episode, anime domain.Anime, episodeCount int) ([]episode, error) {
+ seasons := groupEpisodesBySeason(episodes)
+ season := animeSeasonNumber(anime)
+ if season == 0 {
+ season = seasonForYear(seasons, anime.Year)
+ }
+ if season == 0 {
+ season = 1
+ }
+
+ selected := seasons[season]
+ if len(selected) == 0 {
+ return nil, fmt.Errorf("tvmaze: season %d has no episodes", season)
+ }
+ if episodeCount <= 0 || episodeCount >= len(selected) {
+ return selected, nil
+ }
+ if animePartNumber(anime) > 1 {
+ return selected[len(selected)-episodeCount:], nil
+ }
+ return selected[:episodeCount], nil
+}
+
+func groupEpisodesBySeason(episodes []episode) map[int][]episode {
+ seasons := map[int][]episode{}
+ for _, item := range episodes {
+ if item.Season > 0 {
+ seasons[item.Season] = append(seasons[item.Season], item)
+ }
+ }
+ return seasons
+}
+
+func animeSeasonNumber(anime domain.Anime) int {
+ return titleNumber(animeTitles(anime), seasonNumberPattern)
+}
+
+func animePartNumber(anime domain.Anime) int {
+ return titleNumber(animeTitles(anime), partNumberPattern)
+}
+
+func titleNumber(titles []string, pattern *regexp.Regexp) int {
+ for _, title := range titles {
+ match := pattern.FindStringSubmatch(title)
+ if len(match) <= 1 {
+ continue
+ }
+ for _, value := range match[1:] {
+ number, err := strconv.Atoi(value)
+ if err == nil && number > 0 {
+ return number
+ }
+ }
+ }
+ return 0
+}
+
+func animeTitles(anime domain.Anime) []string {
+ titles := make([]string, 0, 3+len(anime.TitleSynonyms))
+ titles = append(titles, anime.Title, anime.TitleEnglish, anime.TitleJapanese)
+ titles = append(titles, anime.TitleSynonyms...)
+ return titles
+}
+
+func seasonForYear(seasons map[int][]episode, year int) int {
+ if year <= 0 {
+ return 0
+ }
+ matchedSeason := 0
+ for season, episodes := range seasons {
+ if len(episodes) == 0 || !strings.HasPrefix(episodes[0].Airdate, strconv.Itoa(year)+"-") {
+ continue
+ }
+ if matchedSeason != 0 {
+ return 0
+ }
+ matchedSeason = season
+ }
+ return matchedSeason
+}
+
+func (c *Client) search(ctx context.Context, title string) ([]searchResult, error) {
+ var results []searchResult
+ err := c.getJSON(ctx, "/search/shows?q="+url.QueryEscape(title), &results)
+ return results, err
+}
+
+func (c *Client) getJSON(ctx context.Context, path string, target any) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.apiURL()+path, nil)
+ if err != nil {
+ return fmt.Errorf("tvmaze: create request: %w", err)
+ }
+ req.Header.Set("Accept", "application/json")
+ req.Header.Set("User-Agent", "mal/1.0")
+
+ resp, err := c.httpClient.Do(req)
+ if err != nil {
+ return fmt.Errorf("tvmaze: request: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("tvmaze: status %d", resp.StatusCode)
+ }
+ if err := json.NewDecoder(io.LimitReader(resp.Body, 2<<20)).Decode(target); err != nil {
+ return fmt.Errorf("tvmaze: decode response: %w", err)
+ }
+ return nil
+}
+
+func (c *Client) apiURL() string {
+ if c.baseURL != "" {
+ return c.baseURL
+ }
+ return baseURL
+}
+
+func normalizeTitle(value string) string {
+ return strings.Map(func(r rune) rune {
+ if unicode.IsLetter(r) || unicode.IsNumber(r) {
+ return unicode.ToLower(r)
+ }
+ return -1
+ }, strings.TrimSpace(value))
+}
diff --git a/integrations/tvmaze/client_test.go b/integrations/tvmaze/client_test.go
new file mode 100644
index 00000000..409ea6ad
--- /dev/null
+++ b/integrations/tvmaze/client_test.go
@@ -0,0 +1,107 @@
+package tvmaze
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "reflect"
+ "testing"
+
+ "mal/integrations/jikan"
+ "mal/internal/domain"
+)
+
+func TestResolveEpisodeProviderIDRequiresExactAnimatedTitle(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/search/shows" {
+ t.Fatalf("path = %q", r.URL.Path)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[
+ {"show":{"id":1,"name":"May I Ask for One Final Thing Extra","type":"Animation"}},
+ {"show":{"id":80712,"name":"May I Ask for One Final Thing?","type":"Animation"}},
+ {"show":{"id":99,"name":"May I Ask for One Final Thing?","type":"Scripted"}}
+ ]`))
+ }))
+ defer server.Close()
+
+ client := newTestClient(server)
+ id, err := client.ResolveEpisodeProviderID(context.Background(), 59846, []string{"May I Ask for One Final Thing?"})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if id != "80712" {
+ t.Fatalf("id = %q, want 80712", id)
+ }
+}
+
+func TestGetEpisodeTitlesUsesAiringOrder(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if r.URL.Path != "/shows/80712/episodes" {
+ t.Fatalf("path = %q", r.URL.Path)
+ }
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[
+ {"season":1,"number":1,"name":"Wrong season"},
+ {"season":2,"number":1,"name":" First title "},
+ {"season":2,"number":2,"name":"Second title"},
+ {"season":2,"number":3,"name":"Future title"},
+ {"season":3,"number":1,"name":"Another wrong season"}
+ ]`))
+ }))
+ defer server.Close()
+
+ client := newTestClient(server)
+ titles, err := client.GetEpisodeTitlesByProviderID(context.Background(), "80712", domain.Anime{Anime: jikan.Anime{
+ Title: "Example 2nd Season",
+ }}, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := map[int]string{1: "First title", 2: "Second title"}
+ if !reflect.DeepEqual(titles, want) {
+ t.Fatalf("titles = %#v, want %#v", titles, want)
+ }
+}
+
+func TestGetEpisodeTitlesSelectsSplitCourWithinSeason(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`[
+ {"season":2,"number":1,"name":"Cour one A"},
+ {"season":2,"number":2,"name":"Cour one B"},
+ {"season":2,"number":3,"name":"Cour two A"},
+ {"season":2,"number":4,"name":"Cour two B"}
+ ]`))
+ }))
+ defer server.Close()
+
+ client := newTestClient(server)
+ titles, err := client.GetEpisodeTitlesByProviderID(context.Background(), "14459", domain.Anime{Anime: jikan.Anime{
+ Title: "Re:Zero kara Hajimeru Isekai Seikatsu 2nd Season Part 2",
+ }}, 2)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want := map[int]string{1: "Cour two A", 2: "Cour two B"}
+ if !reflect.DeepEqual(titles, want) {
+ t.Fatalf("titles = %#v, want %#v", titles, want)
+ }
+}
+
+func TestTitleSearchCandidatesUseParentShowName(t *testing.T) {
+ got := titleSearchCandidates([]string{
+ "Re:Zero kara Hajimeru Isekai Seikatsu 2nd Season Part 2",
+ }, 6)
+ want := []string{
+ "Re:Zero kara Hajimeru Isekai Seikatsu",
+ "Re:Zero kara Hajimeru Isekai Seikatsu 2nd Season Part 2",
+ }
+ if !reflect.DeepEqual(got, want) {
+ t.Fatalf("candidates = %#v, want %#v", got, want)
+ }
+}
+
+func newTestClient(server *httptest.Server) *Client {
+ return &Client{httpClient: server.Client(), baseURL: server.URL}
+}
diff --git a/internal/anime/catalog_handler.go b/internal/anime/catalog_handler.go
index aed5eabc..b99695c2 100644
--- a/internal/anime/catalog_handler.go
+++ b/internal/anime/catalog_handler.go
@@ -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),
diff --git a/internal/anime/catalog_handler_test.go b/internal/anime/catalog_handler_test.go
new file mode 100644
index 00000000..9efa6121
--- /dev/null
+++ b/internal/anime/catalog_handler_test.go
@@ -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(), "") {
+ 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())
+ }
+}
diff --git a/internal/anime/details_handler.go b/internal/anime/details_handler.go
index 5a331ccb..665c1130 100644
--- a/internal/anime/details_handler.go
+++ b/internal/anime/details_handler.go
@@ -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 {
diff --git a/internal/anime/discovery.go b/internal/anime/discovery.go
new file mode 100644
index 00000000..2d8819e3
--- /dev/null
+++ b/internal/anime/discovery.go
@@ -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}
+}
diff --git a/internal/anime/handler.go b/internal/anime/handler.go
index f3683229..48781beb 100644
--- a/internal/anime/handler.go
+++ b/internal/anime/handler.go
@@ -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)
diff --git a/internal/anime/handler_test.go b/internal/anime/handler_test.go
index 06d8bc1f..dd25c41a 100644
--- a/internal/anime/handler_test.go
+++ b/internal/anime/handler_test.go
@@ -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},
diff --git a/internal/anime/module.go b/internal/anime/module.go
index 8ef63e4f..76af9517 100644
--- a/internal/anime/module.go
+++ b/internal/anime/module.go
@@ -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,
),
diff --git a/internal/anime/recommendations.go b/internal/anime/recommendations.go
index 97a0fe2e..e9ac5b56 100644
--- a/internal/anime/recommendations.go
+++ b/internal/anime/recommendations.go
@@ -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
}
diff --git a/internal/anime/recommendations/recommendations_test.go b/internal/anime/recommendations/recommendations_test.go
index 2e3381ba..7017c528 100644
--- a/internal/anime/recommendations/recommendations_test.go
+++ b/internal/anime/recommendations/recommendations_test.go
@@ -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)
diff --git a/internal/anime/recommendations/rerank.go b/internal/anime/recommendations/rerank.go
index 1032b741..5da4c60d 100644
--- a/internal/anime/recommendations/rerank.go
+++ b/internal/anime/recommendations/rerank.go
@@ -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)
diff --git a/internal/anime/recommendations/scoring.go b/internal/anime/recommendations/scoring.go
index ed120231..6eababc4 100644
--- a/internal/anime/recommendations/scoring.go
+++ b/internal/anime/recommendations/scoring.go
@@ -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
+}
diff --git a/internal/anime/recommendations/types.go b/internal/anime/recommendations/types.go
index edcb4fb5..979cbec4 100644
--- a/internal/anime/recommendations/types.go
+++ b/internal/anime/recommendations/types.go
@@ -25,6 +25,7 @@ type recommendationCandidate struct {
themeMatches int
studioMatches int
demographicMatches int
+ rationale []string
}
type userTasteProfile struct {
diff --git a/internal/anime/recommendations_cache_test.go b/internal/anime/recommendations_cache_test.go
new file mode 100644
index 00000000..7953663d
--- /dev/null
+++ b/internal/anime/recommendations_cache_test.go
@@ -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")
+ }
+}
diff --git a/internal/anime/repository.go b/internal/anime/repository.go
index 62f9f7c9..e2cb5a5a 100644
--- a/internal/anime/repository.go
+++ b/internal/anime/repository.go
@@ -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,
+ })
+}
diff --git a/internal/anime/schedule.go b/internal/anime/schedule.go
deleted file mode 100644
index 761a4728..00000000
--- a/internal/anime/schedule.go
+++ /dev/null
@@ -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()
-}
diff --git a/internal/anime/service.go b/internal/anime/service.go
index 96ddcf92..5eaf7a48 100644
--- a/internal/anime/service.go
+++ b/internal/anime/service.go
@@ -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)
}
diff --git a/internal/anime/service_test.go b/internal/anime/service_test.go
new file mode 100644
index 00000000..305a7cc6
--- /dev/null
+++ b/internal/anime/service_test.go
@@ -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)
+ }
+}
diff --git a/internal/anime/simulcast.go b/internal/anime/simulcast.go
new file mode 100644
index 00000000..3daa0090
--- /dev/null
+++ b/internal/anime/simulcast.go
@@ -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
+}
diff --git a/internal/anime/simulcast_test.go b/internal/anime/simulcast_test.go
new file mode 100644
index 00000000..5e722162
--- /dev/null
+++ b/internal/anime/simulcast_test.go
@@ -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)
+ }
+}
diff --git a/internal/auth/cookie.go b/internal/auth/cookie.go
new file mode 100644
index 00000000..5d7fde7f
--- /dev/null
+++ b/internal/auth/cookie.go
@@ -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
+}
diff --git a/internal/auth/handler.go b/internal/auth/handler.go
index 8794fb80..90a48215 100644
--- a/internal/auth/handler.go
+++ b/internal/auth/handler.go
@@ -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")
}
diff --git a/internal/auth/handler_middleware_test.go b/internal/auth/handler_middleware_test.go
index 149975a0..c9ca7bbc 100644
--- a/internal/auth/handler_middleware_test.go
+++ b/internal/auth/handler_middleware_test.go
@@ -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
+}
diff --git a/internal/auth/middleware.go b/internal/auth/middleware.go
index 01ea7fbc..7b21b07d 100644
--- a/internal/auth/middleware.go
+++ b/internal/auth/middleware.go
@@ -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)
}
}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 00000000..c51a478a
--- /dev/null
+++ b/internal/config/config_test.go
@@ -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")
+ }
+}
diff --git a/internal/database/data_fixes_test.go b/internal/database/data_fixes_test.go
new file mode 100644
index 00000000..d454854f
--- /dev/null
+++ b/internal/database/data_fixes_test.go
@@ -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)
+ }
+}
diff --git a/internal/database/database_test.go b/internal/database/database_test.go
index 52b1e8ae..c06f2e45 100644
--- a/internal/database/database_test.go
+++ b/internal/database/database_test.go
@@ -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)
diff --git a/internal/database/fixes.go b/internal/database/fixes.go
index 13e0ab06..ff415867 100644
--- a/internal/database/fixes.go
+++ b/internal/database/fixes.go
@@ -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
}
diff --git a/internal/database/fixes/fixes_test.go b/internal/database/fixes/fixes_test.go
new file mode 100644
index 00000000..fe3184d8
--- /dev/null
+++ b/internal/database/fixes/fixes_test.go
@@ -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)
+ }
+}
diff --git a/internal/database/migrations/026_add_watchlist_completion_dates.sql b/internal/database/migrations/026_add_watchlist_completion_dates.sql
new file mode 100644
index 00000000..d3c9ac70
--- /dev/null
+++ b/internal/database/migrations/026_add_watchlist_completion_dates.sql
@@ -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;
diff --git a/internal/db/continue_watching_test.go b/internal/db/continue_watching_test.go
new file mode 100644
index 00000000..8e516660
--- /dev/null
+++ b/internal/db/continue_watching_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/db/models.go b/internal/db/models.go
index 1672321d..fbecd3ff 100644
--- a/internal/db/models.go
+++ b/internal/db/models.go
@@ -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"`
}
diff --git a/internal/db/querier.go b/internal/db/querier.go
index 745263ef..515f3c9d 100644
--- a/internal/db/querier.go
+++ b/internal/db/querier.go
@@ -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)
diff --git a/internal/db/queries.sql b/internal/db/queries.sql
index 1d52b6e1..306186bf 100644
--- a/internal/db/queries.sql
+++ b/internal/db/queries.sql
@@ -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;
diff --git a/internal/db/queries.sql.go b/internal/db/queries.sql.go
index bbbaf96a..d05f9fc0 100644
--- a/internal/db/queries.sql.go
+++ b/internal/db/queries.sql.go
@@ -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
}
diff --git a/internal/domain/anime.go b/internal/domain/anime.go
index 500d6f4e..46e562a6 100644
--- a/internal/domain/anime.go
+++ b/internal/domain/anime.go
@@ -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)
}
diff --git a/internal/domain/episodes.go b/internal/domain/episodes.go
index 735415ad..97db6f2c 100644
--- a/internal/domain/episodes.go
+++ b/internal/domain/episodes.go
@@ -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)
+}
diff --git a/internal/domain/playback.go b/internal/domain/playback.go
index 97360a8e..ea3fb963 100644
--- a/internal/domain/playback.go
+++ b/internal/domain/playback.go
@@ -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 {
diff --git a/internal/episodes/module.go b/internal/episodes/module.go
index 9ded3f09..bafacbfa 100644
--- a/internal/episodes/module.go
+++ b/internal/episodes/module.go
@@ -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),
)
diff --git a/internal/episodes/service/cache_store.go b/internal/episodes/service/cache_store.go
index 96f2442a..79ca5ea3 100644
--- a/internal/episodes/service/cache_store.go
+++ b/internal/episodes/service/cache_store.go
@@ -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
+}
diff --git a/internal/episodes/service/classifications.go b/internal/episodes/service/classifications.go
new file mode 100644
index 00000000..9be153c8
--- /dev/null
+++ b/internal/episodes/service/classifications.go
@@ -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
+ }
+}
diff --git a/internal/episodes/service/classifications_test.go b/internal/episodes/service/classifications_test.go
new file mode 100644
index 00000000..c18217f9
--- /dev/null
+++ b/internal/episodes/service/classifications_test.go
@@ -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])
+ }
+}
diff --git a/internal/episodes/service/merge.go b/internal/episodes/service/merge.go
index 99f7bb2b..10b8445a 100644
--- a/internal/episodes/service/merge.go
+++ b/internal/episodes/service/merge.go
@@ -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
}
diff --git a/internal/episodes/service/provider_mapping.go b/internal/episodes/service/provider_mapping.go
index 44cdba9a..6d0a3f78 100644
--- a/internal/episodes/service/provider_mapping.go
+++ b/internal/episodes/service/provider_mapping.go
@@ -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(),
diff --git a/internal/episodes/service/service.go b/internal/episodes/service/service.go
index 1b43db7c..ed9d8ccc 100644
--- a/internal/episodes/service/service.go
+++ b/internal/episodes/service/service.go
@@ -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
-}
diff --git a/internal/episodes/service/service_test.go b/internal/episodes/service/service_test.go
index 5fd200c2..415afe14 100644
--- a/internal/episodes/service/service_test.go
+++ b/internal/episodes/service/service_test.go
@@ -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 {
diff --git a/internal/episodes/service/titles.go b/internal/episodes/service/titles.go
new file mode 100644
index 00000000..04f0dce6
--- /dev/null
+++ b/internal/episodes/service/titles.go
@@ -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)
+}
diff --git a/internal/episodes/service/titles_test.go b/internal/episodes/service/titles_test.go
new file mode 100644
index 00000000..70222ed8
--- /dev/null
+++ b/internal/episodes/service/titles_test.go
@@ -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)
+ }
+}
diff --git a/internal/observability/bytes_test.go b/internal/observability/bytes_test.go
new file mode 100644
index 00000000..5f7d8316
--- /dev/null
+++ b/internal/observability/bytes_test.go
@@ -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")
+ }
+}
diff --git a/internal/observability/clone_test.go b/internal/observability/clone_test.go
new file mode 100644
index 00000000..11bad666
--- /dev/null
+++ b/internal/observability/clone_test.go
@@ -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")
+ }
+}
diff --git a/internal/observability/duration_test.go b/internal/observability/duration_test.go
new file mode 100644
index 00000000..83163f6f
--- /dev/null
+++ b/internal/observability/duration_test.go
@@ -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")
+ }
+}
diff --git a/internal/observability/field_test.go b/internal/observability/field_test.go
new file mode 100644
index 00000000..616894a1
--- /dev/null
+++ b/internal/observability/field_test.go
@@ -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")
+ }
+}
diff --git a/internal/observability/fx.go b/internal/observability/fx.go
index 21e9759a..4c165282 100644
--- a/internal/observability/fx.go
+++ b/internal/observability/fx.go
@@ -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:
diff --git a/internal/observability/fx_test.go b/internal/observability/fx_test.go
new file mode 100644
index 00000000..efb55eeb
--- /dev/null
+++ b/internal/observability/fx_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/internal/observability/localip_test.go b/internal/observability/localip_test.go
new file mode 100644
index 00000000..c2fe1762
--- /dev/null
+++ b/internal/observability/localip_test.go
@@ -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")
+ }
+}
diff --git a/internal/observability/quote_test.go b/internal/observability/quote_test.go
new file mode 100644
index 00000000..e65cff68
--- /dev/null
+++ b/internal/observability/quote_test.go
@@ -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"`)
+ }
+}
diff --git a/internal/observability/status_test.go b/internal/observability/status_test.go
new file mode 100644
index 00000000..0b38518a
--- /dev/null
+++ b/internal/observability/status_test.go
@@ -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")
+ }
+}
diff --git a/internal/playback/handler/episode_metadata_test.go b/internal/playback/handler/episode_metadata_test.go
new file mode 100644
index 00000000..3bc2a628
--- /dev/null
+++ b/internal/playback/handler/episode_metadata_test.go
@@ -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)
+ }
+}
diff --git a/internal/playback/handler/handler.go b/internal/playback/handler/handler.go
index 382d2016..6802cd39 100644
--- a/internal/playback/handler/handler.go
+++ b/internal/playback/handler/handler.go
@@ -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)
-}
diff --git a/internal/playback/handler/manifest_cache.go b/internal/playback/handler/manifest_cache.go
new file mode 100644
index 00000000..0192355a
--- /dev/null
+++ b/internal/playback/handler/manifest_cache.go
@@ -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)
+}
diff --git a/internal/playback/handler/manifest_cache_test.go b/internal/playback/handler/manifest_cache_test.go
new file mode 100644
index 00000000..61da41a7
--- /dev/null
+++ b/internal/playback/handler/manifest_cache_test.go
@@ -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)
+ }
+}
diff --git a/internal/playback/handler/playlist_rewrite_test.go b/internal/playback/handler/playlist_rewrite_test.go
index 73a6c209..1430f5f7 100644
--- a/internal/playback/handler/playlist_rewrite_test.go
+++ b/internal/playback/handler/playlist_rewrite_test.go
@@ -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 {
diff --git a/internal/playback/handler/proxy_request.go b/internal/playback/handler/proxy_request.go
index ce004b80..3a42bc8c 100644
--- a/internal/playback/handler/proxy_request.go
+++ b/internal/playback/handler/proxy_request.go
@@ -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 != "" {
diff --git a/internal/playback/handler/proxy_stream.go b/internal/playback/handler/proxy_stream.go
index 74ffa19e..1e0a0992 100644
--- a/internal/playback/handler/proxy_stream.go
+++ b/internal/playback/handler/proxy_stream.go
@@ -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))
}
diff --git a/internal/playback/handler/proxy_subtitle.go b/internal/playback/handler/proxy_subtitle.go
index b26dd939..be7f81f5 100644
--- a/internal/playback/handler/proxy_subtitle.go
+++ b/internal/playback/handler/proxy_subtitle.go
@@ -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
}
diff --git a/internal/playback/handler/proxy_target_test.go b/internal/playback/handler/proxy_target_test.go
new file mode 100644
index 00000000..679dcf99
--- /dev/null
+++ b/internal/playback/handler/proxy_target_test.go
@@ -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")
+ }
+}
diff --git a/internal/playback/handler/watch_page_test.go b/internal/playback/handler/watch_page_test.go
index 03c5a12e..23e7d6d2 100644
--- a/internal/playback/handler/watch_page_test.go
+++ b/internal/playback/handler/watch_page_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/playback/progress_service_test.go b/internal/playback/progress_service_test.go
index 99ba4c72..635cc444 100644
--- a/internal/playback/progress_service_test.go
+++ b/internal/playback/progress_service_test.go
@@ -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
diff --git a/internal/playback/proxytarget/target.go b/internal/playback/proxytarget/target.go
new file mode 100644
index 00000000..840b4390
--- /dev/null
+++ b/internal/playback/proxytarget/target.go
@@ -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()
+}
diff --git a/internal/playback/proxytarget/target_test.go b/internal/playback/proxytarget/target_test.go
new file mode 100644
index 00000000..8a4dff9b
--- /dev/null
+++ b/internal/playback/proxytarget/target_test.go
@@ -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
+}
diff --git a/internal/playback/service.go b/internal/playback/service.go
index 65c624d9..c1dffdda 100644
--- a/internal/playback/service.go
+++ b/internal/playback/service.go
@@ -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())
-}
diff --git a/internal/playback/source_cache.go b/internal/playback/source_cache.go
new file mode 100644
index 00000000..6c2c59b3
--- /dev/null
+++ b/internal/playback/source_cache.go
@@ -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
+}
diff --git a/internal/playback/source_cache_test.go b/internal/playback/source_cache_test.go
new file mode 100644
index 00000000..b7fd3485
--- /dev/null
+++ b/internal/playback/source_cache_test.go
@@ -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)
+ }
+}
diff --git a/internal/playback/watch_data.go b/internal/playback/watch_data.go
index 2ae495b2..492c92e0 100644
--- a/internal/playback/watch_data.go
+++ b/internal/playback/watch_data.go
@@ -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 {
diff --git a/internal/playback/watch_data_test.go b/internal/playback/watch_data_test.go
index bb2ec4f6..751a93cd 100644
--- a/internal/playback/watch_data_test.go
+++ b/internal/playback/watch_data_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/internal/server/cors_test.go b/internal/server/cors_test.go
new file mode 100644
index 00000000..d16877ae
--- /dev/null
+++ b/internal/server/cors_test.go
@@ -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)
+ }
+}
diff --git a/internal/server/observability.go b/internal/server/observability.go
index 952e8c78..ac514505 100644
--- a/internal/server/observability.go
+++ b/internal/server/observability.go
@@ -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
diff --git a/internal/server/respond_test.go b/internal/server/respond_test.go
new file mode 100644
index 00000000..65ae8224
--- /dev/null
+++ b/internal/server/respond_test.go
@@ -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())
+ }
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index ba2ddbab..d5813233 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -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, `
+