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": `
+
+
+