trim: keep only entrypoint in client.go
This commit is contained in:
@@ -1,22 +1,14 @@
|
|||||||
// Package allanime provides an integration with the AllAnime API for episode playback.
|
|
||||||
package allanime
|
package allanime
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"mal/internal/domain"
|
"mal/internal/domain"
|
||||||
"mal/pkg"
|
|
||||||
netutil "mal/pkg/net"
|
netutil "mal/pkg/net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -28,22 +20,6 @@ const (
|
|||||||
defaultUserAgent = netutil.Firefox121
|
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 {
|
type AllAnimeProvider struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
utlsClient *http.Client
|
utlsClient *http.Client
|
||||||
@@ -67,103 +43,17 @@ func (c *AllAnimeProvider) Name() string {
|
|||||||
return "AllAnime"
|
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) {
|
func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCandidates []string, episode string, mode string) (*domain.StreamResult, error) {
|
||||||
showID := c.resolveShowIDWithFallback(ctx, animeID, titleCandidates, mode)
|
showID := c.resolveShowIDWithFallback(ctx, animeID, titleCandidates, mode)
|
||||||
if showID == "" {
|
if showID == "" {
|
||||||
return nil, fmt.Errorf("allanime: show not found for malID %d", animeID)
|
return nil, fmt.Errorf("allanime: show not found for malID %d", animeID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Get sources
|
|
||||||
sources, err := c.GetEpisodeSources(ctx, showID, episode, mode)
|
sources, err := c.GetEpisodeSources(ctx, showID, episode, mode)
|
||||||
if err != nil || len(sources) == 0 {
|
if err != nil || len(sources) == 0 {
|
||||||
return nil, fmt.Errorf("allanime: no sources for show %s", showID)
|
return nil, fmt.Errorf("allanime: no sources for show %s", showID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Return the first usable source
|
|
||||||
primary := sources[0]
|
primary := sources[0]
|
||||||
|
|
||||||
result := &domain.StreamResult{
|
result := &domain.StreamResult{
|
||||||
@@ -181,95 +71,6 @@ func (c *AllAnimeProvider) GetStreams(ctx context.Context, animeID int, titleCan
|
|||||||
return result, nil
|
return result, 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) 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) {
|
func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, variables map[string]any) (map[string]any, error) {
|
||||||
if mode, ok := variables["translationType"].(string); ok {
|
if mode, ok := variables["translationType"].(string); ok {
|
||||||
variables["translationType"] = strings.ToLower(mode)
|
variables["translationType"] = strings.ToLower(mode)
|
||||||
@@ -315,322 +116,6 @@ func (c *AllAnimeProvider) graphqlRequest(ctx context.Context, query string, var
|
|||||||
return parsed, nil
|
return parsed, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
const episodeQueryHash = "d405d0edd690624b66baba3068e0edc3ac90f1597d898a1ec8db4e5c43c00fec"
|
|
||||||
|
|
||||||
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 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 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 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 {
|
|
||||||
if source, ok := resolveDirectSource(ref); ok {
|
|
||||||
out = append(out, source)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
extracted := c.resolveExtractedSources(ctx, ref)
|
|
||||||
out = append(out, extracted...)
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveDirectSource(ref sourceReference) (StreamSource, bool) {
|
|
||||||
target := strings.TrimSpace(ref.URL)
|
|
||||||
if target == "" {
|
|
||||||
return StreamSource{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
if isHTTPURL(target) {
|
|
||||||
return buildStreamSource(target, detectSourceType(target), ref.Name), true
|
|
||||||
}
|
|
||||||
|
|
||||||
decoded := decodeSourceURL(target)
|
|
||||||
if !isHTTPURL(decoded) {
|
|
||||||
return StreamSource{}, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return buildStreamSource(decoded, detectSourceType(decoded), ref.Name), true
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *AllAnimeProvider) resolveExtractedSources(ctx context.Context, ref sourceReference) []StreamSource {
|
|
||||||
decoded := decodeSourceURL(strings.TrimSpace(ref.URL))
|
|
||||||
if decoded == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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 executeAndReadResponse(client *http.Client, req *http.Request, executeErrPrefix string, readErrPrefix string) (int, []byte, error) {
|
func executeAndReadResponse(client *http.Client, req *http.Request, executeErrPrefix string, readErrPrefix string) (int, []byte, error) {
|
||||||
resp, err := client.Do(req)
|
resp, err := client.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -645,254 +130,3 @@ func executeAndReadResponse(client *http.Client, req *http.Request, executeErrPr
|
|||||||
|
|
||||||
return resp.StatusCode, body, nil
|
return resp.StatusCode, 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
|
|
||||||
return AvailableEpisodes{
|
|
||||||
Sub: stringSliceFromAny(detail["sub"]),
|
|
||||||
Dub: stringSliceFromAny(detail["dub"]),
|
|
||||||
Raw: stringSliceFromAny(detail["raw"]),
|
|
||||||
}, 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"
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user