node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,122 @@
package onlinestream
import (
"errors"
"fmt"
"seanime/internal/extension"
"seanime/internal/util"
"seanime/internal/util/result"
"strings"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
)
var searchResultCache = result.NewCache[string, []*hibikeonlinestream.SearchResult]()
func (r *Repository) ManualSearch(provider string, query string, dub bool) (ret []*hibikeonlinestream.SearchResult, err error) {
defer util.HandlePanicInModuleWithError("onlinestream/ManualSearch", &err)
if query == "" {
return make([]*hibikeonlinestream.SearchResult, 0), nil
}
// Get the search results
providerExtension, ok := extension.GetExtension[extension.OnlinestreamProviderExtension](r.providerExtensionBank, provider)
if !ok {
r.logger.Error().Str("provider", provider).Msg("onlinestream: Provider not found")
return nil, errors.New("onlinestream: Provider not found")
}
normalizedQuery := strings.ToLower(strings.TrimSpace(query))
searchRes, found := searchResultCache.Get(provider + normalizedQuery + fmt.Sprintf("%t", dub))
if found {
return searchRes, nil
}
searchRes, err = providerExtension.GetProvider().Search(hibikeonlinestream.SearchOptions{
Query: normalizedQuery,
Dub: dub,
Year: 0,
})
if err != nil {
r.logger.Error().Err(err).Str("query", normalizedQuery).Msg("onlinestream: Search failed")
return nil, err
}
searchResultCache.Set(provider+normalizedQuery+fmt.Sprintf("%t", dub), searchRes)
return searchRes, nil
}
// ManualMapping is used to manually map an anime to a provider.
// After calling this, the client should re-fetch the episode list.
func (r *Repository) ManualMapping(provider string, mediaId int, animeId string) (err error) {
defer util.HandlePanicInModuleWithError("onlinestream/ManualMapping", &err)
r.logger.Trace().Msgf("onlinestream: Removing cached bucket for %s, media ID: %d", provider, mediaId)
// Delete the cached data if any
epListBucket := r.getFcEpisodeListBucket(provider, mediaId)
_ = r.fileCacher.Remove(epListBucket.Name())
epDataBucket := r.getFcEpisodeDataBucket(provider, mediaId)
_ = r.fileCacher.Remove(epDataBucket.Name())
r.logger.Trace().
Str("provider", provider).
Int("mediaId", mediaId).
Str("animeId", animeId).
Msg("onlinestream: Manual mapping")
// Insert the mapping into the database
err = r.db.InsertOnlinestreamMapping(provider, mediaId, animeId)
if err != nil {
r.logger.Error().Err(err).Msg("onlinestream: Failed to insert mapping")
return err
}
r.logger.Debug().Msg("onlinestream: Manual mapping successful")
return nil
}
type MappingResponse struct {
AnimeId *string `json:"animeId"`
}
func (r *Repository) GetMapping(provider string, mediaId int) (ret MappingResponse) {
defer util.HandlePanicInModuleThen("onlinestream/GetMapping", func() {
ret = MappingResponse{}
})
mapping, found := r.db.GetOnlinestreamMapping(provider, mediaId)
if !found {
return MappingResponse{}
}
return MappingResponse{
AnimeId: &mapping.AnimeID,
}
}
func (r *Repository) RemoveMapping(provider string, mediaId int) (err error) {
defer util.HandlePanicInModuleWithError("onlinestream/RemoveMapping", &err)
// Delete the mapping from the database
err = r.db.DeleteOnlinestreamMapping(provider, mediaId)
if err != nil {
r.logger.Error().Err(err).Msg("onlinestream: Failed to delete mapping")
return err
}
r.logger.Debug().Msg("onlinestream: Mapping removed")
r.logger.Trace().Msgf("onlinestream: Removing cached bucket for %s, media ID: %d", provider, mediaId)
// Delete the cached data if any
epListBucket := r.getFcEpisodeListBucket(provider, mediaId)
_ = r.fileCacher.Remove(epListBucket.Name())
epDataBucket := r.getFcEpisodeDataBucket(provider, mediaId)
_ = r.fileCacher.Remove(epDataBucket.Name())
return nil
}

View File

@@ -0,0 +1,446 @@
package onlinestream_providers
import (
"cmp"
"fmt"
"github.com/PuerkitoBio/goquery"
"github.com/goccy/go-json"
"github.com/gocolly/colly"
"github.com/rs/zerolog"
"net/http"
"net/url"
"regexp"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
onlinestream_sources "seanime/internal/onlinestream/sources"
"seanime/internal/util"
"sort"
"strings"
"sync"
)
type (
Animepahe struct {
BaseURL string
Client http.Client
UserAgent string
logger *zerolog.Logger
}
AnimepaheSearchResult struct {
Data []struct {
ID int `json:"id"`
Title string `json:"title"`
Year int `json:"year"`
Poster string `json:"poster"`
Type string `json:"type"`
Session string `json:"session"`
} `json:"data"`
}
)
func NewAnimepahe(logger *zerolog.Logger) hibikeonlinestream.Provider {
return &Animepahe{
BaseURL: "https://animepahe.ru",
Client: http.Client{},
UserAgent: util.GetRandomUserAgent(),
logger: logger,
}
}
func (g *Animepahe) GetSettings() hibikeonlinestream.Settings {
return hibikeonlinestream.Settings{
EpisodeServers: []string{"animepahe"},
SupportsDub: false,
}
}
func (g *Animepahe) Search(opts hibikeonlinestream.SearchOptions) ([]*hibikeonlinestream.SearchResult, error) {
var results []*hibikeonlinestream.SearchResult
query := opts.Query
dubbed := opts.Dub
g.logger.Debug().Str("query", query).Bool("dubbed", dubbed).Msg("animepahe: Searching anime")
q := url.QueryEscape(query)
request, err := http.NewRequest("GET", g.BaseURL+fmt.Sprintf("/api?m=search&q=%s", q), nil)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to create request")
return nil, err
}
request.Header.Set("User-Agent", g.UserAgent)
request.Header.Set("Cookie", "__ddg1_=;__ddg2_=;")
response, err := g.Client.Do(request)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to send request")
return nil, err
}
defer response.Body.Close()
var searchResult AnimepaheSearchResult
err = json.NewDecoder(response.Body).Decode(&searchResult)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to decode response")
return nil, err
}
for _, data := range searchResult.Data {
results = append(results, &hibikeonlinestream.SearchResult{
ID: cmp.Or(fmt.Sprintf("%d", data.ID), data.Session),
Title: data.Title,
URL: fmt.Sprintf("%s/anime/%d", g.BaseURL, data.ID),
SubOrDub: hibikeonlinestream.Sub,
})
}
return results, nil
}
func (g *Animepahe) FindEpisodes(id string) ([]*hibikeonlinestream.EpisodeDetails, error) {
var episodes []*hibikeonlinestream.EpisodeDetails
q1 := fmt.Sprintf("/anime/%s", id)
if !strings.Contains(id, "-") {
q1 = fmt.Sprintf("/a/%s", id)
}
c := colly.NewCollector(
colly.UserAgent(g.UserAgent),
)
c.OnRequest(func(r *colly.Request) {
r.Headers.Set("Cookie", "__ddg1_=;__ddg2_=")
})
var tempId string
c.OnHTML("head > meta[property='og:url']", func(e *colly.HTMLElement) {
parts := strings.Split(e.Attr("content"), "/")
tempId = parts[len(parts)-1]
})
err := c.Visit(g.BaseURL + q1)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to fetch episodes")
return nil, err
}
// { last_page: number; data: { id: number; episode: number; title: string; snapshot: string; filler: number; created_at?: string }[] }
type data struct {
LastPage int `json:"last_page"`
Data []struct {
ID int `json:"id"`
Episode int `json:"episode"`
Title string `json:"title"`
Snapshot string `json:"snapshot"`
Filler int `json:"filler"`
Session string `json:"session"`
CreatedAt string `json:"created_at"`
} `json:"data"`
}
q2 := fmt.Sprintf("/api?m=release&id=%s&sort=episode_asc&page=1", tempId)
request, err := http.NewRequest("GET", g.BaseURL+q2, nil)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to create request")
return nil, err
}
request.Header.Set("User-Agent", g.UserAgent)
request.Header.Set("Cookie", "__ddg1_=;__ddg2_=")
response, err := g.Client.Do(request)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to send request")
return nil, err
}
defer response.Body.Close()
var d data
err = json.NewDecoder(response.Body).Decode(&d)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to decode response")
return nil, err
}
for _, e := range d.Data {
episodes = append(episodes, &hibikeonlinestream.EpisodeDetails{
Provider: "animepahe",
ID: fmt.Sprintf("%d$%s", e.ID, id),
Number: e.Episode,
URL: fmt.Sprintf("%s/anime/%s/%d", g.BaseURL, id, e.Episode),
Title: cmp.Or(e.Title, "Episode "+fmt.Sprintf("%d", e.Episode)),
})
}
var pageNumbers []int
for i := 2; i <= d.LastPage; i++ {
pageNumbers = append(pageNumbers, i)
}
wg := sync.WaitGroup{}
wg.Add(len(pageNumbers))
mu := sync.Mutex{}
for _, p := range pageNumbers {
go func(p int) {
defer wg.Done()
q2 := fmt.Sprintf("/api?m=release&id=%s&sort=episode_asc&page=%d", tempId, p)
request, err := http.NewRequest("GET", g.BaseURL+q2, nil)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to create request")
return
}
request.Header.Set("User-Agent", g.UserAgent)
request.Header.Set("Cookie", "__ddg1_=;__ddg2_=")
response, err := g.Client.Do(request)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to send request")
return
}
defer response.Body.Close()
var d data
err = json.NewDecoder(response.Body).Decode(&d)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to decode response")
return
}
mu.Lock()
for _, e := range d.Data {
episodes = append(episodes, &hibikeonlinestream.EpisodeDetails{
Provider: "animepahe",
ID: fmt.Sprintf("%d$%s", e.ID, id),
Number: e.Episode,
URL: fmt.Sprintf("%s/anime/%s/%d", g.BaseURL, id, e.Episode),
Title: cmp.Or(e.Title, "Episode "+fmt.Sprintf("%d", e.Episode)),
})
}
mu.Unlock()
}(p)
}
wg.Wait()
g.logger.Debug().Int("count", len(episodes)).Msg("animepahe: Fetched episodes")
sort.Slice(episodes, func(i, j int) bool {
return episodes[i].Number < episodes[j].Number
})
if len(episodes) == 0 {
return nil, fmt.Errorf("no episodes found")
}
// Normalize episode numbers
offset := episodes[0].Number + 1
for i, e := range episodes {
episodes[i].Number = e.Number - offset
}
return episodes, nil
}
func (g *Animepahe) FindEpisodeServer(episodeInfo *hibikeonlinestream.EpisodeDetails, server string) (*hibikeonlinestream.EpisodeServer, error) {
var source *hibikeonlinestream.EpisodeServer
parts := strings.Split(episodeInfo.ID, "$")
if len(parts) < 2 {
return nil, fmt.Errorf("animepahe: Invalid episode ID")
}
episodeID := parts[0]
animeID := parts[1]
q1 := fmt.Sprintf("/anime/%s", animeID)
if !strings.Contains(animeID, "-") {
q1 = fmt.Sprintf("/a/%s", animeID)
}
c := colly.NewCollector(
colly.UserAgent(g.UserAgent),
)
var reqUrl *url.URL
c.OnRequest(func(r *colly.Request) {
r.Headers.Set("Cookie", "__ddg1_=;__ddg2_=")
})
c.OnResponse(func(r *colly.Response) {
reqUrl = r.Request.URL
})
var tempId string
c.OnHTML("head > meta[property='og:url']", func(e *colly.HTMLElement) {
parts := strings.Split(e.Attr("content"), "/")
tempId = parts[len(parts)-1]
})
err := c.Visit(g.BaseURL + q1)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to fetch episodes")
return nil, err
}
var sessionId string
// retain url without query
reqUrlStr := reqUrl.Path
reqUrlStrParts := strings.Split(reqUrlStr, "/anime/")
sessionId = reqUrlStrParts[len(reqUrlStrParts)-1]
// { last_page: number; data: { id: number; episode: number; title: string; snapshot: string; filler: number; created_at?: string }[] }
type data struct {
LastPage int `json:"last_page"`
Data []struct {
ID int `json:"id"`
Session string `json:"session"`
} `json:"data"`
}
q2 := fmt.Sprintf("/api?m=release&id=%s&sort=episode_asc&page=1", tempId)
request, err := http.NewRequest("GET", g.BaseURL+q2, nil)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to create request")
return nil, err
}
request.Header.Set("User-Agent", g.UserAgent)
request.Header.Set("Cookie", "__ddg1_=;__ddg2_=")
response, err := g.Client.Do(request)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to send request")
return nil, err
}
defer response.Body.Close()
var d data
err = json.NewDecoder(response.Body).Decode(&d)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to decode response")
return nil, err
}
episodeSession := ""
for _, e := range d.Data {
if fmt.Sprintf("%d", e.ID) == episodeID {
episodeSession = e.Session
break
}
}
var pageNumbers []int
for i := 1; i <= d.LastPage; i++ {
pageNumbers = append(pageNumbers, i)
}
if episodeSession == "" {
wg := sync.WaitGroup{}
wg.Add(len(pageNumbers))
mu := sync.Mutex{}
for _, p := range pageNumbers {
go func(p int) {
defer wg.Done()
q2 := fmt.Sprintf("/api?m=release&id=%s&sort=episode_asc&page=%d", tempId, p)
request, err := http.NewRequest("GET", g.BaseURL+q2, nil)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to create request")
return
}
request.Header.Set("User-Agent", g.UserAgent)
request.Header.Set("Cookie", "__ddg1_=;__ddg2_=")
response, err := g.Client.Do(request)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to send request")
return
}
defer response.Body.Close()
var d data
err = json.NewDecoder(response.Body).Decode(&d)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to decode response")
return
}
mu.Lock()
for _, e := range d.Data {
if fmt.Sprintf("%d", e.ID) == episodeID {
episodeSession = e.Session
break
}
}
mu.Unlock()
}(p)
}
wg.Wait()
}
if episodeSession == "" {
return nil, fmt.Errorf("animepahe: Episode not found")
}
q3 := fmt.Sprintf("/play/%s/%s", sessionId, episodeSession)
request2, err := http.NewRequest("GET", g.BaseURL+q3, nil)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to create request")
return nil, err
}
request2.Header.Set("User-Agent", g.UserAgent)
request2.Header.Set("Cookie", "__ddg1_=;__ddg2_=")
response2, err := g.Client.Do(request2)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to send request")
return nil, err
}
defer response2.Body.Close()
htmlString := ""
doc, err := goquery.NewDocumentFromReader(response2.Body)
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to parse response")
return nil, err
}
htmlString = doc.Text()
//const regex = /https:\/\/kwik\.si\/e\/\w+/g;
// const matches = watchReq.match(regex);
//
// if (matches === null) return undefined;
re := regexp.MustCompile(`https:\/\/kwik\.si\/e\/\w+`)
matches := re.FindAllString(htmlString, -1)
if len(matches) == 0 {
return nil, fmt.Errorf("animepahe: Failed to find episode source")
}
kwik := onlinestream_sources.NewKwik()
videoSources, err := kwik.Extract(matches[0])
if err != nil {
g.logger.Error().Err(err).Msg("animepahe: Failed to extract video sources")
return nil, fmt.Errorf("animepahe: Failed to extract video sources, %w", err)
}
source = &hibikeonlinestream.EpisodeServer{
Provider: "animepahe",
Server: KwikServer,
Headers: map[string]string{"Referer": "https://kwik.si/"},
VideoSources: videoSources,
}
return source, nil
}

View File

@@ -0,0 +1,158 @@
package onlinestream_providers
import (
"errors"
"github.com/stretchr/testify/assert"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
"seanime/internal/util"
"testing"
)
func TestAnimepahe_Search(t *testing.T) {
ap := NewAnimepahe(util.NewLogger())
tests := []struct {
name string
query string
dubbed bool
}{
{
name: "One Piece",
query: "One Piece",
dubbed: false,
},
{
name: "Blue Lock Season 2",
query: "Blue Lock Season 2",
dubbed: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results, err := ap.Search(hibikeonlinestream.SearchOptions{
Query: tt.query,
Dub: tt.dubbed,
})
if !assert.NoError(t, err) {
t.FailNow()
}
assert.NotEmpty(t, results)
for _, r := range results {
assert.NotEmpty(t, r.ID, "ID is empty")
assert.NotEmpty(t, r.Title, "Title is empty")
assert.NotEmpty(t, r.URL, "URL is empty")
}
util.Spew(results)
})
}
}
func TestAnimepahe_FetchEpisodes(t *testing.T) {
tests := []struct {
name string
id string
}{
{
name: "One Piece",
id: "4",
},
{
name: "Blue Lock Season 2",
id: "5648",
},
}
ap := NewAnimepahe(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
episodes, err := ap.FindEpisodes(tt.id)
if !assert.NoError(t, err) {
t.FailNow()
}
assert.NotEmpty(t, episodes)
for _, e := range episodes {
assert.NotEmpty(t, e.ID, "ID is empty")
assert.NotEmpty(t, e.Number, "Number is empty")
assert.NotEmpty(t, e.URL, "URL is empty")
}
util.Spew(episodes)
})
}
}
func TestAnimepahe_FetchSources(t *testing.T) {
tests := []struct {
name string
episode *hibikeonlinestream.EpisodeDetails
server string
}{
{
name: "One Piece",
episode: &hibikeonlinestream.EpisodeDetails{
ID: "63391$4",
Number: 1115,
URL: "",
},
server: KwikServer,
},
{
name: "Blue Lock Season 2 - Episode 1",
episode: &hibikeonlinestream.EpisodeDetails{
ID: "64056$5648",
Number: 1,
URL: "",
},
server: KwikServer,
},
}
ap := NewAnimepahe(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sources, err := ap.FindEpisodeServer(tt.episode, tt.server)
if err != nil {
if !errors.Is(err, ErrSourceNotFound) {
t.Fatal(err)
}
}
if err != nil {
t.Skip("Source not found")
}
assert.NotEmpty(t, sources)
for _, s := range sources.VideoSources {
assert.NotEmpty(t, s, "Source is empty")
}
util.Spew(sources)
})
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,25 @@
package onlinestream_providers
import "errors"
// Built-in
const (
GogoanimeProvider string = "gogoanime"
ZoroProvider string = "zoro"
)
// Built-in
const (
DefaultServer = "default"
GogocdnServer = "gogocdn"
VidstreamingServer = "vidstreaming"
StreamSBServer = "streamsb"
VidcloudServer = "vidcloud"
StreamtapeServer = "streamtape"
KwikServer = "kwik"
)
var (
ErrSourceNotFound = errors.New("video source not found")
ErrServerNotFound = errors.New("server not found")
)

View File

@@ -0,0 +1,247 @@
package onlinestream_providers
import (
"fmt"
"github.com/gocolly/colly"
"github.com/rs/zerolog"
"net/http"
"net/url"
"seanime/internal/onlinestream/sources"
"seanime/internal/util"
"strconv"
"strings"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
)
type Gogoanime struct {
BaseURL string
AjaxURL string
Client http.Client
UserAgent string
logger *zerolog.Logger
}
func NewGogoanime(logger *zerolog.Logger) hibikeonlinestream.Provider {
return &Gogoanime{
BaseURL: "https://anitaku.to",
AjaxURL: "https://ajax.gogocdn.net",
Client: http.Client{},
UserAgent: util.GetRandomUserAgent(),
logger: logger,
}
}
func (g *Gogoanime) GetSettings() hibikeonlinestream.Settings {
return hibikeonlinestream.Settings{
EpisodeServers: []string{GogocdnServer, VidstreamingServer},
SupportsDub: true,
}
}
func (g *Gogoanime) Search(opts hibikeonlinestream.SearchOptions) ([]*hibikeonlinestream.SearchResult, error) {
var results []*hibikeonlinestream.SearchResult
query := opts.Query
dubbed := opts.Dub
g.logger.Debug().Str("query", query).Bool("dubbed", dubbed).Msg("gogoanime: Searching anime")
c := colly.NewCollector(
colly.UserAgent(g.UserAgent),
)
c.OnHTML(".last_episodes > ul > li", func(e *colly.HTMLElement) {
id := ""
idParts := strings.Split(e.ChildAttr("p.name > a", "href"), "/")
if len(idParts) > 2 {
id = idParts[2]
}
title := e.ChildText("p.name > a")
url := g.BaseURL + e.ChildAttr("p.name > a", "href")
subOrDub := hibikeonlinestream.Sub
if strings.Contains(strings.ToLower(e.ChildText("p.name > a")), "dub") {
subOrDub = hibikeonlinestream.Dub
}
results = append(results, &hibikeonlinestream.SearchResult{
ID: id,
Title: title,
URL: url,
SubOrDub: subOrDub,
})
})
searchURL := g.BaseURL + "/search.html?keyword=" + url.QueryEscape(query)
if dubbed {
searchURL += "%20(Dub)"
}
err := c.Visit(searchURL)
if err != nil {
return nil, err
}
g.logger.Debug().Int("count", len(results)).Msg("gogoanime: Fetched anime")
return results, nil
}
func (g *Gogoanime) FindEpisodes(id string) ([]*hibikeonlinestream.EpisodeDetails, error) {
var episodes []*hibikeonlinestream.EpisodeDetails
g.logger.Debug().Str("id", id).Msg("gogoanime: Fetching episodes")
if !strings.Contains(id, "gogoanime") {
id = fmt.Sprintf("%s/category/%s", g.BaseURL, id)
}
c := colly.NewCollector(
colly.UserAgent(g.UserAgent),
)
var epStart, epEnd, movieID, alias string
c.OnHTML("#episode_page > li > a", func(e *colly.HTMLElement) {
if epStart == "" {
epStart = e.Attr("ep_start")
}
epEnd = e.Attr("ep_end")
})
c.OnHTML("#movie_id", func(e *colly.HTMLElement) {
movieID = e.Attr("value")
})
c.OnHTML("#alias", func(e *colly.HTMLElement) {
alias = e.Attr("value")
})
err := c.Visit(id)
if err != nil {
g.logger.Error().Err(err).Msg("gogoanime: Failed to fetch episodes")
return nil, err
}
c2 := colly.NewCollector(
colly.UserAgent(g.UserAgent),
)
c2.OnHTML("#episode_related > li", func(e *colly.HTMLElement) {
episodeIDParts := strings.Split(e.ChildAttr("a", "href"), "/")
if len(episodeIDParts) < 2 {
return
}
episodeID := strings.TrimSpace(episodeIDParts[1])
episodeNumberStr := strings.TrimPrefix(e.ChildText("div.name"), "EP ")
episodeNumber, err := strconv.Atoi(episodeNumberStr)
if err != nil {
g.logger.Error().Err(err).Str("episodeID", episodeID).Msg("failed to parse episode number")
return
}
episodes = append(episodes, &hibikeonlinestream.EpisodeDetails{
Provider: GogoanimeProvider,
ID: episodeID,
Number: episodeNumber,
URL: g.BaseURL + "/" + episodeID,
})
})
ajaxURL := fmt.Sprintf("%s/ajax/load-list-episode", g.AjaxURL)
ajaxParams := url.Values{
"ep_start": {epStart},
"ep_end": {epEnd},
"id": {movieID},
"alias": {alias},
"default_ep": {"0"},
}
ajaxURLWithParams := fmt.Sprintf("%s?%s", ajaxURL, ajaxParams.Encode())
err = c2.Visit(ajaxURLWithParams)
if err != nil {
g.logger.Error().Err(err).Msg("gogoanime: Failed to fetch episodes")
return nil, err
}
g.logger.Debug().Int("count", len(episodes)).Msg("gogoanime: Fetched episodes")
return episodes, nil
}
func (g *Gogoanime) FindEpisodeServer(episodeInfo *hibikeonlinestream.EpisodeDetails, server string) (*hibikeonlinestream.EpisodeServer, error) {
var source *hibikeonlinestream.EpisodeServer
if server == DefaultServer {
server = GogocdnServer
}
g.logger.Debug().Str("server", string(server)).Str("episodeID", episodeInfo.ID).Msg("gogoanime: Fetching server sources")
c := colly.NewCollector()
switch server {
case VidstreamingServer:
c.OnHTML(".anime_muti_link > ul > li.vidcdn > a", func(e *colly.HTMLElement) {
src := e.Attr("data-video")
gogocdn := onlinestream_sources.NewGogoCDN()
videoSources, err := gogocdn.Extract(src)
if err == nil {
source = &hibikeonlinestream.EpisodeServer{
Provider: GogoanimeProvider,
Server: server,
Headers: map[string]string{
"Referer": g.BaseURL + "/" + episodeInfo.ID,
},
VideoSources: videoSources,
}
}
})
case GogocdnServer, "":
c.OnHTML("#load_anime > div > div > iframe", func(e *colly.HTMLElement) {
src := e.Attr("src")
gogocdn := onlinestream_sources.NewGogoCDN()
videoSources, err := gogocdn.Extract(src)
if err == nil {
source = &hibikeonlinestream.EpisodeServer{
Provider: GogoanimeProvider,
Server: server,
Headers: map[string]string{
"Referer": g.BaseURL + "/" + episodeInfo.ID,
},
VideoSources: videoSources,
}
}
})
case StreamSBServer:
c.OnHTML(".anime_muti_link > ul > li.streamsb > a", func(e *colly.HTMLElement) {
src := e.Attr("data-video")
streamsb := onlinestream_sources.NewStreamSB()
videoSources, err := streamsb.Extract(src)
if err == nil {
source = &hibikeonlinestream.EpisodeServer{
Provider: GogoanimeProvider,
Server: server,
Headers: map[string]string{
"Referer": g.BaseURL + "/" + episodeInfo.ID,
"watchsb": "streamsb",
"User-Agent": g.UserAgent,
},
VideoSources: videoSources,
}
}
})
}
err := c.Visit(g.BaseURL + "/" + episodeInfo.ID)
if err != nil {
return nil, err
}
if source == nil {
g.logger.Warn().Str("server", server).Msg("gogoanime: No sources found")
return nil, ErrSourceNotFound
}
g.logger.Debug().Str("server", server).Int("videoSources", len(source.VideoSources)).Msg("gogoanime: Fetched server sources")
return source, nil
}

View File

@@ -0,0 +1,172 @@
package onlinestream_providers
import (
"errors"
"github.com/davecgh/go-spew/spew"
"github.com/stretchr/testify/assert"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
"seanime/internal/util"
"testing"
)
func TestGogoanime_Search(t *testing.T) {
gogo := NewGogoanime(util.NewLogger())
tests := []struct {
name string
query string
dubbed bool
}{
{
name: "One Piece",
query: "One Piece",
dubbed: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results, err := gogo.Search(hibikeonlinestream.SearchOptions{
Query: tt.query,
Dub: tt.dubbed,
})
if !assert.NoError(t, err) {
t.FailNow()
}
assert.NotEmpty(t, results)
for _, r := range results {
assert.NotEmpty(t, r.ID, "ID is empty")
assert.NotEmpty(t, r.Title, "Title is empty")
assert.NotEmpty(t, r.URL, "URL is empty")
}
spew.Dump(results)
})
}
}
func TestGogoanime_FetchEpisodes(t *testing.T) {
tests := []struct {
name string
id string
}{
{
name: "One Piece",
id: "one-piece",
},
{
name: "One Piece (Dub)",
id: "one-piece-dub",
},
}
gogo := NewGogoanime(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
episodes, err := gogo.FindEpisodes(tt.id)
if !assert.NoError(t, err) {
t.FailNow()
}
assert.NotEmpty(t, episodes)
for _, e := range episodes {
assert.NotEmpty(t, e.ID, "ID is empty")
assert.NotEmpty(t, e.Number, "Number is empty")
assert.NotEmpty(t, e.URL, "URL is empty")
}
spew.Dump(episodes)
})
}
}
func TestGogoanime_FetchSources(t *testing.T) {
tests := []struct {
name string
episode *hibikeonlinestream.EpisodeDetails
server string
}{
{
name: "One Piece",
episode: &hibikeonlinestream.EpisodeDetails{
ID: "one-piece-episode-1075",
Number: 1075,
URL: "https://anitaku.to/one-piece-episode-1075",
},
server: VidstreamingServer,
},
{
name: "One Piece",
episode: &hibikeonlinestream.EpisodeDetails{
ID: "one-piece-episode-1075",
Number: 1075,
URL: "https://anitaku.to/one-piece-episode-1075",
},
server: StreamSBServer,
},
{
name: "One Piece",
episode: &hibikeonlinestream.EpisodeDetails{
ID: "one-piece-episode-1075",
Number: 1075,
URL: "https://anitaku.to/one-piece-episode-1075",
},
server: GogocdnServer,
},
{
name: "Bocchi the Rock!",
episode: &hibikeonlinestream.EpisodeDetails{
ID: "bocchi-the-rock-episode-1",
Number: 1075,
URL: "https://anitaku.to/bocchi-the-rock-episode-1",
},
server: GogocdnServer,
},
}
gogo := NewGogoanime(util.NewLogger())
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
sources, err := gogo.FindEpisodeServer(tt.episode, tt.server)
if err != nil {
if !errors.Is(err, ErrSourceNotFound) {
t.Fatal(err)
}
}
if err != nil {
t.Skip("Source not found")
}
assert.NotEmpty(t, sources)
for _, s := range sources.VideoSources {
assert.NotEmpty(t, s, "Source is empty")
}
spew.Dump(sources)
})
}
}

View File

@@ -0,0 +1,342 @@
package onlinestream_providers
import (
"errors"
"fmt"
"github.com/PuerkitoBio/goquery"
"github.com/goccy/go-json"
"github.com/gocolly/colly"
"github.com/rs/zerolog"
"github.com/samber/lo"
"net/http"
"net/url"
"seanime/internal/onlinestream/sources"
"seanime/internal/util"
"strconv"
"strings"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
)
type Zoro struct {
BaseURL string
Client *http.Client
UserAgent string
logger *zerolog.Logger
}
func NewZoro(logger *zerolog.Logger) hibikeonlinestream.Provider {
return &Zoro{
BaseURL: "https://hianime.to",
UserAgent: util.GetRandomUserAgent(),
Client: &http.Client{},
logger: logger,
}
}
func (z *Zoro) GetSettings() hibikeonlinestream.Settings {
return hibikeonlinestream.Settings{
EpisodeServers: []string{VidcloudServer, VidstreamingServer},
SupportsDub: true,
}
}
func (z *Zoro) Search(opts hibikeonlinestream.SearchOptions) ([]*hibikeonlinestream.SearchResult, error) {
var results []*hibikeonlinestream.SearchResult
query := opts.Query
dubbed := opts.Dub
z.logger.Debug().Str("query", query).Bool("dubbed", dubbed).Msg("zoro: Searching anime")
c := colly.NewCollector()
c.OnHTML(".flw-item", func(e *colly.HTMLElement) {
id := strings.Split(strings.Split(e.ChildAttr(".film-name a", "href"), "/")[1], "?")[0]
title := e.ChildText(".film-name a")
url := strings.Split(z.BaseURL+e.ChildAttr(".film-name a", "href"), "?")[0]
subOrDub := hibikeonlinestream.Sub
foundSub := false
foundDub := false
if e.ChildText(".tick-item.tick-dub") != "" {
foundDub = true
}
if e.ChildText(".tick-item.tick-sub") != "" {
foundSub = true
}
if foundSub && foundDub {
subOrDub = hibikeonlinestream.SubAndDub
} else if foundDub {
subOrDub = hibikeonlinestream.Dub
}
results = append(results, &hibikeonlinestream.SearchResult{
ID: id,
Title: title,
URL: url,
SubOrDub: subOrDub,
})
})
searchURL := z.BaseURL + "/search?keyword=" + url.QueryEscape(query)
err := c.Visit(searchURL)
if err != nil {
return nil, err
}
if dubbed {
results = lo.Filter(results, func(r *hibikeonlinestream.SearchResult, _ int) bool {
return r.SubOrDub == hibikeonlinestream.Dub || r.SubOrDub == hibikeonlinestream.SubAndDub
})
}
z.logger.Debug().Int("count", len(results)).Msg("zoro: Fetched anime")
return results, nil
}
func (z *Zoro) FindEpisodes(id string) ([]*hibikeonlinestream.EpisodeDetails, error) {
var episodes []*hibikeonlinestream.EpisodeDetails
z.logger.Debug().Str("id", id).Msg("zoro: Fetching episodes")
c := colly.NewCollector()
subOrDub := hibikeonlinestream.Sub
c.OnHTML("div.film-stats > div.tick", func(e *colly.HTMLElement) {
if e.ChildText(".tick-item.tick-dub") != "" {
subOrDub = hibikeonlinestream.Dub
}
if e.ChildText(".tick-item.tick-sub") != "" {
if subOrDub == hibikeonlinestream.Dub {
subOrDub = hibikeonlinestream.SubAndDub
}
}
})
watchUrl := fmt.Sprintf("%s/watch/%s", z.BaseURL, id)
err := c.Visit(watchUrl)
if err != nil {
z.logger.Error().Err(err).Msg("zoro: Failed to fetch episodes")
return nil, err
}
// Get episodes
splitId := strings.Split(id, "-")
idNum := splitId[len(splitId)-1]
ajaxUrl := fmt.Sprintf("%s/ajax/v2/episode/list/%s", z.BaseURL, idNum)
c2 := colly.NewCollector(
colly.UserAgent(z.UserAgent),
)
c2.OnRequest(func(r *colly.Request) {
r.Headers.Set("X-Requested-With", "XMLHttpRequest")
r.Headers.Set("Referer", watchUrl)
})
c2.OnResponse(func(r *colly.Response) {
var jsonResponse map[string]interface{}
err = json.Unmarshal(r.Body, &jsonResponse)
if err != nil {
return
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(jsonResponse["html"].(string)))
if err != nil {
return
}
content := doc.Find(".detail-infor-content")
content.Find("a").Each(func(i int, s *goquery.Selection) {
id := s.AttrOr("href", "")
if id == "" {
return
}
hrefParts := strings.Split(s.AttrOr("href", ""), "/")
if len(hrefParts) < 2 {
return
}
if subOrDub == hibikeonlinestream.SubAndDub {
subOrDub = "both"
}
id = fmt.Sprintf("%s$%s", strings.Replace(hrefParts[2], "?ep=", "$episode$", 1), subOrDub)
epNumber, _ := strconv.Atoi(s.AttrOr("data-number", ""))
url := z.BaseURL + s.AttrOr("href", "")
title := s.AttrOr("title", "")
episodes = append(episodes, &hibikeonlinestream.EpisodeDetails{
Provider: ZoroProvider,
ID: id,
Number: epNumber,
URL: url,
Title: title,
})
})
})
err = c2.Visit(ajaxUrl)
if err != nil {
z.logger.Error().Err(err).Msg("zoro: Failed to fetch episodes")
return nil, err
}
z.logger.Debug().Int("count", len(episodes)).Msg("zoro: Fetched episodes")
return episodes, nil
}
func (z *Zoro) FindEpisodeServer(episodeInfo *hibikeonlinestream.EpisodeDetails, server string) (*hibikeonlinestream.EpisodeServer, error) {
var source *hibikeonlinestream.EpisodeServer
if server == DefaultServer {
server = VidcloudServer
}
z.logger.Debug().Str("server", server).Str("episodeID", episodeInfo.ID).Msg("zoro: Fetching server sources")
episodeParts := strings.Split(episodeInfo.ID, "$")
if len(episodeParts) < 3 {
return nil, errors.New("invalid episode id")
}
episodeID := fmt.Sprintf("%s?ep=%s", episodeParts[0], episodeParts[2])
subOrDub := hibikeonlinestream.Sub
if episodeParts[len(episodeParts)-1] == "dub" {
subOrDub = hibikeonlinestream.Dub
}
// Get server
var serverId string
c := colly.NewCollector(
colly.UserAgent(z.UserAgent),
)
c.OnRequest(func(r *colly.Request) {
r.Headers.Set("X-Requested-With", "XMLHttpRequest")
})
c.OnResponse(func(r *colly.Response) {
var jsonResponse map[string]interface{}
err := json.Unmarshal(r.Body, &jsonResponse)
if err != nil {
return
}
doc, err := goquery.NewDocumentFromReader(strings.NewReader(jsonResponse["html"].(string)))
if err != nil {
return
}
switch server {
case VidcloudServer:
serverId = z.findServerId(doc, 4, subOrDub)
case VidstreamingServer:
serverId = z.findServerId(doc, 4, subOrDub)
case StreamSBServer:
serverId = z.findServerId(doc, 4, subOrDub)
case StreamtapeServer:
serverId = z.findServerId(doc, 4, subOrDub)
}
})
ajaxEpisodeUrl := fmt.Sprintf("%s/ajax/v2/episode/servers?episodeId=%s", z.BaseURL, strings.Split(episodeID, "?ep=")[1])
if err := c.Visit(ajaxEpisodeUrl); err != nil {
return nil, err
}
if serverId == "" {
return nil, ErrServerNotFound
}
c2 := colly.NewCollector(
colly.UserAgent(z.UserAgent),
)
c2.OnRequest(func(r *colly.Request) {
r.Headers.Set("X-Requested-With", "XMLHttpRequest")
})
c2.OnResponse(func(r *colly.Response) {
var jsonResponse map[string]interface{}
err := json.Unmarshal(r.Body, &jsonResponse)
if err != nil {
return
}
if _, ok := jsonResponse["link"].(string); !ok {
return
}
switch server {
case VidcloudServer, VidstreamingServer:
megacloud := onlinestream_sources.NewMegaCloud()
sources, err := megacloud.Extract(jsonResponse["link"].(string))
if err != nil {
return
}
source = &hibikeonlinestream.EpisodeServer{
Provider: ZoroProvider,
Server: server,
Headers: map[string]string{},
VideoSources: sources,
}
case StreamtapeServer:
streamtape := onlinestream_sources.NewStreamtape()
sources, err := streamtape.Extract(jsonResponse["link"].(string))
if err != nil {
return
}
source = &hibikeonlinestream.EpisodeServer{
Provider: ZoroProvider,
Server: server,
Headers: map[string]string{
"Referer": jsonResponse["link"].(string),
"User-Agent": z.UserAgent,
},
VideoSources: sources,
}
case StreamSBServer:
streamsb := onlinestream_sources.NewStreamSB()
sources, err := streamsb.Extract(jsonResponse["link"].(string))
if err != nil {
return
}
source = &hibikeonlinestream.EpisodeServer{
Provider: ZoroProvider,
Server: server,
Headers: map[string]string{
"Referer": jsonResponse["link"].(string),
"watchsb": "streamsb",
"User-Agent": z.UserAgent,
},
VideoSources: sources,
}
}
})
// Get sources
serverSourceUrl := fmt.Sprintf("%s/ajax/v2/episode/sources?id=%s", z.BaseURL, serverId)
if err := c2.Visit(serverSourceUrl); err != nil {
return nil, err
}
if source == nil {
z.logger.Warn().Str("server", server).Msg("zoro: No sources found")
return nil, ErrSourceNotFound
}
z.logger.Debug().Str("server", server).Int("videoSources", len(source.VideoSources)).Msg("zoro: Fetched server sources")
return source, nil
}
func (z *Zoro) findServerId(doc *goquery.Document, idx int, subOrDub hibikeonlinestream.SubOrDub) string {
var serverId string
doc.Find(fmt.Sprintf("div.ps_-block.ps_-block-sub.servers-%s > div.ps__-list > div", subOrDub)).Each(func(i int, s *goquery.Selection) {
_serverId := s.AttrOr("data-server-id", "")
if serverId == "" {
if _serverId == strconv.Itoa(idx) {
serverId = s.AttrOr("data-id", "")
}
}
})
return serverId
}

View File

@@ -0,0 +1,194 @@
package onlinestream_providers
import (
"errors"
"github.com/davecgh/go-spew/spew"
"github.com/stretchr/testify/assert"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
"seanime/internal/util"
"testing"
)
func TestZoro_Search(t *testing.T) {
logger := util.NewLogger()
zoro := NewZoro(logger)
tests := []struct {
name string
query string
dubbed bool
}{
{
name: "One Piece",
query: "One Piece",
dubbed: false,
},
{
name: "Dungeon Meshi",
query: "Dungeon Meshi",
dubbed: false,
},
{
name: "Omoi, Omoware, Furi, Furare",
query: "Omoi, Omoware, Furi, Furare",
dubbed: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
results, err := zoro.Search(hibikeonlinestream.SearchOptions{
Query: tt.query,
Dub: tt.dubbed,
})
if !assert.NoError(t, err) {
t.FailNow()
}
assert.NotEmpty(t, results)
for _, r := range results {
assert.NotEmpty(t, r.ID, "ID is empty")
assert.NotEmpty(t, r.Title, "Title is empty")
assert.NotEmpty(t, r.URL, "URL is empty")
}
spew.Dump(results)
})
}
}
func TestZoro_FetchEpisodes(t *testing.T) {
logger := util.NewLogger()
tests := []struct {
name string
id string
}{
{
name: "One Piece",
id: "one-piece-100",
},
{
name: "The Apothecary Diaries",
id: "the-apothecary-diaries-18578",
},
}
zoro := NewZoro(logger)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
episodes, err := zoro.FindEpisodes(tt.id)
if !assert.NoError(t, err) {
t.FailNow()
}
assert.NotEmpty(t, episodes)
for _, e := range episodes {
assert.NotEmpty(t, e.ID, "ID is empty")
assert.NotEmpty(t, e.Number, "Number is empty")
assert.NotEmpty(t, e.URL, "URL is empty")
}
spew.Dump(episodes)
})
}
}
func TestZoro_FetchSources(t *testing.T) {
logger := util.NewLogger()
tests := []struct {
name string
episode *hibikeonlinestream.EpisodeDetails
server string
}{
{
name: "One Piece",
episode: &hibikeonlinestream.EpisodeDetails{
ID: "one-piece-100$episode$120118$both",
Number: 1095,
URL: "https://hianime.to/watch/one-piece-100?ep=120118",
},
server: VidcloudServer,
},
{
name: "One Piece",
episode: &hibikeonlinestream.EpisodeDetails{
ID: "one-piece-100$episode$120118$both",
Number: 1095,
URL: "https://hianime.to/watch/one-piece-100?ep=120118",
},
server: VidstreamingServer,
},
{
name: "One Piece",
episode: &hibikeonlinestream.EpisodeDetails{
ID: "one-piece-100$episode$120118$both",
Number: 1095,
URL: "https://hianime.to/watch/one-piece-100?ep=120118",
},
server: StreamtapeServer,
},
{
name: "One Piece",
episode: &hibikeonlinestream.EpisodeDetails{
ID: "one-piece-100$episode$120118$both",
Number: 1095,
URL: "https://hianime.to/watch/one-piece-100?ep=120118",
},
server: StreamSBServer,
},
{
name: "Apothecary Diaries",
episode: &hibikeonlinestream.EpisodeDetails{
ID: "the-apothecary-diaries-18578$episode$122954$sub",
Number: 24,
URL: "https://hianime.to/watch/the-apothecary-diaries-18578?ep=122954",
},
server: StreamSBServer,
},
}
zoro := NewZoro(logger)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
serverSources, err := zoro.FindEpisodeServer(tt.episode, tt.server)
if err != nil {
if !errors.Is(err, ErrSourceNotFound) && !errors.Is(err, ErrServerNotFound) {
t.Fatal(err)
}
}
if err != nil {
t.Skip(err.Error())
}
assert.NotEmpty(t, serverSources)
for _, s := range serverSources.VideoSources {
assert.NotEmpty(t, s, "Source is empty")
}
spew.Dump(serverSources)
})
}
}

View File

@@ -0,0 +1,288 @@
package onlinestream
import (
"context"
"errors"
"fmt"
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/database/db"
"seanime/internal/extension"
"seanime/internal/library/anime"
"seanime/internal/platforms/platform"
"seanime/internal/util/filecache"
"strconv"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/samber/lo"
)
type (
Repository struct {
logger *zerolog.Logger
providerExtensionBank *extension.UnifiedBank
fileCacher *filecache.Cacher
metadataProvider metadata.Provider
platform platform.Platform
anilistBaseAnimeCache *anilist.BaseAnimeCache
db *db.Database
}
)
var (
ErrNoVideoSourceFound = errors.New("no video source found")
)
type (
Episode struct {
Number int `json:"number"`
Title string `json:"title,omitempty"`
Image string `json:"image,omitempty"`
Description string `json:"description,omitempty"`
IsFiller bool `json:"isFiller,omitempty"`
}
EpisodeSource struct {
Number int `json:"number"`
VideoSources []*VideoSource `json:"videoSources"`
Subtitles []*Subtitle `json:"subtitles,omitempty"`
}
VideoSource struct {
Server string `json:"server"`
Headers map[string]string `json:"headers,omitempty"`
URL string `json:"url"`
Quality string `json:"quality"`
}
EpisodeListResponse struct {
Episodes []*Episode `json:"episodes"`
Media *anilist.BaseAnime `json:"media"`
}
Subtitle struct {
URL string `json:"url"`
Language string `json:"language"`
}
)
type (
NewRepositoryOptions struct {
Logger *zerolog.Logger
FileCacher *filecache.Cacher
MetadataProvider metadata.Provider
Platform platform.Platform
Database *db.Database
}
)
func NewRepository(opts *NewRepositoryOptions) *Repository {
return &Repository{
logger: opts.Logger,
metadataProvider: opts.MetadataProvider,
fileCacher: opts.FileCacher,
providerExtensionBank: extension.NewUnifiedBank(),
anilistBaseAnimeCache: anilist.NewBaseAnimeCache(),
platform: opts.Platform,
db: opts.Database,
}
}
func (r *Repository) InitExtensionBank(bank *extension.UnifiedBank) {
r.providerExtensionBank = bank
r.logger.Debug().Msg("onlinestream: Initialized provider extension bank")
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// getFcEpisodeDataBucket returns a episode data bucket for the provider and mediaId.
// "Episode data" refers to the episodeData struct
//
// e.g., onlinestream_zoro_episode-data_123
func (r *Repository) getFcEpisodeDataBucket(provider string, mediaId int) filecache.Bucket {
return filecache.NewBucket("onlinestream_"+provider+"_episode-data_"+strconv.Itoa(mediaId), time.Hour*24*2)
}
// getFcEpisodeListBucket returns a episode data bucket for the provider and mediaId.
// "Episode list" refers to a slice of onlinestream_providers.EpisodeDetails
//
// e.g., onlinestream_zoro_episode-list_123
func (r *Repository) getFcEpisodeListBucket(provider string, mediaId int) filecache.Bucket {
return filecache.NewBucket("onlinestream_"+provider+"_episode-data_"+strconv.Itoa(mediaId), time.Hour*24*1)
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (r *Repository) getMedia(ctx context.Context, mId int) (*anilist.BaseAnime, error) {
media, err := r.anilistBaseAnimeCache.GetOrSet(mId, func() (*anilist.BaseAnime, error) {
media, err := r.platform.GetAnime(ctx, mId)
if err != nil {
return nil, err
}
return media, nil
})
if err != nil {
return nil, err
}
return media, nil
}
func (r *Repository) GetMedia(ctx context.Context, mId int) (*anilist.BaseAnime, error) {
return r.getMedia(ctx, mId)
}
func (r *Repository) EmptyCache(mediaId int) error {
_ = r.fileCacher.RemoveAllBy(func(filename string) bool {
return strings.HasPrefix(filename, "onlinestream_") && strings.Contains(filename, strconv.Itoa(mediaId))
})
return nil
}
func (r *Repository) GetMediaEpisodes(provider string, media *anilist.BaseAnime, dubbed bool) ([]*Episode, error) {
episodes := make([]*Episode, 0)
if provider == "" {
return episodes, nil
}
// +---------------------+
// | Animap |
// +---------------------+
//animeMetadata, err := r.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, mId)
// //foundAnimeMetadata := err == nil && animeMetadata != nil
//aw := r.metadataProvider.GetAnimeMetadataWrapper(media, animeMetadata)
episodeCollection, err := anime.NewEpisodeCollection(anime.NewEpisodeCollectionOptions{
AnimeMetadata: nil,
Media: media,
MetadataProvider: r.metadataProvider,
Logger: r.logger,
})
foundEpisodeCollection := err == nil && episodeCollection != nil
// +---------------------+
// | Episode list |
// +---------------------+
// Fetch the episode list from the provider
// "from" and "to" are set to 0 in order not to fetch episode servers
ec, err := r.getEpisodeContainer(provider, media, 0, 0, dubbed, media.GetStartYearSafe())
if err != nil {
return nil, err
}
for _, episodeDetails := range ec.ProviderEpisodeList {
// If the title contains "[{", it means it's an episode part (e.g. "Episode 6 [{6.5}]", the episode number should be 6)
if strings.Contains(episodeDetails.Title, "[{") {
ep := strings.Split(episodeDetails.Title, "[{")[1]
ep = strings.Split(ep, "}]")[0]
episodes = append(episodes, &Episode{
Number: episodeDetails.Number,
Title: fmt.Sprintf("Episode %s", ep),
Image: media.GetBannerImageSafe(),
Description: "",
IsFiller: false,
})
} else {
if foundEpisodeCollection {
episode, found := episodeCollection.FindEpisodeByNumber(episodeDetails.Number)
if found {
episodes = append(episodes, &Episode{
Number: episodeDetails.Number,
Title: episode.EpisodeTitle,
Image: episode.EpisodeMetadata.Image,
Description: episode.EpisodeMetadata.Summary,
IsFiller: episode.EpisodeMetadata.IsFiller,
})
} else {
episodes = append(episodes, &Episode{
Number: episodeDetails.Number,
Title: episodeDetails.Title,
Image: media.GetCoverImageSafe(),
})
}
} else {
episodes = append(episodes, &Episode{
Number: episodeDetails.Number,
Title: episodeDetails.Title,
Image: media.GetCoverImageSafe(),
})
}
}
}
episodes = lo.Filter(episodes, func(item *Episode, index int) bool {
return item != nil
})
return episodes, nil
}
func (r *Repository) GetEpisodeSources(ctx context.Context, provider string, mId int, number int, dubbed bool, year int) (*EpisodeSource, error) {
// +---------------------+
// | Media |
// +---------------------+
media, err := r.getMedia(ctx, mId)
if err != nil {
return nil, err
}
// +---------------------+
// | Episode servers |
// +---------------------+
ec, err := r.getEpisodeContainer(provider, media, number, number, dubbed, year)
if err != nil {
return nil, err
}
var sources *EpisodeSource
for _, ep := range ec.Episodes {
if ep.Number == number {
s := &EpisodeSource{
Number: ep.Number,
VideoSources: make([]*VideoSource, 0),
}
for _, es := range ep.Servers {
for _, vs := range es.VideoSources {
s.VideoSources = append(s.VideoSources, &VideoSource{
Server: es.Server,
Headers: es.Headers,
URL: vs.URL,
Quality: vs.Quality,
})
// Add subtitles if available
// Subtitles are stored in each video source, but they are the same, so only add them once.
if len(vs.Subtitles) > 0 && s.Subtitles == nil {
s.Subtitles = make([]*Subtitle, 0, len(vs.Subtitles))
for _, sub := range vs.Subtitles {
s.Subtitles = append(s.Subtitles, &Subtitle{
URL: sub.URL,
Language: sub.Language,
})
}
}
}
}
sources = s
break
}
}
if sources == nil {
return nil, ErrNoVideoSourceFound
}
return sources, nil
}

View File

@@ -0,0 +1,357 @@
package onlinestream
import (
"errors"
"fmt"
"seanime/internal/api/anilist"
"seanime/internal/extension"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
onlinestream_providers "seanime/internal/onlinestream/providers"
"seanime/internal/util/comparison"
"strings"
)
var (
ErrNoAnimeFound = errors.New("anime not found, try manual matching")
ErrNoEpisodes = errors.New("no episodes found")
errNoEpisodeSourceFound = errors.New("no source found for episode")
)
type (
// episodeContainer contains results of fetching the episodes from the provider.
episodeContainer struct {
Provider string
// List of episode details from the provider.
// It is used to get the episode servers.
ProviderEpisodeList []*hibikeonlinestream.EpisodeDetails
// List of episodes with their servers.
Episodes []*episodeData
}
// episodeData contains some details about a provider episode and all available servers.
episodeData struct {
Provider string
ID string
Number int
Title string
Servers []*hibikeonlinestream.EpisodeServer
}
)
// getEpisodeContainer gets the episode details and servers from the specified provider.
// It takes the media ID, titles in order to fetch the episode details.
// - This function can be used to only get the episode details by setting 'from' and 'to' to 0.
//
// Since the episode details are cached, we can request episode servers multiple times without fetching the episode details again.
func (r *Repository) getEpisodeContainer(provider string, media *anilist.BaseAnime, from int, to int, dubbed bool, year int) (*episodeContainer, error) {
r.logger.Debug().
Str("provider", provider).
Int("mediaId", media.ID).
Int("from", from).
Int("to", to).
Bool("dubbed", dubbed).
Msg("onlinestream: Getting episode container")
// Key identifying the provider episode list in the file cache.
// It includes "dubbed" because Gogoanime has a different entry for dubbed anime.
// e.g. 1$provider$true
providerEpisodeListKey := fmt.Sprintf("%d$%s$%v", media.ID, provider, dubbed)
// Create the episode container
ec := &episodeContainer{
Provider: provider,
Episodes: make([]*episodeData, 0),
ProviderEpisodeList: make([]*hibikeonlinestream.EpisodeDetails, 0),
}
// Get the episode details from the provider.
r.logger.Debug().
Str("key", providerEpisodeListKey).
Msgf("onlinestream: Fetching %s episode list", provider)
// Buckets for caching the episode list and episode data.
fcEpisodeListBucket := r.getFcEpisodeListBucket(provider, media.ID)
fcEpisodeDataBucket := r.getFcEpisodeDataBucket(provider, media.ID)
// Check if the episode list is cached to avoid fetching it again.
var providerEpisodeList []*hibikeonlinestream.EpisodeDetails
if found, _ := r.fileCacher.Get(fcEpisodeListBucket, providerEpisodeListKey, &providerEpisodeList); !found {
var err error
providerEpisodeList, err = r.getProviderEpisodeList(provider, media, dubbed, year)
if err != nil {
r.logger.Error().Err(err).Msg("onlinestream: Failed to get provider episodes")
return nil, err // ErrNoAnimeFound or ErrNoEpisodes
}
_ = r.fileCacher.Set(fcEpisodeListBucket, providerEpisodeListKey, providerEpisodeList)
} else {
r.logger.Debug().
Str("key", providerEpisodeListKey).
Msg("onlinestream: Cache HIT for episode list")
}
ec.ProviderEpisodeList = providerEpisodeList
var lastServerError error
for _, episodeDetails := range providerEpisodeList {
if episodeDetails.Number >= from && episodeDetails.Number <= to {
// Check if the episode is cached to avoid fetching the sources again.
key := fmt.Sprintf("%d$%s$%d$%v", media.ID, provider, episodeDetails.Number, dubbed)
r.logger.Debug().
Str("key", key).
Msgf("onlinestream: Fetching episode '%d' servers", episodeDetails.Number)
// Check episode cache
var cached *episodeData
if found, _ := r.fileCacher.Get(fcEpisodeDataBucket, key, &cached); found {
ec.Episodes = append(ec.Episodes, cached)
r.logger.Debug().
Str("key", key).
Msgf("onlinestream: Cache HIT for episode '%d' servers", episodeDetails.Number)
continue
}
// Zoro dubs
if provider == onlinestream_providers.ZoroProvider && dubbed {
// If the episode details have both sub and dub, we need to get the dub episode.
if !strings.HasSuffix(episodeDetails.ID, string(hibikeonlinestream.SubAndDub)) {
// Skip sub-only episodes
continue
}
// Replace "both" with "dub" so that [getProviderEpisodeServers] can find the dub episode.
episodeDetails.ID = strings.Replace(episodeDetails.ID, string(hibikeonlinestream.SubAndDub), string(hibikeonlinestream.Dub), 1)
}
// Fetch episode servers
servers, err := r.getProviderEpisodeServers(provider, episodeDetails)
if err != nil {
lastServerError = err
r.logger.Error().Err(err).Msgf("onlinestream: failed to get episode '%d' servers", episodeDetails.Number)
continue
}
episode := &episodeData{
ID: episodeDetails.ID,
Number: episodeDetails.Number,
Title: episodeDetails.Title,
Servers: servers,
}
ec.Episodes = append(ec.Episodes, episode)
r.logger.Debug().
Str("key", key).
Msgf("onlinestream: Found %d servers for episode '%d'", len(servers), episodeDetails.Number)
_ = r.fileCacher.Set(fcEpisodeDataBucket, key, episode)
}
}
if from > 0 && to > 0 && len(ec.Episodes) == 0 {
r.logger.Error().Err(lastServerError).Msg("onlinestream: No episode servers found")
return nil, fmt.Errorf("no episode servers found, provider returned: '%w'", lastServerError)
}
if len(ec.ProviderEpisodeList) == 0 {
r.logger.Error().Msg("onlinestream: No episodes found for this anime")
return nil, fmt.Errorf("no episodes found for this anime")
}
return ec, nil
}
// getProviderEpisodeServers gets all the available servers for the episode.
// It returns errNoEpisodeSourceFound if no sources are found.
//
// Example:
//
// episodeDetails, _ := getProviderEpisodeListFromTitles(provider, titles, dubbed)
// episodeServers, err := getProviderEpisodeServers(provider, episodeDetails[0])
func (r *Repository) getProviderEpisodeServers(provider string, episodeDetails *hibikeonlinestream.EpisodeDetails) ([]*hibikeonlinestream.EpisodeServer, error) {
var providerServers []*hibikeonlinestream.EpisodeServer
providerExtension, ok := extension.GetExtension[extension.OnlinestreamProviderExtension](r.providerExtensionBank, provider)
if !ok {
return nil, fmt.Errorf("provider extension '%s' not found", provider)
}
for _, episodeServer := range providerExtension.GetProvider().GetSettings().EpisodeServers {
res, err := providerExtension.GetProvider().FindEpisodeServer(episodeDetails, episodeServer)
if err == nil {
// Add the server to the list for the episode
providerServers = append(providerServers, res)
}
}
if len(providerServers) == 0 {
return nil, errNoEpisodeSourceFound
}
return providerServers, nil
}
// getProviderEpisodeList gets all the hibikeonlinestream.EpisodeDetails from the provider based on the anime's titles.
// It returns ErrNoAnimeFound if the anime is not found or ErrNoEpisodes if no episodes are found.
func (r *Repository) getProviderEpisodeList(provider string, media *anilist.BaseAnime, dubbed bool, year int) ([]*hibikeonlinestream.EpisodeDetails, error) {
var ret []*hibikeonlinestream.EpisodeDetails
// romajiTitle := strings.ReplaceAll(media.GetEnglishTitleSafe(), ":", "")
// englishTitle := strings.ReplaceAll(media.GetRomajiTitleSafe(), ":", "")
romajiTitle := media.GetRomajiTitleSafe()
englishTitle := media.GetEnglishTitleSafe()
providerExtension, ok := extension.GetExtension[extension.OnlinestreamProviderExtension](r.providerExtensionBank, provider)
if !ok {
return nil, fmt.Errorf("provider extension '%s' not found", provider)
}
mId := media.ID
var matchId string
// +---------------------+
// | Database |
// +---------------------+
// Search for the mapping in the database
mapping, found := r.db.GetOnlinestreamMapping(provider, mId)
if found {
r.logger.Debug().Str("animeId", mapping.AnimeID).Msg("onlinestream: Using manual mapping")
matchId = mapping.AnimeID
}
if matchId == "" {
// +---------------------+
// | Search |
// +---------------------+
// Get search results.
var searchResults []*hibikeonlinestream.SearchResult
queryMedia := hibikeonlinestream.Media{
ID: media.ID,
IDMal: media.GetIDMal(),
Status: string(*media.GetStatus()),
Format: string(*media.GetFormat()),
EnglishTitle: media.GetTitle().GetEnglish(),
RomajiTitle: media.GetRomajiTitleSafe(),
EpisodeCount: media.GetTotalEpisodeCount(),
Synonyms: media.GetSynonymsContainingSeason(),
IsAdult: *media.GetIsAdult(),
StartDate: &hibikeonlinestream.FuzzyDate{
Year: *media.GetStartDate().GetYear(),
Month: media.GetStartDate().GetMonth(),
Day: media.GetStartDate().GetDay(),
},
}
added := make(map[string]struct{})
if romajiTitle != "" {
// Search by romaji title
res, err := providerExtension.GetProvider().Search(hibikeonlinestream.SearchOptions{
Media: queryMedia,
Query: romajiTitle,
Dub: dubbed,
Year: year,
})
if err == nil && len(res) > 0 {
searchResults = append(searchResults, res...)
for _, r := range res {
added[r.ID] = struct{}{}
}
}
if err != nil {
r.logger.Error().Err(err).Msg("onlinestream: Failed to search for romaji title")
}
r.logger.Debug().
Int("romajiTitleResults", len(res)).
Msg("onlinestream: Found results for romaji title")
}
if englishTitle != "" {
// Search by english title
res, err := providerExtension.GetProvider().Search(hibikeonlinestream.SearchOptions{
Media: queryMedia,
Query: englishTitle,
Dub: dubbed,
Year: year,
})
if err == nil && len(res) > 0 {
for _, r := range res {
if _, ok := added[r.ID]; !ok {
searchResults = append(searchResults, r)
}
}
}
if err != nil {
r.logger.Error().Err(err).Msg("onlinestream: Failed to search for english title")
}
r.logger.Debug().
Int("englishTitleResults", len(res)).
Msg("onlinestream: Found results for english title")
}
if len(searchResults) == 0 {
return nil, fmt.Errorf("automatic matching returned no results")
}
bestResult, found := GetBestSearchResult(searchResults, media.GetAllTitles())
if !found {
return nil, ErrNoAnimeFound
}
matchId = bestResult.ID
}
// Fetch episodes.
ret, err := providerExtension.GetProvider().FindEpisodes(matchId)
if err != nil {
return nil, fmt.Errorf("provider returned an error: %w", err)
}
if len(ret) == 0 {
return nil, fmt.Errorf("provider returned no episodes")
}
return ret, nil
}
func GetBestSearchResult(searchResults []*hibikeonlinestream.SearchResult, titles []*string) (*hibikeonlinestream.SearchResult, bool) {
// Filter results to get the best match.
compBestResults := make([]*comparison.LevenshteinResult, 0, len(searchResults))
for _, r := range searchResults {
// Compare search result title with all titles.
compBestResult, found := comparison.FindBestMatchWithLevenshtein(&r.Title, titles)
if found {
compBestResults = append(compBestResults, compBestResult)
}
}
if len(compBestResults) == 0 {
return nil, false
}
compBestResult := compBestResults[0]
for _, r := range compBestResults {
if r.Distance < compBestResult.Distance {
compBestResult = r
}
}
// Get most accurate search result.
var bestResult *hibikeonlinestream.SearchResult
for _, r := range searchResults {
if r.Title == *compBestResult.OriginalValue {
bestResult = r
break
}
}
return bestResult, true
}

View File

@@ -0,0 +1,112 @@
package onlinestream
import (
"context"
"path/filepath"
"seanime/internal/api/anilist"
onlinestream_providers "seanime/internal/onlinestream/providers"
"seanime/internal/test_utils"
"seanime/internal/util"
"seanime/internal/util/filecache"
"testing"
)
func TestOnlineStream_GetEpisodes(t *testing.T) {
t.Skip("TODO: Fix this test by loading built-in extensions")
test_utils.SetTwoLevelDeep()
test_utils.InitTestProvider(t, test_utils.Anilist())
tempDir := t.TempDir()
anilistClient := anilist.TestGetMockAnilistClient()
//fileCacher, _ := filecache.NewCacher(filepath.Join(test_utils.ConfigData.Path.DataDir, "cache"))
fileCacher, _ := filecache.NewCacher(filepath.Join(tempDir, "cache"))
os := NewRepository(&NewRepositoryOptions{
Logger: util.NewLogger(),
FileCacher: fileCacher,
})
tests := []struct {
name string
mediaId int
from int
to int
provider string
dubbed bool
}{
{
name: "Cowboy Bebop",
mediaId: 1,
from: 1,
to: 2,
provider: onlinestream_providers.GogoanimeProvider,
dubbed: false,
},
{
name: "Cowboy Bebop",
mediaId: 1,
from: 1,
to: 2,
provider: onlinestream_providers.ZoroProvider,
dubbed: false,
},
{
name: "One Piece",
mediaId: 21,
from: 1075,
to: 1076,
provider: onlinestream_providers.ZoroProvider,
dubbed: false,
},
{
name: "Dungeon Meshi",
mediaId: 153518,
from: 1,
to: 1,
provider: onlinestream_providers.ZoroProvider,
dubbed: false,
},
{
name: "Omoi, Omoware, Furi, Furare",
mediaId: 109125,
from: 1,
to: 1,
provider: onlinestream_providers.ZoroProvider,
dubbed: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mediaF, err := anilistClient.BaseAnimeByID(context.Background(), &tt.mediaId)
if err != nil {
t.Fatalf("couldn't get media: %s", err)
}
media := mediaF.GetMedia()
ec, err := os.getEpisodeContainer(tt.provider, media, tt.from, tt.to, tt.dubbed, 0)
if err != nil {
t.Fatalf("couldn't find episodes, %s", err)
}
t.Logf("Provider: %s, found %d episodes for the anime", ec.Provider, len(ec.ProviderEpisodeList))
// Episode Data
for _, ep := range ec.Episodes {
t.Logf("\t\tEpisode %d has %d servers", ep.Number, len(ep.Servers))
for _, s := range ep.Servers {
t.Logf("\t\t\tServer: %s", s.Server)
for _, vs := range s.VideoSources {
t.Logf("\t\t\t\tVideo Source: %s, Type: %s", vs.Quality, vs.Type)
}
}
}
})
}
}

View File

@@ -0,0 +1,24 @@
package onlinestream_sources
import (
"errors"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
)
var (
ErrNoVideoSourceFound = errors.New("no episode source found")
ErrVideoSourceExtraction = errors.New("error while extracting video sources")
)
type VideoExtractor interface {
Extract(uri string) ([]*hibikeonlinestream.VideoSource, error)
}
const (
QualityDefault = "default"
QualityAuto = "auto"
Quality360 = "360"
Quality480 = "480"
Quality720 = "720"
Quality1080 = "1080"
)

View File

@@ -0,0 +1,277 @@
package onlinestream_sources
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"github.com/gocolly/colly"
"io"
"net/http"
"net/url"
"regexp"
"seanime/internal/util"
"strings"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
)
type cdnKeys struct {
key []byte
secondKey []byte
iv []byte
}
type GogoCDN struct {
client *http.Client
serverName string
keys cdnKeys
referrer string
}
func NewGogoCDN() *GogoCDN {
return &GogoCDN{
client: &http.Client{},
serverName: "goload",
keys: cdnKeys{
key: []byte("37911490979715163134003223491201"),
secondKey: []byte("54674138327930866480207815084989"),
iv: []byte("3134003223491201"),
},
}
}
// Extract fetches and extracts video sources from the provided URI.
func (g *GogoCDN) Extract(uri string) (vs []*hibikeonlinestream.VideoSource, err error) {
defer util.HandlePanicInModuleThen("onlinestream/sources/gogocdn/Extract", func() {
err = ErrVideoSourceExtraction
})
// Instantiate a new collector
c := colly.NewCollector(
// Allow visiting the same page multiple times
colly.AllowURLRevisit(),
)
ur, err := url.Parse(uri)
if err != nil {
return nil, err
}
// Variables to hold extracted values
var scriptValue, id string
id = ur.Query().Get("id")
// Find and extract the script value and id
c.OnHTML("script[data-name='episode']", func(e *colly.HTMLElement) {
scriptValue = e.Attr("data-value")
})
// Start scraping
err = c.Visit(uri)
if err != nil {
return nil, err
}
// Check if scriptValue and id are found
if scriptValue == "" || id == "" {
return nil, errors.New("script value or id not found")
}
// Extract video sources
ajaxUrl := fmt.Sprintf("%s://%s/encrypt-ajax.php?%s", ur.Scheme, ur.Host, g.generateEncryptedAjaxParams(id, scriptValue))
req, err := http.NewRequest("GET", ajaxUrl, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01")
encryptedData, err := g.client.Do(req)
if err != nil {
return nil, err
}
defer encryptedData.Body.Close()
encryptedDataBytesRes, err := io.ReadAll(encryptedData.Body)
if err != nil {
return nil, err
}
var encryptedDataBytes map[string]string
err = json.Unmarshal(encryptedDataBytesRes, &encryptedDataBytes)
if err != nil {
return nil, err
}
data, err := g.decryptAjaxData(encryptedDataBytes["data"])
source, ok := data["source"].([]interface{})
// Check if source is found
if !ok {
return nil, ErrNoVideoSourceFound
}
var results []*hibikeonlinestream.VideoSource
urls := make([]string, 0)
for _, src := range source {
s := src.(map[string]interface{})
urls = append(urls, s["file"].(string))
}
sourceBK, ok := data["source_bk"].([]interface{})
if ok {
for _, src := range sourceBK {
s := src.(map[string]interface{})
urls = append(urls, s["file"].(string))
}
}
for _, url := range urls {
vs, ok := g.urlToVideoSource(url, source, sourceBK)
if ok {
results = append(results, vs...)
}
}
return results, nil
}
func (g *GogoCDN) urlToVideoSource(url string, source []interface{}, sourceBK []interface{}) (vs []*hibikeonlinestream.VideoSource, ok bool) {
defer util.HandlePanicInModuleThen("onlinestream/sources/gogocdn/urlToVideoSource", func() {
ok = false
})
ret := make([]*hibikeonlinestream.VideoSource, 0)
if strings.Contains(url, ".m3u8") {
resResult, err := http.Get(url)
if err != nil {
return nil, false
}
defer resResult.Body.Close()
bodyBytes, err := io.ReadAll(resResult.Body)
if err != nil {
return nil, false
}
bodyString := string(bodyBytes)
resolutions := regexp.MustCompile(`(RESOLUTION=)(.*)(\s*?)(\s.*)`).FindAllStringSubmatch(bodyString, -1)
baseURL := url[:strings.LastIndex(url, "/")]
for _, res := range resolutions {
quality := strings.Split(strings.Split(res[2], "x")[1], ",")[0]
url := fmt.Sprintf("%s/%s", baseURL, strings.TrimSpace(res[4]))
ret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceM3U8, Quality: quality + "p"})
}
ret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceM3U8, Quality: "default"})
} else {
for _, src := range source {
s := src.(map[string]interface{})
if s["file"].(string) == url {
quality := strings.Split(s["label"].(string), " ")[0] + "p"
ret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceMP4, Quality: quality})
}
}
if sourceBK != nil {
for _, src := range sourceBK {
s := src.(map[string]interface{})
if s["file"].(string) == url {
ret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceMP4, Quality: "backup"})
}
}
}
}
return ret, true
}
// generateEncryptedAjaxParams generates encrypted AJAX parameters.
func (g *GogoCDN) generateEncryptedAjaxParams(id, scriptValue string) string {
encryptedKey := g.encrypt(id, g.keys.iv, g.keys.key)
decryptedToken := g.decrypt(scriptValue, g.keys.iv, g.keys.key)
return fmt.Sprintf("id=%s&alias=%s", encryptedKey, decryptedToken)
}
// encrypt encrypts the given text using AES CBC mode.
func (g *GogoCDN) encrypt(text string, iv []byte, key []byte) string {
block, _ := aes.NewCipher(key)
textBytes := []byte(text)
textBytes = pkcs7Padding(textBytes, aes.BlockSize)
cipherText := make([]byte, len(textBytes))
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(cipherText, textBytes)
return base64.StdEncoding.EncodeToString(cipherText)
}
// decrypt decrypts the given text using AES CBC mode.
func (g *GogoCDN) decrypt(text string, iv []byte, key []byte) string {
block, _ := aes.NewCipher(key)
cipherText, _ := base64.StdEncoding.DecodeString(text)
plainText := make([]byte, len(cipherText))
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(plainText, cipherText)
plainText = pkcs7Trimming(plainText)
return string(plainText)
}
func (g *GogoCDN) decryptAjaxData(encryptedData string) (map[string]interface{}, error) {
decodedData, err := base64.StdEncoding.DecodeString(encryptedData)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(g.keys.secondKey)
if err != nil {
return nil, err
}
if len(decodedData) < aes.BlockSize {
return nil, fmt.Errorf("cipher text too short")
}
iv := g.keys.iv
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(decodedData, decodedData)
// Remove padding
decodedData = pkcs7Trimming(decodedData)
var data map[string]interface{}
err = json.Unmarshal(decodedData, &data)
if err != nil {
return nil, err
}
return data, nil
}
// pkcs7Padding pads the text to be a multiple of blockSize using Pkcs7 padding.
func pkcs7Padding(text []byte, blockSize int) []byte {
padding := blockSize - len(text)%blockSize
padText := bytes.Repeat([]byte{byte(padding)}, padding)
return append(text, padText...)
}
// pkcs7Trimming removes Pkcs7 padding from the text.
func pkcs7Trimming(text []byte) []byte {
length := len(text)
unpadding := int(text[length-1])
return text[:(length - unpadding)]
}

View File

@@ -0,0 +1,16 @@
package onlinestream_sources
import (
"github.com/davecgh/go-spew/spew"
"github.com/stretchr/testify/assert"
"testing"
)
func TestGogoCDN_Extract(t *testing.T) {
gogo := NewGogoCDN()
ret, err := gogo.Extract("https://embtaku.pro/streaming.php?id=MjExNjU5&title=One+Piece+Episode+1075")
assert.NoError(t, err)
spew.Dump(ret)
}

View File

@@ -0,0 +1,350 @@
package onlinestream_sources
import (
"crypto/aes"
"crypto/cipher"
"crypto/md5"
"encoding/base64"
"encoding/json"
"errors"
"io"
"net/http"
"regexp"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
"seanime/internal/util"
"strconv"
"strings"
)
type MegaCloud struct {
Script string
Sources string
UserAgent string
}
func NewMegaCloud() *MegaCloud {
return &MegaCloud{
Script: "https://megacloud.tv/js/player/a/prod/e1-player.min.js",
Sources: "https://megacloud.tv/embed-2/ajax/e-1/getSources?id=",
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3",
}
}
func (m *MegaCloud) Extract(uri string) (vs []*hibikeonlinestream.VideoSource, err error) {
defer util.HandlePanicInModuleThen("onlinestream/sources/megacloud/Extract", func() {
err = ErrVideoSourceExtraction
})
videoIdParts := strings.Split(uri, "/")
videoId := videoIdParts[len(videoIdParts)-1]
videoId = strings.Split(videoId, "?")[0]
client := &http.Client{}
req, err := http.NewRequest("GET", m.Sources+videoId, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "*/*")
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("User-Agent", m.UserAgent)
req.Header.Set("Referer", uri)
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var srcData map[string]interface{}
err = json.NewDecoder(res.Body).Decode(&srcData)
if err != nil {
return nil, err
}
subtitles := make([]*hibikeonlinestream.VideoSubtitle, 0)
for idx, s := range srcData["tracks"].([]interface{}) {
sub := s.(map[string]interface{})
label, ok := sub["label"].(string)
if ok {
subtitle := &hibikeonlinestream.VideoSubtitle{
URL: sub["file"].(string),
ID: label,
Language: label,
IsDefault: idx == 0,
}
subtitles = append(subtitles, subtitle)
}
}
if encryptedString, ok := srcData["sources"]; ok {
switch encryptedString.(type) {
case []interface{}:
if len(encryptedString.([]interface{})) == 0 {
return nil, ErrNoVideoSourceFound
}
videoSources := make([]*hibikeonlinestream.VideoSource, 0)
if e, ok := encryptedString.([]interface{})[0].(map[string]interface{}); ok {
file, ok := e["file"].(string)
if ok {
videoSources = append(videoSources, &hibikeonlinestream.VideoSource{
URL: file,
Type: map[bool]hibikeonlinestream.VideoSourceType{true: hibikeonlinestream.VideoSourceM3U8, false: hibikeonlinestream.VideoSourceMP4}[strings.Contains(file, ".m3u8")],
Subtitles: subtitles,
Quality: QualityAuto,
})
}
}
if len(videoSources) == 0 {
return nil, ErrNoVideoSourceFound
}
return videoSources, nil
case []map[string]interface{}:
if srcData["encrypted"].(bool) && ok {
videoSources := make([]*hibikeonlinestream.VideoSource, 0)
for _, e := range encryptedString.([]map[string]interface{}) {
videoSources = append(videoSources, &hibikeonlinestream.VideoSource{
URL: e["file"].(string),
Type: map[bool]hibikeonlinestream.VideoSourceType{true: hibikeonlinestream.VideoSourceM3U8, false: hibikeonlinestream.VideoSourceMP4}[strings.Contains(e["file"].(string), ".m3u8")],
Subtitles: subtitles,
Quality: QualityAuto,
})
}
if len(videoSources) == 0 {
return nil, ErrNoVideoSourceFound
}
return videoSources, nil
}
case string:
res, err = client.Get(m.Script)
if err != nil {
return nil, err
}
defer res.Body.Close()
text, err := io.ReadAll(res.Body)
if err != nil {
return nil, errors.New("couldn't fetch script to decrypt resource")
}
values, err := m.extractVariables(string(text))
if err != nil {
return nil, err
}
secret, encryptedSource := m.getSecret(encryptedString.(string), values)
//if err != nil {
// return nil, err
//}
decrypted, err := m.decrypt(encryptedSource, secret)
if err != nil {
return nil, err
}
var decryptedData []map[string]interface{}
err = json.Unmarshal([]byte(decrypted), &decryptedData)
if err != nil {
return nil, err
}
sources := make([]*hibikeonlinestream.VideoSource, 0)
for _, e := range decryptedData {
sources = append(sources, &hibikeonlinestream.VideoSource{
URL: e["file"].(string),
Type: map[bool]hibikeonlinestream.VideoSourceType{true: hibikeonlinestream.VideoSourceM3U8, false: hibikeonlinestream.VideoSourceMP4}[strings.Contains(e["file"].(string), ".m3u8")],
Subtitles: subtitles,
Quality: QualityAuto,
})
}
if len(sources) == 0 {
return nil, ErrNoVideoSourceFound
}
return sources, nil
}
}
return nil, ErrNoVideoSourceFound
}
func (m *MegaCloud) extractVariables(text string) ([][]int, error) {
re := regexp.MustCompile(`case\s*0x[0-9a-f]+:\s*\w+\s*=\s*(\w+)\s*,\s*\w+\s*=\s*(\w+);`)
matches := re.FindAllStringSubmatch(text, -1)
var vars [][]int
for _, match := range matches {
if len(match) < 3 {
continue
}
caseLine := match[0]
if strings.Contains(caseLine, "partKey") {
continue
}
matchKey1, err1 := m.matchingKey(match[1], text)
matchKey2, err2 := m.matchingKey(match[2], text)
if err1 != nil || err2 != nil {
continue
}
key1, err1 := strconv.ParseInt(matchKey1, 16, 64)
key2, err2 := strconv.ParseInt(matchKey2, 16, 64)
if err1 != nil || err2 != nil {
continue
}
vars = append(vars, []int{int(key1), int(key2)})
}
return vars, nil
}
func (m *MegaCloud) matchingKey(value, script string) (string, error) {
regexPattern := `,` + regexp.QuoteMeta(value) + `=((?:0x)?([0-9a-fA-F]+))`
re := regexp.MustCompile(regexPattern)
match := re.FindStringSubmatch(script)
if len(match) > 1 {
return strings.TrimPrefix(match[1], "0x"), nil
}
return "", errors.New("failed to match the key")
}
func (m *MegaCloud) getSecret(encryptedString string, values [][]int) (string, string) {
secret := ""
encryptedSourceArray := strings.Split(encryptedString, "")
currentIndex := 0
for _, index := range values {
start := index[0] + currentIndex
end := start + index[1]
for i := start; i < end; i++ {
secret += string(encryptedString[i])
encryptedSourceArray[i] = ""
}
currentIndex += index[1]
}
encryptedSource := strings.Join(encryptedSourceArray, "")
return secret, encryptedSource
}
//func (m *MegaCloud) getSecret(encryptedString string, values []int) (string, string, error) {
// var secret string
// var encryptedSource = encryptedString
// var totalInc int
//
// for i := 0; i < values[0]; i++ {
// var start, inc int
//
// switch i {
// case 0:
// start = values[2]
// inc = values[1]
// case 1:
// start = values[4]
// inc = values[3]
// case 2:
// start = values[6]
// inc = values[5]
// case 3:
// start = values[8]
// inc = values[7]
// case 4:
// start = values[10]
// inc = values[9]
// case 5:
// start = values[12]
// inc = values[11]
// case 6:
// start = values[14]
// inc = values[13]
// case 7:
// start = values[16]
// inc = values[15]
// case 8:
// start = values[18]
// inc = values[17]
// default:
// return "", "", errors.New("invalid index")
// }
//
// from := start + totalInc
// to := from + inc
//
// secret += encryptedString[from:to]
// encryptedSource = strings.Replace(encryptedSource, encryptedString[from:to], "", 1)
// totalInc += inc
// }
//
// return secret, encryptedSource, nil
//}
func (m *MegaCloud) decrypt(encrypted, keyOrSecret string) (string, error) {
cypher, err := base64.StdEncoding.DecodeString(encrypted)
if err != nil {
return "", err
}
salt := cypher[8:16]
password := append([]byte(keyOrSecret), salt...)
md5Hashes := make([][]byte, 3)
digest := password
for i := 0; i < 3; i++ {
hash := md5.Sum(digest)
md5Hashes[i] = hash[:]
digest = append(hash[:], password...)
}
key := append(md5Hashes[0], md5Hashes[1]...)
iv := md5Hashes[2]
contents := cypher[16:]
block, err := aes.NewCipher(key)
if err != nil {
return "", err
}
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(contents, contents)
contents, err = pkcs7Unpad(contents, block.BlockSize())
if err != nil {
return "", err
}
return string(contents), nil
}
func pkcs7Unpad(data []byte, blockSize int) ([]byte, error) {
if blockSize <= 0 {
return nil, errors.New("invalid blocksize")
}
if len(data)%blockSize != 0 || len(data) == 0 {
return nil, errors.New("invalid PKCS7 data (block size must be a multiple of input length)")
}
padLen := int(data[len(data)-1])
if padLen > blockSize || padLen == 0 {
return nil, errors.New("invalid PKCS7 padding")
}
for i := 0; i < padLen; i++ {
if data[len(data)-1-i] != byte(padLen) {
return nil, errors.New("invalid PKCS7 padding")
}
}
return data[:len(data)-padLen], nil
}

View File

@@ -0,0 +1,111 @@
package onlinestream_sources
import (
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"seanime/internal/util"
"strings"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
)
type StreamSB struct {
Host string
Host2 string
UserAgent string
}
func NewStreamSB() *StreamSB {
return &StreamSB{
Host: "https://streamsss.net/sources50",
Host2: "https://watchsb.com/sources50",
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36",
}
}
func (s *StreamSB) Payload(hex string) string {
return "566d337678566f743674494a7c7c" + hex + "7c7c346b6767586d6934774855537c7c73747265616d7362/6565417268755339773461447c7c346133383438333436313335376136323337373433383634376337633465366534393338373136643732373736343735373237613763376334363733353737303533366236333463353333363534366137633763373337343732363536313664373336327c7c6b586c3163614468645a47617c7c73747265616d7362"
}
func (s *StreamSB) Extract(uri string) (vs []*hibikeonlinestream.VideoSource, err error) {
defer util.HandlePanicInModuleThen("onlinestream/sources/streamsb/Extract", func() {
err = ErrVideoSourceExtraction
})
var ret []*hibikeonlinestream.VideoSource
id := strings.Split(uri, "/e/")[1]
if strings.Contains(id, "html") {
id = strings.Split(id, ".html")[0]
}
if id == "" {
return nil, errors.New("cannot find ID")
}
client := &http.Client{}
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/%s", s.Host, s.Payload(hex.EncodeToString([]byte(id)))), nil)
req.Header.Add("watchsb", "sbstream")
req.Header.Add("User-Agent", s.UserAgent)
req.Header.Add("Referer", uri)
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
var jsonResponse map[string]interface{}
err = json.Unmarshal(body, &jsonResponse)
if err != nil {
return nil, err
}
streamData, ok := jsonResponse["stream_data"].(map[string]interface{})
if !ok {
return nil, ErrNoVideoSourceFound
}
m3u8Urls, err := client.Get(streamData["file"].(string))
if err != nil {
return nil, err
}
defer m3u8Urls.Body.Close()
m3u8Body, err := io.ReadAll(m3u8Urls.Body)
if err != nil {
return nil, err
}
videoList := strings.Split(string(m3u8Body), "#EXT-X-STREAM-INF:")
for _, video := range videoList {
if !strings.Contains(video, "m3u8") {
continue
}
url := strings.Split(video, "\n")[1]
quality := strings.Split(strings.Split(video, "RESOLUTION=")[1], ",")[0]
quality = strings.Split(quality, "x")[1]
ret = append(ret, &hibikeonlinestream.VideoSource{
URL: url,
Quality: quality + "p",
Type: hibikeonlinestream.VideoSourceM3U8,
})
}
ret = append(ret, &hibikeonlinestream.VideoSource{
URL: streamData["file"].(string),
Quality: "auto",
Type: map[bool]hibikeonlinestream.VideoSourceType{true: hibikeonlinestream.VideoSourceM3U8, false: hibikeonlinestream.VideoSourceMP4}[strings.Contains(streamData["file"].(string), ".m3u8")],
})
return ret, nil
}

View File

@@ -0,0 +1,64 @@
package onlinestream_sources
import (
"errors"
"io"
"net/http"
"regexp"
"seanime/internal/util"
"strings"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
)
type (
Streamtape struct {
Client *http.Client
}
)
func NewStreamtape() *Streamtape {
return &Streamtape{
Client: &http.Client{},
}
}
func (s *Streamtape) Extract(uri string) (vs []*hibikeonlinestream.VideoSource, err error) {
defer util.HandlePanicInModuleThen("onlinestream/sources/streamtape/Extract", func() {
err = ErrVideoSourceExtraction
})
var ret []*hibikeonlinestream.VideoSource
resp, err := s.Client.Get(uri)
if err != nil {
return nil, err
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
re := regexp.MustCompile(`robotlink'\).innerHTML = (.*)'`)
match := re.FindStringSubmatch(string(body))
if len(match) == 0 {
return nil, errors.New("could not find robotlink")
}
fhsh := strings.Split(match[1], "+ ('")
fh := fhsh[0]
sh := fhsh[1][3:]
fh = strings.ReplaceAll(fh, "'", "")
url := "https:" + fh + sh
ret = append(ret, &hibikeonlinestream.VideoSource{
URL: url,
Type: map[bool]hibikeonlinestream.VideoSourceType{true: hibikeonlinestream.VideoSourceM3U8, false: hibikeonlinestream.VideoSourceMP4}[strings.Contains(url, ".m3u8")],
Quality: QualityAuto,
})
return ret, nil
}