From 1380fda7f7fb8ef0568b389308b585233aac9095 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Wed, 13 May 2026 11:20:47 +0200 Subject: [PATCH] feat: implement allanime provider with utls support --- integrations/playback/allanime/client.go | 733 ++++++++++++++++++ integrations/playback/allanime/client_test.go | 449 +++++++++++ integrations/playback/allanime/extractor.go | 221 ++++++ integrations/playback/allanime/http_utils.go | 26 + integrations/playback/allanime/module.go | 9 + integrations/playback/allanime/types.go | 52 ++ 6 files changed, 1490 insertions(+) create mode 100644 integrations/playback/allanime/client.go create mode 100644 integrations/playback/allanime/client_test.go create mode 100644 integrations/playback/allanime/extractor.go create mode 100644 integrations/playback/allanime/http_utils.go create mode 100644 integrations/playback/allanime/module.go create mode 100644 integrations/playback/allanime/types.go diff --git a/integrations/playback/allanime/client.go b/integrations/playback/allanime/client.go new file mode 100644 index 0000000..a611e2c --- /dev/null +++ b/integrations/playback/allanime/client.go @@ -0,0 +1,733 @@ +package allanime + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "mal/pkg/net/utls" + "net/http" + "net/url" + "strconv" + "strings" + "time" + "mal/internal/domain" +) + +const ( + allAnimeBaseURL = "https://api.allanime.day" + allAnimeReferer = "https://allmanga.to/" + allAnimeOrigin = "https://youtu-chan.com" + defaultUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0" +) + +var ( + aesKeys = []string{"Xot36i3lK3:v1", "SimtVuagFbGR2K7P"} +) + +var allAnimeUTLSClient = &http.Client{ + Transport: &utls.UtlsRoundTripper{}, + Timeout: 30 * time.Second, +} + +type searchResult struct { + ID string + MalID string + Name string +} + +type AvailableEpisodes struct { + Sub []string + Dub []string + Raw []string +} + +type AllAnimeProvider struct { + httpClient *http.Client + extractor *providerExtractor +} + +func NewAllAnimeProvider() *AllAnimeProvider { + return &AllAnimeProvider{ + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + extractor: newProviderExtractor(), + } +} + +func (c *AllAnimeProvider) Name() string { + return "AllAnime" +} + +func (c *AllAnimeProvider) Search(ctx context.Context, query string, mode string) ([]searchResult, error) { + // 1. Search for the show to get its AllAnime ID + graphqlQuery := `query($search: SearchInput, $limit: Int, $page: Int, $translationType: VaildTranslationTypeEnumType, $countryOrigin: VaildCountryOriginEnumType) { + shows(search: $search, limit: $limit, page: $page, translationType: $translationType, countryOrigin: $countryOrigin) { + edges { + _id + malId + name + } + } + }` + + variables := map[string]any{ + "search": map[string]any{ + "allowAdult": false, + "allowUnknown": false, + "query": query, + }, + "limit": 40, + "page": 1, + "translationType": mode, + "countryOrigin": "ALL", + } + + result, err := c.graphqlRequest(ctx, graphqlQuery, variables) + if err != nil { + return nil, err + } + + data, ok := result["data"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid search response") + } + + shows, ok := data["shows"].(map[string]any) + if !ok { + return nil, fmt.Errorf("invalid shows payload") + } + + edges, ok := shows["edges"].([]any) + if !ok { + return nil, fmt.Errorf("invalid search edges") + } + + out := make([]searchResult, 0, len(edges)) + for _, edge := range edges { + item, ok := edge.(map[string]any) + if !ok { + continue + } + + id, _ := item["_id"].(string) + malID, _ := item["malId"].(string) + name, _ := item["name"].(string) + 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, episode string, mode string) (*domain.StreamResult, error) { + // 1. Search for the show to get its AllAnime ID + searchResults, err := c.Search(ctx, fmt.Sprintf("malId:%d", animeID), mode) + if err != nil || len(searchResults) == 0 { + return nil, fmt.Errorf("allanime: show not found for malID %d", animeID) + } + + showID := searchResults[0].ID + + // 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, + } + + for _, sub := range primary.Subtitles { + result.Subtitles = append(result.Subtitles, domain.Subtitle{ + Label: sub.Lang, + URL: sub.URL, + }) + } + + return result, nil +} + +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) + } + + payload := map[string]any{ + "query": query, + "variables": variables, + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, fmt.Errorf("marshal graphql payload: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, allAnimeBaseURL+"/api", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create graphql request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Referer", allAnimeReferer) + req.Header.Set("User-Agent", defaultUserAgent) + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute graphql request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) + if err != nil { + return nil, fmt.Errorf("read graphql response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("graphql status %d", resp.StatusCode) + } + + var parsed map[string]any + if err := json.Unmarshal(respBody, &parsed); err != nil { + return nil, fmt.Errorf("decode graphql response: %w", err) + } + + if errs, ok := parsed["errors"].([]any); ok && len(errs) > 0 { + return nil, fmt.Errorf("graphql error: %v", errs[0]) + } + + 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, err := allAnimeUTLSClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute GET request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1022)) + if err != nil { + return nil, fmt.Errorf("read response: %w", 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 := 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...) + } + + 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 + } + + 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 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" +} diff --git a/integrations/playback/allanime/client_test.go b/integrations/playback/allanime/client_test.go new file mode 100644 index 0000000..7160881 --- /dev/null +++ b/integrations/playback/allanime/client_test.go @@ -0,0 +1,449 @@ +package allanime + +import ( + "context" + "crypto/aes" + "encoding/json" + "testing" + "mal/internal/domain" +) + +func TestDecodeSourceURL(t *testing.T) { + t.Parallel() + + 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", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + got := decodeSourceURL(tt.encoded) + if got != tt.want { + t.Errorf("decodeSourceURL(%q) = %q, want %q", tt.encoded, got, tt.want) + } + }) + } +} + +func TestDetectStreamType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + url string + wantType string + }{ + { + name: "m3u8 extension", + url: "https://example.com/video.m3u8", + wantType: "m3u8", + }, + { + name: "master m3u8", + url: "https://example.com/master.m3u8", + wantType: "m3u8", + }, + { + name: "mp4 extension", + url: "https://example.com/video.mp4", + wantType: "mp4", + }, + { + name: "unknown", + url: "https://example.com/video.avi", + wantType: "unknown", + }, + { + name: "empty returns unknown", + url: "", + wantType: "unknown", + }, + { + name: "case insensitive - M3U8", + url: "https://example.com/MASTER.M3U8", + wantType: "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) + } + }) + } +} + +func TestDetectEmbedType(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + url string + wantType string + }{ + { + name: "streamwish", + url: "https://streamwish.com/e/abc123", + wantType: "embed", + }, + { + name: "streamsb", + url: "https://streamsb.com/e/abc123", + wantType: "embed", + }, + { + name: "mp4upload", + url: "https://mp4upload.com/e/abc123", + wantType: "embed", + }, + { + name: "ok.ru", + url: "https://ok.ru/video/123", + wantType: "embed", + }, + { + name: "gogoplay", + url: "https://gogoplay.io/embed/123", + wantType: "embed", + }, + { + name: "streamlare", + url: "https://streamlare.com/e/abc", + wantType: "embed", + }, + { + name: "unknown host", + url: "https://unknown.com/video", + wantType: "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) + } + }) + } +} + +func TestBuildStreamSource(t *testing.T) { + t.Parallel() + + t.Run("constructs with correct defaults", func(t *testing.T) { + got := buildStreamSource("https://example.com/video.mp4", "mp4", "test-provider") + + if got.URL != "https://example.com/video.mp4" { + t.Errorf("URL = %q, want %q", got.URL, "https://example.com/video.mp4") + } + if got.Provider != "test-provider" { + t.Errorf("Provider = %q, want %q", got.Provider, "test-provider") + } + if got.Type != "mp4" { + t.Errorf("Type = %q, want %q", got.Type, "mp4") + } + if got.Referer != allAnimeReferer { + t.Errorf("Referer = %q, want %q", got.Referer, allAnimeReferer) + } + }) +} + +func TestBuildSourceReferences(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + rawURLs []any + wantRefs []sourceReference + }{ + { + name: "empty returns empty", + rawURLs: nil, + wantRefs: nil, + }, + { + name: "filters empty URLs", + rawURLs: []any{ + map[string]any{"sourceUrl": "", "sourceName": "test"}, + map[string]any{"sourceUrl": "https://example.com/v.mp4", "sourceName": "default"}, + }, + wantRefs: []sourceReference{ + {URL: "https://example.com/v.mp4", Name: "default"}, + }, + }, + { + name: "deduplicates URLs", + rawURLs: []any{ + map[string]any{"sourceUrl": "https://example.com/v.mp4", "sourceName": "test"}, + map[string]any{"sourceUrl": "https://example.com/v.mp4", "sourceName": "test2"}, + }, + wantRefs: []sourceReference{ + {URL: "https://example.com/v.mp4", Name: "test"}, + }, + }, + { + name: "prioritizes default provider", + rawURLs: []any{ + map[string]any{"sourceUrl": "https://a.com/v.mp4", "sourceName": "fallback"}, + map[string]any{"sourceUrl": "https://b.com/v.mp4", "sourceName": "default"}, + map[string]any{"sourceUrl": "https://c.com/v.mp4", "sourceName": "yt-mp4"}, + }, + wantRefs: []sourceReference{ + {URL: "https://b.com/v.mp4", Name: "default"}, + {URL: "https://c.com/v.mp4", Name: "yt-mp4"}, + {URL: "https://a.com/v.mp4", Name: "fallback"}, + }, + }, + { + name: "skips invalid map entries", + rawURLs: []any{ + "invalid", + 123, + map[string]any{"sourceUrl": "https://example.com/v.mp4"}, + }, + wantRefs: []sourceReference{ + {URL: "https://example.com/v.mp4", Name: ""}, + }, + }, + } + + 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 TestBuildSourceReferencesOrder(t *testing.T) { + t.Parallel() + + rawURLs := []any{ + map[string]any{"sourceUrl": "https://s.com/v.mp4", "sourceName": "s-mp4"}, + map[string]any{"sourceUrl": "https://default.com/v.mp4", "sourceName": "default"}, + map[string]any{"sourceUrl": "https://luf.com/v.mp4", "sourceName": "luf-mp4"}, + map[string]any{"sourceUrl": "https://yt.com/v.mp4", "sourceName": "yt-mp4"}, + } + + got := buildSourceReferences(rawURLs) + + wantOrder := []string{"default", "yt-mp4", "s-mp4", "luf-mp4"} + if len(got) != len(wantOrder) { + t.Fatalf("got %d refs, want %d", len(got), len(wantOrder)) + } + + for i, wantName := range wantOrder { + if got[i].Name != wantName { + t.Errorf("ref[%d].Name = %q, want %q (priority order: default > yt-mp4 > s-mp4 > luf-mp4)", i, got[i].Name, wantName) + } + } +} + +func TestIsLikelyM3U8(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + input []byte + want bool + }{ + { + name: "valid m3u8", + input: []byte("#EXTM3U\n#EXT-X-VERSION:3"), + want: true, + }, + { + name: "with leading spaces", + input: []byte(" #EXTM3U\n"), + want: true, + }, + { + name: "empty", + input: []byte{}, + want: false, + }, + { + name: "not m3u8", + input: []byte(" 0 { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(time.Duration(attempt) * 2 * time.Second): + } + } + + resp, err = doProxiedRequest(ctx, e.httpClient, endpoint, e.referer) + if err == nil { + break + } + + if attempt == 2 { + return nil, fmt.Errorf("fetch provider response: %w", err) + } + } + + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 2*1024*1024)) // 2MB limit + if err != nil { + return nil, fmt.Errorf("read provider response: %w", err) + } + + return e.parseProviderResponse(ctx, string(body)), 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 + + // extract per-source referer if present + refererPattern := regexp.MustCompile(`"Referer":"([^"]+)"`) + if match := refererPattern.FindStringSubmatch(response); len(match) >= 2 { + providerReferer = strings.ReplaceAll(match[1], `\/`, "/") + } + if providerReferer == "" { + providerReferer = e.referer + } + + // extract direct link sources (mp4/embed) + linkPattern := regexp.MustCompile(`"link":"([^"]+)","resolutionStr":"([^"]+)"`) + for _, match := range linkPattern.FindAllStringSubmatch(response, -1) { + if len(match) < 3 { + continue + } + + link := strings.ReplaceAll(match[1], `\/`, "/") + quality := strings.TrimSpace(match[2]) + sourceType := detectStreamType(link) + if sourceType == "unknown" { + sourceType = detectEmbedType(link) + } + + sources = append(sources, StreamSource{ + URL: link, + Quality: quality, + Provider: "wixmp", + Type: sourceType, + Referer: providerReferer, + }) + } + + // extract HLS playlist sources + hlsPattern := regexp.MustCompile(`"url":"([^"]+)","hardsub_lang":"en-US"`) + for _, match := range hlsPattern.FindAllStringSubmatch(response, -1) { + if len(match) < 2 { + continue + } + + playlistURL := strings.ReplaceAll(match[1], `\/`, "/") + if strings.Contains(playlistURL, "master.m3u8") { + parsed, err := e.parseM3U8(ctx, playlistURL, providerReferer) + if err == nil { + sources = append(sources, parsed...) + } + continue + } + + sources = append(sources, StreamSource{ + URL: playlistURL, + Quality: "auto", + Provider: "hls", + Type: "m3u8", + Referer: providerReferer, + }) + } + + // extract subtitles and attach to all sources + subtitlePattern := regexp.MustCompile(`"subtitles":\[(.*?)\]`) + if subtitleMatch := subtitlePattern.FindStringSubmatch(response); len(subtitleMatch) >= 2 { + subtitles := make([]Subtitle, 0) + subtitleEntryPattern := regexp.MustCompile(`"lang":"([^"]+)".*?"src":"([^"]+)"`) + for _, entry := range subtitleEntryPattern.FindAllStringSubmatch(subtitleMatch[1], -1) { + if len(entry) < 3 { + continue + } + + subtitles = append(subtitles, Subtitle{ + Lang: strings.TrimSpace(entry[1]), + URL: strings.ReplaceAll(entry[2], `\/`, "/"), + }) + } + + if len(subtitles) > 0 { + for idx := range sources { + sources[idx].Subtitles = subtitles + } + } + } + + return sources +} + +// parseM3U8 fetches a master playlist and extracts individual stream URLs with bandwidth-derived quality. +func (e *providerExtractor) parseM3U8(ctx context.Context, masterURL string, referer string) ([]StreamSource, error) { + resp, err := doProxiedRequest(ctx, e.httpClient, masterURL, referer) + if err != nil { + return nil, err + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 512*1024)) // 512KB limit + if err != nil { + return nil, err + } + + lines := strings.Split(string(body), "\n") + baseURL := masterURL + if idx := strings.LastIndex(masterURL, "/"); idx >= 0 { + baseURL = masterURL[:idx+1] + } + + 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 + } + } + continue + } + + // skip empty lines and non-stream lines + if trimmed == "" || strings.HasPrefix(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, + Provider: "hls", + Type: "m3u8", + Referer: referer, + }) + } + + return sources, nil +} diff --git a/integrations/playback/allanime/http_utils.go b/integrations/playback/allanime/http_utils.go new file mode 100644 index 0000000..342b551 --- /dev/null +++ b/integrations/playback/allanime/http_utils.go @@ -0,0 +1,26 @@ +package allanime + +import ( + "context" + "net/http" +) + +// doProxiedRequest performs an HTTP GET with standard playback headers. +func doProxiedRequest(ctx context.Context, client *http.Client, url string, referer string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("User-Agent", defaultUserAgent) + if referer != "" { + req.Header.Set("Referer", referer) + } + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + + return resp, nil +} diff --git a/integrations/playback/allanime/module.go b/integrations/playback/allanime/module.go new file mode 100644 index 0000000..2d94809 --- /dev/null +++ b/integrations/playback/allanime/module.go @@ -0,0 +1,9 @@ +package allanime + +import ( + "go.uber.org/fx" +) + +var Module = fx.Options( + fx.Provide(NewAllAnimeProvider), +) diff --git a/integrations/playback/allanime/types.go b/integrations/playback/allanime/types.go new file mode 100644 index 0000000..9b76d50 --- /dev/null +++ b/integrations/playback/allanime/types.go @@ -0,0 +1,52 @@ +package allanime + +// StreamSource represents a video stream from a provider. +type StreamSource struct { + URL string + Quality string + Provider string + Type string // m3u8, mp4, embed, unknown + Referer string + Subtitles []Subtitle + AvailableQualities []StreamSource +} + +type Subtitle struct { + Lang string + URL string +} + +type ModeSource struct { + URL string `json:"url,omitempty"` + Referer string `json:"referer,omitempty"` + Token string `json:"token"` + Subtitles []SubtitleItem `json:"subtitles"` + Qualities []string `json:"qualities,omitempty"` +} + +type SubtitleItem struct { + Lang string `json:"lang"` + URL string `json:"url,omitempty"` + Referer string `json:"referer,omitempty"` + Token string `json:"token"` +} + +type SkipSegment struct { + Type string `json:"type"` + Start float64 `json:"start"` + End float64 `json:"end"` +} + +// WatchPageData is the response payload for the watch page frontend. +type WatchPageData struct { + MalID int + Title string + CurrentEpisode string + StartTimeSeconds float64 + CurrentStatus string + InitialMode string + AvailableModes []string + ModeSources map[string]ModeSource + Segments []SkipSegment + FallbackEpisodes map[string]int +}