Merge upstream/main
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
164
integrations/playback/allanime/sequels.go
Normal file
164
integrations/playback/allanime/sequels.go
Normal file
@@ -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)
|
||||
}
|
||||
100
integrations/playback/allanime/sequels_test.go
Normal file
100
integrations/playback/allanime/sequels_test.go
Normal file
@@ -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<br><i>synopsis</i> & 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
272
integrations/tvmaze/client.go
Normal file
272
integrations/tvmaze/client.go
Normal file
@@ -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))
|
||||
}
|
||||
107
integrations/tvmaze/client_test.go
Normal file
107
integrations/tvmaze/client_test.go
Normal file
@@ -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}
|
||||
}
|
||||
Reference in New Issue
Block a user