fix(autodownloader): duplicate downloads with debrid

fix(autoselect): scoring and comparisons
This commit is contained in:
5rahim
2026-03-28 14:44:29 +01:00
parent ce4ae40d0f
commit 3878bd4683
10 changed files with 324 additions and 204 deletions

View File

@@ -179,7 +179,7 @@ func (a *AllDebrid) doQuery(method, endpoint string, body io.Reader, contentType
if err != nil {
return nil, err
}
a.logger.Debug().Str("method", method).Str("url", u.String()).Msg("alldebrid: doQuery")
req.Header.Set("Authorization", "Bearer "+apiKey)
@@ -234,22 +234,34 @@ func (a *AllDebrid) doQueryCtx(ctx context.Context, method, endpoint string, bod
func (a *AllDebrid) AddTorrent(opts debrid.AddTorrentOptions) (string, error) {
a.logger.Debug().Msgf("alldebrid: AddTorrent called with: %s", opts.MagnetLink)
if opts.InfoHash != "" {
torrents, err := a.GetTorrents()
if err == nil {
for _, torrent := range torrents {
if strings.EqualFold(torrent.Hash, opts.InfoHash) {
a.logger.Debug().Str("torrentId", torrent.ID).Msg("alldebrid: Torrent already added")
return torrent.ID, nil
}
}
}
}
if strings.HasPrefix(opts.MagnetLink, "http") {
a.logger.Debug().Msg("alldebrid: detected http link, using addTorrentFile")
return a.addTorrentFile(opts.MagnetLink)
}
// Endpoint: /magnet/upload
var body bytes.Buffer
writer := multipart.NewWriter(&body)
err := writer.WriteField("magnets[]", opts.MagnetLink)
if err != nil {
return "", err
}
writer.Close()
resp, err := a.doQuery("POST", "/magnet/upload", &body, writer.FormDataContentType())
if err != nil {
a.logger.Error().Err(err).Msgf("alldebrid: AddTorrent failed. URL: %s/magnet/upload", a.baseUrl)
@@ -261,7 +273,7 @@ func (a *AllDebrid) AddTorrent(opts debrid.AddTorrentOptions) (string, error) {
if err := json.Unmarshal(b, &data); err != nil {
return "", err
}
if len(data.Magnets) == 0 {
return "", fmt.Errorf("no magnet added")
}
@@ -269,7 +281,7 @@ func (a *AllDebrid) AddTorrent(opts debrid.AddTorrentOptions) (string, error) {
if data.Magnets[0].Error != nil {
return "", fmt.Errorf("api error: %s", data.Magnets[0].Error.Message)
}
return strconv.FormatInt(data.Magnets[0].ID, 10), nil
}
@@ -319,7 +331,7 @@ func (a *AllDebrid) addTorrentFile(urlStr string) (string, error) {
// Prepare upload
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("files[]", "torrent.torrent")
if err != nil {
return "", err
@@ -353,12 +365,12 @@ func (a *AllDebrid) addTorrentFile(urlStr string) (string, error) {
}
func (a *AllDebrid) GetTorrentStreamUrl(ctx context.Context, opts debrid.StreamTorrentOptions, itemCh chan debrid.TorrentItem) (streamUrl string, err error) {
doneCh := make(chan struct{})
go func(ctx context.Context) {
defer close(doneCh)
for {
select {
case <-ctx.Done():
@@ -372,9 +384,9 @@ func (a *AllDebrid) GetTorrentStreamUrl(ctx context.Context, opts debrid.StreamT
a.logger.Error().Err(sErr).Msg("alldebrid: Failed to get torrent status")
continue // Retry
}
itemCh <- *tInfo
if tInfo.IsReady {
// Get download link
// We need to find the link that matches the file selected
@@ -382,24 +394,24 @@ func (a *AllDebrid) GetTorrentStreamUrl(ctx context.Context, opts debrid.StreamT
// AllDebrid links are usually just a list.
// We need 'GetTorrentInfo' which returns files list and match?
// Or 'GetTorrent' logic.
// Let's call GetTorrentDownloadUrl
url, dErr := a.GetTorrentDownloadUrl(debrid.DownloadTorrentOptions{
ID: opts.ID,
ID: opts.ID,
FileId: opts.FileId,
})
if dErr != nil {
a.logger.Error().Err(dErr).Msg("alldebrid: failed to get download url")
return
}
streamUrl = url
return
}
}
}
}(ctx)
<-doneCh
return
}
@@ -455,11 +467,11 @@ func (a *AllDebrid) GetTorrentDownloadUrl(opts debrid.DownloadTorrentOptions) (s
if err != nil {
return "", err
}
if len(filesResp.Magnets) == 0 {
return "", fmt.Errorf("magnet not found")
}
info := filesResp.Magnets[0]
if info.Error != nil {
return "", fmt.Errorf("api error: %s", info.Error.Message)
@@ -467,7 +479,7 @@ func (a *AllDebrid) GetTorrentDownloadUrl(opts debrid.DownloadTorrentOptions) (s
// Flatten the hierarchical file tree
flatFiles := flattenFileTree(info.Files, "")
if len(flatFiles) == 0 {
return "", fmt.Errorf("no files found in torrent")
}
@@ -478,11 +490,11 @@ func (a *AllDebrid) GetTorrentDownloadUrl(opts debrid.DownloadTorrentOptions) (s
if err != nil {
return "", fmt.Errorf("invalid file id: %s", opts.FileId)
}
if idx < 0 || idx >= len(flatFiles) {
return "", fmt.Errorf("file index out of range")
}
// Unlock/Unrestrict the link
return a.unlockLink(flatFiles[idx].Link)
}
@@ -495,7 +507,7 @@ func (a *AllDebrid) GetTorrentDownloadUrl(opts debrid.DownloadTorrentOptions) (s
a.logger.Error().Err(err).Str("fileName", file.Name).Msg("alldebrid: Failed to unlock link for file")
continue
}
downloadUrls = append(downloadUrls, unlockedUrl)
}
@@ -506,8 +518,6 @@ func (a *AllDebrid) GetTorrentDownloadUrl(opts debrid.DownloadTorrentOptions) (s
return strings.Join(downloadUrls, ","), nil
}
func (a *AllDebrid) GetInstantAvailability(hashes []string) map[string]debrid.TorrentItemInstantAvailability {
// AllDebrid does not have a dedicated instant availability endpoint that checks for cached torrents without adding them.
// We return an empty map to indicate no instant availability check is performed.
@@ -524,35 +534,35 @@ func (a *AllDebrid) GetTorrent(id string) (*debrid.TorrentItem, error) {
func (a *AllDebrid) GetTorrentInfo(opts debrid.GetTorrentInfoOptions) (*debrid.TorrentInfo, error) {
// Similar to RealDebrid approach: Add -> Get Info -> Delete
if opts.MagnetLink == "" {
return nil, fmt.Errorf("magnet link required")
}
id, err := a.AddTorrent(debrid.AddTorrentOptions{MagnetLink: opts.MagnetLink})
if err != nil {
return nil, fmt.Errorf("failed to add torrent for info: %w", err)
}
// Fetch info
status, err := a.getTorrent(id)
if err != nil {
a.DeleteTorrent(id)
return nil, err
}
// Get files to list them
filesResp, err := a.getTorrentFiles(id)
if err != nil {
a.DeleteTorrent(id)
return nil, err
}
if len(filesResp.Magnets) == 0 {
a.DeleteTorrent(id)
return nil, fmt.Errorf("magnet files not found")
}
filesInfo := filesResp.Magnets[0]
// Create info
@@ -562,14 +572,14 @@ func (a *AllDebrid) GetTorrentInfo(opts debrid.GetTorrentInfoOptions) (*debrid.T
Hash: status.Hash,
Size: status.Size,
}
if filesInfo.Files != nil {
for i, l := range filesInfo.Files {
ret.Files = append(ret.Files, &debrid.TorrentItemFile{
ID: strconv.Itoa(i),
Index: i,
Name: l.Name,
Path: l.Name,
Path: l.Name,
Size: l.Size,
})
}
@@ -577,19 +587,19 @@ func (a *AllDebrid) GetTorrentInfo(opts debrid.GetTorrentInfoOptions) (*debrid.T
// Delete
a.DeleteTorrent(id)
return ret, nil
}
func (a *AllDebrid) GetTorrents() ([]*debrid.TorrentItem, error) {
endpoint := "/magnet/status"
// v4.1 API requires POST, not GET
resp, err := a.doQuery("POST", endpoint, nil, "")
if err != nil {
return nil, err
}
var data GetTorrentsResponse
b, _ := json.Marshal(resp.Data)
json.Unmarshal(b, &data)
@@ -598,7 +608,7 @@ func (a *AllDebrid) GetTorrents() ([]*debrid.TorrentItem, error) {
for _, m := range data.Magnets {
ret = append(ret, toDebridTorrent(&m))
}
// Sort by ID desc
slices.SortFunc(ret, func(i, j *debrid.TorrentItem) int {
return strings.Compare(j.ID, i.ID)
@@ -629,48 +639,48 @@ func (a *AllDebrid) DeleteTorrent(id string) error {
func (a *AllDebrid) getTorrent(id string) (*Torrent, error) {
endpoint := "/magnet/status"
var body io.Reader
var contentType string
if id != "" {
// v4.1 API requires POST with form data, not GET with query params
var formBody bytes.Buffer
writer := multipart.NewWriter(&formBody)
writer.WriteField("id", id)
writer.Close()
body = &formBody
contentType = writer.FormDataContentType()
}
resp, err := a.doQuery("POST", endpoint, body, contentType)
if err != nil {
return nil, err
}
if id != "" {
var data GetTorrentResponse
b, _ := json.Marshal(resp.Data)
json.Unmarshal(b, &data)
if data.Magnets.ID == 0 {
a.logger.Error().Any("data", data).Msg("alldebrid: getTorrent - magnet not found in response")
return nil, fmt.Errorf("magnet not found")
}
return &data.Magnets, nil
}
// This branch should mostly not be used by this helper as it's typically called with ID
// But if it is...
var data GetTorrentsResponse
b, _ := json.Marshal(resp.Data)
json.Unmarshal(b, &data)
if len(data.Magnets) == 0 {
return nil, fmt.Errorf("magnet not found")
}
return &data.Magnets[0], nil
}
@@ -687,13 +697,13 @@ func (a *AllDebrid) getTorrentFiles(id string) (*GetTorrentFilesResponse, error)
if err != nil {
return nil, err
}
var data GetTorrentFilesResponse
b, _ := json.Marshal(resp.Data)
if err := json.Unmarshal(b, &data); err != nil {
return nil, err
}
return &data, nil
}
@@ -710,23 +720,23 @@ func (a *AllDebrid) unlockLink(link string) (string, error) {
if err != nil {
return "", err
}
var data UnrestrictLinkResponse
b, _ := json.Marshal(resp.Data)
json.Unmarshal(b, &data)
return data.Link, nil
}
func toDebridTorrent(t *Torrent) *debrid.TorrentItem {
status := toDebridTorrentStatus(t)
// Convert Unix timestamp to RFC3339 format
addedAt := ""
if t.UploadDate > 0 {
addedAt = time.Unix(t.UploadDate, 0).Format(time.RFC3339)
}
// Calculate completion percentage
completionPercentage := 0
if t.Size > 0 && t.Downloaded > 0 {
@@ -742,7 +752,7 @@ func toDebridTorrent(t *Torrent) *debrid.TorrentItem {
Size: t.Size,
FormattedSize: util.Bytes(uint64(t.Size)),
CompletionPercentage: completionPercentage,
ETA: "",
ETA: "",
Status: status,
AddedAt: addedAt,
Speed: util.ToHumanReadableSpeed(int(t.DownloadSpeed)),
@@ -767,4 +777,4 @@ func toDebridTorrentStatus(t *Torrent) debrid.TorrentItemStatus {
}
return debrid.TorrentItemStatusOther
}
}
}

View File

@@ -289,7 +289,7 @@ func (t *RealDebrid) AddTorrent(opts debrid.AddTorrentOptions) (string, error) {
torrents, err := t.getTorrents(false)
if err == nil {
for _, torrent := range torrents {
if torrent.Hash == opts.InfoHash {
if strings.EqualFold(torrent.Hash, opts.InfoHash) {
t.logger.Debug().Str("torrentId", torrent.ID).Msg("realdebrid: Torrent already added")
torrentId = torrent.ID
break

View File

@@ -238,7 +238,7 @@ func (t *TorBox) AddTorrent(opts debrid.AddTorrentOptions) (string, error) {
torrents, err := t.getTorrents()
if err == nil {
for _, torrent := range torrents {
if torrent.Hash == opts.InfoHash {
if strings.EqualFold(torrent.Hash, opts.InfoHash) {
return strconv.Itoa(torrent.ID), nil
}
}

View File

@@ -97,6 +97,7 @@ func (h *Handler) HandleDebridAddTorrents(c echo.Context) error {
// Add the torrent to the debrid service
_, err = h.App.DebridClientRepository.AddAndQueueTorrent(debrid.AddTorrentOptions{
MagnetLink: magnet,
InfoHash: torrent.InfoHash,
SelectFileId: "all",
}, b.Destination, b.Media.ID)
if err != nil {

View File

@@ -323,11 +323,38 @@ func (ad *AutoDownloader) checkForNewEpisodes(ctx context.Context, isSimulation
// runData holds all data needed for checking new episodes
type runData struct {
rules []*anime.AutoDownloaderRule
profiles []*anime.AutoDownloaderProfile
localFileWrapper *anime.LocalFileWrapper
torrents []*NormalizedTorrent
existingTorrents []*torrent_client.Torrent
rules []*anime.AutoDownloaderRule
profiles []*anime.AutoDownloaderProfile
localFileWrapper *anime.LocalFileWrapper
torrents []*NormalizedTorrent
existingTorrentHashes map[string]struct{}
}
func normalizeTorrentHash(hash string) string {
return strings.ToLower(strings.TrimSpace(hash))
}
func addTorrentHash(hashSet map[string]struct{}, hash string) {
normalizedHash := normalizeTorrentHash(hash)
if normalizedHash == "" {
return
}
hashSet[normalizedHash] = struct{}{}
}
func buildExistingTorrentHashes(existingTorrents []*torrent_client.Torrent, existingDebridTorrents []*debrid.TorrentItem) map[string]struct{} {
hashes := make(map[string]struct{}, len(existingTorrents)+len(existingDebridTorrents))
for _, item := range existingTorrents {
addTorrentHash(hashes, item.Hash)
}
for _, item := range existingDebridTorrents {
addTorrentHash(hashes, item.Hash)
}
return hashes
}
// fetchRunData fetches all data needed for checking new episodes
@@ -402,12 +429,23 @@ func (ad *AutoDownloader) fetchRunData(ctx context.Context, ruleIDs ...uint) (*r
existingTorrents, _ = ad.torrentClientRepository.GetList(&torrent_client.GetListOptions{})
}
var existingDebridTorrents []*debrid.TorrentItem
if ad.settings.UseDebrid && ad.debridClientRepository != nil && ad.debridClientRepository.HasProvider() {
provider, err := ad.debridClientRepository.GetProvider()
if err == nil {
existingDebridTorrents, err = provider.GetTorrents()
if err != nil {
ad.logger.Debug().Err(err).Msg("autodownloader: Failed to get debrid torrents for duplicate check")
}
}
}
return &runData{
rules: rules,
profiles: profiles,
localFileWrapper: lfWrapper,
torrents: torrents,
existingTorrents: existingTorrents,
rules: rules,
profiles: profiles,
localFileWrapper: lfWrapper,
torrents: torrents,
existingTorrentHashes: buildExistingTorrentHashes(existingTorrents, existingDebridTorrents),
}, nil
}
@@ -444,7 +482,7 @@ func (ad *AutoDownloader) groupTorrentCandidates(data *runData) map[uint]map[int
// Process each torrent
for _, t := range data.torrents {
// Skip if already exists
if ad.isTorrentAlreadyDownloaded(t, data.existingTorrents) {
if ad.isTorrentAlreadyDownloaded(t, data.existingTorrentHashes) {
continue
}
@@ -495,14 +533,10 @@ func (ad *AutoDownloader) getRuleProfiles(rule *anime.AutoDownloaderRule, profil
return ruleProfiles
}
// isTorrentAlreadyDownloaded checks if a torrent already exists in the client
func (ad *AutoDownloader) isTorrentAlreadyDownloaded(t *NormalizedTorrent, existingTorrents []*torrent_client.Torrent) bool {
for _, et := range existingTorrents {
if et.Hash == t.InfoHash {
return true
}
}
return false
// isTorrentAlreadyDownloaded checks if a torrent already exists in the client or debrid service.
func (ad *AutoDownloader) isTorrentAlreadyDownloaded(t *NormalizedTorrent, existingTorrentHashes map[string]struct{}) bool {
_, exists := existingTorrentHashes[normalizeTorrentHash(t.InfoHash)]
return exists
}
// isEpisodeAlreadyHandled checks if an episode is already in the library or queue but not delayed
@@ -1097,6 +1131,7 @@ downloadScope:
// Add the torrent to the debrid provider and queue it
_, err := ad.debridClientRepository.AddAndQueueTorrent(debrid.AddTorrentOptions{
MagnetLink: magnet,
InfoHash: t.InfoHash,
SelectFileId: "all", // RD-only, select all files
}, rule.Destination, rule.MediaId)
if err != nil {
@@ -1115,6 +1150,7 @@ downloadScope:
// Add the torrent to the debrid provider
_, err = debridProvider.AddTorrent(debrid.AddTorrentOptions{
MagnetLink: magnet,
InfoHash: t.InfoHash,
SelectFileId: "all", // RD-only, select all files
})
if err != nil {

View File

@@ -5,6 +5,7 @@ import (
"seanime/internal/api/anilist"
"seanime/internal/database/db_bridge"
"seanime/internal/database/models"
"seanime/internal/debrid/debrid"
hibiketorrent "seanime/internal/extension/hibike/torrent"
"seanime/internal/library/anime"
"seanime/internal/test_utils"
@@ -233,14 +234,26 @@ func TestGetRuleProfiles(t *testing.T) {
}
}
func TestBuildExistingTorrentHashes(t *testing.T) {
hashes := buildExistingTorrentHashes(
[]*torrent_client.Torrent{{Hash: " hash1 "}, {Hash: "HASH2"}},
[]*debrid.TorrentItem{{Hash: "hash3"}, {Hash: "HaSh4"}, {Hash: ""}},
)
assert.Len(t, hashes, 4)
assert.Contains(t, hashes, "hash1")
assert.Contains(t, hashes, "hash2")
assert.Contains(t, hashes, "hash3")
assert.Contains(t, hashes, "hash4")
}
func TestIsTorrentAlreadyDownloaded(t *testing.T) {
ad := &AutoDownloader{}
existingTorrents := []*torrent_client.Torrent{
{Hash: "hash1"},
{Hash: "hash2"},
{Hash: "hash3"},
}
existingTorrentHashes := buildExistingTorrentHashes(
[]*torrent_client.Torrent{{Hash: "hash1"}, {Hash: "hash2"}},
[]*debrid.TorrentItem{{Hash: "hash3"}},
)
tests := []struct {
name string
@@ -250,7 +263,14 @@ func TestIsTorrentAlreadyDownloaded(t *testing.T) {
{
name: "torrent exists",
torrent: &NormalizedTorrent{
AnimeTorrent: &hibiketorrent.AnimeTorrent{InfoHash: "hash2"},
AnimeTorrent: &hibiketorrent.AnimeTorrent{InfoHash: " HASH2 "},
},
expected: true,
},
{
name: "torrent exists in debrid hashes",
torrent: &NormalizedTorrent{
AnimeTorrent: &hibiketorrent.AnimeTorrent{InfoHash: "hash3"},
},
expected: true,
},
@@ -265,7 +285,7 @@ func TestIsTorrentAlreadyDownloaded(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ad.isTorrentAlreadyDownloaded(tt.torrent, existingTorrents)
result := ad.isTorrentAlreadyDownloaded(tt.torrent, existingTorrentHashes)
assert.Equal(t, tt.expected, result)
})
}

View File

@@ -1,80 +1 @@
package torrent_client
//func TestSmartSelect(t *testing.T) {
// t.Skip("Refactor test")
// test_utils.InitTestProvider(t, test_utils.TorrentClient())
//
// _ = t.TempDir()
//
// anilistClient := anilist.TestGetMockAnilistClient()
// _ = anilist_platform.NewAnilistPlatform(anilistClient, util.NewLogger())
//
// // get repo
//
// tests := []struct {
// name string
// mediaId int
// url string
// selectedEpisodes []int
// client string
// }{
// {
// name: "Kakegurui xx (Season 2)",
// mediaId: 100876,
// url: "https://nyaa.si/view/1553978", // kakegurui season 1 + season 2
// selectedEpisodes: []int{10, 11, 12}, // should select 10, 11, 12 in season 2
// client: QbittorrentClient,
// },
// {
// name: "Spy x Family",
// mediaId: 140960,
// url: "https://nyaa.si/view/1661695", // spy x family (01-25)
// selectedEpisodes: []int{10, 11, 12}, // should select 10, 11, 12
// client: QbittorrentClient,
// },
// {
// name: "Spy x Family Part 2",
// mediaId: 142838,
// url: "https://nyaa.si/view/1661695", // spy x family (01-25)
// selectedEpisodes: []int{10, 11, 12, 13}, // should select 22, 23, 24, 25
// client: QbittorrentClient,
// },
// {
// name: "Kakegurui xx (Season 2)",
// mediaId: 100876,
// url: "https://nyaa.si/view/1553978", // kakegurui season 1 + season 2
// selectedEpisodes: []int{10, 11, 12}, // should select 10, 11, 12 in season 2
// client: TransmissionClient,
// },
// {
// name: "Spy x Family",
// mediaId: 140960,
// url: "https://nyaa.si/view/1661695", // spy x family (01-25)
// selectedEpisodes: []int{10, 11, 12}, // should select 10, 11, 12
// client: TransmissionClient,
// },
// {
// name: "Spy x Family Part 2",
// mediaId: 142838,
// url: "https://nyaa.si/view/1661695", // spy x family (01-25)
// selectedEpisodes: []int{10, 11, 12, 13}, // should select 22, 23, 24, 25
// client: TransmissionClient,
// },
// }
//
// for _, tt := range tests {
//
// t.Run(tt.name, func(t *testing.T) {
//
// repo := getTestRepo(t, tt.client)
//
// ok := repo.Start()
// if !assert.True(t, ok) {
// return
// }
//
// })
//
// }
//
//}

View File

@@ -2,13 +2,14 @@ package transmission
import (
"context"
"github.com/davecgh/go-spew/spew"
"github.com/hekmon/transmissionrpc/v3"
"github.com/stretchr/testify/assert"
"seanime/internal/test_utils"
"seanime/internal/util"
"testing"
"time"
"github.com/davecgh/go-spew/spew"
"github.com/hekmon/transmissionrpc/v3"
"github.com/stretchr/testify/assert"
)
//func TestGetActiveTorrents(t *testing.T) {
@@ -44,14 +45,14 @@ func TestGetFiles(t *testing.T) {
}{
{
name: "[EMBER] Demon Slayer (2023) (Season 3)",
url: "https://animetosho.org/view/ember-demon-slayer-2023-season-3-bdrip-1080p.n1778316",
url: "",
magnet: "",
mediaId: 145139,
expectedNbFiles: 11,
},
{
name: "[Tenrai-Sensei] Kakegurui (Season 1-2 + OVAs)",
url: "https://nyaa.si/view/1553978",
url: "",
magnet: "",
mediaId: 98314,
expectedNbFiles: 27,

View File

@@ -101,6 +101,14 @@ func TestAutoSelect_Filter(t *testing.T) {
},
expected: []string{t8.Name}, // assuming habari detects Web-DL properly or fallback check works
},
{
name: "Source token should not match inside another word",
profile: &anime.AutoSelectProfile{
RequireSource: true,
PreferredSources: []string{"CR"},
},
expected: []string{},
},
{
name: "Dual audio only",
profile: &anime.AutoSelectProfile{
@@ -243,6 +251,78 @@ func TestAutoSelect_Sort(t *testing.T) {
}
}
func TestAutoSelect_Filter_SourceTokenDoesNotMatchInsideWord(t *testing.T) {
s := newTestAutoSelect()
torrents := []*hibiketorrent.AnimeTorrent{
{Name: "Rooster Fighter - 01 A Rooster Among Cranes 1080p WEB-DL.mkv", Seeders: 50},
}
filtered := s.filter(torrents, &anime.AutoSelectProfile{
RequireSource: true,
PreferredSources: []string{"CR"},
})
assert.Empty(t, filtered)
}
func TestAutoSelect_Sort_OrderedPreferencesBeatSoftBonuses(t *testing.T) {
s := newTestAutoSelect()
preferred := &hibiketorrent.AnimeTorrent{
Name: "[ToonsHub] Show - 01 1080p CR WEB-DL AAC2.0 H.264 (Multi-Subs).mkv",
Seeders: 50,
Provider: "catnoise",
}
lowerPriorityButBonusHeavy := &hibiketorrent.AnimeTorrent{
Name: "Show - 01 1080p DSNP WEB-DL DUAL AAC2.0 H.264-VARYG (Dual-Audio, Multi-Subs).mkv",
Seeders: 100,
Provider: "catnoise",
IsBestRelease: true,
}
torrents := []*hibiketorrent.AnimeTorrent{lowerPriorityButBonusHeavy, preferred}
profile := &anime.AutoSelectProfile{
Providers: []string{"catnoise"},
ReleaseGroups: []string{"ToonsHub", "VARYG"},
Resolutions: []string{"1080p"},
PreferredCodecs: []string{"AVC, x264, H.264, H264, H 264"},
PreferredSources: []string{"CR", "DSNP"},
BestReleasePreference: anime.AutoSelectPreferencePrefer,
}
s.sort(torrents, profile)
assert.Equal(t, []string{preferred.Name, lowerPriorityButBonusHeavy.Name}, []string{torrents[0].Name, torrents[1].Name})
}
func TestAutoSelect_Sort_StrongerPrimaryMatchCanBeatHigherReleaseGroup(t *testing.T) {
s := newTestAutoSelect()
higherReleaseGroupButLowerQuality := &hibiketorrent.AnimeTorrent{
Name: "[ToonsHub] Show - 01 720p CR WEB-DL AAC2.0 H.264.mkv",
Seeders: 80,
Provider: "catnoise",
}
lowerReleaseGroupButHigherResolution := &hibiketorrent.AnimeTorrent{
Name: "Show - 01 1080p DSNP WEB-DL AAC2.0 H.264-VARYG.mkv",
Seeders: 40,
Provider: "catnoise",
}
torrents := []*hibiketorrent.AnimeTorrent{higherReleaseGroupButLowerQuality, lowerReleaseGroupButHigherResolution}
profile := &anime.AutoSelectProfile{
ReleaseGroups: []string{"ToonsHub", "VARYG"},
Resolutions: []string{"1080p"},
PreferredCodecs: []string{"AVC, x264, H.264, H264, H 264"},
PreferredSources: []string{"CR", "DSNP"},
}
s.sort(torrents, profile)
assert.Equal(t, []string{lowerReleaseGroupButHigherResolution.Name, higherReleaseGroupButLowerQuality.Name}, []string{torrents[0].Name, torrents[1].Name})
}
func TestAutoSelect_SmartCachedPrioritization(t *testing.T) {
s := newTestAutoSelect()

View File

@@ -12,7 +12,6 @@ import (
)
const (
scoreBestReleaseBase = 200
scoreResolutionBase = 100
scoreResolutionDecay = 10
scoreProviderBase = 5
@@ -35,6 +34,8 @@ type candidate struct {
torrent *hibiketorrent.AnimeTorrent
parsed *habari.Metadata
lowerName string
priority int
bonus int
score int
}
@@ -152,6 +153,36 @@ func checkPreference(condition bool, preference anime.AutoSelectPreference) bool
return true
}
func isTokenChar(char byte) bool {
return (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9')
}
func containsBoundedTerm(lowerValue string, term string) bool {
lowerTerm := strings.ToLower(strings.TrimSpace(term))
if lowerTerm == "" {
return false
}
searchFrom := 0
for {
idx := strings.Index(lowerValue[searchFrom:], lowerTerm)
if idx == -1 {
return false
}
idx += searchFrom
end := idx + len(lowerTerm)
leftBoundary := idx == 0 || !isTokenChar(lowerValue[idx-1])
rightBoundary := end == len(lowerValue) || !isTokenChar(lowerValue[end])
if leftBoundary && rightBoundary {
return true
}
searchFrom = idx + 1
}
}
func (s *AutoSelect) filterCandidates(candidates []*candidate, profile *anime.AutoSelectProfile) []*candidate {
if profile == nil {
return candidates
@@ -224,7 +255,7 @@ func (s *AutoSelect) filterCandidates(candidates []*candidate, profile *anime.Au
}
} else { // Fallback to string matching
for _, lang := range preferredLanguages {
if len(lang) > 3 && strings.Contains(c.lowerName, strings.ToLower(lang)) {
if len(lang) > 3 && containsBoundedTerm(c.lowerName, lang) {
foundLang = true
break
}
@@ -258,7 +289,7 @@ func (s *AutoSelect) filterCandidates(candidates []*candidate, profile *anime.Au
foundCodec = true
break
}
if strings.Contains(c.lowerName, strings.ToLower(codec)) {
if containsBoundedTerm(c.lowerName, codec) {
foundCodec = true
break
}
@@ -278,7 +309,7 @@ func (s *AutoSelect) filterCandidates(candidates []*candidate, profile *anime.Au
foundSource = true
break
}
if strings.Contains(c.lowerName, strings.ToLower(source)) {
if containsBoundedTerm(c.lowerName, source) {
foundSource = true
break
}
@@ -306,12 +337,21 @@ func (s *AutoSelect) filterCandidates(candidates []*candidate, profile *anime.Au
func (s *AutoSelect) sortCandidates(candidates []*candidate, profile *anime.AutoSelectProfile) {
for _, c := range candidates {
c.score = s.calculateScore(c, profile)
c.priority, c.bonus = s.calculateScoreBreakdown(c, profile)
c.score = c.priority + c.bonus
}
slices.SortStableFunc(candidates, func(a, b *candidate) int {
if a.priority != b.priority {
return cmp.Compare(b.priority, a.priority)
}
if a.bonus != b.bonus {
return cmp.Compare(b.bonus, a.bonus)
}
if a.score != b.score {
return cmp.Compare(b.score, a.score) // Higher score first
return cmp.Compare(b.score, a.score)
}
// If the scores are the same, sort by seeders
@@ -325,7 +365,7 @@ func (s *AutoSelect) sortCandidates(candidates []*candidate, profile *anime.Auto
func (s *AutoSelect) smartCachedPrioritization(
torrents []*hibiketorrent.AnimeTorrent,
candidates []*candidate,
profile *anime.AutoSelectProfile,
_ *anime.AutoSelectProfile,
postSearchSort func([]*hibiketorrent.AnimeTorrent) []*TorrentWithCacheStatus,
) []*hibiketorrent.AnimeTorrent {
@@ -395,28 +435,23 @@ func (s *AutoSelect) smartCachedPrioritization(
}
func (s *AutoSelect) calculateScore(c *candidate, profile *anime.AutoSelectProfile) int {
score := 0
priority, bonus := s.calculateScoreBreakdown(c, profile)
return priority + bonus
}
func (s *AutoSelect) calculateScoreBreakdown(c *candidate, profile *anime.AutoSelectProfile) (priority int, bonus int) {
parsed := c.parsed
t := c.torrent
// Boost by provider order
if profile == nil {
return score
return 0, 0
}
//// Awlays best releases as a base line if user doesn't reject them
//if (profile.BestReleasePreference != anime.AutoSelectPreferenceAvoid && profile.BestReleasePreference != anime.AutoSelectPreferenceNever) &&
// c.torrent.IsBestRelease && (t.Seeders == -1 || t.Seeders > 2) {
// // boost only if user isn't looking for low resolutions
// score += scoreBestReleaseBase
//}
// Resolution
if len(profile.Resolutions) > 0 {
for i, res := range profile.Resolutions {
if strings.EqualFold(parsed.VideoResolution, res) {
score += scoreResolutionBase - (i * scoreResolutionDecay)
priority += scoreResolutionBase - (i * scoreResolutionDecay)
break
}
}
@@ -426,7 +461,7 @@ func (s *AutoSelect) calculateScore(c *candidate, profile *anime.AutoSelectProfi
if len(profile.Providers) > 0 {
for i, provider := range profile.Providers {
if strings.EqualFold(t.Provider, provider) {
score += scoreProviderBase - (i * scoreProviderDecay)
priority += scoreProviderBase - (i * scoreProviderDecay)
break
}
}
@@ -436,7 +471,7 @@ func (s *AutoSelect) calculateScore(c *candidate, profile *anime.AutoSelectProfi
if len(profile.ReleaseGroups) > 0 {
for i, group := range profile.ReleaseGroups {
if strings.EqualFold(parsed.ReleaseGroup, group) {
score += scoreReleaseGroupBase - (i * scoreReleaseGroupDecay)
priority += scoreReleaseGroupBase - (i * scoreReleaseGroupDecay)
break
}
}
@@ -445,6 +480,7 @@ func (s *AutoSelect) calculateScore(c *candidate, profile *anime.AutoSelectProfi
// Codec
if len(profile.PreferredCodecs) > 0 {
for i, codecs := range profile.PreferredCodecs {
matched := false
for _, codec := range strings.Split(codecs, ",") {
codec = strings.TrimSpace(codec)
if slices.ContainsFunc(parsed.VideoTerm, func(vt string) bool {
@@ -452,87 +488,102 @@ func (s *AutoSelect) calculateScore(c *candidate, profile *anime.AutoSelectProfi
}) || slices.ContainsFunc(parsed.AudioTerm, func(at string) bool {
return strings.EqualFold(at, codec)
}) {
score += scoreCodecBase - (i * scoreCodecDecay)
priority += scoreCodecBase - (i * scoreCodecDecay)
matched = true
break
}
// Fallback check
if strings.Contains(c.lowerName, strings.ToLower(codec)) {
score += scoreCodecBase - (i * scoreCodecDecay)
if containsBoundedTerm(c.lowerName, codec) {
priority += scoreCodecBase - (i * scoreCodecDecay)
matched = true
break
}
}
if matched {
break
}
}
}
// Source
if len(profile.PreferredSources) > 0 {
for i, sources := range profile.PreferredSources {
matched := false
for _, source := range strings.Split(sources, ",") {
source = strings.TrimSpace(source)
if slices.ContainsFunc(parsed.Source, func(src string) bool {
return strings.EqualFold(src, source)
}) {
score += scoreSourceBase - (i * scoreSourceDecay)
priority += scoreSourceBase - (i * scoreSourceDecay)
matched = true
break
}
if strings.Contains(c.lowerName, strings.ToLower(source)) {
score += scoreSourceBase - (i * scoreSourceDecay)
if containsBoundedTerm(c.lowerName, source) {
priority += scoreSourceBase - (i * scoreSourceDecay)
matched = true
break
}
}
if matched {
break
}
}
}
// Language
if len(profile.PreferredLanguages) > 0 {
for i, languages := range profile.PreferredLanguages {
matched := false
for _, lang := range strings.Split(languages, ",") {
lang = strings.TrimSpace(lang)
if slices.ContainsFunc(parsed.Language, func(pl string) bool {
return strings.EqualFold(pl, lang)
}) {
score += scoreLanguageBase - (i * scoreLanguageDecay)
}) || containsBoundedTerm(c.lowerName, lang) {
priority += scoreLanguageBase - (i * scoreLanguageDecay)
matched = true
break
}
}
if matched {
break
}
}
}
// Multiple audio preference (prefer/avoid)
isMultiAudio := containsMultiOrDual(parsed.AudioTerm)
if profile.MultipleAudioPreference == anime.AutoSelectPreferencePrefer && isMultiAudio {
score += scoreMultiAudio
bonus += scoreMultiAudio
}
if profile.MultipleAudioPreference == anime.AutoSelectPreferenceAvoid && isMultiAudio {
score -= scoreMultiAudio
bonus -= scoreMultiAudio
}
// Multiple subs preference (prefer/avoid)
isMultiSubs := containsMultiOrDual(parsed.Subtitles)
if profile.MultipleSubsPreference == anime.AutoSelectPreferencePrefer && isMultiSubs {
score += scoreMultiSubs
bonus += scoreMultiSubs
}
if profile.MultipleSubsPreference == anime.AutoSelectPreferenceAvoid && isMultiSubs {
score -= scoreMultiSubs
bonus -= scoreMultiSubs
}
// Batch preference (prefer/avoid)
isBatch := t.IsBatch
if profile.BatchPreference == anime.AutoSelectPreferencePrefer && isBatch {
score += scoreBatch
bonus += scoreBatch
}
if profile.BatchPreference == anime.AutoSelectPreferenceAvoid && isBatch {
score -= scoreBatch
bonus -= scoreBatch
}
// Best release preference (prefer/avoid)
isBestRelease := t.IsBestRelease && (t.Seeders == -1 || t.Seeders > 2)
if profile.BestReleasePreference == anime.AutoSelectPreferencePrefer && isBestRelease {
score += scoreBestRelease
bonus += scoreBestRelease
}
if profile.BestReleasePreference == anime.AutoSelectPreferenceAvoid && isBestRelease {
score -= scoreBestRelease
bonus -= scoreBestRelease
}
return score
return priority, bonus
}