Merge branch 'upstream/main' into main
All checks were successful
Build and Push Container Image / build-and-push (push) Successful in 9m21s

This commit is contained in:
2026-06-15 21:37:41 +02:00
150 changed files with 7982 additions and 5572 deletions

View File

@@ -358,7 +358,7 @@ func fetchDocument(ctx context.Context, httpClient *http.Client, url string) (*g
return nil, url, err
}
return document, response.Request.URL.String(), nil
return document, response, nil
}
type timetableAnimeAPI struct {

View File

@@ -149,25 +149,43 @@ func jikanTraceEnabled() bool {
return traceEnabled
}
func logJikanCache(cacheKey string, source string, startedAt time.Time, err error) {
duration := time.Since(startedAt)
if !jikanTraceEnabled() && err == nil && source == "fresh" && duration < 50*time.Millisecond {
return
}
if !jikanTraceEnabled() && err == nil && source == "refresh" && duration < jikanSlowLogThreshold {
return
func shouldSkipJikanCacheLog(source string, duration time.Duration, err error) bool {
if jikanTraceEnabled() || err != nil {
return false
}
level := observability.LogLevelInfo
if source == "fresh" {
return duration < 50*time.Millisecond
}
if source == "refresh" {
return duration < jikanSlowLogThreshold
}
return false
}
func jikanCacheLogLevel(source string, err error) observability.LogLevel {
if err != nil {
level = observability.LogLevelError
} else if source != "fresh" && source != "refresh" {
return observability.LogLevelError
}
if source != "fresh" && source != "refresh" {
// Stale reads are expected sometimes, but worth tracking in logs.
level = observability.LogLevelWarn
return observability.LogLevelWarn
}
return observability.LogLevelInfo
}
func logJikanCache(cacheKey string, source string, startedAt time.Time, err error) {
duration := time.Since(startedAt)
if shouldSkipJikanCacheLog(source, duration, err) {
return
}
observability.LogJSON(
level,
jikanCacheLogLevel(source, err),
"jikan_cache",
"jikan",
"",
@@ -475,82 +493,116 @@ func (c *Client) fetchWithRetry(ctx context.Context, urlStr string, out any) err
for attempt := range maxRetries {
attempts = attempt + 1
select {
case <-ctx.Done():
return logAndReturn(0, fmt.Errorf("request canceled while retrying jikan request: %w", ctx.Err()))
default:
}
if err := c.waitRateLimit(ctx); err != nil {
if err := c.prepareRetryAttempt(ctx); err != nil {
return logAndReturn(0, err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
resp, err := c.doRequest(ctx, urlStr)
if err != nil {
return logAndReturn(0, fmt.Errorf("failed to create jikan request: %w", err))
}
req.Header.Set("User-Agent", netutil.Generic)
resp, err := c.httpClient.Do(req)
if err != nil {
if errors.Is(err, context.Canceled) {
return logAndReturn(0, fmt.Errorf("request canceled while retrying jikan request: %w", err))
}
if attempt < maxRetries-1 && IsRetryableError(err) {
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
return logAndReturn(0, retryErr)
}
retry, requestErr := handleRequestRetry(ctx, err, attempt, maxRetries)
if retry {
continue
}
return logAndReturn(0, fmt.Errorf("jikan api error: %w", err))
return logAndReturn(0, requestErr)
}
if resp.StatusCode != http.StatusOK {
apiErr := &APIError{StatusCode: resp.StatusCode, URL: urlStr}
retryable := isRetryableStatus(resp.StatusCode)
retryAfter := time.Duration(0)
if parsed, ok := parseRetryAfter(resp.Header.Get("Retry-After")); ok {
retryAfter = parsed
}
if retryable && attempt < maxRetries-1 {
_ = resp.Body.Close()
delay := max(retryAfter, retryDelay(attempt))
if retryErr := waitForRetry(ctx, delay); retryErr != nil {
return logAndReturn(resp.StatusCode, retryErr)
}
continue
}
// Best-effort decode (often useful for debugging), but still treat non-200 as error.
_ = json.NewDecoder(resp.Body).Decode(out)
_ = resp.Body.Close()
return logAndReturn(resp.StatusCode, apiErr)
}
err = json.NewDecoder(resp.Body).Decode(out)
_ = resp.Body.Close()
if err == nil {
return logAndReturn(resp.StatusCode, nil)
}
if attempt < maxRetries-1 {
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
return logAndReturn(resp.StatusCode, retryErr)
}
statusCode, retry, err := handleResponseRetry(ctx, resp, urlStr, out, attempt, maxRetries)
if retry {
continue
}
return logAndReturn(resp.StatusCode, fmt.Errorf("failed to decode jikan response: %w", err))
return logAndReturn(statusCode, err)
}
return logAndReturn(0, fmt.Errorf("max retries exceeded for %s", urlStr))
}
func (c *Client) prepareRetryAttempt(ctx context.Context) error {
select {
case <-ctx.Done():
return fmt.Errorf("request canceled while retrying jikan request: %w", ctx.Err())
default:
}
return c.waitRateLimit(ctx)
}
func (c *Client) doRequest(ctx context.Context, urlStr string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, urlStr, nil)
if err != nil {
return nil, fmt.Errorf("failed to create jikan request: %w", err)
}
req.Header.Set("User-Agent", netutil.Generic)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
return resp, nil
}
func handleRequestRetry(ctx context.Context, err error, attempt int, maxRetries int) (bool, error) {
if errors.Is(err, context.Canceled) {
return false, fmt.Errorf("request canceled while retrying jikan request: %w", err)
}
if attempt >= maxRetries-1 || !IsRetryableError(err) {
return false, fmt.Errorf("jikan api error: %w", err)
}
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
return false, retryErr
}
return true, nil
}
func handleResponseRetry(ctx context.Context, resp *http.Response, urlStr string, out any, attempt int, maxRetries int) (int, bool, error) {
if resp.StatusCode != http.StatusOK {
return handleStatusRetry(ctx, resp, urlStr, out, attempt, maxRetries)
}
err := json.NewDecoder(resp.Body).Decode(out)
_ = resp.Body.Close()
if err == nil {
return resp.StatusCode, false, nil
}
if attempt < maxRetries-1 {
if retryErr := waitForRetry(ctx, retryDelay(attempt)); retryErr != nil {
return resp.StatusCode, false, retryErr
}
return resp.StatusCode, true, nil
}
return resp.StatusCode, false, fmt.Errorf("failed to decode jikan response: %w", err)
}
func handleStatusRetry(ctx context.Context, resp *http.Response, urlStr string, out any, attempt int, maxRetries int) (int, bool, error) {
statusCode := resp.StatusCode
apiErr := &APIError{StatusCode: statusCode, URL: urlStr}
retryAfter := time.Duration(0)
if parsed, ok := parseRetryAfter(resp.Header.Get("Retry-After")); ok {
retryAfter = parsed
}
if isRetryableStatus(statusCode) && attempt < maxRetries-1 {
_ = resp.Body.Close()
if retryErr := waitForRetry(ctx, max(retryAfter, retryDelay(attempt))); retryErr != nil {
return statusCode, false, retryErr
}
return statusCode, true, nil
}
// Best-effort decode (often useful for debugging), but still treat non-200 as error.
_ = json.NewDecoder(resp.Body).Decode(out)
_ = resp.Body.Close()
return statusCode, false, apiErr
}
func metricsEndpoint(urlStr string) string {
trimmed := strings.TrimSpace(urlStr)
if trimmed == "" {

View File

@@ -23,42 +23,13 @@ func (fn roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
}
func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlDB := newTestCacheDB(t)
defer sqlDB.Close()
sqlDB.SetMaxOpenConns(1)
_, err = sqlDB.Exec(`
CREATE TABLE jikan_cache (
key TEXT PRIMARY KEY,
data TEXT NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`)
if err != nil {
t.Fatalf("create cache table: %v", err)
}
queries := db.New(sqlDB)
client := NewClient(config.Config{}, queries, observability.NewMetrics())
stale := TopAnimeResponse{Data: []Anime{{MalID: 1, Title: "stale"}}}
staleBytes, err := json.Marshal(stale)
if err != nil {
t.Fatalf("marshal stale response: %v", err)
}
_, err = sqlDB.Exec(
`INSERT INTO jikan_cache (key, data, expires_at) VALUES (?, ?, ?)`,
"top:1",
string(staleBytes),
time.Now().Add(-time.Hour),
)
if err != nil {
t.Fatalf("insert stale cache: %v", err)
}
insertCachedResponse(t, sqlDB, "top:1", stale, time.Now().Add(-time.Hour))
client.httpClient = &http.Client{
Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
@@ -78,11 +49,63 @@ func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
if len(got.Data) != 1 || got.Data[0].Title != "stale" {
t.Fatalf("got %+v, want stale cache response", got.Data)
}
waitForFreshCache(t, sqlDB, client, "top:1")
}
func newTestCacheDB(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
sqlDB, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
sqlDB.SetMaxOpenConns(1)
_, err = sqlDB.ExecContext(ctx, `
CREATE TABLE jikan_cache (
key TEXT PRIMARY KEY,
data TEXT NOT NULL,
expires_at DATETIME NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
`)
if err != nil {
sqlDB.Close()
t.Fatalf("create cache table: %v", err)
}
return sqlDB
}
func insertCachedResponse(t *testing.T, sqlDB *sql.DB, key string, value TopAnimeResponse, expiresAt time.Time) {
t.Helper()
ctx := context.Background()
encoded, err := json.Marshal(value)
if err != nil {
t.Fatalf("marshal cached response: %v", err)
}
_, err = sqlDB.ExecContext(
ctx,
`INSERT INTO jikan_cache (key, data, expires_at) VALUES (?, ?, ?)`,
key,
string(encoded),
expiresAt,
)
if err != nil {
t.Fatalf("insert cached response: %v", err)
}
}
func waitForFreshCache(t *testing.T, sqlDB *sql.DB, client *Client, key string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
var refreshed TopAnimeResponse
if client.getCache(context.Background(), "top:1", &refreshed) && len(refreshed.Data) == 1 && refreshed.Data[0].Title == "fresh" {
if client.getCache(context.Background(), key, &refreshed) && len(refreshed.Data) == 1 && refreshed.Data[0].Title == "fresh" {
return
}
time.Sleep(10 * time.Millisecond)
@@ -90,6 +113,6 @@ func TestGetWithCacheReturnsStaleAndRefreshesAsync(t *testing.T) {
var rawData string
var rawExpires string
_ = sqlDB.QueryRow(`SELECT data, expires_at FROM jikan_cache WHERE key = ?`, "top:1").Scan(&rawData, &rawExpires)
_ = sqlDB.QueryRowContext(context.Background(), `SELECT data, expires_at FROM jikan_cache WHERE key = ?`, key).Scan(&rawData, &rawExpires)
t.Fatalf("cache was not refreshed asynchronously; data=%s expires_at=%s", rawData, rawExpires)
}

View File

@@ -3,6 +3,8 @@ package jikan
import (
"context"
"fmt"
"net/url"
"strconv"
"sync"
"time"
)
@@ -15,7 +17,9 @@ func (c *Client) GetEpisodes(ctx context.Context, animeID int, page int) (Episod
cacheKey := fmt.Sprintf("anime:%d:episodes:%d", animeID, page)
var result EpisodesResponse
reqURL := fmt.Sprintf("%s/anime/%d/episodes?page=%d", c.baseURL, animeID, page)
params := url.Values{}
params.Set("page", strconv.Itoa(page))
reqURL := buildRequestURL(c.baseURL, fmt.Sprintf("/anime/%d/episodes", animeID), params)
err := c.getWithCache(ctx, cacheKey, 12*time.Hour, reqURL, &result)
return result, err

View File

@@ -3,6 +3,8 @@ package jikan
import (
"context"
"fmt"
"net/url"
"strconv"
)
func (c *Client) GetAnimeStaff(ctx context.Context, id int) ([]StaffEntry, error) {
@@ -46,7 +48,9 @@ func (c *Client) GetAnimeReviews(ctx context.Context, id int, page int) ([]Revie
page = 1
}
url := fmt.Sprintf("%s/anime/%d/reviews?page=%d", c.baseURL, id, page)
params := url.Values{}
params.Set("page", strconv.Itoa(page))
url := buildRequestURL(c.baseURL, fmt.Sprintf("/anime/%d/reviews", id), params)
cacheKey := fmt.Sprintf("anime:reviews:%d:%d", id, page)
var resp ReviewsResponse

View File

@@ -56,10 +56,11 @@ func (c *Client) GetProducers(ctx context.Context, query string, page int, limit
func (c *Client) fetchProducersPage(ctx context.Context, query string, page int, limit int) (ProducerListResult, error) {
q := strings.TrimSpace(query)
cacheKey := fmt.Sprintf("producers:%s:%d:%d", q, page, limit)
reqURL := fmt.Sprintf("%s/producers?page=%d&limit=%d", c.baseURL, page, limit)
if q != "" {
reqURL += "&q=" + url.QueryEscape(q)
}
params := url.Values{}
params.Set("page", strconv.Itoa(page))
params.Set("limit", strconv.Itoa(limit))
setQueryValue(params, "q", q)
reqURL := buildRequestURL(c.baseURL, "/producers", params)
var result ProducersResponse
if err := c.getWithCache(ctx, cacheKey, producerCacheTTL, reqURL, &result); err != nil {

View File

@@ -0,0 +1,43 @@
package jikan
import (
"fmt"
"net/url"
"strconv"
)
func buildRequestURL(baseURL, path string, params url.Values) string {
encoded := params.Encode()
if encoded == "" {
return fmt.Sprintf("%s%s", baseURL, path)
}
return fmt.Sprintf("%s%s?%s", baseURL, path, encoded)
}
func setQueryValue(values url.Values, key, value string) {
if value == "" {
values.Del(key)
return
}
values.Set(key, value)
}
func setPositiveIntQueryValue(values url.Values, key string, value int) {
if value <= 0 {
values.Del(key)
return
}
values.Set(key, strconv.Itoa(value))
}
func setTrueQueryValue(values url.Values, key string, enabled bool) {
if !enabled {
values.Del(key)
return
}
values.Set(key, "true")
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"net/http"
"sort"
"strings"
"time"
@@ -20,6 +21,22 @@ const chiakiWatchOrderURL = "https://chiaki.site/?/tools/watch_order/id/%d"
const watchOrderCacheTTL = time.Hour * 24
const maxWatchOrderEntries = 120 // cap to prevent huge relation chains
type WatchOrderMode string
const (
WatchOrderModeMain WatchOrderMode = "main"
WatchOrderModeComplete WatchOrderMode = "complete"
)
func NormalizeWatchOrderMode(value string) WatchOrderMode {
switch WatchOrderMode(strings.ToLower(strings.TrimSpace(value))) {
case WatchOrderModeComplete:
return WatchOrderModeComplete
default:
return WatchOrderModeMain
}
}
// watchOrderTypeLabel normalizes watch order type to display-friendly labels.
func watchOrderTypeLabel(value string) string {
normalized := strings.ToLower(strings.TrimSpace(value))
@@ -28,17 +45,35 @@ func watchOrderTypeLabel(value string) string {
return "TV"
case "movie":
return "Movie"
case "ona":
return "ONA"
case "ova":
return "OVA"
default:
return strings.TrimSpace(value)
}
}
// isAllowedWatchOrderType returns true only for TV and Movie types (filters out specials, etc).
func isTVWatchOrderType(value string) bool {
return strings.EqualFold(strings.TrimSpace(value), "tv")
}
// isAllowedWatchOrderType returns true for the default uncluttered watch order types.
func isAllowedWatchOrderType(value string) bool {
normalized := strings.ToLower(strings.TrimSpace(value))
return normalized == "tv" || normalized == "movie"
}
func hasTVWatchOrderEntry(entries []watchorder.WatchOrderEntry) bool {
for _, entry := range entries {
if isTVWatchOrderType(entry.Type) {
return true
}
}
return false
}
func relationCacheKey(id int) string {
return fmt.Sprintf("relations:watch-order:%d", id)
}
@@ -52,7 +87,7 @@ func (c *Client) refreshWatchOrder(ctx context.Context, id int) (watchorder.Watc
result, err := watchorder.FetchWatchOrder(requestCtx, c.httpClient, watchOrderURL)
if err != nil {
var statusError *watchorder.HTTPStatusError
if errors.As(err, &statusError) && statusError.StatusCode == 404 {
if errors.As(err, &statusError) && statusError.StatusCode == http.StatusNotFound {
return watchorder.WatchOrderResult{}, watchorder.ErrWatchOrderNotFound
}
if errors.Is(err, watchorder.ErrWatchOrderMarkupNotFound) {
@@ -148,50 +183,54 @@ func (c *Client) currentOnlyRelation(ctx context.Context, id int) ([]RelationEnt
}}, nil
}
// GetFullRelations returns related anime based on watch order, with parallel fetching (3 concurrent).
func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry, error) {
result, err := c.getWatchOrder(ctx, id)
if err != nil {
if errors.Is(err, watchorder.ErrWatchOrderNotFound) {
return c.currentOnlyRelation(ctx, id)
}
observability.Warn(
"relations_watch_order_fallback_current_only",
"jikan",
"",
map[string]any{
"anime_id": id,
},
err,
)
func (c *Client) handleWatchOrderError(ctx context.Context, id int, err error) ([]RelationEntry, error) {
if errors.Is(err, watchorder.ErrWatchOrderNotFound) {
return c.currentOnlyRelation(ctx, id)
}
type fetchResult struct {
index int
anime Anime
entry watchorder.WatchOrderEntry
}
observability.Warn(
"relations_watch_order_fallback_current_only",
"jikan",
"",
map[string]any{
"anime_id": id,
},
err,
)
var allowedEntries []watchorder.WatchOrderEntry
return c.currentOnlyRelation(ctx, id)
}
func buildAllowedWatchOrderEntries(result watchorder.WatchOrderResult, mode WatchOrderMode) ([]watchorder.WatchOrderEntry, map[int]bool) {
allowedEntries := make([]watchorder.WatchOrderEntry, 0, len(result.WatchOrder))
seen := make(map[int]bool)
shouldIncludeAllTypes := mode == WatchOrderModeComplete || !hasTVWatchOrderEntry(result.WatchOrder)
for _, entry := range result.WatchOrder {
if len(allowedEntries) >= maxWatchOrderEntries {
break
}
if !isAllowedWatchOrderType(entry.Type) || seen[entry.ID] {
if seen[entry.ID] {
continue
}
if !shouldIncludeAllTypes && !isAllowedWatchOrderType(entry.Type) {
continue
}
seen[entry.ID] = true
allowedEntries = append(allowedEntries, entry)
}
return allowedEntries, seen
}
func (c *Client) fetchRelationResults(ctx context.Context, entries []watchorder.WatchOrderEntry) []fetchResult {
g, gCtx := errgroup.WithContext(ctx)
g.SetLimit(3)
results := make(chan fetchResult, len(allowedEntries))
results := make(chan fetchResult, len(entries))
for i, entry := range allowedEntries {
for i, entry := range entries {
g.Go(func() error {
anime, err := c.GetAnimeByID(gCtx, entry.ID)
if err != nil {
@@ -201,10 +240,12 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
c.EnqueueAnimeFetchRetry(gCtx, entry.ID, err)
return nil
}
select {
case results <- fetchResult{index: i, anime: anime, entry: entry}:
case <-gCtx.Done():
}
return nil
})
}
@@ -214,18 +255,21 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
close(results)
}()
fetched := make([]fetchResult, 0, len(allowedEntries))
fetched := make([]fetchResult, 0, len(entries))
for res := range results {
fetched = append(fetched, res)
}
// Re-sort because they might have finished out of order
sort.Slice(fetched, func(i, j int) bool {
return fetched[i].index < fetched[j].index
})
relations := make([]RelationEntry, 0, len(fetched)+1)
for _, res := range fetched {
return fetched
}
func buildRelationsFromResults(results []fetchResult, id int) []RelationEntry {
relations := make([]RelationEntry, 0, len(results)+1)
for _, res := range results {
relations = append(relations, RelationEntry{
Anime: res.anime,
Relation: watchOrderTypeLabel(res.entry.Type),
@@ -234,18 +278,46 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
})
}
if !seen[id] {
currentAnime, err := c.GetAnimeByID(ctx, id)
if err != nil {
return nil, err
}
return relations
}
relations = append([]RelationEntry{{
Anime: currentAnime,
Relation: "Current",
IsCurrent: true,
IsExtra: false,
}}, relations...)
func (c *Client) ensureCurrentRelation(ctx context.Context, id int, seen map[int]bool, relations []RelationEntry) ([]RelationEntry, error) {
if seen[id] {
return relations, nil
}
currentAnime, err := c.GetAnimeByID(ctx, id)
if err != nil {
return nil, err
}
return append([]RelationEntry{{
Anime: currentAnime,
Relation: "Current",
IsCurrent: true,
IsExtra: false,
}}, relations...), nil
}
type fetchResult struct {
index int
anime Anime
entry watchorder.WatchOrderEntry
}
// GetFullRelations returns related anime based on watch order, with parallel fetching (3 concurrent).
func (c *Client) GetFullRelations(ctx context.Context, id int, mode WatchOrderMode) ([]RelationEntry, error) {
result, err := c.getWatchOrder(ctx, id)
if err != nil {
return c.handleWatchOrderError(ctx, id, err)
}
allowedEntries, seen := buildAllowedWatchOrderEntries(result, mode)
fetched := c.fetchRelationResults(ctx, allowedEntries)
relations := buildRelationsFromResults(fetched, id)
relations, err = c.ensureCurrentRelation(ctx, id, seen, relations)
if err != nil {
return nil, err
}
if len(relations) == 0 {
@@ -257,6 +329,6 @@ func (c *Client) GetFullRelations(ctx context.Context, id int) ([]RelationEntry,
func (c *Client) WarmFullRelations(id int) {
c.runAsyncRefresh(func(ctx context.Context) {
_, _ = c.GetFullRelations(ctx, id)
_, _ = c.GetFullRelations(ctx, id, WatchOrderModeMain)
})
}

View File

@@ -1,6 +1,9 @@
package jikan
import "testing"
import (
"mal/integrations/watchorder"
"testing"
)
func runBoolCases(t *testing.T, tests []struct {
name string
@@ -36,6 +39,138 @@ func TestIsAllowedWatchOrderType(t *testing.T) {
runBoolCases(t, tests, isAllowedWatchOrderType)
}
func TestNormalizeWatchOrderMode(t *testing.T) {
tests := []struct {
name string
input string
want WatchOrderMode
}{
{name: "empty defaults main", input: "", want: WatchOrderModeMain},
{name: "main", input: "main", want: WatchOrderModeMain},
{name: "complete", input: "complete", want: WatchOrderModeComplete},
{name: "case and whitespace", input: " COMPLETE ", want: WatchOrderModeComplete},
{name: "unknown defaults main", input: "everything", want: WatchOrderModeMain},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
got := NormalizeWatchOrderMode(testCase.input)
if got != testCase.want {
t.Fatalf("expected %q, got %q", testCase.want, got)
}
})
}
}
func TestHasTVWatchOrderEntry(t *testing.T) {
tests := []struct {
name string
entries []watchorder.WatchOrderEntry
want bool
}{
{
name: "contains tv",
entries: []watchorder.WatchOrderEntry{
{ID: 1, Type: "Movie"},
{ID: 2, Type: " TV "},
},
want: true,
},
{
name: "ona only",
entries: []watchorder.WatchOrderEntry{
{ID: 1, Type: "ONA"},
{ID: 2, Type: "Special"},
},
want: false,
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
got := hasTVWatchOrderEntry(testCase.entries)
if got != testCase.want {
t.Fatalf("expected %v, got %v", testCase.want, got)
}
})
}
}
func TestBuildAllowedWatchOrderEntriesKeepsDefaultTypesWhenTVExists(t *testing.T) {
result := watchorder.WatchOrderResult{
WatchOrder: []watchorder.WatchOrderEntry{
{ID: 1, Type: "TV"},
{ID: 2, Type: "Special"},
{ID: 3, Type: "Movie"},
{ID: 4, Type: "ONA"},
},
}
entries, seen := buildAllowedWatchOrderEntries(result, WatchOrderModeMain)
if len(entries) != 2 {
t.Fatalf("expected 2 entries, got %d", len(entries))
}
if entries[0].ID != 1 || entries[1].ID != 3 {
t.Fatalf("unexpected entries: %+v", entries)
}
if !seen[1] || !seen[3] || seen[2] || seen[4] {
t.Fatalf("unexpected seen map: %+v", seen)
}
}
func TestBuildAllowedWatchOrderEntriesIncludesAllTypesWhenNoTVExists(t *testing.T) {
result := watchorder.WatchOrderResult{
WatchOrder: []watchorder.WatchOrderEntry{
{ID: 1, Type: "ONA"},
{ID: 2, Type: "Special"},
{ID: 3, Type: "Movie"},
{ID: 1, Type: "ONA"},
},
}
entries, seen := buildAllowedWatchOrderEntries(result, WatchOrderModeMain)
if len(entries) != 3 {
t.Fatalf("expected 3 entries, got %d", len(entries))
}
if entries[0].ID != 1 || entries[1].ID != 2 || entries[2].ID != 3 {
t.Fatalf("unexpected entries: %+v", entries)
}
if !seen[1] || !seen[2] || !seen[3] {
t.Fatalf("unexpected seen map: %+v", seen)
}
}
func TestBuildAllowedWatchOrderEntriesIncludesAllTypesInCompleteMode(t *testing.T) {
result := watchorder.WatchOrderResult{
WatchOrder: []watchorder.WatchOrderEntry{
{ID: 1, Type: "TV"},
{ID: 2, Type: "Special"},
{ID: 3, Type: "ONA"},
{ID: 4, Type: "Movie"},
},
}
entries, seen := buildAllowedWatchOrderEntries(result, WatchOrderModeComplete)
if len(entries) != 4 {
t.Fatalf("expected 4 entries, got %d", len(entries))
}
for index, entry := range entries {
wantID := index + 1
if entry.ID != wantID {
t.Fatalf("expected entry %d to have id %d, got %+v", index, wantID, entry)
}
}
if !seen[1] || !seen[2] || !seen[3] || !seen[4] {
t.Fatalf("unexpected seen map: %+v", seen)
}
}
func TestWatchOrderTypeLabel(t *testing.T) {
tests := []struct {
name string
@@ -44,6 +179,8 @@ func TestWatchOrderTypeLabel(t *testing.T) {
}{
{name: "tv", input: "tv", want: "TV"},
{name: "movie", input: "movie", want: "Movie"},
{name: "ona", input: "ona", want: "ONA"},
{name: "ova", input: "ova", want: "OVA"},
{name: "trimmed passthrough", input: " tv special ", want: "tv special"},
}

View File

@@ -8,8 +8,7 @@ import (
"strings"
)
// SearchAdvanced performs a filtered anime search with type, status, ordering, genre filters, and studio (producer) filters.
func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (SearchResult, error) {
func normalizeSearchPagination(page, limit int) (int, int) {
if page < 1 {
page = 1
}
@@ -17,46 +16,47 @@ func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, o
limit = 0
}
genresParam := ""
if len(genres) > 0 {
ids := make([]string, len(genres))
for i, g := range genres {
ids[i] = strconv.Itoa(g)
}
genresParam = strings.Join(ids, ",")
return page, limit
}
func joinGenreIDs(genres []int) string {
if len(genres) == 0 {
return ""
}
ids := make([]string, len(genres))
for i, g := range genres {
ids[i] = strconv.Itoa(g)
}
return strings.Join(ids, ",")
}
func buildAdvancedSearchURL(baseURL, query, animeType, status, orderBy, sort, genres string, studioID int, sfw bool, page, limit int) string {
params := url.Values{}
params.Set("page", strconv.Itoa(page))
setTrueQueryValue(params, "sfw", sfw)
setQueryValue(params, "q", query)
setQueryValue(params, "type", animeType)
setQueryValue(params, "status", status)
setPositiveIntQueryValue(params, "producers", studioID)
setQueryValue(params, "order_by", orderBy)
setQueryValue(params, "sort", sort)
setQueryValue(params, "genres", genres)
setPositiveIntQueryValue(params, "limit", limit)
return buildRequestURL(baseURL, "/anime", params)
}
// SearchAdvanced performs a filtered anime search with type, status, ordering, genre filters, and studio (producer) filters.
func (c *Client) SearchAdvanced(ctx context.Context, query, animeType, status, orderBy, sort string, genres []int, studioID int, sfw bool, page, limit int) (SearchResult, error) {
page, limit = normalizeSearchPagination(page, limit)
genresParam := joinGenreIDs(genres)
cacheKey := fmt.Sprintf("search:%s:%s:%s:%s:%s:%s:%d:%v:%d:%d", query, animeType, status, orderBy, sort, genresParam, studioID, sfw, page, limit)
var result SearchResponse
reqURL := fmt.Sprintf("%s/anime?page=%d", c.baseURL, page)
if sfw {
reqURL += "&sfw=true"
}
if query != "" {
reqURL += "&q=" + url.QueryEscape(query)
}
if animeType != "" {
reqURL += "&type=" + url.QueryEscape(animeType)
}
if status != "" {
reqURL += "&status=" + url.QueryEscape(status)
}
if studioID > 0 {
reqURL += "&producers=" + strconv.Itoa(studioID)
}
if orderBy != "" {
reqURL += "&order_by=" + url.QueryEscape(orderBy)
}
if sort != "" {
reqURL += "&sort=" + url.QueryEscape(sort)
}
if genresParam != "" {
reqURL += "&genres=" + genresParam
}
if limit > 0 {
reqURL += fmt.Sprintf("&limit=%d", limit)
}
reqURL := buildAdvancedSearchURL(c.baseURL, query, animeType, status, orderBy, sort, genresParam, studioID, sfw, page, limit)
if err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result); err != nil {
return SearchResult{}, err
@@ -76,7 +76,9 @@ func (c *Client) GetTopAnime(ctx context.Context, page int) (TopAnimeResult, err
cacheKey := fmt.Sprintf("top:%d", page)
var result TopAnimeResponse
reqURL := fmt.Sprintf("%s/top/anime?page=%d", c.baseURL, page)
params := url.Values{}
params.Set("page", strconv.Itoa(page))
reqURL := buildRequestURL(c.baseURL, "/top/anime", params)
if err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result); err != nil {
return TopAnimeResult{}, err

View File

@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"math/rand"
"net/url"
"strconv"
"time"
)
@@ -30,7 +32,9 @@ func (c *Client) getSeasonList(ctx context.Context, page int, season string) (To
cacheKey := fmt.Sprintf("seasons_%s:%d", season, page)
var result TopAnimeResponse
reqURL := fmt.Sprintf("%s/seasons/%s?page=%d", c.baseURL, season, page)
params := url.Values{}
params.Set("page", strconv.Itoa(page))
reqURL := buildRequestURL(c.baseURL, fmt.Sprintf("/seasons/%s", season), params)
err := c.getWithCache(ctx, cacheKey, shortCacheTTL, reqURL, &result)
if err != nil {
@@ -44,76 +48,119 @@ func (c *Client) getSeasonList(ctx context.Context, page int, season string) (To
}
// seedRandomPool seeds the in-memory pool of random anime
func (c *Client) seedRandomPool(ctx context.Context) error {
func (c *Client) seedRandomPool(ctx context.Context) {
if !c.markRandomPoolInitialized() {
return
}
c.loadCachedRandomPool(ctx)
// Fetch a solid baseline in the background, then start refreshing.
go c.seedRandomPoolBaseline()
}
func (c *Client) markRandomPoolInitialized() bool {
c.poolMu.Lock()
defer c.poolMu.Unlock()
if c.poolInitialized {
c.poolMu.Unlock()
return false
}
c.poolInitialized = true
return true
}
func (c *Client) loadCachedRandomPool(ctx context.Context) {
cachedJSONs, err := c.db.GetAllCachedAnime(ctx)
if err != nil || len(cachedJSONs) == 0 {
return
}
loadedAnimes := decodeCachedAnime(cachedJSONs)
if len(loadedAnimes) == 0 {
return
}
c.poolMu.Lock()
c.randomPool = append(c.randomPool, loadedAnimes...)
c.poolMu.Unlock()
}
func decodeCachedAnime(cachedJSONs []string) []Anime {
loadedAnimes := make([]Anime, 0, len(cachedJSONs))
for _, dataStr := range cachedJSONs {
var anime Anime
if err := json.Unmarshal([]byte(dataStr), &anime); err != nil || anime.MalID == 0 {
continue
}
loadedAnimes = append(loadedAnimes, anime)
}
return loadedAnimes
}
func (c *Client) seedRandomPoolBaseline() {
bgCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
fetchedAnimes := c.fetchBaselineAnime(bgCtx)
if len(fetchedAnimes) > 0 {
c.appendUniqueRandomPool(fetchedAnimes)
}
// Start background refresher once seeding completes
c.startPoolRefresher()
}
func (c *Client) fetchBaselineAnime(ctx context.Context) []Anime {
topPageOne := c.fetchTopAnimePage(ctx, 1)
topPageTwo := c.fetchTopAnimePage(ctx, 2)
currentSeason := c.fetchCurrentSeasonAnime(ctx)
fetchedAnimes := make([]Anime, 0, len(topPageOne)+len(topPageTwo)+len(currentSeason))
fetchedAnimes = append(fetchedAnimes, topPageOne...)
fetchedAnimes = append(fetchedAnimes, topPageTwo...)
fetchedAnimes = append(fetchedAnimes, currentSeason...)
return fetchedAnimes
}
func (c *Client) fetchTopAnimePage(ctx context.Context, page int) []Anime {
top, err := c.GetTopAnime(ctx, page)
if err != nil {
return nil
}
c.poolInitialized = true
c.poolMu.Unlock()
// 1. Try to load all cached anime from the database
cachedJSONs, err := c.db.GetAllCachedAnime(ctx)
if err == nil && len(cachedJSONs) > 0 {
var loadedAnimes []Anime
for _, dataStr := range cachedJSONs {
var anime Anime
if err := json.Unmarshal([]byte(dataStr), &anime); err == nil && anime.MalID > 0 {
loadedAnimes = append(loadedAnimes, anime)
}
}
return top.Animes
}
if len(loadedAnimes) > 0 {
c.poolMu.Lock()
c.randomPool = append(c.randomPool, loadedAnimes...)
c.poolMu.Unlock()
}
func (c *Client) fetchCurrentSeasonAnime(ctx context.Context) []Anime {
now, err := c.GetSeasonsNow(ctx, 1)
if err != nil {
return nil
}
// 2. Fetch Top Anime page 1 & 2 to ensure we have a robust baseline of high-quality popular anime
go func() {
bgCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return now.Animes
}
var fetchedAnimes []Anime
func (c *Client) appendUniqueRandomPool(animes []Anime) {
c.poolMu.Lock()
defer c.poolMu.Unlock()
top, err := c.GetTopAnime(bgCtx, 1)
if err == nil && len(top.Animes) > 0 {
fetchedAnimes = append(fetchedAnimes, top.Animes...)
seen := make(map[int]bool, len(c.randomPool)+len(animes))
for _, anime := range c.randomPool {
seen[anime.MalID] = true
}
for _, anime := range animes {
if seen[anime.MalID] {
continue
}
top2, err := c.GetTopAnime(bgCtx, 2)
if err == nil && len(top2.Animes) > 0 {
fetchedAnimes = append(fetchedAnimes, top2.Animes...)
}
now, err := c.GetSeasonsNow(bgCtx, 1)
if err == nil && len(now.Animes) > 0 {
fetchedAnimes = append(fetchedAnimes, now.Animes...)
}
if len(fetchedAnimes) > 0 {
c.poolMu.Lock()
// Use map to de-duplicate any anime
seen := make(map[int]bool)
for _, a := range c.randomPool {
seen[a.MalID] = true
}
for _, a := range fetchedAnimes {
if !seen[a.MalID] {
c.randomPool = append(c.randomPool, a)
seen[a.MalID] = true
}
}
c.poolMu.Unlock()
}
// Start background refresher once seeding completes
c.startPoolRefresher()
}()
return nil
c.randomPool = append(c.randomPool, anime)
seen[anime.MalID] = true
}
}
// startPoolRefresher runs in the background to slowly mix in true random anime
@@ -162,7 +209,7 @@ func (c *Client) GetRandomAnime(ctx context.Context) (Anime, error) {
c.poolMu.Unlock()
if !initialized {
_ = c.seedRandomPool(ctx)
c.seedRandomPool(ctx)
}
c.poolMu.RLock()

View File

@@ -33,12 +33,18 @@ type Aired struct {
String string `json:"string"`
}
type TitleEntry struct {
Type string `json:"type"`
Title string `json:"title"`
}
type Anime struct {
MalID int `json:"mal_id"`
Title string `json:"title"`
TitleEnglish string `json:"title_english"`
TitleJapanese string `json:"title_japanese"`
TitleSynonyms []string `json:"title_synonyms"`
MalID int `json:"mal_id"`
Title string `json:"title"`
TitleEnglish string `json:"title_english"`
TitleJapanese string `json:"title_japanese"`
TitleSynonyms []string `json:"title_synonyms"`
Titles []TitleEntry `json:"titles"`
Images struct {
Jpg struct {
LargeImageURL string `json:"large_image_url"`
@@ -230,35 +236,34 @@ func (a Anime) DurationSeconds() float64 {
return 0
}
var hours, minutes int
var isHours bool
var currentNum string
var currentValue int
hasValue := false
for _, c := range a.Duration {
if c >= '0' && c <= '9' {
currentNum += string(c)
} else if c == ' ' && currentNum != "" {
val, _ := strconv.Atoi(currentNum)
if isHours {
hours = val
} else {
minutes = val
}
currentNum = ""
} else if len(currentNum) > 0 && (c == 'h' || c == 'H') {
isHours = true
val, _ := strconv.Atoi(currentNum)
hours = val
currentNum = ""
for _, token := range strings.Fields(strings.ToLower(a.Duration)) {
value, err := strconv.Atoi(token)
if err == nil {
currentValue = value
hasValue = true
continue
}
if !hasValue {
continue
}
switch {
case strings.HasPrefix(token, "h"):
hours = currentValue
hasValue = false
case strings.HasPrefix(token, "m"):
minutes = currentValue
hasValue = false
}
}
if currentNum != "" {
val, _ := strconv.Atoi(currentNum)
if isHours {
hours = val
} else {
minutes = val
}
if hasValue {
minutes = currentValue
}
return float64(hours*60+minutes) * 60
}
@@ -455,13 +460,16 @@ type ReviewsResponse struct {
Pagination Pagination `json:"pagination"`
}
// DisplayTitle returns English title if available, otherwise Japanese, then default.
// DisplayTitle returns English title if available, otherwise default title, titles[0], then Japanese.
func (a Anime) DisplayTitle() string {
if a.TitleEnglish != "" {
return a.TitleEnglish
}
if a.TitleJapanese != "" {
return a.TitleJapanese
if a.Title != "" {
return a.Title
}
return a.Title
if len(a.Titles) > 0 && a.Titles[0].Title != "" {
return a.Titles[0].Title
}
return a.TitleJapanese
}

View File

@@ -0,0 +1,27 @@
package jikan
import "testing"
func TestAnimeDisplayTitlePrefersTitleBeforeJapanese(t *testing.T) {
anime := Anime{
Title: "Cyberpunk: Edgerunners",
TitleJapanese: "サイバーパンク エッジランナーズ",
}
if got := anime.DisplayTitle(); got != "Cyberpunk: Edgerunners" {
t.Fatalf("DisplayTitle() = %q, want default title", got)
}
}
func TestAnimeDisplayTitleFallsBackToFirstTitleEntryBeforeJapanese(t *testing.T) {
anime := Anime{
TitleJapanese: "サイバーパンク エッジランナーズ",
Titles: []TitleEntry{
{Type: "Default", Title: "Cyberpunk: Edgerunners"},
},
}
if got := anime.DisplayTitle(); got != "Cyberpunk: Edgerunners" {
t.Fatalf("DisplayTitle() = %q, want first title entry", got)
}
}

View File

@@ -0,0 +1,102 @@
package allanime
import (
"context"
"fmt"
"mal/internal/domain"
"strconv"
"strings"
)
type AvailableEpisodes struct {
Sub []string
Dub []string
Raw []string
}
func (c *AllAnimeProvider) GetEpisodeAvailability(ctx context.Context, animeID int, titleCandidates []string) (domain.EpisodeAvailability, error) {
showID, err := c.ResolveEpisodeProviderID(ctx, animeID, titleCandidates)
if err != nil {
return domain.EpisodeAvailability{}, err
}
return c.GetEpisodeAvailabilityByProviderID(ctx, showID)
}
func (c *AllAnimeProvider) GetEpisodeAvailabilityByProviderID(ctx context.Context, showID string) (domain.EpisodeAvailability, error) {
available, err := c.GetAvailableEpisodes(ctx, showID)
if err != nil {
return domain.EpisodeAvailability{}, err
}
sub := parseEpisodeNumbers(append(available.Sub, available.Raw...))
dub := parseEpisodeNumbers(available.Dub)
return domain.EpisodeAvailability{Sub: sub, Dub: dub}, nil
}
func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) {
graphqlQuery := `query($showId: String!) {
show(_id: $showId) {
availableEpisodesDetail
lastEpisodeInfo
}
}`
result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{"showId": showID})
if err != nil {
return AvailableEpisodes{}, err
}
data, ok := result["data"].(map[string]any)
if !ok {
return AvailableEpisodes{}, fmt.Errorf("invalid response")
}
show, ok := data["show"].(map[string]any)
if !ok || show == nil {
return AvailableEpisodes{}, fmt.Errorf("show not found")
}
detail, ok := show["availableEpisodesDetail"].(map[string]any)
if !ok {
return AvailableEpisodes{}, fmt.Errorf("invalid detail")
}
return AvailableEpisodes{
Sub: stringSliceFromAny(detail["sub"]),
Dub: stringSliceFromAny(detail["dub"]),
Raw: stringSliceFromAny(detail["raw"]),
}, nil
}
func parseEpisodeNumbers(raw []string) []int {
seen := make(map[int]bool, len(raw))
out := make([]int, 0, len(raw))
for _, value := range raw {
n, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || n <= 0 || seen[n] {
continue
}
seen[n] = true
out = append(out, n)
}
return out
}
func stringSliceFromAny(value any) []string {
items, ok := value.([]any)
if !ok {
return nil
}
values := make([]string, 0, len(items))
for _, item := range items {
str, ok := item.(string)
if !ok {
continue
}
values = append(values, str)
}
return values
}

View File

@@ -1,49 +1,26 @@
// Package allanime provides an integration with the AllAnime API for episode playback.
package allanime
import (
"bytes"
"context"
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"mal/internal/domain"
"mal/pkg"
netutil "mal/pkg/net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const (
allAnimeBaseURL = "https://api.allanime.day"
allAnimeReferer = "https://allmanga.to/"
allAnimeSiteURL = "https://allanime.day"
allAnimeReferer = "https://youtu-chan.com"
allAnimeOrigin = "https://youtu-chan.com"
defaultUserAgent = netutil.Firefox121
)
var (
aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"}
)
type searchResult struct {
ID string
MalID string
Name string
}
type AvailableEpisodes struct {
Sub []string
Dub []string
Raw []string
}
type AllAnimeProvider struct {
httpClient *http.Client
utlsClient *http.Client
@@ -67,139 +44,23 @@ func (c *AllAnimeProvider) Name() string {
return "AllAnime"
}
const searchQuery = `query(
$search: SearchInput
$translationType: VaildTranslationTypeEnumType
$limit: Int = 40
$page: Int = 1
$countryOrigin: VaildCountryOriginEnumType = ALL
) {
shows(
search: $search
limit: $limit
page: $page
translationType: $translationType
countryOrigin: $countryOrigin
) {
edges {
_id
malId
name
}
}
}`
func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
type searchData struct {
Shows struct {
Edges []struct {
ID string `json:"_id"`
MalID string `json:"malId"`
Name string `json:"name"`
} `json:"edges"`
} `json:"shows"`
}
type searchInput struct {
AllowAdult bool `json:"allowAdult"`
AllowUnknown bool `json:"allowUnknown"`
Query string `json:"query"`
}
type searchVariables struct {
Search searchInput `json:"search"`
TranslationType string `json:"translationType"`
}
vars := searchVariables{
Search: searchInput{
AllowAdult: false,
AllowUnknown: false,
Query: query,
},
TranslationType: mode,
}
data, err := graphql.Post[searchData](ctx, c.httpClient, allAnimeBaseURL+"/api", searchQuery, vars, graphql.PostOptions{
Headers: map[string]string{
"Referer": allAnimeReferer,
"User-Agent": defaultUserAgent,
},
BodyMax: netutil.MiB2,
})
if err != nil {
return nil, err
}
out := make([]searchResult, 0, len(data.Shows.Edges))
for _, edge := range data.Shows.Edges {
id := edge.ID
malID := edge.MalID
name := edge.Name
if unquoted, err := strconv.Unquote("\"" + name + "\""); err == nil {
name = unquoted
}
name = strings.TrimSpace(name)
if id == "" {
continue
}
out = append(out, searchResult{ID: id, MalID: malID, Name: name})
}
return out, nil
}
func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string) (*domain.StreamResult, error) {
// 1. Search for the show to get its AllAnime ID
// Try each title candidate, preferring results with matching malId
targetMalIDStr := strconv.Itoa(animeID)
var showID string
var firstAvailableShowID string
for _, title := range titleCandidates {
searchResults, err := c.Search(ctx, title, mode)
if err != nil || len(searchResults) == 0 {
continue
}
for _, res := range searchResults {
if res.MalID == targetMalIDStr {
showID = res.ID
break
}
}
if showID != "" {
break
}
if firstAvailableShowID == "" {
firstAvailableShowID = searchResults[0].ID
}
}
if showID == "" {
showID = firstAvailableShowID
}
showID := c.resolveShowIDWithFallback(ctx, animeID, titleCandidates, mode)
if showID == "" {
return nil, fmt.Errorf("allanime: show not found for malID %d", animeID)
}
// 2. Get sources
sources, err := c.GetEpisodeSources(ctx, showID, episode, mode)
if err != nil || len(sources) == 0 {
return nil, fmt.Errorf("allanime: no sources for show %s", showID)
}
// 3. Return the first usable source
primary := sources[0]
result := &domain.StreamResult{
URL: primary.URL,
Referer: primary.Referer,
Type: primary.Type,
}
for _, sub := range primary.Subtitles {
@@ -212,65 +73,6 @@ func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCan
return result, nil
}
func (c *AllAnimeProvider) GetEpisodeAvailability(ctx context.Context, animeID int, titleCandidates []string) (domain.EpisodeAvailability, error) {
showID, err := c.ResolveEpisodeProviderID(ctx, animeID, titleCandidates)
if err != nil {
return domain.EpisodeAvailability{}, err
}
return c.GetEpisodeAvailabilityByProviderID(ctx, showID)
}
func (c *AllAnimeProvider) ResolveEpisodeProviderID(ctx context.Context, animeID int, titleCandidates []string) (string, error) {
for _, mode := range []string{"sub", "dub"} {
showID, err := c.resolveShowIDStrict(ctx, animeID, titleCandidates, mode)
if err == nil {
return showID, nil
}
}
return "", fmt.Errorf("allanime: no exact mal id match for %d", animeID)
}
func (c *AllAnimeProvider) GetEpisodeAvailabilityByProviderID(ctx context.Context, showID string) (domain.EpisodeAvailability, error) {
available, err := c.GetAvailableEpisodes(ctx, showID)
if err != nil {
return domain.EpisodeAvailability{}, err
}
sub := parseEpisodeNumbers(append(available.Sub, available.Raw...))
dub := parseEpisodeNumbers(available.Dub)
return domain.EpisodeAvailability{Sub: sub, Dub: dub}, nil
}
func (c *AllAnimeProvider) resolveShowIDStrict(ctx context.Context, animeID int, titleCandidates []string, mode string) (string, error) {
targetMalIDStr := strconv.Itoa(animeID)
for _, title := range titleCandidates {
searchResults, err := c.Search(ctx, title, mode)
if err != nil {
continue
}
for _, res := range searchResults {
if res.MalID == targetMalIDStr {
return res.ID, nil
}
}
}
return "", fmt.Errorf("allanime: no exact mal id match for %d in %s search", animeID, mode)
}
func parseEpisodeNumbers(raw []string) []int {
seen := make(map[int]bool, len(raw))
out := make([]int, 0, len(raw))
for _, value := range raw {
n, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || n <= 0 || seen[n] {
continue
}
seen[n] = true
out = append(out, n)
}
return out
}
func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, variables map[string]any) (map[string]any, error) {
if mode, ok := variables["translationType"].(string); ok {
variables["translationType"] = strings.ToLower(mode)
@@ -295,13 +97,13 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
req.Header.Set("Referer", allAnimeReferer)
req.Header.Set("User-Agent", defaultUserAgent)
resp, respBody, err := executeAndReadResponse(c.httpClient, req, "execute graphql request", "read graphql response")
statusCode, respBody, err := executeAndReadResponse(c.httpClient, req, "execute graphql request", "read graphql response")
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("graphql status %d", resp.StatusCode)
if statusCode != http.StatusOK {
return nil, fmt.Errorf("graphql status %d", statusCode)
}
var parsed map[string]any
@@ -316,487 +118,17 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
return parsed, nil
}
const episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"
func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) {
mode = strings.ToLower(mode)
varsJSON := fmt.Sprintf(`{"showId":"%s","translationType":"%s","episodeString":"%s"}`, showID, mode, episode)
extJSON := fmt.Sprintf(`{"persistedQuery":{"version":1,"sha256Hash":"%s"}}`, episodeQueryHash)
apiURL := fmt.Sprintf("%s/api?variables=%s&extensions=%s",
allAnimeBaseURL,
url.QueryEscape(varsJSON),
url.QueryEscape(extJSON))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil)
if err != nil {
return nil, fmt.Errorf("create GET request: %w", err)
}
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")
resp, respBody, err := executeAndReadResponse(c.utlsClient, req, "execute GET request", "read response")
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("GET status %d: %s", resp.StatusCode, string(respBody))
}
var parsed map[string]any
if err := json.Unmarshal(respBody, &parsed); err != nil {
return nil, fmt.Errorf("decode response: %w", err)
}
if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 {
return nil, fmt.Errorf("graphql error: %v", errs[0])
}
data, ok := parsed["data"].(map[string]any)
if !ok {
return nil, fmt.Errorf("no data in response")
}
var toBeParsed string
if s, ok := data["tobeparsed"].(string); ok && s != "" {
toBeParsed = s
} else if episodeData, ok := data["episode"].(map[string]any); ok {
if s, ok := episodeData["tobeparsed"].(string); ok {
toBeParsed = s
}
}
if toBeParsed != "" {
decrypted, err := decryptTobeparsed(toBeParsed)
if err != nil {
return nil, fmt.Errorf("decrypt tobeparsed: %w", err)
}
var ep map[string]any
if jerr := json.Unmarshal(decrypted, &ep); jerr != nil {
return nil, fmt.Errorf("unmarshal decrypted: %w", jerr)
}
var sourceURLs []any
if srcs, ok := ep["sourceUrls"].([]any); ok {
sourceURLs = srcs
} else if epInner, ok := ep["episode"].(map[string]any); ok {
if srcs, ok := epInner["sourceUrls"].([]any); ok {
sourceURLs = srcs
}
}
if len(sourceURLs) > 0 {
return map[string]any{
"episode": map[string]any{
"sourceUrls": sourceURLs,
},
}, nil
}
}
if episodeData, ok := data["episode"].(map[string]any); ok {
if srcs, ok := episodeData["sourceUrls"].([]any); ok && len(srcs) > 0 {
return parsed, nil
}
}
return nil, fmt.Errorf("no usable data in response")
}
// GetEpisodeSources fetches stream URLs for a given show, episode, and mode (dub/sub).
func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) {
episodeQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) {
sourceUrls
}
}`
result, err := c.graphqlRequestWithHash(ctx, showID, episode, mode)
if err == nil {
sources := c.extractSourceURLsFromData(ctx, result)
if len(sources) > 0 {
return sources, nil
}
}
result, err = c.graphqlRequest(ctx, episodeQuery, map[string]any{
"showId": showID,
"translationType": mode,
"episodeString": episode,
})
if err != nil {
return nil, err
}
data, ok := result["data"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid source response")
}
rawSourceURLs, ok := data["episode"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid episode response")
}
sourceURLs, ok := rawSourceURLs["sourceUrls"].([]any)
if !ok || len(sourceURLs) == 0 {
return nil, fmt.Errorf("no source urls")
}
references := buildSourceReferences(sourceURLs)
if len(references) == 0 {
return nil, fmt.Errorf("no source references")
}
out := c.resolveSourceReferences(ctx, references)
if len(out) == 0 {
return nil, fmt.Errorf("no playable sources extracted")
}
return out, nil
}
func (c *AllAnimeProvider) extractSourceURLsFromData(ctx context.Context, data map[string]any) []StreamSource {
episodeData, ok := data["episode"].(map[string]any)
if !ok {
return nil
}
sourceURLs, ok := episodeData["sourceUrls"].([]any)
if !ok || len(sourceURLs) == 0 {
return nil
}
references := buildSourceReferences(sourceURLs)
if len(references) == 0 {
return nil
}
return c.resolveSourceReferences(ctx, references)
}
func (c *AllAnimeProvider) resolveSourceReferences(ctx context.Context, references []sourceReference) []StreamSource {
out := make([]StreamSource, 0, len(references))
for _, ref := range references {
target := strings.TrimSpace(ref.URL)
if target == "" {
continue
}
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
sourceType := detectStreamType(target)
if sourceType == "unknown" {
sourceType = detectEmbedType(target)
}
out = append(out, buildStreamSource(target, sourceType, ref.Name))
continue
}
decoded := decodeSourceURL(target)
if decoded == "" {
continue
}
if strings.HasPrefix(decoded, "http://") || strings.HasPrefix(decoded, "https://") {
sourceType := detectStreamType(decoded)
if sourceType == "unknown" {
sourceType = detectEmbedType(decoded)
}
out = append(out, buildStreamSource(decoded, sourceType, ref.Name))
continue
}
if !strings.HasPrefix(decoded, "/") {
decoded = "/" + decoded
}
extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded)
if err != nil {
continue
}
out = append(out, extracted...)
}
return out
}
func executeAndReadResponse(client *http.Client, req *http.Request, executeErrPrefix string, readErrPrefix string) (*http.Response, []byte, error) {
func executeAndReadResponse(client *http.Client, req *http.Request, executeErrPrefix string, readErrPrefix string) (int, []byte, error) {
resp, err := client.Do(req)
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", executeErrPrefix, err)
return 0, nil, fmt.Errorf("%s: %w", executeErrPrefix, err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
if err != nil {
return nil, nil, fmt.Errorf("%s: %w", readErrPrefix, err)
return 0, nil, fmt.Errorf("%s: %w", readErrPrefix, err)
}
return resp, body, nil
}
func buildStreamSource(url, sourceType, provider string) StreamSource {
return StreamSource{
URL: url,
Provider: provider,
Type: sourceType,
Referer: allAnimeReferer,
}
}
type sourceReference struct {
URL string
Name string
}
// buildSourceReferences orders source URLs by provider priority, deduplicating entries.
func buildSourceReferences(rawSourceURLs []any) []sourceReference {
priorityOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
prioritySet := map[string]struct{}{"default": {}, "yt-mp4": {}, "s-mp4": {}, "luf-mp4": {}}
prioritized := make(map[string]sourceReference)
fallback := make([]sourceReference, 0, len(rawSourceURLs))
seen := make(map[string]struct{})
for _, source := range rawSourceURLs {
item, ok := source.(map[string]any)
if !ok {
continue
}
sourceURL, _ := item["sourceUrl"].(string)
sourceName, _ := item["sourceName"].(string)
sourceURL = strings.TrimSpace(sourceURL)
sourceName = strings.TrimSpace(sourceName)
if sourceURL == "" {
continue
}
if _, exists := seen[sourceURL]; exists {
continue
}
seen[sourceURL] = struct{}{}
ref := sourceReference{URL: sourceURL, Name: sourceName}
normalized := strings.ToLower(sourceName)
// separate prioritized providers from fallback
if _, prioritizedProvider := prioritySet[normalized]; prioritizedProvider {
if _, exists := prioritized[normalized]; !exists {
prioritized[normalized] = ref
}
continue
}
fallback = append(fallback, ref)
}
// output: prioritized in order, then fallback
ordered := make([]sourceReference, 0, len(prioritized)+len(fallback))
for _, provider := range priorityOrder {
if ref, ok := prioritized[provider]; ok {
ordered = append(ordered, ref)
}
}
ordered = append(ordered, fallback...)
return ordered
}
func decryptTobeparsed(encoded string) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("base64 decode failed: %w", err)
}
if len(raw) < 29 {
return nil, fmt.Errorf("encrypted payload too short")
}
version := raw[0]
iv := raw[1:13]
cipherText := raw[13 : len(raw)-16]
for _, keyStr := range aesKeys {
key := sha256.Sum256([]byte(keyStr))
block, err := aes.NewCipher(key[:])
if err != nil {
continue
}
if version == 1 {
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, fmt.Errorf("decryption failed")
}
func tryDecryptCTR(block cipher.Block, iv []byte, cipherText []byte) []byte {
ctrIV := append([]byte{}, iv...)
ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02)
ctr := cipher.NewCTR(block, ctrIV)
plainText := make([]byte, len(cipherText))
ctr.XORKeyStream(plainText, cipherText)
return plainText
}
// GetAvailableEpisodes returns the count of sub/dub/raw episodes available for a show.
func (c *AllAnimeProvider) GetAvailableEpisodes(ctx context.Context, showID string) (AvailableEpisodes, error) {
graphqlQuery := `query($showId: String!) {
show(_id: $showId) {
availableEpisodesDetail
lastEpisodeInfo
}
}`
result, err := c.graphqlRequest(ctx, graphqlQuery, map[string]any{"showId": showID})
if err != nil {
return AvailableEpisodes{}, err
}
data, ok := result["data"].(map[string]any)
if !ok {
return AvailableEpisodes{}, fmt.Errorf("invalid response")
}
show, ok := data["show"].(map[string]any)
if !ok || show == nil {
return AvailableEpisodes{}, fmt.Errorf("show not found")
}
detail, ok := show["availableEpisodesDetail"].(map[string]any)
if !ok {
return AvailableEpisodes{}, fmt.Errorf("invalid detail")
}
var count AvailableEpisodes
if sub, ok := detail["sub"].([]any); ok {
for _, s := range sub {
if str, ok := s.(string); ok {
count.Sub = append(count.Sub, str)
}
}
}
if dub, ok := detail["dub"].([]any); ok {
for _, s := range dub {
if str, ok := s.(string); ok {
count.Dub = append(count.Dub, str)
}
}
}
if raw, ok := detail["raw"].([]any); ok {
for _, s := range raw {
if str, ok := s.(string); ok {
count.Raw = append(count.Raw, str)
}
}
}
return count, nil
}
func decodeSourceURL(encoded string) string {
if encoded == "" {
return ""
}
encoded = strings.TrimPrefix(encoded, "--")
substitutions := map[string]string{
"79": "A", "7a": "B", "7b": "C", "7c": "D", "7d": "E",
"7e": "F", "7f": "G", "70": "H", "71": "I", "72": "J",
"73": "K", "74": "L", "75": "M", "76": "N", "77": "O",
"68": "P", "69": "Q", "6a": "R", "6b": "S", "6c": "T",
"6d": "U", "6e": "V", "6f": "W", "60": "X", "61": "Y",
"62": "Z",
"59": "a", "5a": "b", "5b": "c", "5c": "d", "5d": "e",
"5e": "f", "5f": "g", "50": "h", "51": "i", "52": "j",
"53": "k", "54": "l", "55": "m", "56": "n", "57": "o",
"48": "p", "49": "q", "4a": "r", "4b": "s", "4c": "t",
"4d": "u", "4e": "v", "4f": "w", "40": "x", "41": "y",
"42": "z",
"08": "0", "09": "1", "0a": "2", "0b": "3", "0c": "4",
"0d": "5", "0e": "6", "0f": "7", "00": "8", "01": "9",
"15": "-", "16": ".", "67": "_", "46": "~", "02": ":",
"17": "/", "07": "?", "1b": "#", "63": "[", "65": "]",
"78": "@", "19": "!", "1c": "$", "1e": "&", "10": "(",
"11": ")", "12": "*", "13": "+", "14": ",", "03": ";",
"05": "=", "1d": "%",
}
var result strings.Builder
for idx := 0; idx < len(encoded); {
if idx+2 <= len(encoded) {
pair := encoded[idx : idx+2]
if sub, ok := substitutions[pair]; ok {
result.WriteString(sub)
idx += 2
continue
}
}
result.WriteByte(encoded[idx])
idx++
}
decoded := result.String()
if strings.Contains(decoded, "/clock") && !strings.Contains(decoded, "/clock.json") {
decoded = strings.Replace(decoded, "/clock", "/clock.json", 1)
}
return decoded
}
func detectStreamType(sourceURL string) string {
lower := strings.ToLower(sourceURL)
if strings.Contains(lower, ".m3u8") || strings.Contains(lower, "master.m3u8") {
return "m3u8"
}
if strings.Contains(lower, ".mp4") {
return "mp4"
}
return "unknown"
}
func detectEmbedType(rawURL string) string {
lower := strings.ToLower(rawURL)
embedHosts := []string{"streamwish", "streamsb", "mp4upload", "ok.ru", "gogoplay", "streamlare"}
for _, host := range embedHosts {
if strings.Contains(lower, host) {
return "embed"
}
}
return "unknown"
return resp.StatusCode, body, nil
}

View File

@@ -20,167 +20,182 @@ func isLikelyMP4(data []byte) bool {
return string(data[4:8]) == "ftyp"
}
func TestDecodeSourceURL(t *testing.T) {
t.Parallel()
type stringTransformTestCase struct {
name string
input string
want string
}
tests := []struct {
name string
encoded string
want string
}{
{
name: "empty returns empty",
encoded: "",
want: "",
},
{
name: "with double prefix stripped",
encoded: "--example.com/video.mp4",
want: "example.com/video.mp4",
},
{
name: "hex substitution",
encoded: "7aexample",
want: "Bexample",
},
{
name: "mixed substitution",
encoded: "79url7a01",
want: "AurlB9",
},
{
name: "clock replacement",
encoded: "/clock",
want: "/clock.json",
},
{
name: "no clock replacement if already json",
encoded: "/clock.json",
want: "/clock.json",
},
{
name: "complex url",
encoded: "--79stream7acom",
want: "AstreamBcom",
},
}
type sourceReferencesTestCase struct {
name string
rawURLs []any
wantRefs []sourceReference
}
func runStringTransformTests(t *testing.T, tests []stringTransformTestCase, fn func(string) string) {
t.Helper()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := decodeSourceURL(tt.encoded)
got := fn(tt.input)
if got != tt.want {
t.Errorf("decodeSourceURL(%q) = %q, want %q", tt.encoded, got, tt.want)
t.Errorf("got %q for input %q, want %q", got, tt.input, tt.want)
}
})
}
}
func runSourceReferenceTests(t *testing.T, tests []sourceReferencesTestCase) {
t.Helper()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := buildSourceReferences(tt.rawURLs)
if len(got) != len(tt.wantRefs) {
t.Errorf("got %d refs, want %d", len(got), len(tt.wantRefs))
return
}
for i, want := range tt.wantRefs {
if got[i].URL != want.URL {
t.Errorf("ref[%d].URL = %q, want %q", i, got[i].URL, want.URL)
}
if got[i].Name != want.Name {
t.Errorf("ref[%d].Name = %q, want %q", i, got[i].Name, want.Name)
}
}
})
}
}
func TestDecodeSourceURL(t *testing.T) {
t.Parallel()
tests := []stringTransformTestCase{
{
name: "empty returns empty",
input: "",
want: "",
},
{
name: "with double prefix stripped",
input: "--example.com/video.mp4",
want: "example.com/video.mp4",
},
{
name: "hex substitution",
input: "7aexample",
want: "Bexample",
},
{
name: "mixed substitution",
input: "79url7a01",
want: "AurlB9",
},
{
name: "clock replacement",
input: "/clock",
want: "/clock.json",
},
{
name: "no clock replacement if already json",
input: "/clock.json",
want: "/clock.json",
},
{
name: "complex url",
input: "--79stream7acom",
want: "AstreamBcom",
},
}
runStringTransformTests(t, tests, decodeSourceURL)
}
func TestDetectStreamType(t *testing.T) {
t.Parallel()
tests := []struct {
name string
url string
wantType string
}{
tests := []stringTransformTestCase{
{
name: "m3u8 extension",
url: "https://example.com/video.m3u8",
wantType: "m3u8",
name: "m3u8 extension",
input: "https://example.com/video.m3u8",
want: "m3u8",
},
{
name: "master m3u8",
url: "https://example.com/master.m3u8",
wantType: "m3u8",
name: "master m3u8",
input: "https://example.com/master.m3u8",
want: "m3u8",
},
{
name: "mp4 extension",
url: "https://example.com/video.mp4",
wantType: "mp4",
name: "mp4 extension",
input: "https://example.com/video.mp4",
want: "mp4",
},
{
name: "unknown",
url: "https://example.com/video.avi",
wantType: "unknown",
name: "unknown",
input: "https://example.com/video.avi",
want: "unknown",
},
{
name: "empty returns unknown",
url: "",
wantType: "unknown",
name: "empty returns unknown",
input: "",
want: "unknown",
},
{
name: "case insensitive - M3U8",
url: "https://example.com/MASTER.M3U8",
wantType: "m3u8",
name: "case insensitive - M3U8",
input: "https://example.com/MASTER.M3U8",
want: "m3u8",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := detectStreamType(tt.url)
if got != tt.wantType {
t.Errorf("detectStreamType(%q) = %q, want %q", tt.url, got, tt.wantType)
}
})
}
runStringTransformTests(t, tests, detectStreamType)
}
func TestDetectEmbedType(t *testing.T) {
t.Parallel()
tests := []struct {
name string
url string
wantType string
}{
tests := []stringTransformTestCase{
{
name: "streamwish",
url: "https://streamwish.com/e/abc123",
wantType: "embed",
name: "streamwish",
input: "https://streamwish.com/e/abc123",
want: "embed",
},
{
name: "streamsb",
url: "https://streamsb.com/e/abc123",
wantType: "embed",
name: "streamsb",
input: "https://streamsb.com/e/abc123",
want: "embed",
},
{
name: "mp4upload",
url: "https://mp4upload.com/e/abc123",
wantType: "embed",
name: "mp4upload",
input: "https://mp4upload.com/e/abc123",
want: "embed",
},
{
name: "ok.ru",
url: "https://ok.ru/video/123",
wantType: "embed",
name: "ok.ru",
input: "https://ok.ru/video/123",
want: "embed",
},
{
name: "gogoplay",
url: "https://gogoplay.io/embed/123",
wantType: "embed",
name: "gogoplay",
input: "https://gogoplay.io/embed/123",
want: "embed",
},
{
name: "streamlare",
url: "https://streamlare.com/e/abc",
wantType: "embed",
name: "streamlare",
input: "https://streamlare.com/e/abc",
want: "embed",
},
{
name: "unknown host",
url: "https://unknown.com/video",
wantType: "unknown",
name: "unknown host",
input: "https://unknown.com/video",
want: "unknown",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := detectEmbedType(tt.url)
if got != tt.wantType {
t.Errorf("detectEmbedType(%q) = %q, want %q", tt.url, got, tt.wantType)
}
})
}
runStringTransformTests(t, tests, detectEmbedType)
}
func TestBuildStreamSource(t *testing.T) {
@@ -204,14 +219,21 @@ func TestBuildStreamSource(t *testing.T) {
})
}
func TestResolveDirectSourceSkipsEmbeds(t *testing.T) {
t.Parallel()
if _, ok := resolveDirectSource(sourceReference{
URL: "https://ok.ru/videoembed/123",
Name: "ok",
}); ok {
t.Fatal("expected embed URL to require extraction")
}
}
func TestBuildSourceReferences(t *testing.T) {
t.Parallel()
tests := []struct {
name string
rawURLs []any
wantRefs []sourceReference
}{
tests := []sourceReferencesTestCase{
{
name: "empty returns empty",
rawURLs: nil,
@@ -263,26 +285,7 @@ func TestBuildSourceReferences(t *testing.T) {
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
got := buildSourceReferences(tt.rawURLs)
if len(got) != len(tt.wantRefs) {
t.Errorf("got %d refs, want %d", len(got), len(tt.wantRefs))
return
}
for i, want := range tt.wantRefs {
if got[i].URL != want.URL {
t.Errorf("ref[%d].URL = %q, want %q", i, got[i].URL, want.URL)
}
if got[i].Name != want.Name {
t.Errorf("ref[%d].Name = %q, want %q", i, got[i].Name, want.Name)
}
}
})
}
runSourceReferenceTests(t, tests)
}
func TestBuildSourceReferencesOrder(t *testing.T) {
@@ -391,6 +394,27 @@ func TestIsLikelyMP4(t *testing.T) {
}
}
func TestParseOKRUSources(t *testing.T) {
t.Parallel()
body := `{"flashvars":{"metadata":"{\"hlsManifestUrl\":\"https://vd.example.test/video.m3u8?cmd=videoPlayerCdn\\u0026id=123\"}"}}`
got := parseOKRUSources(body, allAnimeReferer)
if len(got) != 1 {
t.Fatalf("len(got) = %d, want 1", len(got))
}
if got[0].URL != "https://vd.example.test/video.m3u8?cmd=videoPlayerCdn&id=123" {
t.Fatalf("URL = %q", got[0].URL)
}
if got[0].Type != "m3u8" {
t.Fatalf("Type = %q, want m3u8", got[0].Type)
}
if got[0].Provider != "ok" {
t.Fatalf("Provider = %q, want ok", got[0].Provider)
}
}
func TestDecryptTobeparsed(t *testing.T) {
t.Parallel()

View File

@@ -0,0 +1,235 @@
package allanime
import (
"crypto/aes"
"crypto/cipher"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"strings"
)
var (
aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"}
)
func decryptTobeparsed(encoded string) ([]byte, error) {
raw, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return nil, fmt.Errorf("base64 decode failed: %w", err)
}
if len(raw) < 29 {
return nil, fmt.Errorf("encrypted payload too short")
}
version := raw[0]
iv := raw[1:13]
cipherText := raw[13 : len(raw)-16]
for _, keyStr := range aesKeys {
key := sha256.Sum256([]byte(keyStr))
block, err := aes.NewCipher(key[:])
if err != nil {
continue
}
if version == 1 {
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, fmt.Errorf("decryption failed")
}
func tryDecryptCTR(block cipher.Block, iv []byte, cipherText []byte) []byte {
ctrIV := append([]byte{}, iv...)
ctrIV = append(ctrIV, 0x00, 0x00, 0x00, 0x02)
ctr := cipher.NewCTR(block, ctrIV)
plainText := make([]byte, len(cipherText))
ctr.XORKeyStream(plainText, cipherText)
return plainText
}
func decodeSourceURL(encoded string) string {
if encoded == "" {
return ""
}
encoded = strings.TrimPrefix(encoded, "--")
substitutions := map[string]string{
"79": "A", "7a": "B", "7b": "C", "7c": "D", "7d": "E",
"7e": "F", "7f": "G", "70": "H", "71": "I", "72": "J",
"73": "K", "74": "L", "75": "M", "76": "N", "77": "O",
"68": "P", "69": "Q", "6a": "R", "6b": "S", "6c": "T",
"6d": "U", "6e": "V", "6f": "W", "60": "X", "61": "Y",
"62": "Z",
"59": "a", "5a": "b", "5b": "c", "5c": "d", "5d": "e",
"5e": "f", "5f": "g", "50": "h", "51": "i", "52": "j",
"53": "k", "54": "l", "55": "m", "56": "n", "57": "o",
"48": "p", "49": "q", "4a": "r", "4b": "s", "4c": "t",
"4d": "u", "4e": "v", "4f": "w", "40": "x", "41": "y",
"42": "z",
"08": "0", "09": "1", "0a": "2", "0b": "3", "0c": "4",
"0d": "5", "0e": "6", "0f": "7", "00": "8", "01": "9",
"15": "-", "16": ".", "67": "_", "46": "~", "02": ":",
"17": "/", "07": "?", "1b": "#", "63": "[", "65": "]",
"78": "@", "19": "!", "1c": "$", "1e": "&", "10": "(",
"11": ")", "12": "*", "13": "+", "14": ",", "03": ";",
"05": "=", "1d": "%",
}
var result strings.Builder
for idx := 0; idx < len(encoded); {
if idx+2 <= len(encoded) {
pair := encoded[idx : idx+2]
if sub, ok := substitutions[pair]; ok {
result.WriteString(sub)
idx += 2
continue
}
}
result.WriteByte(encoded[idx])
idx++
}
decoded := result.String()
if strings.Contains(decoded, "/clock") && !strings.Contains(decoded, "/clock.json") {
decoded = strings.Replace(decoded, "/clock", "/clock.json", 1)
}
return decoded
}
func responseFromTobeparsed(data map[string]any) (map[string]any, error) {
toBeParsed := firstNonEmptyString(
nestedString(data, "tobeparsed"),
nestedString(data, "episode", "tobeparsed"),
)
if toBeParsed == "" {
return nil, nil
}
decrypted, err := decryptTobeparsed(toBeParsed)
if err != nil {
return nil, fmt.Errorf("decrypt tobeparsed: %w", err)
}
parsed, err := parseGraphQLResponse(decrypted, "unmarshal decrypted")
if err != nil {
return nil, err
}
sourceURLs := firstNonEmptySlice(
nestedSlice(parsed, "sourceUrls"),
nestedSlice(parsed, "episode", "sourceUrls"),
)
if len(sourceURLs) == 0 {
return nil, nil
}
return map[string]any{
"episode": map[string]any{
"sourceUrls": sourceURLs,
},
}, nil
}
func hasEpisodeSourceURLs(data map[string]any) bool {
return len(nestedSlice(data, "episode", "sourceUrls")) > 0
}
func parseGraphQLResponse(respBody []byte, decodeErrPrefix string) (map[string]any, error) {
var parsed map[string]any
if err := json.Unmarshal(respBody, &parsed); err != nil {
return nil, fmt.Errorf("%s: %w", decodeErrPrefix, err)
}
if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 {
return nil, fmt.Errorf("graphql error: %v", errs[0])
}
return parsed, nil
}
func firstNonEmptyString(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func firstNonEmptySlice(values ...[]any) []any {
for _, value := range values {
if len(value) > 0 {
return value
}
}
return nil
}
func nestedString(data map[string]any, path ...string) string {
value, ok := nestedValue(data, path...)
if !ok {
return ""
}
str, ok := value.(string)
if !ok {
return ""
}
return str
}
func nestedSlice(data map[string]any, path ...string) []any {
value, ok := nestedValue(data, path...)
if !ok {
return nil
}
slice, ok := value.([]any)
if !ok {
return nil
}
return slice
}
func nestedValue(data map[string]any, path ...string) (any, bool) {
var current any = data
for _, key := range path {
currentMap, ok := current.(map[string]any)
if !ok {
return nil, false
}
current, ok = currentMap[key]
if !ok {
return nil, false
}
}
return current, true
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"fmt"
"html"
"io"
netutil "mal/pkg/net"
"net/http"
@@ -19,10 +20,27 @@ type providerExtractor struct {
referer string
}
type providerLinkItem struct {
link string
resolutionStr string
}
type providerHLSItem struct {
url string
hardsubLang string
}
type providerResponseData struct {
referer string
links []providerLinkItem
hls []providerHLSItem
subtitles []Subtitle
}
func newProviderExtractor() *providerExtractor {
return &providerExtractor{
httpClient: &http.Client{Timeout: 30 * time.Second},
baseURL: allAnimeBaseURL,
baseURL: allAnimeSiteURL,
referer: allAnimeReferer,
}
}
@@ -63,65 +81,45 @@ func (e *providerExtractor) ExtractVideoLinks(ctx context.Context, providerPath
return e.parseProviderResponse(ctx, string(body)), nil
}
func (e *providerExtractor) ExtractEmbedVideoLinks(ctx context.Context, rawURL string) ([]StreamSource, error) {
resp, err := doProxiedRequest(ctx, e.httpClient, rawURL, e.referer)
if err != nil {
return nil, fmt.Errorf("fetch embed response: %w", err)
}
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(io.LimitReader(resp.Body, netutil.MiB2))
if err != nil {
return nil, fmt.Errorf("read embed response: %w", err)
}
return parseExternalEmbedResponse(rawURL, string(body), e.referer), nil
}
// parseProviderResponse extracts stream sources from provider JSON response.
func (e *providerExtractor) parseProviderResponse(ctx context.Context, response string) []StreamSource {
sources := make([]StreamSource, 0)
providerReferer := e.referer
var root any
if err := json.Unmarshal([]byte(response), &root); err != nil {
return sources
return []StreamSource{}
}
type linkItem struct {
link string
resolutionStr string
}
type hlsItem struct {
url string
hardsubLang string
}
data := collectProviderResponseData(root, e.referer)
sources := buildProviderLinkSources(data.links, data.referer)
sources = append(sources, e.buildProviderHLSSources(ctx, data.hls, data.referer)...)
linkItems := make([]linkItem, 0)
hlsItems := make([]hlsItem, 0)
subtitles := make([]Subtitle, 0)
attachSubtitles(sources, data.subtitles)
return sources
}
func collectProviderResponseData(root any, fallbackReferer string) providerResponseData {
data := providerResponseData{referer: fallbackReferer}
var walk func(v any)
walk = func(v any) {
switch x := v.(type) {
case map[string]any:
if ref, ok := x["Referer"].(string); ok && strings.TrimSpace(ref) != "" {
providerReferer = strings.TrimSpace(ref)
}
if link, ok := x["link"].(string); ok {
if res, ok := x["resolutionStr"].(string); ok {
linkItems = append(linkItems, linkItem{link: link, resolutionStr: res})
}
}
if u, ok := x["url"].(string); ok {
if lang, ok := x["hardsub_lang"].(string); ok {
hlsItems = append(hlsItems, hlsItem{url: u, hardsubLang: lang})
}
}
if subs, ok := x["subtitles"].([]any); ok {
for _, sub := range subs {
obj, ok := sub.(map[string]any)
if !ok {
continue
}
lang, _ := obj["lang"].(string)
src, _ := obj["src"].(string)
lang = strings.TrimSpace(lang)
src = strings.TrimSpace(src)
if lang == "" || src == "" {
continue
}
subtitles = append(subtitles, Subtitle{Lang: lang, URL: src})
}
}
collectProviderMapData(x, &data)
for _, child := range x {
walk(child)
}
@@ -133,42 +131,98 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
}
walk(root)
if providerReferer == "" {
providerReferer = e.referer
if data.referer == "" {
data.referer = fallbackReferer
}
for _, item := range linkItems {
return data
}
func collectProviderMapData(node map[string]any, data *providerResponseData) {
if ref, ok := node["Referer"].(string); ok {
if trimmedRef := strings.TrimSpace(ref); trimmedRef != "" {
data.referer = trimmedRef
}
}
if link, ok := node["link"].(string); ok {
if res, ok := node["resolutionStr"].(string); ok {
data.links = append(data.links, providerLinkItem{link: link, resolutionStr: res})
}
}
if url, ok := node["url"].(string); ok {
if lang, ok := node["hardsub_lang"].(string); ok {
data.hls = append(data.hls, providerHLSItem{url: url, hardsubLang: lang})
}
}
if subs, ok := node["subtitles"].([]any); ok {
data.subtitles = append(data.subtitles, parseProviderSubtitles(subs)...)
}
}
func parseProviderSubtitles(items []any) []Subtitle {
subtitles := make([]Subtitle, 0, len(items))
for _, item := range items {
node, ok := item.(map[string]any)
if !ok {
continue
}
lang, _ := node["lang"].(string)
src, _ := node["src"].(string)
lang = strings.TrimSpace(lang)
src = strings.TrimSpace(src)
if lang == "" || src == "" {
continue
}
subtitles = append(subtitles, Subtitle{Lang: lang, URL: src})
}
return subtitles
}
func buildProviderLinkSources(items []providerLinkItem, referer string) []StreamSource {
sources := make([]StreamSource, 0, len(items))
for _, item := range items {
link := strings.TrimSpace(item.link)
if link == "" {
continue
}
quality := strings.TrimSpace(item.resolutionStr)
sourceType := detectStreamType(link)
if sourceType == "unknown" {
sourceType = detectEmbedType(link)
}
sources = append(sources, StreamSource{
URL: link,
Quality: quality,
Quality: strings.TrimSpace(item.resolutionStr),
Provider: "wixmp",
Type: sourceType,
Referer: providerReferer,
Type: detectProviderSourceType(link),
Referer: referer,
})
}
for _, item := range hlsItems {
if strings.TrimSpace(item.url) == "" {
continue
}
if item.hardsubLang != "en-US" {
return sources
}
func detectProviderSourceType(link string) string {
sourceType := detectStreamType(link)
if sourceType != "unknown" {
return sourceType
}
return detectEmbedType(link)
}
func (e *providerExtractor) buildProviderHLSSources(ctx context.Context, items []providerHLSItem, referer string) []StreamSource {
sources := make([]StreamSource, 0, len(items))
for _, item := range items {
playlistURL, ok := providerPlaylistURL(item)
if !ok {
continue
}
playlistURL := strings.TrimSpace(item.url)
if strings.Contains(playlistURL, "master.m3u8") {
parsed, err := e.parseM3U8(ctx, playlistURL, providerReferer)
parsed, err := e.parseM3U8(ctx, playlistURL, referer)
if err == nil {
sources = append(sources, parsed...)
}
@@ -180,17 +234,30 @@ func (e *providerExtractor) parseProviderResponse(ctx context.Context, response
Quality: "auto",
Provider: "hls",
Type: "m3u8",
Referer: providerReferer,
Referer: referer,
})
}
if len(subtitles) > 0 && len(sources) > 0 {
for idx := range sources {
sources[idx].Subtitles = append([]Subtitle(nil), subtitles...)
}
return sources
}
func providerPlaylistURL(item providerHLSItem) (string, bool) {
playlistURL := strings.TrimSpace(item.url)
if playlistURL == "" || item.hardsubLang != "en-US" {
return "", false
}
return sources
return playlistURL, true
}
func attachSubtitles(sources []StreamSource, subtitles []Subtitle) {
if len(subtitles) == 0 || len(sources) == 0 {
return
}
for idx := range sources {
sources[idx].Subtitles = append([]Subtitle(nil), subtitles...)
}
}
// parseM3U8 fetches a master playlist and extracts individual stream URLs with bandwidth-derived quality.
@@ -206,60 +273,159 @@ func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, ref
return nil, err
}
lines := strings.Split(string(body), "\n")
baseURL := masterURL
if idx := strings.LastIndex(masterURL, "/"); idx >= 0 {
baseURL = masterURL[:idx+1]
}
return parseM3U8Sources(string(body), masterURL, referer), nil
}
func parseM3U8Sources(body string, masterURL string, referer string) []StreamSource {
lines := strings.Split(body, "\n")
baseURL := playlistBaseURL(masterURL)
bwPattern := regexp.MustCompile(`BANDWIDTH=(\d+)`)
currentBandwidth := 0
sources := make([]StreamSource, 0)
bwPattern := regexp.MustCompile(`BANDWIDTH=(\d+)`)
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if strings.HasPrefix(trimmed, "#EXT-X-STREAM-INF") {
match := bwPattern.FindStringSubmatch(trimmed)
if len(match) >= 2 {
value, convErr := strconv.Atoi(match[1])
if convErr == nil {
currentBandwidth = value
}
}
if bandwidth, ok := parseStreamBandwidth(trimmed, bwPattern); ok {
currentBandwidth = bandwidth
continue
}
// skip empty lines and non-stream lines
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
if shouldSkipM3U8Line(trimmed) {
continue
}
streamURL := trimmed
if !strings.HasPrefix(streamURL, "http://") && !strings.HasPrefix(streamURL, "https://") {
streamURL = baseURL + streamURL
}
quality := "auto"
kbps := currentBandwidth / 1000
switch {
case kbps >= 8000:
quality = "1080p"
case kbps >= 5000:
quality = "720p"
case kbps >= 2500:
quality = "480p"
case kbps > 0:
quality = "360p"
}
sources = append(sources, StreamSource{
URL: streamURL,
Quality: quality,
URL: resolvePlaylistURL(trimmed, baseURL),
Quality: qualityFromBandwidth(currentBandwidth),
Provider: "hls",
Type: "m3u8",
Referer: referer,
})
}
return sources, nil
return sources
}
func playlistBaseURL(masterURL string) string {
if idx := strings.LastIndex(masterURL, "/"); idx >= 0 {
return masterURL[:idx+1]
}
return masterURL
}
func parseStreamBandwidth(line string, bwPattern *regexp.Regexp) (int, bool) {
if !strings.HasPrefix(line, "#EXT-X-STREAM-INF") {
return 0, false
}
match := bwPattern.FindStringSubmatch(line)
if len(match) < 2 {
return 0, true
}
value, err := strconv.Atoi(match[1])
if err != nil {
return 0, true
}
return value, true
}
func shouldSkipM3U8Line(line string) bool {
return line == "" || strings.HasPrefix(line, "#")
}
func resolvePlaylistURL(streamURL string, baseURL string) string {
if strings.HasPrefix(streamURL, "http://") || strings.HasPrefix(streamURL, "https://") {
return streamURL
}
return baseURL + streamURL
}
func qualityFromBandwidth(bandwidth int) string {
kbps := bandwidth / 1000
switch {
case kbps >= 8000:
return "1080p"
case kbps >= 5000:
return "720p"
case kbps >= 2500:
return "480p"
case kbps > 0:
return "360p"
default:
return "auto"
}
}
func parseExternalEmbedResponse(rawURL string, body string, fallbackReferer string) []StreamSource {
switch {
case strings.Contains(strings.ToLower(rawURL), "ok.ru/"):
return parseOKRUSources(body, fallbackReferer)
case strings.Contains(strings.ToLower(rawURL), "mp4upload.com/"):
return parseMP4UploadSources(body, fallbackReferer)
default:
return nil
}
}
func parseOKRUSources(body string, referer string) []StreamSource {
unescapedBody := html.UnescapeString(body)
manifestPattern := regexp.MustCompile(`\\"hlsManifestUrl\\":\\"([^"]+)\\"|"hlsManifestUrl":"([^"]+)"`)
match := manifestPattern.FindStringSubmatch(unescapedBody)
if len(match) < 3 {
return nil
}
playlistURL := decodeEscapedMediaURL(firstNonEmptyString(match[1], match[2]))
if playlistURL == "" {
return nil
}
return []StreamSource{{
URL: playlistURL,
Quality: "auto",
Provider: "ok",
Type: "m3u8",
Referer: referer,
}}
}
func parseMP4UploadSources(body string, referer string) []StreamSource {
srcPattern := regexp.MustCompile(`(?m)src:\s*"([^"]+)"`)
match := srcPattern.FindStringSubmatch(body)
if len(match) < 2 {
return nil
}
mediaURL := decodeEscapedMediaURL(match[1])
if mediaURL == "" {
return nil
}
return []StreamSource{{
URL: mediaURL,
Provider: "mp4upload",
Type: detectProviderSourceType(mediaURL),
Referer: referer,
}}
}
func decodeEscapedMediaURL(raw string) string {
if unquoted, err := strconv.Unquote(`"` + raw + `"`); err == nil {
raw = unquoted
}
replacer := strings.NewReplacer(
`\\u002F`, `/`,
`\\u0026`, "&",
`\/`, `/`,
`\u002F`, `/`,
`\u0026`, "&",
`&amp;`, "&",
)
return strings.TrimSpace(replacer.Replace(raw))
}

View File

@@ -0,0 +1,156 @@
package allanime
import (
"context"
"fmt"
"mal/pkg"
netutil "mal/pkg/net"
"strconv"
"strings"
)
const searchQuery = `query(
$search: SearchInput
$translationType: VaildTranslationTypeEnumType
$limit: Int = 40
$page: Int = 1
$countryOrigin: VaildCountryOriginEnumType = ALL
) {
shows(
search: $search
limit: $limit
page: $page
translationType: $translationType
countryOrigin: $countryOrigin
) {
edges {
_id
malId
name
}
}
}`
type searchResult struct {
ID string
MalID string
Name string
}
func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) {
type searchData struct {
Shows struct {
Edges []struct {
ID string `json:"_id"`
MalID string `json:"malId"`
Name string `json:"name"`
} `json:"edges"`
} `json:"shows"`
}
type searchInput struct {
AllowAdult bool `json:"allowAdult"`
AllowUnknown bool `json:"allowUnknown"`
Query string `json:"query"`
}
type searchVariables struct {
Search searchInput `json:"search"`
TranslationType string `json:"translationType"`
}
vars := searchVariables{
Search: searchInput{
AllowAdult: false,
AllowUnknown: false,
Query: query,
},
TranslationType: mode,
}
data, err := graphql.Post[searchData](ctx, c.httpClient, allAnimeBaseURL+"/api", searchQuery, vars, graphql.PostOptions{
Headers: map[string]string{
"Referer": allAnimeReferer,
"User-Agent": defaultUserAgent,
},
BodyMax: netutil.MiB2,
})
if err != nil {
return nil, err
}
out := make([]searchResult, 0, len(data.Shows.Edges))
for _, edge := range data.Shows.Edges {
id := edge.ID
malID := edge.MalID
name := edge.Name
if unquoted, err := strconv.Unquote("\"" + name + "\""); err == nil {
name = unquoted
}
name = strings.TrimSpace(name)
if id == "" {
continue
}
out = append(out, searchResult{ID: id, MalID: malID, Name: name})
}
return out, nil
}
func (c *AllAnimeProvider) resolveShowIDWithFallback(ctx context.Context, animeID int, titleCandidates []string, mode string) string {
targetMalIDStr := strconv.Itoa(animeID)
firstAvailableShowID := ""
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 firstAvailableShowID == "" {
firstAvailableShowID = searchResults[0].ID
}
}
return firstAvailableShowID
}
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.resolveShowIDStrict(ctx, animeID, titleCandidates, mode)
if err == nil {
return showID, nil
}
}
return "", fmt.Errorf("allanime: no exact mal id match for %d", animeID)
}
func (c *AllAnimeProvider) resolveShowIDStrict(ctx context.Context, animeID int, titleCandidates []string, mode string) (string, error) {
targetMalIDStr := strconv.Itoa(animeID)
for _, title := range titleCandidates {
searchResults, err := c.Search(ctx, title, mode)
if err != nil {
continue
}
for _, res := range searchResults {
if res.MalID == targetMalIDStr {
return res.ID, nil
}
}
}
return "", fmt.Errorf("allanime: no exact mal id match for %d in %s search", animeID, mode)
}

View File

@@ -0,0 +1,316 @@
package allanime
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
)
const episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"
type sourceReference struct {
URL string
Name string
}
func (c *AllAnimeProvider) GetEpisodeSources(ctx context.Context, showID string, episode string, mode string) ([]StreamSource, error) {
episodeQuery := `query($showId: String!, $translationType: VaildTranslationTypeEnumType!, $episodeString: String!) {
episode(showId: $showId, translationType: $translationType, episodeString: $episodeString) {
sourceUrls
}
}`
result, err := c.graphqlRequestWithHash(ctx, showID, episode, mode)
if err == nil {
sources := c.extractSourceURLsFromData(ctx, result)
if len(sources) > 0 {
return sources, nil
}
}
result, err = c.graphqlRequest(ctx, episodeQuery, map[string]any{
"showId": showID,
"translationType": mode,
"episodeString": episode,
})
if err != nil {
return nil, err
}
data, ok := result["data"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid source response")
}
rawSourceURLs, ok := data["episode"].(map[string]any)
if !ok {
return nil, fmt.Errorf("invalid episode response")
}
sourceURLs, ok := rawSourceURLs["sourceUrls"].([]any)
if !ok || len(sourceURLs) == 0 {
return nil, fmt.Errorf("no source urls")
}
references := buildSourceReferences(sourceURLs)
if len(references) == 0 {
return nil, fmt.Errorf("no source references")
}
out := c.resolveSourceReferences(ctx, references)
if len(out) == 0 {
return nil, fmt.Errorf("no playable sources extracted")
}
return out, nil
}
func (c *AllAnimeProvider) extractSourceURLsFromData(ctx context.Context, data map[string]any) []StreamSource {
episodeData, ok := data["episode"].(map[string]any)
if !ok {
return nil
}
sourceURLs, ok := episodeData["sourceUrls"].([]any)
if !ok || len(sourceURLs) == 0 {
return nil
}
references := buildSourceReferences(sourceURLs)
if len(references) == 0 {
return nil
}
return c.resolveSourceReferences(ctx, references)
}
func (c *AllAnimeProvider) resolveSourceReferences(ctx context.Context, references []sourceReference) []StreamSource {
out := make([]StreamSource, 0, len(references))
for _, ref := range references {
if source, ok := resolveDirectSource(ref); ok {
out = append(out, source)
return out
}
extracted := c.resolveExtractedSources(ctx, ref)
if len(extracted) > 0 {
out = append(out, extracted...)
return out
}
}
return out
}
func resolveDirectSource(ref sourceReference) (StreamSource, bool) {
target := strings.TrimSpace(ref.URL)
if target == "" {
return StreamSource{}, false
}
if isHTTPURL(target) {
if detectEmbedType(target) == "embed" {
return StreamSource{}, false
}
return buildStreamSource(target, detectSourceType(target), ref.Name), true
}
decoded := decodeSourceURL(target)
if !isHTTPURL(decoded) {
return StreamSource{}, false
}
if detectEmbedType(decoded) == "embed" {
return StreamSource{}, false
}
return buildStreamSource(decoded, detectSourceType(decoded), ref.Name), true
}
func (c *AllAnimeProvider) resolveExtractedSources(ctx context.Context, ref sourceReference) []StreamSource {
rawURL := strings.TrimSpace(ref.URL)
decoded := decodeSourceURL(rawURL)
if decoded == "" {
return nil
}
if isHTTPURL(decoded) {
extracted, err := c.extractor.ExtractEmbedVideoLinks(ctx, decoded)
if err != nil {
return nil
}
return extracted
}
if !strings.HasPrefix(decoded, "/") {
decoded = "/" + decoded
}
extracted, err := c.extractor.ExtractVideoLinks(ctx, decoded)
if err != nil {
return nil
}
return extracted
}
func detectSourceType(sourceURL string) string {
sourceType := detectStreamType(sourceURL)
if sourceType != "unknown" {
return sourceType
}
return detectEmbedType(sourceURL)
}
func isHTTPURL(value string) bool {
return strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://")
}
func buildStreamSource(url, sourceType, provider string) StreamSource {
return StreamSource{
URL: url,
Provider: provider,
Type: sourceType,
Referer: allAnimeReferer,
}
}
func buildSourceReferences(rawSourceURLs []any) []sourceReference {
priorityOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"}
prioritySet := map[string]struct{}{"default": {}, "yt-mp4": {}, "s-mp4": {}, "luf-mp4": {}}
prioritized := make(map[string]sourceReference)
fallback := make([]sourceReference, 0, len(rawSourceURLs))
seen := make(map[string]struct{})
for _, source := range rawSourceURLs {
item, ok := source.(map[string]any)
if !ok {
continue
}
sourceURL, _ := item["sourceUrl"].(string)
sourceName, _ := item["sourceName"].(string)
sourceURL = strings.TrimSpace(sourceURL)
sourceName = strings.TrimSpace(sourceName)
if sourceURL == "" {
continue
}
if _, exists := seen[sourceURL]; exists {
continue
}
seen[sourceURL] = struct{}{}
ref := sourceReference{URL: sourceURL, Name: sourceName}
normalized := strings.ToLower(sourceName)
if _, prioritizedProvider := prioritySet[normalized]; prioritizedProvider {
if _, exists := prioritized[normalized]; !exists {
prioritized[normalized] = ref
}
continue
}
fallback = append(fallback, ref)
}
ordered := make([]sourceReference, 0, len(prioritized)+len(fallback))
for _, provider := range priorityOrder {
if ref, ok := prioritized[provider]; ok {
ordered = append(ordered, ref)
}
}
ordered = append(ordered, fallback...)
return ordered
}
func (c *AllAnimeProvider) graphqlRequestWithHash(ctx context.Context, showID, episode, mode string) (map[string]any, error) {
req, err := newEpisodeHashRequest(ctx, showID, episode, mode)
if err != nil {
return nil, fmt.Errorf("create GET request: %w", err)
}
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")
statusCode, respBody, err := executeAndReadResponse(c.utlsClient, req, "execute GET request", "read response")
if err != nil {
return nil, err
}
if statusCode != http.StatusOK {
return nil, fmt.Errorf("GET status %d: %s", statusCode, string(respBody))
}
parsed, err := parseGraphQLResponse(respBody, "decode response")
if err != nil {
return nil, err
}
data, ok := parsed["data"].(map[string]any)
if !ok {
return nil, fmt.Errorf("no data in response")
}
decrypted, err := responseFromTobeparsed(data)
if err != nil {
return nil, err
}
if decrypted != nil {
return decrypted, nil
}
if hasEpisodeSourceURLs(data) {
return parsed, nil
}
return nil, fmt.Errorf("no usable data in response")
}
func newEpisodeHashRequest(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)
params := url.Values{}
params.Set("variables", varsJSON)
params.Set("extensions", extJSON)
return http.NewRequestWithContext(ctx, http.MethodGet, fmt.Sprintf("%s/api?%s", allAnimeBaseURL, params.Encode()), nil)
}
func detectStreamType(sourceURL string) string {
lower := strings.ToLower(sourceURL)
if strings.Contains(lower, ".m3u8") || strings.Contains(lower, "master.m3u8") {
return "m3u8"
}
if strings.Contains(lower, ".mp4") {
return "mp4"
}
return "unknown"
}
func detectEmbedType(rawURL string) string {
lower := strings.ToLower(rawURL)
embedHosts := []string{"streamwish", "streamsb", "mp4upload", "ok.ru", "gogoplay", "streamlare"}
for _, host := range embedHosts {
if strings.Contains(lower, host) {
return "embed"
}
}
return "unknown"
}