Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/providers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 │
└─────────────────────────────────────────────────────────┘
```
Expand Down Expand Up @@ -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). |

Expand Down Expand Up @@ -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:

Expand Down
7 changes: 7 additions & 0 deletions internal/curd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions internal/loadproviders/load.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
8 changes: 8 additions & 0 deletions internal/localTracking.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions internal/mpv_playlist.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions internal/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 == "" {
Expand Down
4 changes: 2 additions & 2 deletions internal/provider_disabled_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion internal/provider_migrate_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
67 changes: 65 additions & 2 deletions internal/provider_stack_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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 {
Expand Down
79 changes: 79 additions & 0 deletions internal/providers/anikoto/client.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading