Files
seanime-docker/seanime-2.9.10/internal/torrents/animetosho/provider.go
2025-09-20 14:08:38 +01:00

732 lines
19 KiB
Go

package animetosho
import (
"bytes"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"seanime/internal/api/anilist"
hibiketorrent "seanime/internal/extension/hibike/torrent"
"seanime/internal/util"
"strings"
"sync"
"time"
"github.com/5rahim/habari"
"github.com/goccy/go-json"
"github.com/rs/zerolog"
"github.com/samber/lo"
)
var (
JsonFeedUrl = util.Decode("aHR0cHM6Ly9mZWVkLmFuaW1ldG9zaG8ub3JnL2pzb24=")
ProviderName = "animetosho"
)
type (
Provider struct {
logger *zerolog.Logger
sneedexNyaaIDs map[int]struct{}
}
)
func NewProvider(logger *zerolog.Logger) hibiketorrent.AnimeProvider {
ret := &Provider{
logger: logger,
sneedexNyaaIDs: make(map[int]struct{}),
}
go ret.loadSneedex()
return ret
}
func (at *Provider) GetSettings() hibiketorrent.AnimeProviderSettings {
return hibiketorrent.AnimeProviderSettings{
Type: hibiketorrent.AnimeProviderTypeMain,
CanSmartSearch: true,
SmartSearchFilters: []hibiketorrent.AnimeProviderSmartSearchFilter{
hibiketorrent.AnimeProviderSmartSearchFilterBatch,
hibiketorrent.AnimeProviderSmartSearchFilterEpisodeNumber,
hibiketorrent.AnimeProviderSmartSearchFilterResolution,
hibiketorrent.AnimeProviderSmartSearchFilterBestReleases,
},
SupportsAdult: false,
}
}
// GetLatest returns all the latest torrents currently visible on the site
func (at *Provider) GetLatest() (ret []*hibiketorrent.AnimeTorrent, err error) {
at.logger.Debug().Msg("animetosho: Fetching latest torrents")
query := "?q="
torrents, err := at.fetchTorrents(query)
if err != nil {
return nil, err
}
ret = at.torrentSliceToAnimeTorrentSlice(torrents, false, &hibiketorrent.Media{})
return ret, nil
}
func (at *Provider) Search(opts hibiketorrent.AnimeSearchOptions) (ret []*hibiketorrent.AnimeTorrent, err error) {
at.logger.Debug().Str("query", opts.Query).Msg("animetosho: Searching for torrents")
query := fmt.Sprintf("?q=%s", url.QueryEscape(sanitizeTitle(opts.Query)))
atTorrents, err := at.fetchTorrents(query)
if err != nil {
return nil, err
}
ret = at.torrentSliceToAnimeTorrentSlice(atTorrents, false, &opts.Media)
return ret, nil
}
func (at *Provider) SmartSearch(opts hibiketorrent.AnimeSmartSearchOptions) ([]*hibiketorrent.AnimeTorrent, error) {
if opts.BestReleases {
return at.smartSearchBestReleases(&opts)
}
if opts.Batch {
return at.smartSearchBatch(&opts)
}
return at.smartSearchSingleEpisode(&opts)
}
func (at *Provider) smartSearchSingleEpisode(opts *hibiketorrent.AnimeSmartSearchOptions) (ret []*hibiketorrent.AnimeTorrent, err error) {
ret = make([]*hibiketorrent.AnimeTorrent, 0)
at.logger.Debug().Int("aid", opts.AnidbAID).Msg("animetosho: Searching batches by Episode ID")
foundByID := false
atTorrents := make([]*Torrent, 0)
if opts.AnidbEID > 0 {
// Get all torrents by Episode ID
atTorrents, err = at.searchByEID(opts.AnidbEID, opts.Resolution)
if err != nil {
return nil, err
}
foundByID = true
}
if foundByID {
// Get all torrents with only 1 file
atTorrents = lo.Filter(atTorrents, func(t *Torrent, _ int) bool {
return t.NumFiles == 1
})
ret = at.torrentSliceToAnimeTorrentSlice(atTorrents, true, &opts.Media)
return
}
at.logger.Debug().Msg("animetosho: Searching batches by query")
// If we couldn't find batches by AniDB Episode ID, use query builder
queries := buildSmartSearchQueries(opts)
wg := sync.WaitGroup{}
mu := sync.Mutex{}
for _, query := range queries {
wg.Add(1)
go func(query string) {
defer wg.Done()
at.logger.Trace().Str("query", query).Msg("animetosho: Searching by query")
torrents, err := at.fetchTorrents(fmt.Sprintf("?only_tor=1&q=%s&qx=1", url.QueryEscape(query)))
if err != nil {
return
}
for _, t := range torrents {
// Skip if torrent has more than 1 file
if t.NumFiles > 1 && !(opts.Media.Format == string(anilist.MediaFormatMovie) && opts.Media.EpisodeCount == 1) {
continue
}
mu.Lock()
ret = append(ret, t.toAnimeTorrent(&opts.Media))
mu.Unlock()
}
}(query)
}
wg.Wait()
// Remove duplicates
lo.UniqBy(ret, func(t *hibiketorrent.AnimeTorrent) string {
return t.Link
})
return
}
func (at *Provider) smartSearchBatch(opts *hibiketorrent.AnimeSmartSearchOptions) (ret []*hibiketorrent.AnimeTorrent, err error) {
ret = make([]*hibiketorrent.AnimeTorrent, 0)
at.logger.Debug().Int("aid", opts.AnidbAID).Msg("animetosho: Searching batches by Anime ID")
foundByID := false
atTorrents := make([]*Torrent, 0)
if opts.AnidbAID > 0 {
// Get all torrents by Anime ID
atTorrents, err = at.searchByAID(opts.AnidbAID, opts.Resolution)
if err != nil {
return nil, err
}
// Retain batches ONLY if the media is NOT a movie or single-episode
// i.e. if the media is a movie or single-episode return all torrents
if !(opts.Media.Format == string(anilist.MediaFormatMovie) || opts.Media.EpisodeCount == 1) {
batchTorrents := lo.Filter(atTorrents, func(t *Torrent, _ int) bool {
return t.NumFiles > 1
})
if len(batchTorrents) > 0 {
atTorrents = batchTorrents
}
}
if len(atTorrents) > 0 {
foundByID = true
}
}
if foundByID {
ret = at.torrentSliceToAnimeTorrentSlice(atTorrents, true, &opts.Media)
return
}
at.logger.Debug().Msg("animetosho: Searching batches by query")
// If we couldn't find batches by AniDB Anime ID, use query builder
queries := buildSmartSearchQueries(opts)
wg := sync.WaitGroup{}
mu := sync.Mutex{}
for _, query := range queries {
wg.Add(1)
go func(query string) {
defer wg.Done()
at.logger.Trace().Str("query", query).Msg("animetosho: Searching by query")
torrents, err := at.fetchTorrents(fmt.Sprintf("?only_tor=1&q=%s&order=size-d", url.QueryEscape(query)))
if err != nil {
return
}
for _, t := range torrents {
// Skip if not batch only if the media is not a movie or single-episode
if t.NumFiles == 1 && !(opts.Media.Format == string(anilist.MediaFormatMovie) && opts.Media.EpisodeCount == 1) {
continue
}
mu.Lock()
ret = append(ret, t.toAnimeTorrent(&opts.Media))
mu.Unlock()
}
}(query)
}
wg.Wait()
// Remove duplicates
lo.UniqBy(ret, func(t *hibiketorrent.AnimeTorrent) string {
return t.Link
})
return
}
type sneedexItem struct {
NyaaIDs []int `json:"nyaaIDs"`
EntryID string `json:"entryID"`
}
func (at *Provider) loadSneedex() {
// Load Sneedex Nyaa IDs
resp, err := http.Get("https://sneedex.moe/api/public/nyaa")
if err != nil {
at.logger.Error().Err(err).Msg("animetosho: Failed to fetch Sneedex Nyaa IDs")
return
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
if err != nil {
at.logger.Error().Err(err).Msg("animetosho: Failed to read Sneedex Nyaa IDs response")
return
}
var sneedexItems []*sneedexItem
if err := json.Unmarshal(b, &sneedexItems); err != nil {
at.logger.Error().Err(err).Msg("animetosho: Failed to unmarshal Sneedex Nyaa IDs")
return
}
for _, item := range sneedexItems {
for _, nyaaID := range item.NyaaIDs {
at.sneedexNyaaIDs[nyaaID] = struct{}{}
}
}
at.logger.Debug().Int("count", len(at.sneedexNyaaIDs)).Msg("animetosho: Loaded Sneedex Nyaa IDs")
}
func (at *Provider) smartSearchBestReleases(opts *hibiketorrent.AnimeSmartSearchOptions) ([]*hibiketorrent.AnimeTorrent, error) {
return at.findSneedexBestReleases(opts)
}
func (at *Provider) findSneedexBestReleases(opts *hibiketorrent.AnimeSmartSearchOptions) ([]*hibiketorrent.AnimeTorrent, error) {
ret := make([]*hibiketorrent.AnimeTorrent, 0)
at.logger.Debug().Int("aid", opts.AnidbAID).Msg("animetosho: Searching best releases by Anime ID")
if opts.AnidbAID > 0 {
// Get all torrents by Anime ID
atTorrents, err := at.searchByAID(opts.AnidbAID, opts.Resolution)
if err != nil {
return nil, err
}
// Filter by Sneedex Nyaa IDs
atTorrents = lo.Filter(atTorrents, func(t *Torrent, _ int) bool {
_, found := at.sneedexNyaaIDs[t.NyaaId]
return found
})
ret = at.torrentSliceToAnimeTorrentSlice(atTorrents, true, &opts.Media)
}
return ret, nil
}
//--------------------------------------------------------------------------------------------------------------------------------------------------//
func (at *Provider) GetTorrentInfoHash(torrent *hibiketorrent.AnimeTorrent) (string, error) {
return torrent.InfoHash, nil
}
func (at *Provider) GetTorrentMagnetLink(torrent *hibiketorrent.AnimeTorrent) (string, error) {
return torrent.MagnetLink, nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func buildSmartSearchQueries(opts *hibiketorrent.AnimeSmartSearchOptions) (ret []string) {
hasSingleEpisode := opts.Media.EpisodeCount == 1 || opts.Media.Format == string(anilist.MediaFormatMovie)
var queryStr []string // Final search query string, used for caching
allTitles := []string{opts.Media.RomajiTitle}
if opts.Media.EnglishTitle != nil {
allTitles = append(allTitles, *opts.Media.EnglishTitle)
}
for _, title := range opts.Media.Synonyms {
allTitles = append(allTitles, title)
}
//
// Media only has 1 episode
//
if hasSingleEpisode {
str := ""
// 1. Build a query string
qTitles := "("
for _, title := range allTitles {
qTitles += fmt.Sprintf("%s | ", sanitizeTitle(title))
}
qTitles = qTitles[:len(qTitles)-3] + ")"
str += qTitles
// 2. Add resolution
if opts.Resolution != "" {
str += " " + opts.Resolution
}
// e.g. (Attack on Titan|Shingeki no Kyojin) 1080p
queryStr = []string{str}
} else {
//
// Media has multiple episodes
//
if !opts.Batch { // Single episode search
qTitles := buildTitleString(opts)
qEpisodes := buildEpisodeString(opts)
str := ""
// 1. Add titles
str += qTitles
// 2. Add episodes
if qEpisodes != "" {
str += " " + qEpisodes
}
// 3. Add resolution
if opts.Resolution != "" {
str += " " + opts.Resolution
}
queryStr = append(queryStr, str)
// If we can also search for absolute episodes (there is an offset)
if opts.Media.AbsoluteSeasonOffset > 0 {
// Parse a good title
metadata := habari.Parse(opts.Media.RomajiTitle)
// 1. Start building a new query string
absoluteQueryStr := metadata.Title
// 2. Add episodes
ep := opts.EpisodeNumber + opts.Media.AbsoluteSeasonOffset
absoluteQueryStr += fmt.Sprintf(` ("%d"|"e%d"|"ep%d")`, ep, ep, ep)
// 3. Add resolution
if opts.Resolution != "" {
absoluteQueryStr += " " + opts.Resolution
}
// Overwrite queryStr by adding the absolute query string
queryStr = append(queryStr, fmt.Sprintf("(%s) | (%s)", absoluteQueryStr, str))
}
} else {
// Batch search
// e.g. "(Shingeki No Kyojin | Attack on Titan) ("Batch"|"Complete Series") 1080"
str := fmt.Sprintf(`(%s)`, opts.Media.RomajiTitle)
if opts.Media.EnglishTitle != nil {
str = fmt.Sprintf(`(%s | %s)`, opts.Media.RomajiTitle, *opts.Media.EnglishTitle)
}
str += " " + buildBatchGroup(&opts.Media)
if opts.Resolution != "" {
str += " " + opts.Resolution
}
queryStr = []string{str}
}
}
for _, q := range queryStr {
ret = append(ret, q)
ret = append(ret, q+" -S0")
}
return
}
// searches for torrents by Anime ID
func (at *Provider) searchByAID(aid int, quality string) (torrents []*Torrent, err error) {
q := url.QueryEscape(formatQuality(quality))
query := fmt.Sprintf(`?order=size-d&aid=%d&q=%s`, aid, q)
return at.fetchTorrents(query)
}
// searches for torrents by Episode ID
func (at *Provider) searchByEID(eid int, quality string) (torrents []*Torrent, err error) {
q := url.QueryEscape(formatQuality(quality))
query := fmt.Sprintf(`?eid=%d&q=%s`, eid, q)
return at.fetchTorrents(query)
}
func (at *Provider) fetchTorrents(suffix string) (torrents []*Torrent, err error) {
furl := JsonFeedUrl + suffix
at.logger.Debug().Str("url", furl).Msg("animetosho: Fetching torrents")
resp, err := http.Get(furl)
if err != nil {
return nil, err
}
defer resp.Body.Close()
// Check if the request was successful (status code 200)
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to fetch torrents, %s", resp.Status)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
// Parse the feed
var ret []*Torrent
if err := json.Unmarshal(b, &ret); err != nil {
return nil, err
}
for _, t := range ret {
if t.Seeders > 100000 {
t.Seeders = 0
}
if t.Leechers > 100000 {
t.Leechers = 0
}
}
return ret, nil
}
func formatQuality(quality string) string {
if quality == "" {
return ""
}
quality = strings.TrimSuffix(quality, "p")
return fmt.Sprintf(`%s`, quality)
}
// sanitizeTitle removes characters that impact the search query
func sanitizeTitle(t string) string {
// Replace hyphens with spaces
t = strings.ReplaceAll(t, "-", " ")
// Remove everything except alphanumeric characters, spaces.
re := regexp.MustCompile(`[^a-zA-Z0-9\s]`)
t = re.ReplaceAllString(t, "")
// Trim large spaces
re2 := regexp.MustCompile(`\s+`)
t = re2.ReplaceAllString(t, " ")
// return strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(strings.ReplaceAll(t, "!", ""), ":", ""), "[", ""), "]", "")
return t
}
func getAllTitles(media *hibiketorrent.Media) []string {
titles := make([]string, 0)
titles = append(titles, media.RomajiTitle)
if media.EnglishTitle != nil {
titles = append(titles, *media.EnglishTitle)
}
for _, title := range media.Synonyms {
titles = append(titles, title)
}
return titles
}
// ("01"|"e01") -S0
func buildEpisodeString(opts *hibiketorrent.AnimeSmartSearchOptions) string {
episodeStr := ""
if opts.EpisodeNumber != -1 {
pEp := zeropad(opts.EpisodeNumber)
episodeStr = fmt.Sprintf(`("%s"|"e%d") -S0`, pEp, opts.EpisodeNumber)
}
return episodeStr
}
func buildTitleString(opts *hibiketorrent.AnimeSmartSearchOptions) string {
romTitle := sanitizeTitle(opts.Media.RomajiTitle)
engTitle := ""
if opts.Media.EnglishTitle != nil {
engTitle = sanitizeTitle(*opts.Media.EnglishTitle)
}
season := 0
// create titles by extracting season/part info
titles := make([]string, 0)
for _, title := range getAllTitles(&opts.Media) {
s, cTitle := util.ExtractSeasonNumber(title)
if s != 0 { // update season if it got parsed
season = s
}
if cTitle != "" { // add "cleaned" titles
titles = append(titles, sanitizeTitle(cTitle))
}
}
// Check season from synonyms, only update season if it's still 0
for _, synonym := range opts.Media.Synonyms {
s, _ := util.ExtractSeasonNumber(synonym)
if s != 0 && season == 0 {
season = s
}
}
// add romaji and english titles to the title list
titles = append(titles, romTitle)
if len(engTitle) > 0 {
titles = append(titles, engTitle)
}
// convert III and II to season
// these will get cleaned later
if season == 0 && strings.Contains(strings.ToLower(romTitle), " iii") {
season = 3
}
if season == 0 && strings.Contains(strings.ToLower(romTitle), " ii") {
season = 2
}
if engTitle != "" {
if season == 0 && strings.Contains(strings.ToLower(engTitle), " iii") {
season = 3
}
if season == 0 && strings.Contains(strings.ToLower(engTitle), " ii") {
season = 2
}
}
// also, split romaji title by colon,
// if first part is long enough, add it to the title list
// DEVNOTE maybe we should only do that if the season IS found
split := strings.Split(romTitle, ":")
if len(split) > 1 && len(split[0]) > 8 {
titles = append(titles, split[0])
}
if engTitle != "" {
split = strings.Split(engTitle, ":")
if len(split) > 1 && len(split[0]) > 8 {
titles = append(titles, split[0])
}
}
// clean titles
for i, title := range titles {
titles[i] = strings.TrimSpace(strings.ReplaceAll(title, ":", " "))
titles[i] = strings.TrimSpace(strings.ReplaceAll(titles[i], "-", " "))
titles[i] = strings.Join(strings.Fields(titles[i]), " ")
titles[i] = strings.ToLower(titles[i])
if season != 0 {
titles[i] = strings.ReplaceAll(titles[i], " iii", "")
titles[i] = strings.ReplaceAll(titles[i], " ii", "")
}
}
titles = lo.Uniq(titles)
shortestTitle := ""
for _, title := range titles {
if shortestTitle == "" || len(title) < len(shortestTitle) {
shortestTitle = title
}
}
/////////////////////// Season
seasonBuff := bytes.NewBufferString("")
if season > 0 {
// (season 1|season 01|s1|s01)
// Season section
// e.g. S1, season 1, season 01
seasonBuff.WriteString(fmt.Sprintf(`"%s %s%d" | `, shortestTitle, "season ", season))
seasonBuff.WriteString(fmt.Sprintf(`"%s %s%s" | `, shortestTitle, "season ", zeropad(season)))
seasonBuff.WriteString(fmt.Sprintf(`"%s %s%d" | `, shortestTitle, "s", season))
seasonBuff.WriteString(fmt.Sprintf(`"%s %s%s"`, shortestTitle, "s", zeropad(season)))
}
qTitles := "("
for idx, title := range titles {
qTitles += "\"" + title + "\"" + " | "
if idx == len(titles)-1 {
qTitles = qTitles[:len(qTitles)-3]
}
}
qTitles += seasonBuff.String()
qTitles += ")"
return qTitles
}
func zeropad(v interface{}) string {
switch i := v.(type) {
case int:
return fmt.Sprintf("%02d", i)
case string:
return fmt.Sprintf("%02s", i)
default:
return ""
}
}
func buildBatchGroup(m *hibiketorrent.Media) string {
buff := bytes.NewBufferString("")
buff.WriteString("(")
// e.g. 01-12
s1 := fmt.Sprintf(`"%s%s%s"`, zeropad("1"), " - ", zeropad(m.EpisodeCount))
buff.WriteString(s1)
buff.WriteString("|")
// e.g. 01~12
s2 := fmt.Sprintf(`"%s%s%s"`, zeropad("1"), " ~ ", zeropad(m.EpisodeCount))
buff.WriteString(s2)
buff.WriteString("|")
// e.g. 01~12
buff.WriteString(`"Batch"|`)
buff.WriteString(`"Complete"|`)
buff.WriteString(`"+ OVA"|`)
buff.WriteString(`"+ Specials"|`)
buff.WriteString(`"+ Special"|`)
buff.WriteString(`"Seasons"|`)
buff.WriteString(`"Parts"`)
buff.WriteString(")")
return buff.String()
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (at *Provider) torrentSliceToAnimeTorrentSlice(torrents []*Torrent, confirmed bool, media *hibiketorrent.Media) []*hibiketorrent.AnimeTorrent {
wg := sync.WaitGroup{}
mu := sync.Mutex{}
ret := make([]*hibiketorrent.AnimeTorrent, 0)
for _, torrent := range torrents {
wg.Add(1)
go func(torrent *Torrent) {
defer wg.Done()
t := torrent.toAnimeTorrent(media)
_, isBest := at.sneedexNyaaIDs[torrent.NyaaId]
t.IsBestRelease = isBest
t.Confirmed = confirmed
mu.Lock()
ret = append(ret, t)
mu.Unlock()
}(torrent)
}
wg.Wait()
return ret
}
func (t *Torrent) toAnimeTorrent(media *hibiketorrent.Media) *hibiketorrent.AnimeTorrent {
metadata := habari.Parse(t.Title)
formattedDate := ""
parsedDate := time.Unix(int64(t.Timestamp), 0)
formattedDate = parsedDate.Format(time.RFC3339)
ret := &hibiketorrent.AnimeTorrent{
Name: t.Title,
Date: formattedDate,
Size: t.TotalSize,
FormattedSize: util.Bytes(uint64(t.TotalSize)),
Seeders: t.Seeders,
Leechers: t.Leechers,
DownloadCount: t.TorrentDownloadCount,
Link: t.Link,
DownloadUrl: t.TorrentUrl,
MagnetLink: t.MagnetUri,
InfoHash: t.InfoHash,
Resolution: metadata.VideoResolution,
IsBatch: t.NumFiles > 1,
EpisodeNumber: 0,
ReleaseGroup: metadata.ReleaseGroup,
Provider: ProviderName,
IsBestRelease: false,
Confirmed: false,
}
episode := -1
if len(metadata.EpisodeNumber) == 1 {
episode = util.StringToIntMust(metadata.EpisodeNumber[0])
}
// Force set episode number to 1 if it's a movie or single-episode and the torrent isn't a batch
if !ret.IsBatch && episode == -1 && (media.EpisodeCount == 1 || media.Format == string(anilist.MediaFormatMovie)) {
episode = 1
}
ret.EpisodeNumber = episode
return ret
}