From c8d464c8af5ad0dbc4612691573912c0f160e79a Mon Sep 17 00:00:00 2001 From: itsmenewbie03 <2101102516@student.buksu.edu.ph> Date: Fri, 25 Sep 2026 08:53:49 +0800 Subject: [PATCH] fix(senshi): restore Vidcloud playback --- README.md | 2 +- docs/providers.md | 2 +- internal/providers/senshi/client.go | 10 +- internal/providers/senshi/em3u8.go | 359 ++++++++++++++++++++ internal/providers/senshi/em3u8_test.go | 109 ++++++ internal/providers/senshi/episodes.go | 3 - internal/providers/senshi/live_test.go | 97 ++++++ internal/providers/senshi/provider.go | 2 +- internal/providers/senshi/provider_test.go | 148 +++----- internal/providers/senshi/register.go | 4 +- internal/providers/senshi/search.go | 25 +- internal/providers/senshi/streams.go | 116 +++++-- internal/providers/senshi/subtitles.go | 49 --- internal/providers/senshi/subtitles_test.go | 22 -- internal/providers/senshi/types.go | 62 ++-- 15 files changed, 780 insertions(+), 230 deletions(-) create mode 100644 internal/providers/senshi/em3u8.go create mode 100644 internal/providers/senshi/em3u8_test.go create mode 100644 internal/providers/senshi/live_test.go diff --git a/README.md b/README.md index 51c2451..3000b09 100644 --- a/README.md +++ b/README.md @@ -424,7 +424,7 @@ If the browser reaches the localhost callback page but curd does not continue au - [MyAnimeList API](https://myanimelist.net/apiconfig/references/api/v2) - MyAnimeList OAuth and tracking sync - [AniSkip API](https://api.aniskip.com/api-docs) - Get anime intro and outro timings - [AllAnime Content](https://allanime.to/) - Fetch anime url -- [Senshi Project](https://senshi.live/) - Default provider with direct HLS streams and MAL-based catalog matching +- [Senshi Project](https://senshi.to/) - Default provider with Vidcloud HLS streams and MAL-based catalog matching - [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 diff --git a/docs/providers.md b/docs/providers.md index e3554ed..95250a3 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -327,7 +327,7 @@ Document the decision in a short ADR before building. The compile-time registry | Provider | Package | Notes | |----------|---------|-------| -| Senshi | `internal/providers/senshi` | REST search/episodes, direct HLS from `/episode-embeds`, MAL id keys and `/posters/{mal_id}.webp` thumbnails | +| Senshi | `internal/providers/senshi` | REST search/episodes, Vidcloud source resolution with encrypted HLS proxying, and MAL id keys | | AniPub | `internal/providers/anipub` | JSON search/info/details APIs, MegaPlay embed resolution via `/stream/getSources`, MAL id in `ExtraData` for tracker matching | | 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` | diff --git a/internal/providers/senshi/client.go b/internal/providers/senshi/client.go index 3a5de9a..789eac9 100644 --- a/internal/providers/senshi/client.go +++ b/internal/providers/senshi/client.go @@ -13,7 +13,7 @@ import ( const userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" -var baseURL = "https://senshi.live" +var baseURL = "https://senshi.to" func newRequest(method, rawURL string) (*http.Request, error) { req, err := http.NewRequest(method, rawURL, nil) @@ -22,6 +22,10 @@ func newRequest(method, rawURL string) (*http.Request, error) { } req.Header.Set("User-Agent", userAgent) req.Header.Set("Referer", baseURL+"/") + req.Header.Set("Accept", "application/json") + if strings.HasPrefix(rawURL, vidcloudSourcesBaseURL+"/") { + req.Header.Set("Origin", baseURL) + } return req, nil } @@ -41,6 +45,10 @@ func fetchJSON(method, rawURL string, payload any, dest any) error { } req.Header.Set("User-Agent", userAgent) req.Header.Set("Referer", baseURL+"/") + req.Header.Set("Accept", "application/json") + if strings.HasPrefix(rawURL, vidcloudSourcesBaseURL+"/") { + req.Header.Set("Origin", baseURL) + } if payload != nil { req.Header.Set("Content-Type", "application/json") } diff --git a/internal/providers/senshi/em3u8.go b/internal/providers/senshi/em3u8.go new file mode 100644 index 0000000..5f6bf9a --- /dev/null +++ b/internal/providers/senshi/em3u8.go @@ -0,0 +1,359 @@ +package senshi + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "io" + "net" + "net/http" + "net/url" + "regexp" + "strconv" + "strings" + "sync" + + "github.com/wraient/curd/internal/curdhost" +) + +const senshiEncryptedPlaylistPrefix = "EM3U8v1:" + +var ( + senshiFallbackPlaylistKey, _ = hex.DecodeString("6EE2721327ED469BB6D93AB9B7A838045190B5BA85D9CEA3B1E17805F7B4AEF6") + senshiPlaylistKey = append([]byte(nil), senshiFallbackPlaylistKey...) + senshiPlaylistKeyMu sync.Mutex + senshiProxyMu sync.Mutex + senshiProxyServer *senshiProxy + senshiIndexScriptRE = regexp.MustCompile(`assets/(index-[A-Za-z0-9_-]+)\.js`) + senshiWatchScriptRE = regexp.MustCompile(`WatchPage-([A-Za-z0-9_-]+)\.js`) + senshiKeyArrayRE = regexp.MustCompile(`Uint8Array\.from\(\[((?:\d{1,3},){31}\d{1,3})\]\)`) + senshiURIAttributeRE = regexp.MustCompile(`URI="([^"]+)"`) +) + +type senshiProxySession struct { + audioRendition string +} + +type senshiProxy struct { + baseURL string + server *http.Server + sessions map[string]senshiProxySession +} + +func registerSenshiStream(masterURL, audioRendition, subtitleURL string) (string, string, error) { + proxy, err := getSenshiProxy() + if err != nil { + return "", "", err + } + id, err := randomSenshiSessionID() + if err != nil { + return "", "", err + } + senshiProxyMu.Lock() + proxy.sessions[id] = senshiProxySession{audioRendition: audioRendition} + senshiProxyMu.Unlock() + + streamURL := proxy.proxyURL(id, masterURL) + proxiedSubtitle := "" + if strings.TrimSpace(subtitleURL) != "" { + proxiedSubtitle = proxy.proxyURL(id, subtitleURL) + } + return streamURL, proxiedSubtitle, nil +} + +func getSenshiProxy() (*senshiProxy, error) { + senshiProxyMu.Lock() + defer senshiProxyMu.Unlock() + if senshiProxyServer != nil { + return senshiProxyServer, nil + } + + proxy := &senshiProxy{sessions: make(map[string]senshiProxySession)} + mux := http.NewServeMux() + mux.HandleFunc("/stream/", proxy.handle) + server := &http.Server{Handler: mux} + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("start Senshi proxy: %w", err) + } + proxy.baseURL = "http://" + listener.Addr().String() + proxy.server = server + go func() { _ = server.Serve(listener) }() + senshiProxyServer = proxy + return proxy, nil +} + +func (p *senshiProxy) proxyURL(sessionID, upstream string) string { + return p.baseURL + "/stream/" + sessionID + "?url=" + url.QueryEscape(upstream) +} + +func (p *senshiProxy) handle(w http.ResponseWriter, r *http.Request) { + path := strings.TrimPrefix(r.URL.Path, "/stream/") + sessionID := strings.Trim(path, "/") + senshiProxyMu.Lock() + session, ok := p.sessions[sessionID] + senshiProxyMu.Unlock() + if !ok { + http.NotFound(w, r) + return + } + + upstream := strings.TrimSpace(r.URL.Query().Get("url")) + parsed, err := url.Parse(upstream) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + http.Error(w, "invalid upstream URL", http.StatusBadRequest) + return + } + + body, contentType, status, err := fetchSenshiResource(upstream) + if err != nil { + http.Error(w, err.Error(), status) + return + } + if isSenshiPlaylistURL(upstream) { + playlist, err := decryptSenshiPlaylist(body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + playlist = filterSenshiAudioRenditions(playlist, session.audioRendition) + playlist = p.rewritePlaylist(sessionID, upstream, playlist) + w.Header().Set("Content-Type", "application/vnd.apple.mpegurl") + _, _ = io.WriteString(w, playlist) + return + } + if contentType != "" { + w.Header().Set("Content-Type", contentType) + } + if strings.HasSuffix(strings.ToLower(parsed.Path), ".jpg") { + w.Header().Set("Content-Type", "video/mp2t") + } + _, _ = w.Write(body) +} + +func fetchSenshiResource(rawURL string) ([]byte, string, int, error) { + req, err := newRequest(http.MethodGet, rawURL) + if err != nil { + return nil, "", http.StatusBadRequest, err + } + req.Header.Set("Origin", baseURL) + resp, err := curdhost.HTTPClient().Do(req) + if err != nil { + return nil, "", http.StatusBadGateway, err + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", http.StatusBadGateway, err + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + return nil, "", http.StatusBadGateway, fmt.Errorf("Senshi upstream returned status %d", resp.StatusCode) + } + return body, resp.Header.Get("Content-Type"), http.StatusBadGateway, nil +} + +func decryptSenshiPlaylist(body []byte) (string, error) { + text := strings.TrimSpace(string(body)) + if !strings.HasPrefix(text, senshiEncryptedPlaylistPrefix) { + return string(body), nil + } + + senshiPlaylistKeyMu.Lock() + key := append([]byte(nil), senshiPlaylistKey...) + senshiPlaylistKeyMu.Unlock() + decrypted, err := decryptSenshiPlaylistWithKey(text, key) + if err == nil { + return decrypted, nil + } + fresh, refreshErr := refreshSenshiPlaylistKey() + if refreshErr != nil { + return "", fmt.Errorf("decrypt Senshi playlist: %w", err) + } + decrypted, err = decryptSenshiPlaylistWithKey(text, fresh) + if err != nil { + return "", fmt.Errorf("decrypt Senshi playlist with refreshed key: %w", err) + } + return decrypted, nil +} + +func decryptSenshiPlaylistWithKey(body string, key []byte) (string, error) { + data, err := base64.StdEncoding.DecodeString(strings.TrimSpace(strings.TrimPrefix(body, senshiEncryptedPlaylistPrefix))) + if err != nil { + return "", err + } + if len(data) < 12+16 { + return "", fmt.Errorf("truncated encrypted playlist") + } + block, err := aes.NewCipher(key) + if err != nil { + return "", err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", err + } + plain, err := gcm.Open(nil, data[:12], data[12:], nil) + if err != nil { + return "", err + } + return string(plain), nil +} + +func refreshSenshiPlaylistKey() ([]byte, error) { + home, err := fetchSenshiText(baseURL + "/") + if err != nil { + return nil, err + } + indexMatch := senshiIndexScriptRE.FindStringSubmatch(home) + if len(indexMatch) != 2 { + return nil, fmt.Errorf("Senshi index script not found") + } + indexJS, err := fetchSenshiText(baseURL + "/assets/" + indexMatch[1] + ".js") + if err != nil { + return nil, err + } + watchMatch := senshiWatchScriptRE.FindStringSubmatch(indexJS) + if len(watchMatch) != 2 { + return nil, fmt.Errorf("Senshi watch script not found") + } + watchJS, err := fetchSenshiText(baseURL + "/assets/WatchPage-" + watchMatch[1] + ".js") + if err != nil { + return nil, err + } + matches := senshiKeyArrayRE.FindAllStringSubmatch(watchJS, -1) + if len(matches) != 2 { + return nil, fmt.Errorf("expected two Senshi playlist key arrays, got %d", len(matches)) + } + a, err := parseSenshiKeyArray(matches[0][1]) + if err != nil { + return nil, err + } + b, err := parseSenshiKeyArray(matches[1][1]) + if err != nil { + return nil, err + } + key := make([]byte, 32) + for i := range key { + key[i] = a[i] ^ b[i] + } + senshiPlaylistKeyMu.Lock() + senshiPlaylistKey = append([]byte(nil), key...) + senshiPlaylistKeyMu.Unlock() + return key, nil +} + +func parseSenshiKeyArray(csv string) ([]byte, error) { + parts := strings.Split(csv, ",") + if len(parts) != 32 { + return nil, fmt.Errorf("invalid Senshi key array length %d", len(parts)) + } + key := make([]byte, len(parts)) + for i, part := range parts { + value, err := strconv.Atoi(strings.TrimSpace(part)) + if err != nil || value < 0 || value > 255 { + return nil, fmt.Errorf("invalid Senshi key byte %q", part) + } + key[i] = byte(value) + } + return key, nil +} + +func fetchSenshiText(rawURL string) (string, error) { + body, _, _, err := fetchSenshiResource(rawURL) + return string(body), err +} + +func (p *senshiProxy) rewritePlaylist(sessionID, parentURL, playlist string) string { + parent, err := url.Parse(parentURL) + if err != nil { + return playlist + } + lines := strings.Split(strings.ReplaceAll(playlist, "\r\n", "\n"), "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed == "" { + continue + } + if strings.HasPrefix(trimmed, "#") { + lines[i] = senshiURIAttributeRE.ReplaceAllStringFunc(line, func(attribute string) string { + match := senshiURIAttributeRE.FindStringSubmatch(attribute) + if len(match) != 2 { + return attribute + } + resolved := resolveSenshiURL(parent, match[1]) + return `URI="` + p.proxyURL(sessionID, resolved) + `"` + }) + continue + } + resolved := resolveSenshiURL(parent, trimmed) + lines[i] = p.proxyURL(sessionID, resolved) + } + return strings.Join(lines, "\n") +} + +func resolveSenshiURL(parent *url.URL, ref string) string { + parsed, err := url.Parse(strings.TrimSpace(ref)) + if err != nil { + return ref + } + return parent.ResolveReference(parsed).String() +} + +func isSenshiPlaylistURL(rawURL string) bool { + parsed, err := url.Parse(rawURL) + if err != nil { + return false + } + path := strings.ToLower(parsed.Path) + return strings.HasSuffix(path, ".m3u8") || strings.HasSuffix(path, ".txt") +} + +func filterSenshiAudioRenditions(manifest, pattern string) string { + if pattern == "" { + return manifest + } + lines := strings.Split(manifest, "\n") + audioCount, matchingCount := 0, 0 + for _, line := range lines { + if strings.Contains(line, "#EXT-X-MEDIA") && strings.Contains(line, "TYPE=AUDIO") { + audioCount++ + if strings.Contains(line, pattern) { + matchingCount++ + } + } + } + if audioCount == 0 || matchingCount == 0 || matchingCount == audioCount { + return manifest + } + for i, line := range lines { + if !strings.Contains(line, "#EXT-X-MEDIA") || !strings.Contains(line, "TYPE=AUDIO") { + continue + } + if strings.Contains(line, pattern) { + lines[i] = strings.Replace(line, "DEFAULT=NO", "DEFAULT=YES", 1) + } else { + lines[i] = "" + } + } + return strings.Join(lines, "\n") +} + +func randomSenshiSessionID() (string, error) { + buf := make([]byte, 8) + if _, err := rand.Read(buf); err != nil { + return "", err + } + return hex.EncodeToString(buf), nil +} + +func resetSenshiProxyForTest() { + senshiProxyMu.Lock() + defer senshiProxyMu.Unlock() + if senshiProxyServer != nil { + _ = senshiProxyServer.server.Close() + } + senshiProxyServer = nil +} diff --git a/internal/providers/senshi/em3u8_test.go b/internal/providers/senshi/em3u8_test.go new file mode 100644 index 0000000..8171dc5 --- /dev/null +++ b/internal/providers/senshi/em3u8_test.go @@ -0,0 +1,109 @@ +package senshi + +import ( + "crypto/aes" + "crypto/cipher" + "encoding/base64" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/wraient/curd/internal/curdhost" +) + +func TestDecryptSenshiPlaylist(t *testing.T) { + plain := "#EXTM3U\nsegment.jpg\n" + block, err := aes.NewCipher(senshiFallbackPlaylistKey) + if err != nil { + t.Fatal(err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + nonce := []byte("123456789012") + payload := append(append([]byte(nil), nonce...), gcm.Seal(nil, nonce, []byte(plain), nil)...) + encrypted := senshiEncryptedPlaylistPrefix + base64.StdEncoding.EncodeToString(payload) + + senshiPlaylistKeyMu.Lock() + senshiPlaylistKey = append([]byte(nil), senshiFallbackPlaylistKey...) + senshiPlaylistKeyMu.Unlock() + got, err := decryptSenshiPlaylist([]byte(encrypted)) + if err != nil { + t.Fatalf("decryptSenshiPlaylist: %v", err) + } + if got != plain { + t.Fatalf("got %q, want %q", got, plain) + } +} + +func TestSenshiProxyRewritesNestedPlaylistsAndSegments(t *testing.T) { + proxy := &senshiProxy{baseURL: "http://127.0.0.1:1234"} + manifest := "#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,URI=\"audio/0_ja.txt\"\nvideo/main.m3u8\nsegments/one.jpg?sig=x\n" + got := proxy.rewritePlaylist("session", "https://cdn.example/path/master.txt", manifest) + + if !strings.Contains(got, "http://127.0.0.1:1234/stream/session?url=") { + t.Fatalf("nested playlists were not proxied:\n%s", got) + } + if !strings.Contains(got, "segments%2Fone.jpg") { + t.Fatalf("segment URL was not proxied:\n%s", got) + } +} + +func TestFilterSenshiAudioRenditions(t *testing.T) { + manifest := "#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,DEFAULT=NO,URI=\"audio/0_ja.txt\"\n#EXT-X-MEDIA:TYPE=AUDIO,DEFAULT=NO,URI=\"audio/1_en.txt\"\n" + got := filterSenshiAudioRenditions(manifest, "1_en") + if strings.Contains(got, "0_ja") || !strings.Contains(got, "1_en") || !strings.Contains(got, "DEFAULT=YES") { + t.Fatalf("unexpected filtered manifest:\n%s", got) + } +} + +func TestRefreshSenshiPlaylistKeyFromSiteBundles(t *testing.T) { + first := make([]string, 32) + second := make([]string, 32) + for i := range first { + first[i] = fmt.Sprintf("%d", i) + second[i] = fmt.Sprintf("%d", 255-i) + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/": + _, _ = io.WriteString(w, ``) + case "/assets/index-example.js": + _, _ = io.WriteString(w, `import("./WatchPage-example.js")`) + case "/assets/WatchPage-example.js": + _, _ = io.WriteString(w, "Uint8Array.from(["+strings.Join(first, ",")+"]);Uint8Array.from(["+strings.Join(second, ",")+"])") + 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 + senshiPlaylistKeyMu.Lock() + senshiPlaylistKey = append([]byte(nil), senshiFallbackPlaylistKey...) + senshiPlaylistKeyMu.Unlock() + }) + + key, err := refreshSenshiPlaylistKey() + if err != nil { + t.Fatalf("refreshSenshiPlaylistKey: %v", err) + } + if len(key) != 32 { + t.Fatalf("key length = %d", len(key)) + } + for i, value := range key { + if value != 255 { + t.Fatalf("key[%d] = %d, want 255", i, value) + } + } +} diff --git a/internal/providers/senshi/episodes.go b/internal/providers/senshi/episodes.go index 989c5e7..7e400f9 100644 --- a/internal/providers/senshi/episodes.go +++ b/internal/providers/senshi/episodes.go @@ -26,9 +26,6 @@ func episodesList(showID, mode string) ([]string, error) { seen := map[int]struct{}{} for _, ep := range episodes { epNo := ep.EpID - if epNo <= 0 { - epNo = ep.ID - } if epNo > 0 { seen[epNo] = struct{}{} } diff --git a/internal/providers/senshi/live_test.go b/internal/providers/senshi/live_test.go new file mode 100644 index 0000000..a007468 --- /dev/null +++ b/internal/providers/senshi/live_test.go @@ -0,0 +1,97 @@ +package senshi + +import ( + "io" + "net/http" + "os" + "strings" + "testing" + + "github.com/wraient/curd/internal/curdhost" + "github.com/wraient/curd/internal/providers" +) + +// Run with: CURD_LIVE_SENSHI=1 go test ./internal/providers/senshi -run TestLiveSenshiPlaybackResolution -v +func TestLiveSenshiPlaybackResolution(t *testing.T) { + if os.Getenv("CURD_LIVE_SENSHI") == "" { + t.Skip("set CURD_LIVE_SENSHI=1 to run the live Senshi flow") + } + previousClient := curdhost.HTTPClient + curdhost.HTTPClient = func() *http.Client { return http.DefaultClient } + t.Cleanup(func() { + curdhost.HTTPClient = previousClient + resetSenshiProxyForTest() + }) + + results, err := searchAnime("frieren", "sub") + if err != nil || len(results) == 0 { + t.Fatalf("search Senshi: results=%d err=%v", len(results), err) + } + episodes, err := episodesList(results[0].Key, "sub") + if err != nil || len(episodes) == 0 { + t.Fatalf("list Senshi episodes: episodes=%d err=%v", len(episodes), err) + } + links, _, err := getEpisodeStreamsForMode(results[0].Key, providers.PlaybackConfig{SubOrDub: "sub"}, 1) + if err != nil || len(links) == 0 { + t.Fatalf("resolve Senshi stream: links=%d err=%v", len(links), err) + } + resp, err := http.Get(links[0]) + if err != nil { + t.Fatalf("fetch proxied Senshi master: %v", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read proxied Senshi master: %v", err) + } + if resp.StatusCode != http.StatusOK || !strings.HasPrefix(strings.TrimSpace(string(body)), "#EXTM3U") { + t.Fatalf("proxied Senshi master status=%d body=%q", resp.StatusCode, body) + } + var variantURL string + for _, line := range strings.Split(string(body), "\n") { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + variantURL = line + break + } + } + if variantURL == "" { + t.Fatal("proxied Senshi master did not contain a variant URL") + } + variantResp, err := http.Get(variantURL) + if err != nil { + t.Fatalf("fetch proxied Senshi variant: %v", err) + } + defer variantResp.Body.Close() + variantBody, err := io.ReadAll(variantResp.Body) + if err != nil { + t.Fatalf("read proxied Senshi variant: %v", err) + } + if variantResp.StatusCode != http.StatusOK || !strings.HasPrefix(strings.TrimSpace(string(variantBody)), "#EXTM3U") { + t.Fatalf("proxied Senshi variant status=%d body=%q", variantResp.StatusCode, variantBody) + } + var segmentURL string + for _, line := range strings.Split(string(variantBody), "\n") { + line = strings.TrimSpace(line) + if line != "" && !strings.HasPrefix(line, "#") { + segmentURL = line + break + } + } + if segmentURL == "" { + t.Fatal("proxied Senshi variant did not contain a segment URL") + } + segmentReq, err := http.NewRequest(http.MethodGet, segmentURL, nil) + if err != nil { + t.Fatalf("build Senshi segment request: %v", err) + } + segmentReq.Header.Set("Referer", baseURL+"/") + segmentResp, err := http.DefaultClient.Do(segmentReq) + if err != nil { + t.Fatalf("fetch Senshi segment with MPV-compatible headers: %v", err) + } + defer segmentResp.Body.Close() + if segmentResp.StatusCode != http.StatusOK { + t.Fatalf("Senshi segment status=%d", segmentResp.StatusCode) + } +} diff --git a/internal/providers/senshi/provider.go b/internal/providers/senshi/provider.go index 986d54d..61b7107 100644 --- a/internal/providers/senshi/provider.go +++ b/internal/providers/senshi/provider.go @@ -2,7 +2,7 @@ package senshi import "github.com/wraient/curd/internal/providers" -// Provider implements senshi.live catalog search and direct HLS stream resolution. +// Provider implements senshi.to catalog search and Vidcloud HLS stream resolution. type Provider struct{} func (p *Provider) Name() string { diff --git a/internal/providers/senshi/provider_test.go b/internal/providers/senshi/provider_test.go index 1e24d24..7bd5ed3 100644 --- a/internal/providers/senshi/provider_test.go +++ b/internal/providers/senshi/provider_test.go @@ -45,6 +45,13 @@ func TestSearchAnimeParsesResults(t *testing.T) { http.NotFound(w, r) return } + var request map[string]any + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatalf("decode search request: %v", err) + } + if request["sortBy"] != "score_desc" || request["languagePreference"] != "EN" || request["limit"] != float64(30) { + t.Fatalf("incomplete search request: %#v", request) + } _ = json.NewEncoder(w).Encode(filterResponse{ Data: []animeItem{{ ID: 52991, @@ -54,6 +61,7 @@ func TestSearchAnimeParsesResults(t *testing.T) { Type: "TV", AniEpisodes: "28", AniYear: 2023, + AnimePicture: "/images/frieren.webp", }}, Total: 1, }) @@ -75,7 +83,7 @@ func TestSearchAnimeParsesResults(t *testing.T) { if options[0].Key != "52991" { t.Fatalf("unexpected key %q", options[0].Key) } - if !strings.Contains(options[0].Thumbnail, "/posters/52991.webp") { + if !strings.Contains(options[0].Thumbnail, "/images/frieren.webp") { t.Fatalf("unexpected thumbnail %q", options[0].Thumbnail) } item, ok := options[0].ExtraData.(SearchItem) @@ -111,60 +119,26 @@ func TestEpisodesListParsesEpisodeIDs(t *testing.T) { } } -func TestGetEpisodeStreamsForModeSelectsHardSub(t *testing.T) { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/episode-embeds/52991/1" { - http.NotFound(w, r) - return - } - _ = json.NewEncoder(w).Encode([]embedItem{ - {URL: "https://cdn.example/sub.m3u8", Status: "HardSub"}, - {URL: "https://cdn.example/dub.m3u8", Status: "Dub"}, - }) - })) - defer server.Close() - - withSenshiTestClient(t, server.Client()) - originalBase := baseURL - t.Cleanup(func() { baseURL = originalBase }) - baseURL = server.URL - - links, hints, err := getEpisodeStreamsForMode("52991", providers.PlaybackConfig{SubOrDub: "sub"}, 1) - if err != nil { - t.Fatalf("getEpisodeStreamsForMode: %v", err) - } - if len(links) != 1 || links[0] != "https://cdn.example/sub.m3u8" { - t.Fatalf("unexpected links %#v", links) - } - if hints[links[0]].Referrer != baseURL+"/" { - t.Fatalf("unexpected referrer %q", hints[links[0]].Referrer) - } - if hints[links[0]].Subtitle != "" { - t.Fatalf("expected no external subtitle for hard sub, got %q", hints[links[0]].Subtitle) - } -} - -func TestGetEpisodeStreamsForModeLoadsSoftSubtitles(t *testing.T) { - const manifestPath = "/sub_filemoon.json" - +func TestGetEpisodeStreamsForModeResolvesVidcloudThroughProxy(t *testing.T) { var server *httptest.Server + var sawVideoHeaders bool server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/episode-embeds/62435/1": - manifestURL := server.URL + manifestPath - serverFM := "https://embed.example/e/abc/?sub.info=" + manifestURL - _ = json.NewEncoder(w).Encode([]embedItem{{ - URL: "https://cdn.example/soft.m3u8", - ServerFM: strPtr(serverFM), - Status: "HardSub", - MaskedBaseURL: "https://ninstream.com/example/base", - }}) - case r.URL.Path == manifestPath: - _ = json.NewEncoder(w).Encode([]senshiSubtitleTrack{{ - Src: server.URL + "/sub_2_eng.vtt", - Label: "ENG", - Default: true, + switch r.URL.Path { + case "/episode-embeds/62435/1": + _ = json.NewEncoder(w).Encode([]embedItem{{RemoteSourceID: intPtr(42), Status: "HardSub"}}) + case "/_v1/sources": + if r.URL.Query().Get("id") != "42" { + t.Fatalf("unexpected source id %q", r.URL.Query().Get("id")) + } + _ = json.NewEncoder(w).Encode([]vidcloudEntry{{ + Source: &vidcloudSource{Src: server.URL + "/master.txt"}, + Tracks: []vidcloudTrack{{URL: server.URL + "/english.vtt", Label: "ENG", Default: true}}, }}) + case "/master.txt": + sawVideoHeaders = r.Header.Get("Origin") == baseURL && r.Header.Get("Referer") == baseURL+"/" + _, _ = io.WriteString(w, "#EXTM3U\n#EXT-X-MEDIA:TYPE=AUDIO,DEFAULT=NO,URI=\"audio/0_ja.txt\"\n#EXT-X-MEDIA:TYPE=AUDIO,DEFAULT=NO,URI=\"audio/1_en.txt\"\n#EXT-X-STREAM-INF:BANDWIDTH=1\nvideo/main.txt\n") + case "/english.vtt": + _, _ = io.WriteString(w, "WEBVTT\n") default: http.NotFound(w, r) } @@ -173,65 +147,43 @@ func TestGetEpisodeStreamsForModeLoadsSoftSubtitles(t *testing.T) { withSenshiTestClient(t, server.Client()) originalBase := baseURL - t.Cleanup(func() { baseURL = originalBase }) + originalVidcloudBase := vidcloudSourcesBaseURL + resetSenshiProxyForTest() + t.Cleanup(func() { + baseURL = originalBase + vidcloudSourcesBaseURL = originalVidcloudBase + resetSenshiProxyForTest() + }) baseURL = server.URL + vidcloudSourcesBaseURL = server.URL links, hints, err := getEpisodeStreamsForMode("62435", providers.PlaybackConfig{SubOrDub: "sub"}, 1) if err != nil { t.Fatalf("getEpisodeStreamsForMode: %v", err) } - if len(links) != 1 || links[0] != "https://cdn.example/soft.m3u8" { + if len(links) != 1 { t.Fatalf("unexpected links %#v", links) } - if hints[links[0]].Subtitle != server.URL+"/sub_2_eng.vtt" { - t.Fatalf("unexpected subtitle %q", hints[links[0]].Subtitle) - } -} - -func TestGetEpisodeStreamsForModeHardStillInjectsSenshiSubs(t *testing.T) { - const manifestPath = "/sub_filemoon.json" - - var server *httptest.Server - server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch { - case r.URL.Path == "/episode-embeds/62435/1": - manifestURL := server.URL + manifestPath - serverFM := "https://embed.example/e/abc/?sub.info=" + manifestURL - _ = json.NewEncoder(w).Encode([]embedItem{{ - URL: "https://cdn.example/soft.m3u8", - ServerFM: strPtr(serverFM), - Status: "HardSub", - MaskedBaseURL: "https://ninstream.com/example/base", - }}) - case r.URL.Path == manifestPath: - _ = json.NewEncoder(w).Encode([]senshiSubtitleTrack{{ - Src: server.URL + "/sub_2_eng.vtt", - Label: "ENG", - Default: true, - }}) - default: - http.NotFound(w, r) - } - })) - defer server.Close() - - withSenshiTestClient(t, server.Client()) - originalBase := baseURL - t.Cleanup(func() { baseURL = originalBase }) - baseURL = server.URL - - links, hints, err := getEpisodeStreamsForMode("62435", providers.PlaybackConfig{SubOrDub: "sub", SubStyle: "hard"}, 1) + resp, err := http.Get(links[0]) if err != nil { - t.Fatalf("getEpisodeStreamsForMode: %v", err) + t.Fatalf("fetch proxied manifest: %v", err) } - if len(links) != 1 { - t.Fatalf("unexpected links %#v", links) + manifest, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + if !sawVideoHeaders { + t.Fatal("proxied playlist request did not include Senshi Origin and Referer") + } + if strings.Contains(string(manifest), "1_en") || !strings.Contains(string(manifest), "0_ja") { + t.Fatalf("unexpected filtered manifest:\n%s", manifest) + } + if !strings.Contains(string(manifest), "127.0.0.1") { + t.Fatalf("nested playlist was not proxied:\n%s", manifest) } - if hints[links[0]].Subtitle != server.URL+"/sub_2_eng.vtt" { - t.Fatalf("expected senshi external subs even with hard preference, got %q", hints[links[0]].Subtitle) + if !strings.Contains(hints[links[0]].Subtitle, "127.0.0.1") { + t.Fatalf("subtitle was not proxied: %q", hints[links[0]].Subtitle) } } -func strPtr(value string) *string { +func intPtr(value int) *int { return &value } diff --git a/internal/providers/senshi/register.go b/internal/providers/senshi/register.go index 4cf708e..78f54c6 100644 --- a/internal/providers/senshi/register.go +++ b/internal/providers/senshi/register.go @@ -5,8 +5,8 @@ import "github.com/wraient/curd/internal/providers" func init() { providers.Register(providers.Meta{ Name: "senshi", - Aliases: []string{"senshi.live", "senshi project"}, - Referrer: "https://senshi.live/", + Aliases: []string{"senshi.to", "senshi.live", "senshi project"}, + Referrer: "https://senshi.to/", }, func() providers.Provider { return &Provider{} }) diff --git a/internal/providers/senshi/search.go b/internal/providers/senshi/search.go index 2540d71..1089906 100644 --- a/internal/providers/senshi/search.go +++ b/internal/providers/senshi/search.go @@ -18,9 +18,19 @@ func searchAnime(query, mode string) ([]providers.SelectionOption, error) { var payload filterResponse err := fetchJSON(http.MethodPost, baseURL+"/anime/filter", map[string]any{ - "searchTerm": query, - "page": 1, - "limit": 25, + "searchTerm": query, + "types": []string{}, + "genres": []string{}, + "status": []string{}, + "seasons": []string{}, + "year": "", + "studios": []string{}, + "producers": []string{}, + "languages": []string{}, + "page": 1, + "limit": 30, + "sortBy": "score_desc", + "languagePreference": "EN", }, &payload) if err != nil { return nil, err @@ -44,7 +54,7 @@ func searchAnime(query, mode string) ([]providers.SelectionOption, error) { Key: strconv.Itoa(malID), Label: label, Title: title, - Thumbnail: posterURL(malID), + Thumbnail: searchThumbnail(item), ExtraData: toSearchItem(item), }) } @@ -54,6 +64,13 @@ func searchAnime(query, mode string) ([]providers.SelectionOption, error) { return options, nil } +func searchThumbnail(item animeItem) string { + if thumbnail := absoluteURL(item.AnimePicture); thumbnail != "" { + return thumbnail + } + return posterURL(item.ID) +} + func formatSearchLabel(item animeItem) string { title := strings.TrimSpace(item.TitleEnglish) if title == "" { diff --git a/internal/providers/senshi/streams.go b/internal/providers/senshi/streams.go index 295c6ba..88b1bcc 100644 --- a/internal/providers/senshi/streams.go +++ b/internal/providers/senshi/streams.go @@ -3,11 +3,15 @@ package senshi import ( "fmt" "net/http" + "net/url" + "strconv" "strings" "github.com/wraient/curd/internal/providers" ) +var vidcloudSourcesBaseURL = "https://s.vidcloud.se" + func getEpisodeStreamsForMode(malIDStr string, config providers.PlaybackConfig, epNo int) ([]string, map[string]providers.StreamPlaybackHint, error) { malID, err := parseMalID(malIDStr) if err != nil { @@ -18,44 +22,106 @@ func getEpisodeStreamsForMode(malIDStr string, config providers.PlaybackConfig, } mode := providers.NormalizeTranslationType(config.SubOrDub) - var embeds []embedItem - url := fmt.Sprintf("%s/episode-embeds/%d/%d", baseURL, malID, epNo) - if err := fetchJSON(http.MethodGet, url, nil, &embeds); err != nil { + embedURL := fmt.Sprintf("%s/episode-embeds/%d/%d", baseURL, malID, epNo) + if err := fetchJSON(http.MethodGet, embedURL, nil, &embeds); err != nil { return nil, nil, err } - if len(embeds) == 0 { - return nil, nil, fmt.Errorf("no streams found for episode %d", epNo) - } - wantStatus := "HardSub" + audioRendition := "0_ja" if mode == "dub" { - wantStatus = "Dub" + audioRendition = "1_en" } - for _, item := range embeds { - if !strings.EqualFold(strings.TrimSpace(item.Status), wantStatus) { - continue - } - streamURL := strings.TrimSpace(item.URL) - if streamURL == "" { + links := make([]string, 0) + hints := make(map[string]providers.StreamPlaybackHint) + seen := make(map[string]struct{}) + var lastErr error + for _, embed := range embeds { + if !senshiEmbedMatchesMode(embed.Status, mode) || embed.RemoteSourceID == nil || *embed.RemoteSourceID <= 0 { continue } - subtitle := "" - if mode == "sub" { - // Senshi labels streams HardSub even when subtitles are external. - subtitle = resolveSenshiSubtitle(item) + entries, err := fetchVidcloudEntries(*embed.RemoteSourceID) + if err != nil { + lastErr = err + continue } - - hints := map[string]providers.StreamPlaybackHint{ - streamURL: { - Referrer: baseURL + "/", - Subtitle: subtitle, - }, + for _, entry := range entries { + if entry.Source == nil || strings.TrimSpace(entry.Source.Src) == "" { + continue + } + sourceURL := strings.TrimSpace(entry.Source.Src) + subtitle := pickVidcloudSubtitle(entry.Tracks, mode) + if parent, parseErr := url.Parse(sourceURL); parseErr == nil && subtitle != "" { + subtitle = resolveSenshiURL(parent, subtitle) + } + streamURL, subtitleURL, err := registerSenshiStream(sourceURL, audioRendition, subtitle) + if err != nil { + lastErr = err + continue + } + if _, exists := seen[streamURL]; exists { + continue + } + seen[streamURL] = struct{}{} + links = append(links, streamURL) + hints[streamURL] = providers.StreamPlaybackHint{Referrer: baseURL + "/", Subtitle: subtitleURL} } - return []string{streamURL}, hints, nil } + if len(links) > 0 { + return links, hints, nil + } + if lastErr != nil { + return nil, nil, fmt.Errorf("resolve Senshi %s stream for episode %d: %w", mode, epNo, lastErr) + } return nil, nil, fmt.Errorf("no %s streams found for episode %d", mode, epNo) } + +func senshiEmbedMatchesMode(status, mode string) bool { + if mode == "dub" { + return strings.EqualFold(strings.TrimSpace(status), "Dub") + } + return isSubEmbedStatus(status) +} + +func fetchVidcloudEntries(sourceID int) ([]vidcloudEntry, error) { + endpoint, err := url.Parse(vidcloudSourcesBaseURL + "/_v1/sources") + if err != nil { + return nil, err + } + query := endpoint.Query() + query.Set("id", strconv.Itoa(sourceID)) + endpoint.RawQuery = query.Encode() + + var entries []vidcloudEntry + if err := fetchJSON(http.MethodGet, endpoint.String(), nil, &entries); err != nil { + return nil, err + } + return entries, nil +} + +func pickVidcloudSubtitle(tracks []vidcloudTrack, mode string) string { + filtered := make([]senshiSubtitleTrack, 0, len(tracks)) + all := make([]senshiSubtitleTrack, 0, len(tracks)) + for _, track := range tracks { + trackURL := strings.TrimSpace(track.URL) + if trackURL == "" { + trackURL = strings.TrimSpace(track.VTTURL) + } + if trackURL == "" || strings.EqualFold(strings.TrimSpace(track.Label), "chapter") { + continue + } + candidate := senshiSubtitleTrack{Src: trackURL, Label: track.Label, Default: track.Default} + all = append(all, candidate) + isDub := strings.Contains(strings.ToLower(track.Label), "dub") || strings.Contains(strings.ToLower(trackURL), "ai_dub") + if (mode == "dub") == isDub { + filtered = append(filtered, candidate) + } + } + if len(filtered) == 0 { + filtered = all + } + return pickSenshiSubtitleTrack(filtered) +} diff --git a/internal/providers/senshi/subtitles.go b/internal/providers/senshi/subtitles.go index 38106cd..0310c54 100644 --- a/internal/providers/senshi/subtitles.go +++ b/internal/providers/senshi/subtitles.go @@ -25,43 +25,6 @@ type senshiSubtitleTrack struct { Default bool `json:"default"` } -func senshiSubtitleManifestURL(item embedItem) string { - if item.ServerFM != nil { - if manifest := subtitleInfoFromURL(strings.TrimSpace(*item.ServerFM)); manifest != "" { - return manifest - } - } - base := strings.TrimSpace(item.MaskedBaseURL) - if base == "" { - return "" - } - return strings.TrimRight(base, "/") + "/sub_filemoon.json" -} - -func subtitleInfoFromURL(rawURL string) string { - if rawURL == "" { - return "" - } - parsed, err := url.Parse(rawURL) - if err != nil { - return "" - } - return strings.TrimSpace(parsed.Query().Get("sub.info")) -} - -func fetchSenshiSubtitle(manifestURL string) (string, error) { - manifestURL = strings.TrimSpace(manifestURL) - if manifestURL == "" { - return "", nil - } - - var tracks []senshiSubtitleTrack - if err := fetchJSON(http.MethodGet, manifestURL, nil, &tracks); err != nil { - return "", err - } - return pickSenshiSubtitleTrack(tracks), nil -} - func pickSenshiSubtitleTrack(tracks []senshiSubtitleTrack) string { // Senshi can put a forced/signs-only English track before the full dialogue // track. Prefer a non-forced default before considering label fallbacks. @@ -106,18 +69,6 @@ func isSenshiForcedSubtitleLabel(label string) bool { strings.Contains(label, "song") } -func resolveSenshiSubtitle(item embedItem) string { - manifestURL := senshiSubtitleManifestURL(item) - if manifestURL == "" { - return "" - } - subtitle, err := fetchSenshiSubtitle(manifestURL) - if err != nil { - return "" - } - return prepareSenshiSubtitle(subtitle) -} - func prepareSenshiSubtitle(subtitleURL string) string { subtitleURL = strings.TrimSpace(subtitleURL) if subtitleURL == "" { diff --git a/internal/providers/senshi/subtitles_test.go b/internal/providers/senshi/subtitles_test.go index c6ef08b..0e18824 100644 --- a/internal/providers/senshi/subtitles_test.go +++ b/internal/providers/senshi/subtitles_test.go @@ -7,13 +7,6 @@ import ( "testing" ) -func TestSubtitleInfoFromURL(t *testing.T) { - rawURL := "https://embed.example/e/abc/?sub.info=https%3A%2F%2Fninstream.com%2Fmanifest.json" - if got := subtitleInfoFromURL(rawURL); got != "https://ninstream.com/manifest.json" { - t.Fatalf("unexpected manifest url %q", got) - } -} - func TestPickSenshiSubtitleTrackPrefersEnglishDefault(t *testing.T) { tracks := []senshiSubtitleTrack{ {Src: "https://cdn.example/jpn.vtt", Label: "JPN"}, @@ -45,21 +38,6 @@ func TestPickSenshiSubtitleTrackAvoidsForcedEnglishFallback(t *testing.T) { } } -func TestSenshiSubtitleManifestURLUsesServerFM(t *testing.T) { - serverFM := "https://embed.example/e/abc/?sub.info=https://ninstream.com/manifest.json" - item := embedItem{ServerFM: &serverFM} - if got := senshiSubtitleManifestURL(item); got != "https://ninstream.com/manifest.json" { - t.Fatalf("unexpected manifest url %q", got) - } -} - -func TestSenshiSubtitleManifestURLFallsBackToMaskedBase(t *testing.T) { - item := embedItem{MaskedBaseURL: "https://ninstream.com/example/base"} - if got := senshiSubtitleManifestURL(item); got != "https://ninstream.com/example/base/sub_filemoon.json" { - t.Fatalf("unexpected manifest url %q", got) - } -} - func TestValidatedSenshiASSURLPrefersOriginalStyledTrack(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/sub_2_eng.ass" { diff --git a/internal/providers/senshi/types.go b/internal/providers/senshi/types.go index ed3e550..d09fa25 100644 --- a/internal/providers/senshi/types.go +++ b/internal/providers/senshi/types.go @@ -2,14 +2,14 @@ package senshi // SearchItem is stored in SelectionOption.ExtraData for mapping hints. type SearchItem struct { - MalID int - PublicID string - Title string - Type string - Episodes int - Year int - Score float64 - Status string + MalID int + PublicID string + Title string + Type string + Episodes int + Year int + Score float64 + Status string } type filterResponse struct { @@ -18,16 +18,16 @@ type filterResponse struct { } type animeItem struct { - ID int `json:"id"` - PublicID string `json:"public_id"` - AnimePicture string `json:"anime_picture"` - Title string `json:"title"` - TitleEnglish string `json:"title_english"` - Type string `json:"type"` - AniEpisodes string `json:"ani_episodes"` - AniStatus string `json:"ani_status"` - AniYear int `json:"ani_year"` - Score float64 `json:"score"` + ID int `json:"id"` + PublicID string `json:"public_id"` + AnimePicture string `json:"anime_picture"` + Title string `json:"title"` + TitleEnglish string `json:"title_english"` + Type string `json:"type"` + AniEpisodes string `json:"ani_episodes"` + AniStatus string `json:"ani_status"` + AniYear int `json:"ani_year"` + Score float64 `json:"score"` } type episodeItem struct { @@ -40,9 +40,25 @@ type episodeItem struct { } type embedItem struct { - URL string `json:"url"` - Server2 *string `json:"server2"` - ServerFM *string `json:"serverFM"` - Status string `json:"status"` - MaskedBaseURL string `json:"masked_base_url"` + URL string `json:"url"` + RemoteSourceID *int `json:"remote_source_id"` + Status string `json:"status"` +} + +type vidcloudEntry struct { + Source *vidcloudSource `json:"source"` + Tracks []vidcloudTrack `json:"tracks"` +} + +type vidcloudSource struct { + Src string `json:"src"` + Quality string `json:"quality"` + Audio string `json:"audio"` +} + +type vidcloudTrack struct { + URL string `json:"url"` + VTTURL string `json:"vtt_url"` + Label string `json:"label"` + Default bool `json:"default"` }