From 296954c0aa144a449a16c624fa3d47f4c58033f1 Mon Sep 17 00:00:00 2001 From: itsmenewbie03 <2101102516@student.buksu.edu.ph> Date: Fri, 25 Sep 2026 14:20:36 +0800 Subject: [PATCH] feat(anikoto): add native provider Port current Tachiyomi flow with VRF catalog and MegaPlay extraction. Skip next-episode prefetch because signed stream URLs expire quickly. --- README.md | 1 + docs/providers.md | 4 +- internal/curd.go | 7 + internal/loadproviders/load.go | 1 + internal/localTracking.go | 8 + internal/mpv_playlist.go | 8 + internal/provider.go | 5 + internal/provider_disabled_test.go | 4 +- internal/provider_migrate_test.go | 2 +- internal/provider_stack_test.go | 67 +++++- internal/providers/anikoto/client.go | 79 +++++++ internal/providers/anikoto/episodes.go | 160 ++++++++++++++ internal/providers/anikoto/htmlutil.go | 56 +++++ internal/providers/anikoto/live_test.go | 132 +++++++++++ internal/providers/anikoto/megaplay.go | 233 ++++++++++++++++++++ internal/providers/anikoto/provider.go | 37 ++++ internal/providers/anikoto/provider_test.go | 221 +++++++++++++++++++ internal/providers/anikoto/register.go | 14 ++ internal/providers/anikoto/search.go | 128 +++++++++++ internal/providers/anikoto/streams.go | 198 +++++++++++++++++ internal/providers/anikoto/types.go | 99 +++++++++ internal/providers/anikoto/vrf.go | 78 +++++++ internal/providers/registry.go | 1 + 23 files changed, 1537 insertions(+), 6 deletions(-) create mode 100644 internal/providers/anikoto/client.go create mode 100644 internal/providers/anikoto/episodes.go create mode 100644 internal/providers/anikoto/htmlutil.go create mode 100644 internal/providers/anikoto/live_test.go create mode 100644 internal/providers/anikoto/megaplay.go create mode 100644 internal/providers/anikoto/provider.go create mode 100644 internal/providers/anikoto/provider_test.go create mode 100644 internal/providers/anikoto/register.go create mode 100644 internal/providers/anikoto/search.go create mode 100644 internal/providers/anikoto/streams.go create mode 100644 internal/providers/anikoto/types.go create mode 100644 internal/providers/anikoto/vrf.go diff --git a/README.md b/README.md index 66311e4..05f89b4 100644 --- a/README.md +++ b/README.md @@ -428,6 +428,7 @@ If the browser reaches the localhost callback page but curd does not continue au - [AniPub](https://anipub.xyz/) - Fast JSON catalog APIs with MegaPlay HLS streams - [AniNeko Content](https://anineko.to/) - Alternative provider with soft/hard sub stream selection - [Animepahe Content](https://animepahe.pw/) - Alternative provider for 1080p streams +- [Anikoto](https://anikototv.to/) - VRF-protected catalog with native MegaPlay stream resolution - [Jikan](https://jikan.moe/) - Get filler episode number ## Credits diff --git a/docs/providers.md b/docs/providers.md index 95250a3..832aed0 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -36,7 +36,7 @@ This document explains the intent of the design, how to add a provider, how user │ internal/providers/ │ │ registry · types · Provider interface │ ├─────────────────────────────────────────────────────────┤ -│ allanime/ animepahe/ yourprovider/ │ +│ allanime/ animepahe/ anikoto/ yourprovider/ │ │ search · episodes · streams · register.go │ └─────────────────────────────────────────────────────────┘ ``` @@ -170,6 +170,7 @@ func init() { | `Referrer` | Default HTTP Referer for mpv when playing this provider's links. | | `DefaultDisabled` | If true, provider is off until user enables it (see Animepahe). | | `DisableReason` | Shown when a disabled provider is requested. | +| `NoPrefetch` | Skip next-episode link prefetch for providers whose resolved links expire quickly. | | `OptOutToken` | Config token to permanently skip fallback prompts (e.g. `no-animepahe`). | | `FallbackPrompt` | Reserved for host fallback UX (Animepahe chromium warning). | @@ -332,6 +333,7 @@ Document the decision in a short ADR before building. The compile-time registry | AniNeko | `internal/providers/anineko` | AJAX search, HTML scrape, bibiemb/vibeplayer embed resolution, `SubStyle` / `HintResolver` | | AllAnime | `internal/providers/allanime` | GraphQL search/episodes, parallel stream resolution, `HintResolver` | | Animepahe | `internal/providers/animepahe` | DDoS-Guard + rod browser, `IDResolver`, `DefaultDisabled`, `OptOutToken` | +| Anikoto | `internal/providers/anikoto` | VRF-protected HTML/JSON catalog, native server selection, and MegaPlay AES/HMAC stream resolution | Key host files: diff --git a/internal/curd.go b/internal/curd.go index 1963050..b4c3109 100644 --- a/internal/curd.go +++ b/internal/curd.go @@ -1345,9 +1345,16 @@ func StartCurd(userCurdConfig *CurdConfig, anime *Anime) string { nextEpisode := *anime nextEpisode.ProviderId = anime.ProviderId nextEpisode.ProviderName = anime.ProviderName + if providerNoPrefetch(CurrentAnimeProviderName(&nextEpisode)) { + anime.Ep.NextEpisode = NextEpisode{} + return + } nextResult, err := ResolveEpisodeURL(*userCurdConfig, &nextEpisode, nextEpNum) if err != nil { Log(fmt.Sprintf("Error getting next episode link for ep %d: %v", nextEpNum, err)) + } else if providerNoPrefetch(nextResult.ProviderName) { + anime.Ep.NextEpisode = NextEpisode{} + return } else { anime.Ep.NextEpisode = NextEpisode{ Number: nextEpNum, diff --git a/internal/loadproviders/load.go b/internal/loadproviders/load.go index 9012b78..4bafd8f 100644 --- a/internal/loadproviders/load.go +++ b/internal/loadproviders/load.go @@ -3,6 +3,7 @@ package loadproviders import ( _ "github.com/wraient/curd/internal/providers/allanime" + _ "github.com/wraient/curd/internal/providers/anikoto" _ "github.com/wraient/curd/internal/providers/animepahe" _ "github.com/wraient/curd/internal/providers/anineko" _ "github.com/wraient/curd/internal/providers/anipub" diff --git a/internal/localTracking.go b/internal/localTracking.go index bc9bb1c..215f9de 100644 --- a/internal/localTracking.go +++ b/internal/localTracking.go @@ -637,11 +637,19 @@ func prefetchNextUntrackedEpisode(userCurdConfig *CurdConfig, anime *Anime) { nextEpisode := *anime nextEpisode.ProviderId = anime.ProviderId nextEpisode.ProviderName = anime.ProviderName + if providerNoPrefetch(CurrentAnimeProviderName(&nextEpisode)) { + anime.Ep.NextEpisode = NextEpisode{} + return + } nextResult, err := ResolveEpisodeURL(*userCurdConfig, &nextEpisode, nextEpNum) if err != nil { Log(fmt.Sprintf("Error getting next untracked episode link for ep %d: %v", nextEpNum, err)) return } + if providerNoPrefetch(nextResult.ProviderName) { + anime.Ep.NextEpisode = NextEpisode{} + return + } anime.Ep.NextEpisode = NextEpisode{ Number: nextEpNum, Links: nextResult.Links, diff --git a/internal/mpv_playlist.go b/internal/mpv_playlist.go index 33b9cda..98652b4 100644 --- a/internal/mpv_playlist.go +++ b/internal/mpv_playlist.go @@ -1123,11 +1123,19 @@ func (c *MPVPlaylistController) prefetchAfterPlaylistSwitch(currentEp int) { cfg := *c.config cfg.SubOrDub = c.preferredMode next := *c.anime + if providerNoPrefetch(CurrentAnimeProviderName(&next)) { + c.anime.Ep.NextEpisode = NextEpisode{} + return + } result, err := ResolveEpisodeURL(cfg, &next, nextEp) if err != nil || len(result.Links) == 0 { Log(fmt.Sprintf("MPV playlist: prefetch ep %d: %v", nextEp, err)) return } + if providerNoPrefetch(result.ProviderName) { + c.anime.Ep.NextEpisode = NextEpisode{} + return + } c.anime.Ep.NextEpisode = NextEpisode{ Number: nextEp, Links: result.Links, diff --git a/internal/provider.go b/internal/provider.go index 666d2e3..cd3128e 100644 --- a/internal/provider.go +++ b/internal/provider.go @@ -180,6 +180,11 @@ func animepaheDeclinedInConfig(config *CurdConfig) bool { return declined } +func providerNoPrefetch(providerName string) bool { + meta, ok := providers.MetaFor(providerName) + return ok && meta.NoPrefetch +} + func ProviderStackContains(config *CurdConfig, providerName string) bool { providerName = normalizeProviderName(providerName) if providerName == "" { diff --git a/internal/provider_disabled_test.go b/internal/provider_disabled_test.go index b51b5e4..eac2f17 100644 --- a/internal/provider_disabled_test.go +++ b/internal/provider_disabled_test.go @@ -29,11 +29,11 @@ func TestConfiguredProviderNamesFiltersDisabledProviders(t *testing.T) { cfg *CurdConfig want []string }{ - {name: "empty", cfg: &CurdConfig{}, want: []string{"senshi", "anipub", "anineko"}}, + {name: "empty", cfg: &CurdConfig{}, want: []string{"senshi", "anipub", "anineko", "anikoto"}}, {name: "json list", cfg: &CurdConfig{Provider: `["allanime","animepahe"]`}, want: []string{"senshi"}}, {name: "animepahe only", cfg: &CurdConfig{Provider: `["animepahe"]`}, want: []string{"senshi"}}, {name: "allanime only", cfg: &CurdConfig{Provider: `["allanime"]`}, want: []string{"senshi"}}, - {name: "legacy alias", cfg: &CurdConfig{Provider: "stacked"}, want: []string{"senshi", "anipub", "anineko"}}, + {name: "legacy alias", cfg: &CurdConfig{Provider: "stacked"}, want: []string{"senshi", "anipub", "anineko", "anikoto"}}, } for _, tc := range cases { diff --git a/internal/provider_migrate_test.go b/internal/provider_migrate_test.go index 078e32d..5b8fe68 100644 --- a/internal/provider_migrate_test.go +++ b/internal/provider_migrate_test.go @@ -248,7 +248,7 @@ func TestCompareVersionsOrdering(t *testing.T) { func TestConfiguredProviderNamesUsesStackedByDefault(t *testing.T) { withAllProvidersEnabledForTest(t) got := ConfiguredProviderNames(&CurdConfig{}) - want := []string{"senshi", "anipub", "anineko", "allanime", "animepahe"} + want := []string{"senshi", "anipub", "anineko", "allanime", "animepahe", "anikoto"} if len(got) != len(want) { t.Fatalf("got %v, want %v", got, want) } diff --git a/internal/provider_stack_test.go b/internal/provider_stack_test.go index ef3928d..3edcbdf 100644 --- a/internal/provider_stack_test.go +++ b/internal/provider_stack_test.go @@ -87,6 +87,69 @@ func (s *stackStubProviderBridge) GetEpisodeURLForMode(config providers.Playback return s.stackStubProvider.GetEpisodeURLForMode(CurdConfig{SubOrDub: config.SubOrDub}, id, epNo, mode) } +func TestProviderNoPrefetchMetadata(t *testing.T) { + if !providerNoPrefetch("anikoto") { + t.Fatal("expected Anikoto to disable next-episode prefetch") + } + if providerNoPrefetch("senshi") { + t.Fatal("expected Senshi to keep next-episode prefetch") + } +} + +func TestPrefetchNextUntrackedEpisodeSkipsNoPrefetchProvider(t *testing.T) { + provider := &stackStubProvider{name: "anikoto"} + withProviderFactories(t, provider) + anime := &Anime{ + ProviderId: "show-id", + ProviderName: "anikoto", + Ep: Episode{Number: 1}, + } + + prefetchNextUntrackedEpisode(&CurdConfig{}, anime) + + if len(provider.calls) != 0 { + t.Fatalf("no-prefetch provider was called: %#v", provider.calls) + } + if anime.Ep.NextEpisode.Number != 0 || len(anime.Ep.NextEpisode.Links) != 0 { + t.Fatalf("unexpected prefetched episode: %#v", anime.Ep.NextEpisode) + } +} + +func TestPrefetchNextUntrackedEpisodeDiscardsNoPrefetchFallback(t *testing.T) { + senshi := &stackStubProvider{ + name: "senshi", + episodeErrors: map[string]map[string]error{ + "show-id": {"sub": errors.New("provider unavailable")}, + }, + } + anikoto := &stackStubProvider{ + name: "anikoto", + searchResults: map[string][]SelectionOption{ + "sub": {{Key: "show-id", Title: "Example"}}, + }, + episodeResults: map[string]map[string][]string{ + "show-id": {"sub": {"https://cdn.example/short-lived.m3u8"}}, + }, + } + withProviderFactories(t, senshi, anikoto) + config := &CurdConfig{Provider: `["senshi","anikoto"]`} + anime := &Anime{ + Title: AnimeTitle{Romaji: "Example"}, + ProviderId: "show-id", + ProviderName: "senshi", + Ep: Episode{Number: 1}, + } + + prefetchNextUntrackedEpisode(config, anime) + + if len(senshi.calls) == 0 || len(anikoto.calls) == 0 { + t.Fatalf("expected stack fallback during prefetch: senshi=%#v anikoto=%#v", senshi.calls, anikoto.calls) + } + if anime.Ep.NextEpisode.Number != 0 || len(anime.Ep.NextEpisode.Links) != 0 { + t.Fatalf("no-prefetch fallback was cached: %#v", anime.Ep.NextEpisode) + } +} + func TestConfiguredProviderNamesAcceptsOrderedLists(t *testing.T) { withAllProvidersEnabledForTest(t) @@ -95,11 +158,11 @@ func TestConfiguredProviderNamesAcceptsOrderedLists(t *testing.T) { cfg *CurdConfig want []string }{ - {name: "empty", cfg: &CurdConfig{}, want: []string{"senshi", "anipub", "anineko", "allanime", "animepahe"}}, + {name: "empty", cfg: &CurdConfig{}, want: []string{"senshi", "anipub", "anineko", "allanime", "animepahe", "anikoto"}}, {name: "json list", cfg: &CurdConfig{Provider: `["allanime","animepahe"]`}, want: []string{"allanime", "animepahe"}}, {name: "comma list", cfg: &CurdConfig{Provider: "animepahe,allanime"}, want: []string{"animepahe", "allanime"}}, {name: "plus list", cfg: &CurdConfig{Provider: "allanime+animepahe"}, want: []string{"allanime", "animepahe"}}, - {name: "legacy alias", cfg: &CurdConfig{Provider: "stacked"}, want: []string{"senshi", "anipub", "anineko", "allanime", "animepahe"}}, + {name: "legacy alias", cfg: &CurdConfig{Provider: "stacked"}, want: []string{"senshi", "anipub", "anineko", "allanime", "animepahe", "anikoto"}}, } for _, tc := range cases { diff --git a/internal/providers/anikoto/client.go b/internal/providers/anikoto/client.go new file mode 100644 index 0000000..7a50287 --- /dev/null +++ b/internal/providers/anikoto/client.go @@ -0,0 +1,79 @@ +package anikoto + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/wraient/curd/internal/curdhost" +) + +const ( + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36" + maxResponseBytes = 16 << 20 +) + +var baseURL = "https://anikototv.to" + +func newRequest(method, rawURL, referer, accept string, ajax bool) (*http.Request, error) { + req, err := http.NewRequest(method, rawURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Referer", referer) + req.Header.Set("Accept", accept) + if ajax { + req.Header.Set("X-Requested-With", "XMLHttpRequest") + } + return req, nil +} + +func fetchResource(rawURL, referer, accept string, ajax bool) ([]byte, error) { + req, err := newRequest(http.MethodGet, rawURL, referer, accept, ajax) + if err != nil { + return nil, err + } + return doRequest(req) +} + +func decodeAjaxResponse(raw []byte, dest ajaxStatusResponse) error { + if err := decodeJSON(raw, dest); err != nil { + return err + } + if status := dest.statusCode(); status != 0 && status != http.StatusOK { + return fmt.Errorf("Anikoto AJAX response returned status %d", status) + } + return nil +} + +func decodeJSON(raw []byte, dest any) error { + if err := json.Unmarshal(raw, dest); err != nil { + return fmt.Errorf("parse Anikoto response: %w", err) + } + return nil +} + +func doRequest(req *http.Request) ([]byte, error) { + client := curdhost.HTTPClient() + if client == nil { + return nil, fmt.Errorf("Anikoto HTTP client is not configured") + } + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes+1)) + if err != nil { + return nil, err + } + if len(raw) > maxResponseBytes { + return nil, fmt.Errorf("Anikoto response is too large") + } + if !curdhost.HTTPStatusOK(resp.StatusCode) { + return nil, curdhost.HTTPStatusError("Anikoto request", resp.StatusCode, raw) + } + return raw, nil +} diff --git a/internal/providers/anikoto/episodes.go b/internal/providers/anikoto/episodes.go new file mode 100644 index 0000000..86e8747 --- /dev/null +++ b/internal/providers/anikoto/episodes.go @@ -0,0 +1,160 @@ +package anikoto + +import ( + "fmt" + "net/url" + "sort" + "strconv" + "strings" + + "github.com/wraient/curd/internal/providers" +) + +func episodesList(showID, mode string) ([]string, error) { + _, _, err := parseShowID(showID) + if err != nil { + return nil, err + } + mode = providers.NormalizeTranslationType(mode) + episodes, err := fetchEpisodeMetadata(showID) + if err != nil { + return nil, err + } + numbers := make([]int, 0, len(episodes)) + seen := make(map[int]struct{}) + for _, episode := range episodes { + if !episodeAvailableForMode(episode, mode) { + continue + } + if _, exists := seen[episode.Number]; exists { + continue + } + seen[episode.Number] = struct{}{} + numbers = append(numbers, episode.Number) + } + sort.Ints(numbers) + result := make([]string, 0, len(numbers)) + for _, number := range numbers { + result = append(result, strconv.Itoa(number)) + } + if len(result) == 0 { + return nil, fmt.Errorf("no Anikoto %s episodes found", mode) + } + return result, nil +} + +func fetchEpisodeMetadata(showID string) ([]episodeMetadata, error) { + slug, animeID, err := parseShowID(showID) + if err != nil { + return nil, err + } + if animeID == 0 { + animeID, err = resolveAnimeID(slug) + if err != nil { + return nil, err + } + } + endpoint := fmt.Sprintf("%s/ajax/episode/list/%d?vrf=%s", baseURL, animeID, encryptVRF(strconv.Itoa(animeID))) + referer := watchURL(slug, 0) + raw, err := fetchResource(endpoint, referer, "application/json, text/javascript, */*; q=0.01", true) + if err != nil { + return nil, err + } + var response ajaxHTMLResponse + if err := decodeAjaxResponse(raw, &response); err != nil { + return nil, err + } + episodes := parseEpisodeMetadata(response.Result) + if len(episodes) == 0 { + return nil, fmt.Errorf("no Anikoto episodes found for %q", slug) + } + return episodes, nil +} + +func parseEpisodeMetadata(body string) []episodeMetadata { + episodes := make([]episodeMetadata, 0) + seen := make(map[int]struct{}) + for _, tag := range htmlTagPattern.FindAllString(body, -1) { + if !strings.HasPrefix(strings.ToLower(tag), " 0 { + return animeID, nil + } + } + } + return 0, fmt.Errorf("Anikoto anime ID not found for %q", slug) +} + +func episodeForNumber(showID string, number int) (episodeMetadata, error) { + episodes, err := fetchEpisodeMetadata(showID) + if err != nil { + return episodeMetadata{}, err + } + for _, episode := range episodes { + if episode.Number == number { + return episode, nil + } + } + return episodeMetadata{}, fmt.Errorf("Anikoto episode %d not found", number) +} + +func episodeAvailableForMode(episode episodeMetadata, mode string) bool { + if mode == "dub" { + return episode.Dub + } + return episode.Sub +} + +func watchURL(slug string, episode int) string { + path := fmt.Sprintf("%s/watch/%s", strings.TrimRight(baseURL, "/"), url.PathEscape(slug)) + if episode > 0 { + path += fmt.Sprintf("/ep-%d", episode) + } + return path +} diff --git a/internal/providers/anikoto/htmlutil.go b/internal/providers/anikoto/htmlutil.go new file mode 100644 index 0000000..3333e66 --- /dev/null +++ b/internal/providers/anikoto/htmlutil.go @@ -0,0 +1,56 @@ +package anikoto + +import ( + "html" + "regexp" + "strings" +) + +var ( + htmlTagPattern = regexp.MustCompile(`(?is)<[a-z][^>]*>`) + htmlAttrPattern = regexp.MustCompile(`([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))`) + htmlBreakPattern = regexp.MustCompile(`(?i)`) + htmlAnyTagPattern = regexp.MustCompile(`(?is)<[^>]+>`) +) + +func parseHTMLAttrs(tag string) map[string]string { + attrs := make(map[string]string) + for _, match := range htmlAttrPattern.FindAllStringSubmatch(tag, -1) { + value := match[2] + if value == "" { + value = match[3] + } + if value == "" { + value = match[4] + } + attrs[strings.ToLower(match[1])] = html.UnescapeString(value) + } + return attrs +} + +func htmlHasClass(attrs map[string]string, class string) bool { + for _, value := range strings.Fields(attrs["class"]) { + if value == class { + return true + } + } + return false +} + +func cleanHTMLText(value string) string { + value = htmlBreakPattern.ReplaceAllString(value, "\n") + value = htmlAnyTagPattern.ReplaceAllString(value, " ") + value = html.UnescapeString(value) + return strings.Join(strings.Fields(value), " ") +} + +func absoluteAnikotoURL(rawURL string) string { + rawURL = strings.TrimSpace(rawURL) + if rawURL == "" { + return "" + } + if strings.HasPrefix(rawURL, "http://") || strings.HasPrefix(rawURL, "https://") { + return rawURL + } + return baseURL + "/" + strings.TrimLeft(rawURL, "/") +} diff --git a/internal/providers/anikoto/live_test.go b/internal/providers/anikoto/live_test.go new file mode 100644 index 0000000..574bb55 --- /dev/null +++ b/internal/providers/anikoto/live_test.go @@ -0,0 +1,132 @@ +package anikoto + +import ( + "io" + "net/http" + "net/url" + "os" + "strings" + "testing" + + "github.com/wraient/curd/internal/curdhost" + "github.com/wraient/curd/internal/providers" +) + +func TestLiveAnikotoProviderFlow(t *testing.T) { + if os.Getenv("CURD_LIVE_ANIKOTO") == "" { + t.Skip("set CURD_LIVE_ANIKOTO=1 to run the live Anikoto flow") + } + previousClient := curdhost.HTTPClient + curdhost.HTTPClient = func() *http.Client { return http.DefaultClient } + t.Cleanup(func() { curdhost.HTTPClient = previousClient }) + + provider := &Provider{} + results, err := provider.SearchAnime("frieren", "sub") + if err != nil || len(results) == 0 { + t.Fatalf("search Anikoto: results=%d err=%v", len(results), err) + } + selected := -1 + for index, result := range results { + if strings.EqualFold(result.Title, "Frieren: Beyond Journey's End") { + selected = index + break + } + } + if selected < 0 { + t.Fatalf("main Frieren result not found in %#v", results) + } + episodes, err := provider.EpisodesList(results[selected].Key, "sub") + if err != nil || len(episodes) == 0 { + t.Fatalf("list Anikoto episodes: episodes=%d err=%v", len(episodes), err) + } + links, hints, err := provider.GetEpisodeURLForModeWithHints(providers.PlaybackConfig{SubOrDub: "sub"}, results[selected].Key, 1, "sub") + if err != nil || len(links) == 0 { + t.Fatalf("resolve Anikoto stream: links=%d err=%v", len(links), err) + } + + link := links[0] + hint := hints[link] + req, err := http.NewRequest(http.MethodGet, link, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Referer", hint.Referrer) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("fetch Anikoto master: %v", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + t.Fatalf("read Anikoto master: %v", err) + } + if resp.StatusCode != http.StatusOK || !strings.HasPrefix(strings.TrimSpace(string(body)), "#EXTM3U") { + t.Fatalf("unexpected Anikoto master status=%d body=%q", resp.StatusCode, body) + } + + fetch := func(rawURL, referer, byteRange string) ([]byte, int) { + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Referer", referer) + if byteRange != "" { + req.Header.Set("Range", byteRange) + } + response, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("fetch Anikoto media %s: %v", rawURL, err) + } + defer response.Body.Close() + payload, err := io.ReadAll(io.LimitReader(response.Body, maxResponseBytes)) + if err != nil { + t.Fatalf("read Anikoto media %s: %v", rawURL, err) + } + return payload, response.StatusCode + } + + masterURL, err := url.Parse(link) + if err != nil { + t.Fatal(err) + } + variantPath := firstAnikotoMediaPath(string(body)) + if variantPath == "" { + t.Fatal("Anikoto master did not contain a variant") + } + variantRef, err := url.Parse(variantPath) + if err != nil { + t.Fatal(err) + } + variantBody, status := fetch(masterURL.ResolveReference(variantRef).String(), hint.Referrer, "") + if status != http.StatusOK || !strings.HasPrefix(strings.TrimSpace(string(variantBody)), "#EXTM3U") { + t.Fatalf("unexpected Anikoto variant status=%d body=%q", status, variantBody) + } + segmentPath := firstAnikotoMediaPath(string(variantBody)) + if segmentPath == "" { + t.Fatal("Anikoto variant did not contain a segment") + } + segmentRef, err := url.Parse(segmentPath) + if err != nil { + t.Fatal(err) + } + _, status = fetch(masterURL.ResolveReference(segmentRef).String(), hint.Referrer, "bytes=0-1") + if status != http.StatusOK && status != http.StatusPartialContent { + t.Fatalf("unexpected Anikoto segment status=%d", status) + } + if hint.Subtitle != "" { + _, status = fetch(hint.Subtitle, hint.Referrer, "bytes=0-1") + if status != http.StatusOK && status != http.StatusPartialContent { + t.Fatalf("unexpected Anikoto subtitle status=%d", status) + } + } +} + +func firstAnikotoMediaPath(manifest string) string { + for _, line := range strings.Split(manifest, "\n") { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + return line + } + } + return "" +} diff --git a/internal/providers/anikoto/megaplay.go b/internal/providers/anikoto/megaplay.go new file mode 100644 index 0000000..ac085c0 --- /dev/null +++ b/internal/providers/anikoto/megaplay.go @@ -0,0 +1,233 @@ +package anikoto + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "net/url" + "regexp" + "strings" + "time" +) + +const ( + megaPlayAESKey = "i?LMTAx0Q6,:}50U" + megaPlayAESIV = "W0;27ToaUpl_P%'c" + megaPlayTokenSecret = "MpCdnT0k3n!9f2K#xQ7vL5mR8wN1pY4s" +) + +var ( + megaPlayMediaIDPattern = regexp.MustCompile(`(?i)data-id\s*=\s*["']([^"']+)["']`) + megaPlayFileIDPattern = regexp.MustCompile(`(?i)File\s+(\d+)`) + megaPlayFilePattern = regexp.MustCompile(`(?i)"file"\s*:\s*"([^"]+)"`) + megaPlayURLPattern = regexp.MustCompile(`https?://[^"\s]+`) + megaPlayPathKeyPattern = regexp.MustCompile(`(?i)/([a-f0-9]{32})/([a-f0-9]{32})/`) +) + +func resolveMegaPlay(embedURL, mode string) (resolvedStream, error) { + embedURL = strings.TrimSpace(embedURL) + parsed, err := url.Parse(embedURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return resolvedStream{}, fmt.Errorf("invalid Anikoto MegaPlay URL %q", embedURL) + } + + page, err := fetchResource(embedURL, baseURL+"/", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", true) + if err != nil { + return resolvedStream{}, err + } + mediaID := "" + if mediaIDMatch := megaPlayMediaIDPattern.FindSubmatch(page); len(mediaIDMatch) > 1 { + mediaID = strings.TrimSpace(string(mediaIDMatch[1])) + } + if mediaID == "" { + if mediaIDMatch := megaPlayFileIDPattern.FindSubmatch(page); len(mediaIDMatch) > 1 { + mediaID = strings.TrimSpace(string(mediaIDMatch[1])) + } + } + if mediaID == "" { + return resolvedStream{}, fmt.Errorf("Anikoto MegaPlay media ID not found") + } + + endpoint := *parsed + endpoint.Path = "/stream/getSources" + endpoint.RawPath = "" + query := endpoint.Query() + query.Set("id", mediaID) + endpoint.RawQuery = query.Encode() + raw, err := fetchResource(endpoint.String(), embedURL, "application/json,*/*", true) + if err != nil { + return resolvedStream{}, err + } + var payload megaPlaySourcesResponse + if err := decodeJSON(raw, &payload); err != nil { + return resolvedStream{}, err + } + streamURL, err := resolveMegaPlaySource(payload) + if err != nil { + return resolvedStream{}, err + } + referrer := parsed.Scheme + "://" + parsed.Host + "/" + return resolvedStream{ + URL: streamURL, + Referrer: referrer, + Subtitle: pickMegaPlaySubtitle(payload.Tracks, mode), + }, nil +} + +func resolveMegaPlaySource(payload megaPlaySourcesResponse) (string, error) { + source := strings.TrimSpace(string(payload.Sources)) + decrypted := false + if strings.TrimSpace(payload.Enc) != "" { + plain, err := decryptMegaPlaySource(payload.Enc) + if err != nil { + if source == "" { + return "", fmt.Errorf("decrypt Anikoto MegaPlay source: %w", err) + } + } else if file := extractMegaPlayFile(plain); file != "" { + source = file + decrypted = true + } + } + if source == "" { + return "", fmt.Errorf("Anikoto MegaPlay source is missing") + } + if !decrypted { + return source, nil + } + return signMegaPlayURL(source) +} + +func decryptMegaPlaySource(encoded string) (string, error) { + data, err := decodeMegaPlayBase64(encoded) + if err != nil { + return "", err + } + if len(data) == 0 || len(data)%aes.BlockSize != 0 { + return "", fmt.Errorf("encrypted Anikoto MegaPlay source has invalid length %d", len(data)) + } + key := make([]byte, 32) + copy(key, []byte(megaPlayAESKey)) + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + decrypted := make([]byte, len(data)) + cipher.NewCBCDecrypter(block, []byte(megaPlayAESIV)).CryptBlocks(decrypted, data) + unpadded, err := unpadPKCS7(decrypted, aes.BlockSize) + if err != nil { + return "", err + } + return string(unpadded), nil +} + +func unpadPKCS7(data []byte, blockSize int) ([]byte, error) { + if len(data) == 0 { + return nil, fmt.Errorf("empty Anikoto MegaPlay plaintext") + } + padding := int(data[len(data)-1]) + if padding == 0 || padding > blockSize || padding > len(data) { + return nil, fmt.Errorf("invalid Anikoto MegaPlay padding") + } + for _, value := range data[len(data)-padding:] { + if int(value) != padding { + return nil, fmt.Errorf("invalid Anikoto MegaPlay padding") + } + } + return data[:len(data)-padding], nil +} + +func decodeMegaPlayBase64(encoded string) ([]byte, error) { + encoded = strings.TrimSpace(strings.ReplaceAll(strings.ReplaceAll(encoded, "-", "+"), "_", "/")) + encodings := []*base64.Encoding{base64.RawStdEncoding, base64.StdEncoding, base64.RawURLEncoding, base64.URLEncoding} + var lastErr error + for _, encoding := range encodings { + data, err := encoding.DecodeString(encoded) + if err == nil { + return data, nil + } + lastErr = err + } + return nil, lastErr +} + +func extractMegaPlayFile(plain string) string { + plain = strings.TrimSpace(plain) + if strings.HasPrefix(plain, "http://") || strings.HasPrefix(plain, "https://") { + return plain + } + var object struct { + File string `json:"file"` + } + if err := json.Unmarshal([]byte(plain), &object); err == nil && strings.TrimSpace(object.File) != "" { + return strings.TrimSpace(object.File) + } + if match := megaPlayFilePattern.FindStringSubmatch(plain); len(match) > 1 { + return strings.TrimSpace(match[1]) + } + if match := megaPlayURLPattern.FindString(plain); match != "" { + return strings.TrimSpace(match) + } + return "" +} + +func signMegaPlayURL(rawURL string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", fmt.Errorf("invalid Anikoto MegaPlay stream URL %q", rawURL) + } + query := parsed.Query() + if query.Get("token") != "" { + return parsed.String(), nil + } + match := megaPlayPathKeyPattern.FindStringSubmatch(parsed.Path) + if len(match) < 3 { + return parsed.String(), nil + } + payload := fmt.Sprintf("%d|%s/%s", time.Now().Unix()+90, strings.ToLower(match[1]), strings.ToLower(match[2])) + mac := hmac.New(sha256.New, []byte(megaPlayTokenSecret)) + _, _ = mac.Write([]byte(payload)) + token := base64.RawURLEncoding.EncodeToString([]byte(payload)) + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)) + query.Set("token", token) + parsed.RawQuery = query.Encode() + return parsed.String(), nil +} + +func pickMegaPlaySubtitle(tracks []megaPlayTrack, mode string) string { + if mode == "dub" { + return "" + } + fallback := "" + for _, track := range tracks { + file := strings.TrimSpace(track.File) + if file == "" || !strings.EqualFold(strings.TrimSpace(track.Kind), "captions") { + continue + } + label := strings.ToLower(strings.TrimSpace(track.Label)) + if track.Default && (label == "" || strings.Contains(label, "english") || strings.Contains(label, "eng")) { + return file + } + if fallback == "" { + fallback = file + } + } + for _, track := range tracks { + label := strings.ToLower(strings.TrimSpace(track.Label)) + if strings.TrimSpace(track.File) != "" && strings.EqualFold(strings.TrimSpace(track.Kind), "captions") && strings.Contains(label, "english") { + return strings.TrimSpace(track.File) + } + } + return fallback +} + +func isMegaPlayURL(rawURL string) bool { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return false + } + host := strings.ToLower(parsed.Hostname()) + return strings.HasPrefix(host, "megaplay.") || strings.Contains(strings.ToLower(parsed.Path), "/stream/") +} diff --git a/internal/providers/anikoto/provider.go b/internal/providers/anikoto/provider.go new file mode 100644 index 0000000..6dad13e --- /dev/null +++ b/internal/providers/anikoto/provider.go @@ -0,0 +1,37 @@ +package anikoto + +import "github.com/wraient/curd/internal/providers" + +var ( + _ providers.Provider = (*Provider)(nil) + _ providers.ModeResolver = (*Provider)(nil) + _ providers.HintResolver = (*Provider)(nil) +) + +type Provider struct{} + +func (p *Provider) Name() string { + return "anikoto" +} + +func (p *Provider) SearchAnime(query, mode string) ([]providers.SelectionOption, error) { + return searchAnime(query, mode) +} + +func (p *Provider) EpisodesList(showID, mode string) ([]string, error) { + return episodesList(showID, mode) +} + +func (p *Provider) GetEpisodeURL(config providers.PlaybackConfig, id string, epNo int) ([]string, error) { + links, _, err := p.GetEpisodeURLForModeWithHints(config, id, epNo, config.SubOrDub) + return links, err +} + +func (p *Provider) GetEpisodeURLForMode(config providers.PlaybackConfig, id string, epNo int, mode string) ([]string, error) { + links, _, err := p.GetEpisodeURLForModeWithHints(config, id, epNo, mode) + return links, err +} + +func (p *Provider) GetEpisodeURLForModeWithHints(config providers.PlaybackConfig, id string, epNo int, mode string) ([]string, map[string]providers.StreamPlaybackHint, error) { + return getEpisodeStreamsForMode(id, epNo, mode) +} diff --git a/internal/providers/anikoto/provider_test.go b/internal/providers/anikoto/provider_test.go new file mode 100644 index 0000000..09f73b9 --- /dev/null +++ b/internal/providers/anikoto/provider_test.go @@ -0,0 +1,221 @@ +package anikoto + +import ( + "bytes" + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "regexp" + "strings" + "testing" + + "github.com/wraient/curd/internal/curdhost" +) + +func TestAnikotoProviderFlow(t *testing.T) { + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/filter": + if r.URL.Query().Get("keyword") != "frieren" || r.URL.Query().Get("vrf") == "" { + t.Fatalf("unexpected search query: %s", r.URL.RawQuery) + } + html := `` + _, _ = io.WriteString(w, strings.ReplaceAll(html, "SERVER_URL", server.URL)) + case r.URL.Path == "/watch/fallback-id/ep-1": + _, _ = io.WriteString(w, `
`) + case r.URL.Path == "/ajax/episode/list/6351": + if r.URL.Query().Get("vrf") == "" { + t.Fatal("episode VRF is empty") + } + _ = json.NewEncoder(w).Encode(map[string]any{"status": 200, "result": ` + `}) + case r.URL.Path == "/ajax/server/list": + if r.URL.Query().Get("servers") != "episode-token-1" { + t.Fatalf("unexpected server token %q", r.URL.Query().Get("servers")) + } + _ = json.NewEncoder(w).Encode(map[string]any{"status": 200, "result": ` +
+
    +
  • Vidstream-2
  • +
  • HD-1
  • +
+
    +
  • HD-1
  • +
+
`}) + case r.URL.Path == "/ajax/server": + serverPath := "" + switch r.URL.Query().Get("get") { + case "sub-link": + serverPath = "/stream/s-2/107257/sub" + case "dub-link": + serverPath = "/stream/s-2/107258/dub" + default: + http.Error(w, "unsupported test server", http.StatusBadRequest) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"status": 200, "result": map[string]any{"url": server.URL + serverPath}}) + case strings.HasPrefix(r.URL.Path, "/stream/s-2/") && (strings.HasSuffix(r.URL.Path, "/sub") || strings.HasSuffix(r.URL.Path, "/dub")): + if r.Header.Get("Referer") != server.URL+"/" { + t.Fatalf("unexpected embed referrer %q", r.Header.Get("Referer")) + } + _, _ = io.WriteString(w, `
`) + case r.URL.Path == "/stream/getSources": + if r.URL.Query().Get("id") != "13461" || !strings.HasPrefix(r.Header.Get("Referer"), server.URL+"/stream/s-2/") { + t.Fatalf("unexpected getSources request: id=%q referer=%q", r.URL.Query().Get("id"), r.Header.Get("Referer")) + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "enc": encryptMegaPlayFixture(t, `{"file":"`+server.URL+`/cdn/0123456789abcdef0123456789abcdef/fedcba9876543210fedcba9876543210/master.m3u8"}`), + "tracks": []map[string]any{ + {"file": server.URL + "/sub/eng.vtt", "label": "English", "kind": "captions", "default": true}, + {"file": server.URL + "/sub/forced.vtt", "label": "Forced", "kind": "captions"}, + }, + }) + case strings.HasPrefix(r.URL.Path, "/cdn/") && strings.HasSuffix(r.URL.Path, "/master.m3u8"): + if r.Header.Get("Referer") != server.URL+"/" { + t.Fatalf("signed master requires referrer, got %q", r.Header.Get("Referer")) + } + _, _ = io.WriteString(w, "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1\nindex.m3u8\n") + default: + http.NotFound(w, r) + } + })) + defer server.Close() + + previousBase := baseURL + previousClient := curdhost.HTTPClient + baseURL = server.URL + curdhost.HTTPClient = func() *http.Client { return server.Client() } + t.Cleanup(func() { + baseURL = previousBase + curdhost.HTTPClient = previousClient + }) + + animeID, err := resolveAnimeID("fallback-id") + if err != nil || animeID != 6351 { + t.Fatalf("resolveAnimeID() = %d, %v", animeID, err) + } + + searchResults, err := searchAnime("frieren", "sub") + if err != nil { + t.Fatalf("searchAnime: %v", err) + } + if len(searchResults) != 1 || searchResults[0].Key != "frieren-test-abc#6351" { + t.Fatalf("unexpected search results: %#v", searchResults) + } + + episodes, err := episodesList(searchResults[0].Key, "sub") + if err != nil { + t.Fatalf("episodesList: %v", err) + } + if len(episodes) != 2 || episodes[0] != "1" || episodes[1] != "2" { + t.Fatalf("unexpected episodes: %#v", episodes) + } + dubEpisodes, err := episodesList(searchResults[0].Key, "dub") + if err != nil { + t.Fatalf("episodesList(dub): %v", err) + } + if len(dubEpisodes) != 1 || dubEpisodes[0] != "1" { + t.Fatalf("unexpected dub episodes: %#v", dubEpisodes) + } + + links, hints, err := getEpisodeStreamsForMode(searchResults[0].Key, 1, "sub") + if err != nil { + t.Fatalf("getEpisodeStreamsForMode: %v", err) + } + if len(links) != 1 { + t.Fatalf("unexpected stream links: %#v", links) + } + if !regexp.MustCompile(`[?&]token=`).MatchString(links[0]) { + t.Fatalf("MegaPlay URL was not signed: %s", links[0]) + } + if hints[links[0]].Referrer != server.URL+"/" { + t.Fatalf("unexpected playback referrer %q", hints[links[0]].Referrer) + } + if hints[links[0]].Subtitle != server.URL+"/sub/eng.vtt" { + t.Fatalf("unexpected subtitle %q", hints[links[0]].Subtitle) + } + + req, err := http.NewRequest(http.MethodGet, links[0], nil) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Referer", hints[links[0]].Referrer) + resp, err := server.Client().Do(req) + if err != nil { + t.Fatalf("fetch signed master: %v", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusOK || !strings.HasPrefix(string(body), "#EXTM3U") { + t.Fatalf("unexpected signed master status=%d body=%q", resp.StatusCode, body) + } + + dubLinks, dubHints, err := getEpisodeStreamsForMode(searchResults[0].Key, 1, "dub") + if err != nil { + t.Fatalf("resolve dub stream: %v", err) + } + if len(dubLinks) != 1 || !strings.Contains(dubLinks[0], "token=") { + t.Fatalf("unexpected dub links: %#v", dubLinks) + } + if dubHints[dubLinks[0]].Subtitle != "" { + t.Fatalf("unexpected dub subtitle %q", dubHints[dubLinks[0]].Subtitle) + } +} + +func TestEncryptVRF(t *testing.T) { + if got := encryptVRF("6351"); got != "cE9mNXJ3M0h3b0VNODVnRA%3D%3D" { + t.Fatalf("encryptVRF() = %q", got) + } +} + +func TestResolveMegaPlaySourcePreservesPlainSource(t *testing.T) { + raw := "https://cdn.example/0123456789abcdef0123456789abcdef/fedcba9876543210fedcba9876543210/master.m3u8" + got, err := resolveMegaPlaySource(megaPlaySourcesResponse{Sources: flexibleString(raw)}) + if err != nil { + t.Fatalf("resolveMegaPlaySource: %v", err) + } + if got != raw { + t.Fatalf("plain source changed: %s", got) + } +} + +func TestParseAnikotoServerCandidatesFiltersMode(t *testing.T) { + html := `
  • Vidstream-2
  • HD-1
` + sub := parseServerCandidates(html, "sub") + dub := parseServerCandidates(html, "dub") + if len(sub) != 1 || sub[0].ID != "sub-link" { + t.Fatalf("unexpected sub candidates: %#v", sub) + } + if len(dub) != 1 || dub[0].ID != "dub-link" { + t.Fatalf("unexpected dub candidates: %#v", dub) + } +} + +func encryptMegaPlayFixture(t *testing.T, plaintext string) string { + t.Helper() + key := make([]byte, 32) + copy(key, []byte("i?LMTAx0Q6,:}50U")) + block, err := aes.NewCipher(key) + if err != nil { + t.Fatal(err) + } + iv := []byte("W0;27ToaUpl_P%'c") + padded := pkcs7Pad([]byte(plaintext), 16) + ciphertext := make([]byte, len(padded)) + cipher.NewCBCEncrypter(block, iv).CryptBlocks(ciphertext, padded) + return base64.RawURLEncoding.EncodeToString(ciphertext) +} + +func pkcs7Pad(data []byte, size int) []byte { + padding := size - len(data)%size + return append(data, bytes.Repeat([]byte{byte(padding)}, padding)...) +} diff --git a/internal/providers/anikoto/register.go b/internal/providers/anikoto/register.go new file mode 100644 index 0000000..3f5f86c --- /dev/null +++ b/internal/providers/anikoto/register.go @@ -0,0 +1,14 @@ +package anikoto + +import "github.com/wraient/curd/internal/providers" + +func init() { + providers.Register(providers.Meta{ + Name: "anikoto", + Aliases: []string{"anikoto.tv"}, + Referrer: "https://anikototv.to/", + NoPrefetch: true, + }, func() providers.Provider { + return &Provider{} + }) +} diff --git a/internal/providers/anikoto/search.go b/internal/providers/anikoto/search.go new file mode 100644 index 0000000..4dfeb3b --- /dev/null +++ b/internal/providers/anikoto/search.go @@ -0,0 +1,128 @@ +package anikoto + +import ( + "fmt" + "net/url" + "regexp" + "strconv" + "strings" + + "github.com/wraient/curd/internal/curdhost" + "github.com/wraient/curd/internal/providers" +) + +var ( + anikotoNameAnchorPattern = regexp.MustCompile(`(?is)]*\bclass\s*=\s*["'][^"']*\bname\b[^"']*["'][^>]*)>(.*?)`) + anikotoPosterPattern = regexp.MustCompile(`(?is)]*\bclass\s*=\s*["'][^"']*\bposter\b[^"']*["'][^>]*>`) + anikotoImagePattern = regexp.MustCompile(`(?is)]*>`) + anikotoWatchSlugPattern = regexp.MustCompile(`(?i)/watch/([^/?#]+)`) +) + +func searchAnime(query, mode string) ([]providers.SelectionOption, error) { + query = strings.TrimSpace(query) + if query == "" { + return nil, fmt.Errorf("empty search query") + } + _ = mode + + params := url.Values{} + params.Set("keyword", query) + params.Set("page", "1") + params.Set("vrf", encryptVRFRaw(query)) + raw, err := fetchResource(baseURL+"/filter?"+params.Encode(), baseURL+"/", "text/html,application/xhtml+xml", false) + if err != nil { + return nil, err + } + + options := parseSearchOptions(string(raw)) + if len(options) == 0 { + return nil, fmt.Errorf("no Anikoto results for %q", query) + } + return options, nil +} + +func parseSearchOptions(body string) []providers.SelectionOption { + searchBody := body + for _, location := range htmlTagPattern.FindAllStringIndex(body, -1) { + attrs := parseHTMLAttrs(body[location[0]:location[1]]) + if strings.HasPrefix(strings.ToLower(body[location[0]:location[1]]), "= 0 { + searchBody = searchBody[:navigation] + } + + tags := htmlTagPattern.FindAllStringIndex(searchBody, -1) + itemStarts := make([]int, 0) + for _, location := range tags { + tag := searchBody[location[0]:location[1]] + attrs := parseHTMLAttrs(tag) + if strings.HasPrefix(strings.ToLower(tag), " 0 { + key += "#" + strconv.Itoa(animeID) + } + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + + englishTitle := cleanHTMLText(nameMatch[2]) + japaneseTitle := cleanHTMLText(anchorAttrs["data-jp"]) + title := englishTitle + if curdhost.AnimeNameLanguage != nil && strings.EqualFold(curdhost.AnimeNameLanguage(), "romaji") && japaneseTitle != "" { + title = japaneseTitle + } + if title == "" { + continue + } + + thumbnail := "" + if image := anikotoImagePattern.FindString(block); image != "" { + imageAttrs := parseHTMLAttrs(image) + thumbnail = absoluteAnikotoURL(imageAttrs["data-src"]) + if thumbnail == "" { + thumbnail = absoluteAnikotoURL(imageAttrs["src"]) + } + } + options = append(options, providers.SelectionOption{ + Key: key, + Label: title, + Title: title, + Thumbnail: thumbnail, + }) + } + return options +} diff --git a/internal/providers/anikoto/streams.go b/internal/providers/anikoto/streams.go new file mode 100644 index 0000000..a2e6fd6 --- /dev/null +++ b/internal/providers/anikoto/streams.go @@ -0,0 +1,198 @@ +package anikoto + +import ( + "errors" + "fmt" + "net/url" + "regexp" + "strings" + + "github.com/wraient/curd/internal/providers" +) + +var anikotoListItemPattern = regexp.MustCompile(`(?is)]*>.*?`) + +func getEpisodeStreamsForMode(showID string, epNo int, mode string) ([]string, map[string]providers.StreamPlaybackHint, error) { + slug, _, err := parseShowID(showID) + if err != nil { + return nil, nil, err + } + if epNo <= 0 { + return nil, nil, fmt.Errorf("invalid episode number %d", epNo) + } + mode = providers.NormalizeTranslationType(mode) + episode, err := episodeForNumber(showID, epNo) + if err != nil { + return nil, nil, err + } + if !episodeAvailableForMode(episode, mode) { + return nil, nil, fmt.Errorf("Anikoto episode %d is unavailable in %s mode", epNo, mode) + } + + referer := watchURL(slug, epNo) + candidates, err := fetchServerCandidates(episode.IDs, referer, mode) + if err != nil { + return nil, nil, err + } + links := make([]string, 0, len(candidates)) + hints := make(map[string]providers.StreamPlaybackHint) + seen := make(map[string]struct{}) + var resolutionErrors []error + for _, candidate := range candidates { + embedURL, err := resolveServerCandidate(candidate.ID, referer) + if err != nil { + resolutionErrors = append(resolutionErrors, fmt.Errorf("%s: %w", candidate.Name, err)) + continue + } + var stream resolvedStream + switch { + case isMegaPlayURL(embedURL): + stream, err = resolveMegaPlay(embedURL, mode) + case isDirectM3U8(embedURL): + stream = resolvedStream{URL: embedURL, Referrer: baseURL + "/"} + default: + continue + } + if err != nil || strings.TrimSpace(stream.URL) == "" { + if err != nil { + resolutionErrors = append(resolutionErrors, fmt.Errorf("%s: %w", candidate.Name, err)) + } + continue + } + if _, exists := seen[stream.URL]; exists { + continue + } + seen[stream.URL] = struct{}{} + links = append(links, stream.URL) + hints[stream.URL] = providers.StreamPlaybackHint{Referrer: stream.Referrer, Subtitle: stream.Subtitle} + } + if len(links) == 0 { + if err := errors.Join(resolutionErrors...); err != nil { + return nil, nil, fmt.Errorf("no playable Anikoto %s streams for episode %d: %w", mode, epNo, err) + } + return nil, nil, fmt.Errorf("no playable Anikoto %s streams found for episode %d", mode, epNo) + } + return links, hints, nil +} + +func fetchServerCandidates(episodeIDs, referer, mode string) ([]serverCandidate, error) { + params := url.Values{} + params.Set("servers", episodeIDs) + endpoint := baseURL + "/ajax/server/list?" + params.Encode() + raw, err := fetchResource(endpoint, referer, "application/json, text/javascript, */*; q=0.01", true) + if err != nil { + return nil, err + } + var response ajaxHTMLResponse + if err := decodeAjaxResponse(raw, &response); err != nil { + return nil, err + } + candidates := parseServerCandidates(response.Result, mode) + if len(candidates) == 0 { + return nil, fmt.Errorf("no Anikoto servers found") + } + return candidates, nil +} + +func parseServerCandidates(body, mode string) []serverCandidate { + tags := htmlTagPattern.FindAllStringIndex(body, -1) + typeStarts := make([]int, 0) + typeTags := make([]string, 0) + for _, location := range tags { + tag := body[location[0]:location[1]] + attrs := parseHTMLAttrs(tag) + if strings.HasPrefix(strings.ToLower(tag), "")) + if name == "" { + name = serverID + } + candidates = append(candidates, serverCandidate{Type: serverType, ID: serverID, Name: name}) + } + } + return candidates +} + +func canonicalAnikotoServerType(value string) string { + normalized := strings.NewReplacer("-", "", " ", "", "_", "").Replace(strings.ToLower(strings.TrimSpace(value))) + switch normalized { + case "sub": + return "Sub" + case "hsub": + return "HSub" + case "ssub": + return "SSub" + case "dub": + return "Dub" + case "adub": + return "ADub" + default: + return "" + } +} + +func anikotoServerTypeMatchesMode(serverType, mode string) bool { + if mode == "" { + return true + } + if mode == "dub" { + return serverType == "Dub" || serverType == "ADub" + } + return serverType == "Sub" || serverType == "HSub" || serverType == "SSub" +} + +func resolveServerCandidate(serverID, referer string) (string, error) { + if strings.HasPrefix(serverID, "http://") || strings.HasPrefix(serverID, "https://") { + return serverID, nil + } + params := url.Values{} + params.Set("get", serverID) + endpoint := baseURL + "/ajax/server?" + params.Encode() + raw, err := fetchResource(endpoint, referer, "application/json, text/javascript, */*; q=0.01", true) + if err != nil { + return "", err + } + var response ajaxURLResponse + if err := decodeAjaxResponse(raw, &response); err != nil { + return "", err + } + if strings.TrimSpace(response.Result.URL) == "" { + return "", fmt.Errorf("Anikoto embed URL is missing") + } + return strings.TrimSpace(response.Result.URL), nil +} + +func isDirectM3U8(rawURL string) bool { + parsed, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return false + } + path := strings.ToLower(parsed.Path) + return strings.HasSuffix(path, ".m3u8") && !strings.Contains(path, "/stream/") +} diff --git a/internal/providers/anikoto/types.go b/internal/providers/anikoto/types.go new file mode 100644 index 0000000..ba49211 --- /dev/null +++ b/internal/providers/anikoto/types.go @@ -0,0 +1,99 @@ +package anikoto + +import ( + "bytes" + "encoding/json" +) + +type ajaxHTMLResponse struct { + Status int `json:"status"` + Result string `json:"result"` +} + +type ajaxStatusResponse interface { + statusCode() int +} + +func (r *ajaxHTMLResponse) statusCode() int { + return r.Status +} + +func (r *ajaxURLResponse) statusCode() int { + return r.Status +} + +type ajaxURLResponse struct { + Status int `json:"status"` + Result struct { + URL string `json:"url"` + } `json:"result"` +} + +type episodeMetadata struct { + Number int + IDs string + Sub bool + Dub bool +} + +type serverCandidate struct { + Type string + ID string + Name string +} + +type resolvedStream struct { + URL string + Referrer string + Subtitle string +} + +type megaPlaySourcesResponse struct { + Enc string `json:"enc"` + Sources flexibleString `json:"sources"` + Tracks []megaPlayTrack `json:"tracks"` +} + +type megaPlayTrack struct { + File string `json:"file"` + Label string `json:"label"` + Kind string `json:"kind"` + Default bool `json:"default"` +} + +type flexibleString string + +func (s *flexibleString) UnmarshalJSON(data []byte) error { + data = bytes.TrimSpace(data) + if len(data) == 0 || bytes.Equal(data, []byte("null")) { + *s = "" + return nil + } + if data[0] == '"' { + var value string + if err := json.Unmarshal(data, &value); err != nil { + return err + } + *s = flexibleString(value) + return nil + } + if data[0] == '[' { + var values []json.RawMessage + if err := json.Unmarshal(data, &values); err != nil { + return err + } + if len(values) > 0 { + return s.UnmarshalJSON(values[0]) + } + *s = "" + return nil + } + var object struct { + File string `json:"file"` + } + if err := json.Unmarshal(data, &object); err != nil { + return err + } + *s = flexibleString(object.File) + return nil +} diff --git a/internal/providers/anikoto/vrf.go b/internal/providers/anikoto/vrf.go new file mode 100644 index 0000000..c0857dc --- /dev/null +++ b/internal/providers/anikoto/vrf.go @@ -0,0 +1,78 @@ +package anikoto + +import ( + "encoding/base64" + "net/url" + "strings" +) + +type vrfExchange struct { + from string + to string +} + +var vrfExchanges = []vrfExchange{ + {from: "AP6GeR8H0lwUz1", to: "UAz8Gwl10P6ReH"}, + {from: "1majSlPQd2M5", to: "da1l2jSmP5QM"}, + {from: "CPYvHj09Au3", to: "0jHA9CPYu3v"}, +} + +func encryptVRF(input string) string { + return url.QueryEscape(encryptVRFRaw(input)) +} + +func encryptVRFRaw(input string) string { + value := input + value = exchangeVRF(value, vrfExchanges[0]) + value = rc4Encrypt("ItFKjuWokn4ZpB", value) + value = rc4Encrypt("fOyt97QWFB3", value) + value = exchangeVRF(value, vrfExchanges[1]) + value = exchangeVRF(value, vrfExchanges[2]) + value = reverseString(value) + value = rc4Encrypt("736y1uTJpBLUX", value) + return base64.URLEncoding.EncodeToString([]byte(value)) +} + +func exchangeVRF(input string, exchange vrfExchange) string { + var result strings.Builder + for _, char := range input { + index := strings.IndexRune(exchange.from, char) + if index >= 0 { + result.WriteByte(exchange.to[index]) + } else { + result.WriteRune(char) + } + } + return result.String() +} + +func reverseString(input string) string { + runes := []rune(input) + for left, right := 0, len(runes)-1; left < right; left, right = left+1, right-1 { + runes[left], runes[right] = runes[right], runes[left] + } + return string(runes) +} + +func rc4Encrypt(key string, input string) string { + var state [256]byte + for index := range state { + state[index] = byte(index) + } + keyBytes := []byte(key) + j := 0 + for index := 0; index < len(state); index++ { + j = (j + int(state[index]) + int(keyBytes[index%len(keyBytes)])) % 256 + state[index], state[j] = state[j], state[index] + } + result := make([]byte, len(input)) + i := 0 + j = 0 + for index := range input { + i = (i + 1) % 256 + j = (j + int(state[i])) % 256 + state[i], state[j] = state[j], state[i] + result[index] = input[index] ^ state[(int(state[i])+int(state[j]))%256] + } + return base64.URLEncoding.EncodeToString(result) +} diff --git a/internal/providers/registry.go b/internal/providers/registry.go index 18ab68e..e2d5626 100644 --- a/internal/providers/registry.go +++ b/internal/providers/registry.go @@ -14,6 +14,7 @@ type Meta struct { Referrer string DefaultDisabled bool DisableReason string + NoPrefetch bool // OptOutToken excludes a provider from automatic fallback prompts, e.g. "no-animepahe". OptOutToken string // FallbackPrompt is shown when the host offers this provider as a fallback.