Skip to content

Commit fdcb48e

Browse files
committed
feat(superflix): implement next episode prefetching and enhance stream caching mechanisms
1 parent ae324ab commit fdcb48e

8 files changed

Lines changed: 505 additions & 10 deletions

File tree

internal/api/enhanced.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -659,8 +659,86 @@ var (
659659
// sfReleaseBrowserFn closes the solver window after a resolve. A seam so tests
660660
// can assert it fires on every path (cache hit, server list, sniff, error).
661661
sfReleaseBrowserFn = superflix.ReleaseSharedBrowser
662+
663+
// sfPrefetchNextFn warms the next episode's stream cache after a successful
664+
// resolve. A seam so tests can stub it out or drive it directly.
665+
sfPrefetchNextFn = maybePrefetchNextSuperFlixEpisode
662666
)
663667

668+
// sfPrefetchBudget bounds the background next-episode warm-up. The chain is
669+
// plain HTTP (browser solve forbidden), but the player-page fetch retries past
670+
// token-less shells and the transport may honor a Retry-After, so give it room;
671+
// nothing user-visible waits on this.
672+
const sfPrefetchBudget = 45 * time.Second
673+
674+
var (
675+
// sfPrefetchInFlight dedupes concurrent warm-ups of the same episode.
676+
sfPrefetchInFlight sync.Map
677+
// sfPrefetchWG tracks warm-up goroutines so tests can wait for them.
678+
sfPrefetchWG sync.WaitGroup
679+
)
680+
681+
// maybePrefetchNextSuperFlixEpisode warms the NEXT episode's (host, hash) cache
682+
// entry in the background, so a binge's "next episode" opens through the ~1s
683+
// cache fast path instead of paying the server-list wait again.
684+
//
685+
// Strictly best-effort and invisible: the whole chain runs with the browser
686+
// solve FORBIDDEN (WithoutBrowserSolve), so it can never pop a window — with the
687+
// Cloudflare clearance warm from the play that just happened, the tokened player
688+
// page is reachable over plain HTTP. Any failure (gate re-armed, no next
689+
// episode, rate limit) only means the next play resolves normally. The server is
690+
// picked silently from the user's remembered preference and the pick is NOT
691+
// re-persisted, so prefetch never influences a later prompt.
692+
//
693+
// GOANIME_SF_NO_PREFETCH disables it (escape hatch for metered connections or
694+
// if SuperFlix ever turns hostile to the extra requests).
695+
func maybePrefetchNextSuperFlixEpisode(sfClient *superflix.SuperFlixClient, tmdbID, sfType, season, epNum string) {
696+
if sfType != "serie" || os.Getenv("GOANIME_SF_NO_PREFETCH") != "" {
697+
return
698+
}
699+
n, err := strconv.Atoi(strings.TrimSpace(epNum))
700+
if err != nil || n < 1 {
701+
return
702+
}
703+
next := strconv.Itoa(n + 1)
704+
if superflix.HasCachedStream(sfType, tmdbID, season, next) {
705+
return
706+
}
707+
key := sfType + ":" + tmdbID + ":" + season + ":" + next
708+
if _, running := sfPrefetchInFlight.LoadOrStore(key, struct{}{}); running {
709+
return
710+
}
711+
// Capture the seams synchronously: the goroutine may outlive a test that
712+
// restores them, and reading the package vars there would be a data race.
713+
getServers, streamFromServer := sfGetServersFn, sfStreamFromServerFn
714+
sfPrefetchWG.Add(1)
715+
go func() {
716+
defer sfPrefetchWG.Done()
717+
defer sfPrefetchInFlight.Delete(key)
718+
719+
ctx, cancel := context.WithTimeout(superflix.WithoutBrowserSolve(context.Background()), sfPrefetchBudget)
720+
defer cancel()
721+
722+
servers, tokens, err := getServers(sfClient, ctx, sfType, tmdbID, season, next)
723+
if err != nil || len(servers) == 0 {
724+
util.Debug("SuperFlix prefetch: server list unavailable; next episode will resolve normally", "key", key, "err", err)
725+
return
726+
}
727+
candidates := orderedServers(servers)
728+
if pref, ok := recallSuperFlixServer(tmdbID); ok {
729+
candidates = narrowByMemory(candidates, pref)
730+
}
731+
// StreamFromServer caches the (host, hash) — the browser-gated fact —
732+
// as a side effect; the stream URL itself is discarded (signed links
733+
// expire, and the cache replay signs a fresh one at play time).
734+
if _, err := streamFromServer(sfClient, ctx, tokens, candidates[0].IDString(), sfType, tmdbID, season, next); err != nil {
735+
util.Debug("SuperFlix prefetch failed; next episode will resolve normally", "key", key, "err", err)
736+
return
737+
}
738+
util.Debug("SuperFlix prefetch: next episode cached for instant start", "key", key)
739+
}()
740+
}
741+
664742
// superFlixStream resolves a SuperFlix stream, preferring the path that lets the
665743
// user actually choose.
666744
//
@@ -786,6 +864,10 @@ func GetSuperFlixStreamURL(media *models.Anime, episode *models.Episode, quality
786864
return "", fmt.Errorf("failed to get SuperFlix stream: %w", describeSuperFlixErr(err))
787865
}
788866

867+
// Warm the NEXT episode in the background (best-effort, plain HTTP, no
868+
// browser window) so a binge's next play starts from the cache fast path.
869+
sfPrefetchNextFn(sfClient, tmdbID, sfType, season, epNum)
870+
789871
// Store referer globally for mpv playback
790872
if result.Referer != "" {
791873
util.SetGlobalReferer(result.Referer)
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
package api
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"sync"
7+
"testing"
8+
9+
"github.com/alvarorichard/Goanime/internal/scraper/providers/superflix"
10+
"github.com/stretchr/testify/assert"
11+
"github.com/stretchr/testify/require"
12+
)
13+
14+
// stubPrefetchSeams replaces the server-list and stream seams with counters and
15+
// returns them, restoring the originals on cleanup. The stubs are safe to call
16+
// from the prefetch goroutine after the test ends (they touch only their own
17+
// captured state, never testing.T).
18+
type prefetchSeamCalls struct {
19+
mu sync.Mutex
20+
getServersEp []string
21+
streamFromServer []string // "serverID episode" per call
22+
}
23+
24+
func stubPrefetchSeams(t *testing.T, servers []superflix.SuperFlixServer) *prefetchSeamCalls {
25+
t.Helper()
26+
calls := &prefetchSeamCalls{}
27+
pl, ps := sfGetServersFn, sfStreamFromServerFn
28+
t.Cleanup(func() { sfGetServersFn, sfStreamFromServerFn = pl, ps })
29+
30+
sfGetServersFn = func(_ *superflix.SuperFlixClient, _ context.Context, _, _, _, episode string) ([]superflix.SuperFlixServer, *superflix.SuperFlixTokens, error) {
31+
calls.mu.Lock()
32+
calls.getServersEp = append(calls.getServersEp, episode)
33+
calls.mu.Unlock()
34+
return servers, &superflix.SuperFlixTokens{ContentID: "1", PageToken: "tok"}, nil
35+
}
36+
sfStreamFromServerFn = func(_ *superflix.SuperFlixClient, _ context.Context, _ *superflix.SuperFlixTokens, serverID, _, _, _, episode string) (*superflix.SuperFlixStreamResult, error) {
37+
calls.mu.Lock()
38+
calls.streamFromServer = append(calls.streamFromServer, serverID+" "+episode)
39+
calls.mu.Unlock()
40+
return &superflix.SuperFlixStreamResult{StreamURL: "https://cdn/x.m3u8"}, nil
41+
}
42+
return calls
43+
}
44+
45+
func sfTestServer(id string, audioType int, isFile bool) superflix.SuperFlixServer {
46+
return superflix.SuperFlixServer{
47+
ID: json.RawMessage(`"` + id + `"`),
48+
Name: "Servidor " + id,
49+
Type: audioType,
50+
IsFile: isFile,
51+
}
52+
}
53+
54+
// The warm-up must target episode N+1 and honor the remembered server
55+
// preference silently — no picker, no persisted pick.
56+
func TestMaybePrefetchNextSuperFlixEpisode_WarmsNextEpisode(t *testing.T) {
57+
dub := sfTestServer("111", superflix.SuperFlixAudioDubbed, false)
58+
leg := sfTestServer("222", superflix.SuperFlixAudioSubtitled, false)
59+
calls := stubPrefetchSeams(t, []superflix.SuperFlixServer{dub, leg})
60+
61+
const tmdbID = "goanime-prefetch-test-424242"
62+
t.Cleanup(resetSuperFlixServerPrefs)
63+
resetSuperFlixServerPrefs()
64+
// The user picked Legendado on the episode that just played.
65+
rememberSuperFlixServer(tmdbID, leg)
66+
67+
maybePrefetchNextSuperFlixEpisode(nil, tmdbID, "serie", "1", "3")
68+
sfPrefetchWG.Wait()
69+
70+
calls.mu.Lock()
71+
defer calls.mu.Unlock()
72+
require.Equal(t, []string{"4"}, calls.getServersEp, "must warm exactly the NEXT episode")
73+
require.Equal(t, []string{"222 4"}, calls.streamFromServer,
74+
"must resolve through the remembered (legendado) server, silently")
75+
}
76+
77+
// Guards: anything that is not a numbered series episode must not spawn a
78+
// warm-up at all, and the kill-switch must be honored.
79+
func TestMaybePrefetchNextSuperFlixEpisode_Guards(t *testing.T) {
80+
tests := []struct {
81+
name string
82+
sfType string
83+
epNum string
84+
env string
85+
}{
86+
{"movie is skipped", "filme", "1", ""},
87+
{"non-numeric episode is skipped", "serie", "especial", ""},
88+
{"episode zero is skipped", "serie", "0", ""},
89+
{"kill-switch is honored", "serie", "3", "1"},
90+
}
91+
for _, tt := range tests {
92+
t.Run(tt.name, func(t *testing.T) {
93+
calls := stubPrefetchSeams(t, []superflix.SuperFlixServer{
94+
sfTestServer("111", superflix.SuperFlixAudioDubbed, false),
95+
})
96+
if tt.env != "" {
97+
t.Setenv("GOANIME_SF_NO_PREFETCH", tt.env)
98+
}
99+
100+
maybePrefetchNextSuperFlixEpisode(nil, "goanime-prefetch-guard-test", tt.sfType, "1", tt.epNum)
101+
sfPrefetchWG.Wait()
102+
103+
calls.mu.Lock()
104+
defer calls.mu.Unlock()
105+
assert.Empty(t, calls.getServersEp, "no warm-up may run for this input")
106+
})
107+
}
108+
}
109+
110+
// A failing server list must die silently in the background — no panic, no
111+
// stream call — and release the in-flight slot so a later attempt can run.
112+
func TestMaybePrefetchNextSuperFlixEpisode_FailureIsSilentAndReleasesSlot(t *testing.T) {
113+
pl, ps := sfGetServersFn, sfStreamFromServerFn
114+
t.Cleanup(func() { sfGetServersFn, sfStreamFromServerFn = pl, ps })
115+
116+
var mu sync.Mutex
117+
var attempts int
118+
sfGetServersFn = func(_ *superflix.SuperFlixClient, _ context.Context, _, _, _, _ string) ([]superflix.SuperFlixServer, *superflix.SuperFlixTokens, error) {
119+
mu.Lock()
120+
attempts++
121+
mu.Unlock()
122+
return nil, nil, superflix.ErrSuperFlixRateLimited
123+
}
124+
streamCalled := false
125+
sfStreamFromServerFn = func(_ *superflix.SuperFlixClient, _ context.Context, _ *superflix.SuperFlixTokens, _, _, _, _, _ string) (*superflix.SuperFlixStreamResult, error) {
126+
streamCalled = true
127+
return nil, nil
128+
}
129+
130+
const tmdbID = "goanime-prefetch-fail-test"
131+
maybePrefetchNextSuperFlixEpisode(nil, tmdbID, "serie", "1", "7")
132+
sfPrefetchWG.Wait()
133+
// The in-flight slot must be free again: a second attempt runs.
134+
maybePrefetchNextSuperFlixEpisode(nil, tmdbID, "serie", "1", "7")
135+
sfPrefetchWG.Wait()
136+
137+
mu.Lock()
138+
defer mu.Unlock()
139+
assert.Equal(t, 2, attempts, "failed warm-up must release the in-flight slot")
140+
assert.False(t, streamCalled, "no stream resolve after a failed server list")
141+
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package superflix
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"testing"
7+
8+
"github.com/PuerkitoBio/goquery"
9+
)
10+
11+
// benchSearchHTML builds a search results page with n media cards in the
12+
// current SuperFlix markup (group/card, data-msg buttons, mt-3 span metadata).
13+
func benchSearchHTML(n int) string {
14+
var b strings.Builder
15+
b.WriteString(`<html><body><div id="results">`)
16+
for i := 0; i < n; i++ {
17+
fmt.Fprintf(&b, `
18+
<div class="group/card">
19+
<img alt="Título de Teste %d" src="https://d1muf25xaso8hp.cloudfront.net/https://image.tmdb.org/t/p/w342/poster%d.jpg">
20+
<h3>Título de Teste %d</h3>
21+
<button data-msg="Copiar TMDB" data-copy="%d"></button>
22+
<button data-msg="Copiar IMDB" data-copy="tt%07d"></button>
23+
<button data-msg="Copiar Link" data-copy="https://superflixapi.pro/serie/%d"></button>
24+
<div class="mt-3"><span>2021</span><span>Série</span></div>
25+
</div>`, i, i, i, 100000+i, i, 100000+i)
26+
}
27+
b.WriteString(`</div></body></html>`)
28+
return b.String()
29+
}
30+
31+
func BenchmarkParseCards(b *testing.B) {
32+
html := benchSearchHTML(48) // a full search results page
33+
c := &SuperFlixClient{}
34+
b.ReportAllocs()
35+
b.ResetTimer()
36+
for i := 0; i < b.N; i++ {
37+
doc, err := goquery.NewDocumentFromReader(strings.NewReader(html))
38+
if err != nil {
39+
b.Fatal(err)
40+
}
41+
if got := c.parseCards(doc); len(got) != 48 {
42+
b.Fatalf("expected 48 cards, got %d", len(got))
43+
}
44+
}
45+
}
46+
47+
// benchEpisodesHTML builds a serie page carrying a window.allEpisodes blob with
48+
// the given seasons × episodes.
49+
func benchEpisodesHTML(seasons, episodes int) string {
50+
var b strings.Builder
51+
b.WriteString(`<html><head><script>window.allEpisodes = {`)
52+
for s := 1; s <= seasons; s++ {
53+
if s > 1 {
54+
b.WriteString(",")
55+
}
56+
fmt.Fprintf(&b, `"%d":[`, s)
57+
for e := 1; e <= episodes; e++ {
58+
if e > 1 {
59+
b.WriteString(",")
60+
}
61+
fmt.Fprintf(&b, `{"epi_num":%d,"title":"Episódio %d","air_date":"2021-04-%02d"}`, e, e, (e%28)+1)
62+
}
63+
b.WriteString("]")
64+
}
65+
b.WriteString(`};</script></head><body></body></html>`)
66+
return b.String()
67+
}
68+
69+
func BenchmarkExtractEpisodes(b *testing.B) {
70+
html := benchEpisodesHTML(10, 24) // long-running dorama/anime scale
71+
c := &SuperFlixClient{}
72+
b.ReportAllocs()
73+
b.ResetTimer()
74+
for i := 0; i < b.N; i++ {
75+
out, err := c.ExtractEpisodes(html)
76+
if err != nil {
77+
b.Fatal(err)
78+
}
79+
if len(out) != 10 {
80+
b.Fatalf("expected 10 seasons, got %d", len(out))
81+
}
82+
}
83+
}

internal/scraper/providers/superflix/servers_nosolve_test.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ func TestGetServers_AllowsBrowserSolve(t *testing.T) {
7676
// gate if it has to. Locking the enhancement's no-solve rule must not accidentally
7777
// gag the stream path.
7878
func TestStreamFromServer_DoesNotForbidBrowserSolve(t *testing.T) {
79-
t.Parallel()
79+
// Not parallel: swaps the global stream cache. Without the swap this test
80+
// wrote its httptest (host, hash) into the USER'S real on-disk cache.
81+
withFreshStreamCache(t)
8082

8183
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
8284
switch {

0 commit comments

Comments
 (0)