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 1/3] 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. From 3c74dd2b1902c46d2ca281e7c988a717b497a4e3 Mon Sep 17 00:00:00 2001 From: itsmenewbie03 <2101102516@student.buksu.edu.ph> Date: Sun, 11 Oct 2026 18:02:40 +0800 Subject: [PATCH 2/3] fix(anikoto): skip ad-injected megaplay CDN candidates Anikoto rotates MegaPlay CDN hosts per resolve and some of them prepend an undecodable ad image as the first HLS segment. MPV opens the master fine, then dies on the decoy ("first segment has no audio, video, or subtitle data (likely PNG)") and never reaches the working CDNs. AnyMex played because its player tolerated the injected segment. Extract AniPub's stream validator into a shared providers/hlsverify package and add a leading-decoy rule: a stream whose first segment is an ad/decoy cannot be played, since players open from the first segment and cannot skip it. Non-200 manifests are rejected too, which covers the 403 the user saw when a rotated host refused the request. Anikoto now validates each resolved candidate and puts proven-good ones first, keeping unvalidated ones as a fallback so a transient validation failure never drops a link. Verified against Black Clover Season 2 E1: the ad-injected megap.shiora.top candidate is deprioritized and MPV plays the clean fetch.nexabloom.top stream with no extra flags. --- internal/providers/anikoto/streams.go | 2 +- internal/providers/anikoto/validate.go | 39 +++ internal/providers/anikoto/validate_test.go | 108 ++++++++ internal/providers/anipub/validate.go | 233 ++-------------- internal/providers/hlsverify/verify.go | 290 ++++++++++++++++++++ internal/providers/hlsverify/verify_test.go | 134 +++++++++ 6 files changed, 590 insertions(+), 216 deletions(-) create mode 100644 internal/providers/anikoto/validate.go create mode 100644 internal/providers/anikoto/validate_test.go create mode 100644 internal/providers/hlsverify/verify.go create mode 100644 internal/providers/hlsverify/verify_test.go diff --git a/internal/providers/anikoto/streams.go b/internal/providers/anikoto/streams.go index a2e6fd6..bd50059 100644 --- a/internal/providers/anikoto/streams.go +++ b/internal/providers/anikoto/streams.go @@ -72,7 +72,7 @@ func getEpisodeStreamsForMode(showID string, epNo int, mode string) ([]string, m } return nil, nil, fmt.Errorf("no playable Anikoto %s streams found for episode %d", mode, epNo) } - return links, hints, nil + return prioritizePlayableStreams(links, hints), hints, nil } func fetchServerCandidates(episodeIDs, referer, mode string) ([]serverCandidate, error) { diff --git a/internal/providers/anikoto/validate.go b/internal/providers/anikoto/validate.go new file mode 100644 index 0000000..ed6c167 --- /dev/null +++ b/internal/providers/anikoto/validate.go @@ -0,0 +1,39 @@ +package anikoto + +import ( + "fmt" + + "github.com/wraient/curd/internal/curdhost" + "github.com/wraient/curd/internal/providers" + "github.com/wraient/curd/internal/providers/hlsverify" +) + +// prioritizePlayableStreams moves streams that pass HLS validation ahead of +// ones that do not. Anikoto rotates MegaPlay CDN hosts per request and some of +// them prepend an undecodable ad image as the first segment, which makes mpv +// fail immediately. Validated streams are preferred; unvalidated streams are +// kept as a fallback so a transient validation failure never loses a link. +func prioritizePlayableStreams(links []string, hints map[string]providers.StreamPlaybackHint) []string { + if len(links) < 2 { + return links + } + + validated := make([]string, 0, len(links)) + unvalidated := make([]string, 0, len(links)) + for _, link := range links { + hint := hints[link] + err := hlsverify.Validate(link, hlsverify.Options{ + Referer: hint.Referrer, + UserAgent: userAgent, + }) + if err != nil { + if curdhost.Log != nil { + curdhost.Log(fmt.Sprintf("Anikoto deprioritized stream: %v", err)) + } + unvalidated = append(unvalidated, link) + continue + } + validated = append(validated, link) + } + return append(validated, unvalidated...) +} diff --git a/internal/providers/anikoto/validate_test.go b/internal/providers/anikoto/validate_test.go new file mode 100644 index 0000000..2b85822 --- /dev/null +++ b/internal/providers/anikoto/validate_test.go @@ -0,0 +1,108 @@ +package anikoto + +import ( + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/wraient/curd/internal/curdhost" + "github.com/wraient/curd/internal/providers" +) + +func cleanStreamServer(t *testing.T) *httptest.Server { + t.Helper() + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/master.m3u8"): + fmt.Fprintf(w, "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000\n%s/media.m3u8\n", server.URL) + case strings.HasSuffix(r.URL.Path, "/media.m3u8"): + fmt.Fprintf(w, "#EXTM3U\n#EXTINF:4.0,\n%s/seg-1.jpg\n#EXT-X-ENDLIST\n", server.URL) + default: + w.Write([]byte{0x47, 0x40, 0x11, 0x10}) + } + })) + t.Cleanup(server.Close) + return server +} + +func adInjectedStreamServer(t *testing.T) *httptest.Server { + t.Helper() + var server *httptest.Server + server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasSuffix(r.URL.Path, "/master.m3u8"): + fmt.Fprintf(w, "#EXTM3U\n#EXT-X-STREAM-INF:BANDWIDTH=1000000\n%s/media.m3u8\n", server.URL) + case strings.HasSuffix(r.URL.Path, "/media.m3u8"): + fmt.Fprintf(w, "#EXTM3U\n#EXTINF:4.0,\nhttps://p16-ad-site-sign-sg.tiktokcdn.com/ad-site-i18n-sg/ad.image\n#EXTINF:4.0,\n%s/seg-1.jpg\n#EXT-X-ENDLIST\n", server.URL) + default: + w.Write([]byte{0x47, 0x40, 0x11, 0x10}) + } + })) + t.Cleanup(server.Close) + return server +} + +func withAnikotoValidatorClient(t *testing.T, client *http.Client) { + t.Helper() + previous := curdhost.HTTPClient + curdhost.HTTPClient = func() *http.Client { return client } + t.Cleanup(func() { curdhost.HTTPClient = previous }) +} + +func TestPrioritizePlayableStreamsMovesCleanStreamsFirst(t *testing.T) { + clean := cleanStreamServer(t) + advertised := adInjectedStreamServer(t) + withAnikotoValidatorClient(t, clean.Client()) + + adLink := advertised.URL + "/master.m3u8" + cleanLink := clean.URL + "/master.m3u8" + links := []string{adLink, cleanLink} + hints := map[string]providers.StreamPlaybackHint{ + adLink: {Referrer: "https://megaplay.buzz/"}, + cleanLink: {Referrer: "https://megaplay.buzz/"}, + } + + got := prioritizePlayableStreams(links, hints) + if len(got) != 2 { + t.Fatalf("expected 2 links, got %d", len(got)) + } + if got[0] != cleanLink { + t.Fatalf("expected clean stream first, got %q", got[0]) + } + if got[1] != adLink { + t.Fatalf("expected ad stream last, got %q", got[1]) + } +} + +func TestPrioritizePlayableStreamsKeepsOrderWhenAllValid(t *testing.T) { + clean := cleanStreamServer(t) + withAnikotoValidatorClient(t, clean.Client()) + + first := clean.URL + "/master.m3u8" + second := clean.URL + "/other/master.m3u8" + links := []string{first, second} + hints := map[string]providers.StreamPlaybackHint{ + first: {Referrer: "https://megaplay.buzz/"}, + second: {Referrer: "https://megaplay.buzz/"}, + } + + got := prioritizePlayableStreams(links, hints) + if got[0] != first || got[1] != second { + t.Fatalf("expected order preserved, got %v", got) + } +} + +func TestPrioritizePlayableStreamsSkipsSingleLink(t *testing.T) { + withAnikotoValidatorClient(t, nil) + + link := "https://unreachable.example/master.m3u8" + got := prioritizePlayableStreams([]string{link}, map[string]providers.StreamPlaybackHint{ + link: {Referrer: "https://megaplay.buzz/"}, + }) + if len(got) != 1 || got[0] != link { + t.Fatalf("expected single link unchanged, got %v", got) + } +} diff --git a/internal/providers/anipub/validate.go b/internal/providers/anipub/validate.go index a724499..f007673 100644 --- a/internal/providers/anipub/validate.go +++ b/internal/providers/anipub/validate.go @@ -2,13 +2,10 @@ package anipub import ( "fmt" - "io" - "net/http" "net/url" - "strconv" "strings" - "github.com/wraient/curd/internal/curdhost" + "github.com/wraient/curd/internal/providers/hlsverify" ) // decoySegmentMarkers identify ad/decoy segments that the megap.kotocdn.site @@ -16,14 +13,7 @@ import ( // 302 redirects to them) served from ByteDance ad infrastructure; mpv can // never decode them, so playback never starts and the player sits on its idle // "Drop files or URLs to play here." screen. -var decoySegmentMarkers = []string{ - "ibyteimg.com", - "byteimg.com", - "ad-site-i18n", -} - -// maxPlaylistBytes bounds how much of a manifest we download during validation. -const maxPlaylistBytes = 2 << 20 +var decoySegmentMarkers = hlsverify.DefaultDecoyMarkers // isMegaplayCDNHost reports whether the stream is hosted by the megaplay CDN, // which is the only CDN this validation is scoped to. @@ -34,8 +24,8 @@ func isMegaplayCDNHost(host string) bool { // validateResolvedStream inspects a resolved anipub stream URL before it is // handed to the media player. When the megaplay CDN is serving an ad-injected -// decoy playlist (or a fully decoy one), an error is returned so the caller -// can fall back to another provider instead of opening an idle mpv window. +// decoy playlist, an error is returned so the caller can fall back to another +// provider instead of opening an idle mpv window. func validateResolvedStream(rawURL string) error { streamURL, err := url.Parse(strings.TrimSpace(rawURL)) if err != nil || streamURL.Scheme == "" || streamURL.Host == "" { @@ -44,200 +34,32 @@ func validateResolvedStream(rawURL string) error { if !isMegaplayCDNHost(streamURL.Host) { return nil } - - v := &hlsStreamValidator{ - client: curdhost.HTTPClient(), - referrer: megaplayBaseURL + "/", - } - - masterBody, err := v.fetch(streamURL.String()) - if err != nil { - return fmt.Errorf("anipub stream manifest fetch failed: %w", err) - } - if !strings.Contains(masterBody, "#EXTM3U") { - return fmt.Errorf("anipub stream manifest %q is not an HLS playlist", rawURL) - } - - mediaURL, err := selectMediaPlaylistURL(streamURL, masterBody) - if err != nil { - return err - } - mediaBody, err := v.fetch(mediaURL) - if err != nil { - return fmt.Errorf("anipub stream media playlist fetch failed: %w", err) - } - - segments := parsePlaylistSegments(mediaBody) - if len(segments) == 0 { - return fmt.Errorf("anipub stream %q has no media segments", rawURL) - } - - decoyCount := 0 - for _, segment := range segments { - if isDecoySegmentURI(segment) { - decoyCount++ - } - } - if decoyCount == len(segments) { - return fmt.Errorf("anipub stream %q is an ad-injected decoy: all %d segments are ad/decoy content", rawURL, len(segments)) - } - if decoyRatio := float64(decoyCount) / float64(len(segments)); decoyRatio >= 0.5 { - return fmt.Errorf("anipub stream %q is an ad-injected decoy: %d/%d segments are ad/decoy content", rawURL, decoyCount, len(segments)) - } - - // The first non-decoy segment may still redirect to ad/decoy content, so - // probe its magic bytes before trusting the playlist. - for _, segment := range segments { - if isDecoySegmentURI(segment) { - continue - } - if data := v.fetchRange(segment, 0, 15); looksLikeDecoySegment(data) { - return fmt.Errorf("anipub stream %q first media segment is not video content", rawURL) - } - break - } - - return nil -} - -// hlsStreamValidator fetches manifests and probe bytes for a stream. -type hlsStreamValidator struct { - client *http.Client - referrer string -} - -func (v *hlsStreamValidator) fetch(rawURL string) (string, error) { - if v.client == nil { - return "", fmt.Errorf("http client not configured") - } - req, err := http.NewRequest(http.MethodGet, rawURL, nil) - if err != nil { - return "", err - } - req.Header.Set("User-Agent", userAgent) - req.Header.Set("Referer", v.referrer) - req.Header.Set("Accept", "application/vnd.apple.mpegurl, application/x-mpegURL, */*") - - resp, err := v.client.Do(req) - if err != nil { - return "", err - } - defer resp.Body.Close() - - if !curdhost.HTTPStatusOK(resp.StatusCode) { - body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) - return "", curdhost.HTTPStatusError("megaplay hls manifest", resp.StatusCode, body) - } - body, err := io.ReadAll(io.LimitReader(resp.Body, maxPlaylistBytes)) - if err != nil { - return "", err - } - return string(body), nil -} - -// fetchRange requests the first few bytes of a segment so its magic bytes can -// be inspected. Errors are swallowed: an unavailable probe must not reject a -// stream that mpv could still play. -func (v *hlsStreamValidator) fetchRange(rawURL string, start, end int) []byte { - if v.client == nil { - return nil - } - req, err := http.NewRequest(http.MethodGet, rawURL, nil) - if err != nil { - return nil - } - req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end)) - req.Header.Set("User-Agent", userAgent) - req.Header.Set("Referer", v.referrer) - - resp, err := v.client.Do(req) - if err != nil { - return nil - } - defer resp.Body.Close() - if !curdhost.HTTPStatusOK(resp.StatusCode) { - return nil - } - - buf := make([]byte, end-start+1) - n, _ := io.ReadFull(resp.Body, buf) - return buf[:n] + return hlsverify.Validate(rawURL, hlsverify.Options{ + Referer: megaplayBaseURL + "/", + UserAgent: userAgent, + DecoyMarkers: decoySegmentMarkers, + }) } // selectMediaPlaylistURL picks the highest-bandwidth variant from a master // playlist, or returns the master URL itself when it is a media playlist. func selectMediaPlaylistURL(masterURL *url.URL, body string) (string, error) { - if !strings.Contains(body, "#EXT-X-STREAM-INF") { - return masterURL.String(), nil - } - - bestBandwidth := -1 - bestURI := "" - lines := strings.Split(body, "\n") - for i := 0; i < len(lines); i++ { - line := strings.TrimSpace(lines[i]) - if !strings.HasPrefix(line, "#EXT-X-STREAM-INF") { - continue - } - bandwidth := parseBandwidth(line) - if bandwidth <= bestBandwidth { - continue - } - for j := i + 1; j < len(lines); j++ { - next := strings.TrimSpace(lines[j]) - if next == "" || strings.HasPrefix(next, "#") { - continue - } - bestBandwidth = bandwidth - bestURI = next - break - } - } - if bestURI == "" { - return "", fmt.Errorf("no variant playlist found in master playlist") - } - ref, err := url.Parse(bestURI) - if err != nil { - return "", fmt.Errorf("invalid variant playlist uri %q: %w", bestURI, err) - } - resolved := masterURL.ResolveReference(ref) - return resolved.String(), nil -} - -func parseBandwidth(infLine string) int { - upper := strings.ToUpper(infLine) - idx := strings.Index(upper, "BANDWIDTH=") - if idx < 0 { - return 0 - } - rest := upper[idx+len("BANDWIDTH="):] - if comma := strings.IndexByte(rest, ','); comma >= 0 { - rest = rest[:comma] - } - value, err := strconv.Atoi(strings.TrimSpace(rest)) - if err != nil { - return 0 - } - return value + return hlsverify.SelectMediaPlaylistURL(masterURL, body) } // parsePlaylistSegments extracts media segment URIs from a playlist body. func parsePlaylistSegments(body string) []string { - var segments []string - for _, raw := range strings.Split(body, "\n") { - line := strings.TrimSpace(raw) - if line == "" || strings.HasPrefix(line, "#") { - continue - } - segments = append(segments, line) - } - return segments + return hlsverify.ParsePlaylistSegments(body) } func isDecoySegmentURI(raw string) bool { + return isDecoySegmentURIMarkers(raw, decoySegmentMarkers) +} + +func isDecoySegmentURIMarkers(raw string, markers []string) bool { lower := strings.ToLower(raw) - for _, marker := range decoySegmentMarkers { - if strings.Contains(lower, marker) { + for _, marker := range markers { + if strings.Contains(lower, strings.ToLower(marker)) { return true } } @@ -247,24 +69,5 @@ func isDecoySegmentURI(raw string) bool { // looksLikeDecoySegment reports whether probe bytes look like an image, an // HTML error page, or other non-video content instead of an HLS media segment. func looksLikeDecoySegment(data []byte) bool { - if len(data) < 4 { - return false - } - if data[0] == 0x89 && data[1] == 'P' && data[2] == 'N' && data[3] == 'G' { - return true - } - if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF { - return true - } - if string(data[:4]) == "GIF8" { - return true - } - if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" { - return true - } - lower := strings.ToLower(string(data)) - if strings.HasPrefix(lower, "<") || strings.Contains(lower, " 0 { + return o.DecoyMarkers + } + return DefaultDecoyMarkers +} + +func (o Options) userAgent() string { + if strings.TrimSpace(o.UserAgent) != "" { + return o.UserAgent + } + return defaultUserAgent +} + +// Validate inspects a resolved stream URL. It returns an error when the stream +// is an ad-injected decoy (leading, majority, or fully decoy) or when its first +// real media segment is not decodable video content. +func Validate(rawURL string, opts Options) error { + streamURL, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil || streamURL.Scheme == "" || streamURL.Host == "" { + return fmt.Errorf("invalid stream url %q", rawURL) + } + + v := &streamValidator{opts: opts} + + masterBody, err := v.fetch(streamURL.String()) + if err != nil { + return fmt.Errorf("stream manifest fetch failed: %w", err) + } + if !strings.Contains(masterBody, "#EXTM3U") { + return fmt.Errorf("stream manifest %q is not an HLS playlist", rawURL) + } + + mediaURL, err := SelectMediaPlaylistURL(streamURL, masterBody) + if err != nil { + return err + } + mediaBody, err := v.fetch(mediaURL) + if err != nil { + return fmt.Errorf("stream media playlist fetch failed: %w", err) + } + + segments := ParsePlaylistSegments(mediaBody) + if len(segments) == 0 { + return fmt.Errorf("stream %q has no media segments", rawURL) + } + + markers := opts.markers() + + // A leading decoy is fatal: the player opens from the first segment and + // cannot skip an undecodable image, so such a stream never starts. + if isDecoySegmentURI(segments[0], markers) { + return fmt.Errorf("stream %q begins with an ad/decoy segment", rawURL) + } + + decoyCount := 0 + for _, segment := range segments { + if isDecoySegmentURI(segment, markers) { + decoyCount++ + } + } + if decoyCount == len(segments) { + return fmt.Errorf("stream %q is an ad-injected decoy: all %d segments are ad/decoy content", rawURL, len(segments)) + } + if decoyRatio := float64(decoyCount) / float64(len(segments)); decoyRatio >= 0.5 { + return fmt.Errorf("stream %q is an ad-injected decoy: %d/%d segments are ad/decoy content", rawURL, decoyCount, len(segments)) + } + + // The first non-decoy segment may still redirect to ad/decoy content, so + // probe its magic bytes before trusting the playlist. + for _, segment := range segments { + if isDecoySegmentURI(segment, markers) { + continue + } + if data := v.fetchRange(segment, 0, 15); LooksLikeDecoySegment(data) { + return fmt.Errorf("stream %q first media segment is not video content", rawURL) + } + break + } + + return nil +} + +// streamValidator fetches manifests and probe bytes for a stream. +type streamValidator struct { + opts Options +} + +func (v *streamValidator) fetch(rawURL string) (string, error) { + client := curdhost.HTTPClient() + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return "", err + } + req.Header.Set("User-Agent", v.opts.userAgent()) + req.Header.Set("Referer", v.opts.Referer) + req.Header.Set("Accept", "application/vnd.apple.mpegurl, application/x-mpegURL, */*") + + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if !curdhost.HTTPStatusOK(resp.StatusCode) { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return "", curdhost.HTTPStatusError("hls manifest", resp.StatusCode, body) + } + body, err := io.ReadAll(io.LimitReader(resp.Body, maxPlaylistBytes)) + if err != nil { + return "", err + } + return string(body), nil +} + +// fetchRange requests the first few bytes of a segment so its magic bytes can +// be inspected. Errors are swallowed: an unavailable probe must not reject a +// stream that mpv could still play. +func (v *streamValidator) fetchRange(rawURL string, start, end int) []byte { + client := curdhost.HTTPClient() + req, err := http.NewRequest(http.MethodGet, rawURL, nil) + if err != nil { + return nil + } + req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", start, end)) + req.Header.Set("User-Agent", v.opts.userAgent()) + req.Header.Set("Referer", v.opts.Referer) + + resp, err := client.Do(req) + if err != nil { + return nil + } + defer resp.Body.Close() + if !curdhost.HTTPStatusOK(resp.StatusCode) { + return nil + } + + buf := make([]byte, end-start+1) + n, _ := io.ReadFull(resp.Body, buf) + return buf[:n] +} + +// SelectMediaPlaylistURL picks the highest-bandwidth variant from a master +// playlist, or returns the master URL itself when it is a media playlist. +func SelectMediaPlaylistURL(masterURL *url.URL, body string) (string, error) { + if !strings.Contains(body, "#EXT-X-STREAM-INF") { + return masterURL.String(), nil + } + + bestBandwidth := -1 + bestURI := "" + lines := strings.Split(body, "\n") + for i := 0; i < len(lines); i++ { + line := strings.TrimSpace(lines[i]) + if !strings.HasPrefix(line, "#EXT-X-STREAM-INF") { + continue + } + bandwidth := parseBandwidth(line) + if bandwidth <= bestBandwidth { + continue + } + for j := i + 1; j < len(lines); j++ { + next := strings.TrimSpace(lines[j]) + if next == "" || strings.HasPrefix(next, "#") { + continue + } + bestBandwidth = bandwidth + bestURI = next + break + } + } + if bestURI == "" { + return "", fmt.Errorf("no variant playlist found in master playlist") + } + ref, err := url.Parse(bestURI) + if err != nil { + return "", fmt.Errorf("invalid variant playlist uri %q: %w", bestURI, err) + } + resolved := masterURL.ResolveReference(ref) + return resolved.String(), nil +} + +func parseBandwidth(infLine string) int { + upper := strings.ToUpper(infLine) + idx := strings.Index(upper, "BANDWIDTH=") + if idx < 0 { + return 0 + } + rest := upper[idx+len("BANDWIDTH="):] + if comma := strings.IndexByte(rest, ','); comma >= 0 { + rest = rest[:comma] + } + value, err := strconv.Atoi(strings.TrimSpace(rest)) + if err != nil { + return 0 + } + return value +} + +// ParsePlaylistSegments extracts media segment URIs from a playlist body. +func ParsePlaylistSegments(body string) []string { + var segments []string + for _, raw := range strings.Split(body, "\n") { + line := strings.TrimSpace(raw) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + segments = append(segments, line) + } + return segments +} + +func isDecoySegmentURI(raw string, markers []string) bool { + lower := strings.ToLower(raw) + for _, marker := range markers { + if strings.Contains(lower, strings.ToLower(marker)) { + return true + } + } + return false +} + +// LooksLikeDecoySegment reports whether probe bytes look like an image, an +// HTML error page, or other non-video content instead of an HLS media segment. +func LooksLikeDecoySegment(data []byte) bool { + if len(data) < 4 { + return false + } + if bytes.HasPrefix(data, []byte{0x89, 'P', 'N', 'G'}) { + return true + } + if bytes.HasPrefix(data, []byte{0xFF, 0xD8, 0xFF}) { + return true + } + if string(data[:4]) == "GIF8" { + return true + } + if len(data) >= 12 && string(data[:4]) == "RIFF" && string(data[8:12]) == "WEBP" { + return true + } + lower := strings.ToLower(string(data)) + if strings.HasPrefix(lower, "<") || strings.Contains(lower, "ad"), true}, + {"mpegts", []byte{0x47, 0x40, 0x11, 0x10}, false}, + {"short", []byte{0x47}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := LooksLikeDecoySegment(tc.data); got != tc.want { + t.Fatalf("LooksLikeDecoySegment = %v, want %v", got, tc.want) + } + }) + } +} From 6726ef6bfc9a2f05306f0488c6893289af58404a Mon Sep 17 00:00:00 2001 From: itsmenewbie03 <2101102516@student.buksu.edu.ph> Date: Sun, 11 Oct 2026 18:02:42 +0800 Subject: [PATCH 3/3] test: align provider stack expectations with defaults 4a6f620 disabled anineko by default and added stackcheck_test.go for it, but left provider_disabled_test.go asserting anineko stays enabled and omitting anikoto, which 296954c added to the default stack. Update the stale cases to the intended defaults: anineko default-disabled with a reason, anikoto default-enabled. --- internal/provider_disabled_test.go | 19 ++++++++++++++----- internal/stackcheck_test.go | 4 ++-- 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/internal/provider_disabled_test.go b/internal/provider_disabled_test.go index eac2f17..cdaa620 100644 --- a/internal/provider_disabled_test.go +++ b/internal/provider_disabled_test.go @@ -2,18 +2,24 @@ package internal import "testing" -func TestProviderEnabledDisablesAllanimeAndAnimepaheByDefault(t *testing.T) { +func TestProviderDisabledByDefaultSet(t *testing.T) { if ProviderEnabled("allanime") != false { t.Fatal("expected allanime to be disabled by default") } if ProviderEnabled("animepahe") != false { t.Fatal("expected animepahe to be disabled by default") } + if ProviderEnabled("anineko") != false { + t.Fatal("expected anineko to be disabled by default") + } if ProviderEnabled("senshi") != true { t.Fatal("expected senshi to stay enabled") } - if ProviderEnabled("anineko") != true { - t.Fatal("expected anineko to stay enabled") + if ProviderEnabled("anipub") != true { + t.Fatal("expected anipub to stay enabled") + } + if ProviderEnabled("anikoto") != true { + t.Fatal("expected anikoto to stay enabled") } if reason := ProviderDisabledReason("allanime"); reason == "" { t.Fatal("expected allanime disable reason") @@ -21,6 +27,9 @@ func TestProviderEnabledDisablesAllanimeAndAnimepaheByDefault(t *testing.T) { if reason := ProviderDisabledReason("animepahe"); reason == "" { t.Fatal("expected animepahe disable reason") } + if reason := ProviderDisabledReason("anineko"); reason == "" { + t.Fatal("expected anineko disable reason") + } } func TestConfiguredProviderNamesFiltersDisabledProviders(t *testing.T) { @@ -29,11 +38,11 @@ func TestConfiguredProviderNamesFiltersDisabledProviders(t *testing.T) { cfg *CurdConfig want []string }{ - {name: "empty", cfg: &CurdConfig{}, want: []string{"senshi", "anipub", "anineko", "anikoto"}}, + {name: "empty", cfg: &CurdConfig{}, want: []string{"senshi", "anipub", "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", "anikoto"}}, + {name: "legacy alias", cfg: &CurdConfig{Provider: "stacked"}, want: []string{"senshi", "anipub", "anikoto"}}, } for _, tc := range cases { diff --git a/internal/stackcheck_test.go b/internal/stackcheck_test.go index 1d92668..7e79a11 100644 --- a/internal/stackcheck_test.go +++ b/internal/stackcheck_test.go @@ -5,9 +5,9 @@ import ( "testing" ) -func TestDefaultStackIsSenshiAnipub(t *testing.T) { +func TestDefaultStackIsSenshiAnipubAnikoto(t *testing.T) { got := defaultEnabledProviderStack() - want := []string{"senshi", "anipub"} + want := []string{"senshi", "anipub", "anikoto"} if !reflect.DeepEqual(got, want) { t.Fatalf("default stack = %v, want %v", got, want) }