Files
seanime-docker/seanime-2.9.10/internal/library/autodownloader/autodownloader.go
2025-09-20 14:08:38 +01:00

1015 lines
30 KiB
Go

package autodownloader
import (
"fmt"
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/database/db"
"seanime/internal/database/db_bridge"
"seanime/internal/database/models"
debrid_client "seanime/internal/debrid/client"
"seanime/internal/debrid/debrid"
"seanime/internal/events"
hibiketorrent "seanime/internal/extension/hibike/torrent"
"seanime/internal/hook"
"seanime/internal/library/anime"
"seanime/internal/notifier"
"seanime/internal/torrent_clients/torrent_client"
"seanime/internal/torrents/torrent"
"seanime/internal/util"
"seanime/internal/util/comparison"
"sort"
"strings"
"sync"
"time"
"github.com/5rahim/habari"
"github.com/adrg/strutil/metrics"
"github.com/rs/zerolog"
"github.com/samber/lo"
"github.com/samber/mo"
"github.com/sourcegraph/conc/pool"
)
const (
ComparisonThreshold = 0.8
)
type (
AutoDownloader struct {
logger *zerolog.Logger
torrentClientRepository *torrent_client.Repository
torrentRepository *torrent.Repository
debridClientRepository *debrid_client.Repository
database *db.Database
animeCollection mo.Option[*anilist.AnimeCollection]
wsEventManager events.WSEventManagerInterface
settings *models.AutoDownloaderSettings
metadataProvider metadata.Provider
settingsUpdatedCh chan struct{}
stopCh chan struct{}
startCh chan struct{}
debugTrace bool
mu sync.Mutex
isOffline *bool
}
NewAutoDownloaderOptions struct {
Logger *zerolog.Logger
TorrentClientRepository *torrent_client.Repository
TorrentRepository *torrent.Repository
WSEventManager events.WSEventManagerInterface
Database *db.Database
MetadataProvider metadata.Provider
DebridClientRepository *debrid_client.Repository
IsOffline *bool
}
tmpTorrentToDownload struct {
torrent *NormalizedTorrent
episode int
}
)
func New(opts *NewAutoDownloaderOptions) *AutoDownloader {
return &AutoDownloader{
logger: opts.Logger,
torrentClientRepository: opts.TorrentClientRepository,
torrentRepository: opts.TorrentRepository,
database: opts.Database,
wsEventManager: opts.WSEventManager,
animeCollection: mo.None[*anilist.AnimeCollection](),
metadataProvider: opts.MetadataProvider,
debridClientRepository: opts.DebridClientRepository,
settings: &models.AutoDownloaderSettings{
Provider: torrent.ProviderAnimeTosho, // Default provider, will be updated after the settings are fetched
Interval: 20,
Enabled: false,
DownloadAutomatically: false,
EnableEnhancedQueries: false,
},
settingsUpdatedCh: make(chan struct{}, 1),
stopCh: make(chan struct{}, 1),
startCh: make(chan struct{}, 1),
debugTrace: true,
mu: sync.Mutex{},
isOffline: opts.IsOffline,
}
}
// SetSettings should be called after the settings are fetched and updated from the database.
// If the AutoDownloader is not active, it will start it if the settings are enabled.
// If the AutoDownloader is active, it will stop it if the settings are disabled.
func (ad *AutoDownloader) SetSettings(settings *models.AutoDownloaderSettings, provider string) {
defer util.HandlePanicInModuleThen("autodownloader/SetSettings", func() {})
event := &AutoDownloaderSettingsUpdatedEvent{
Settings: settings,
}
_ = hook.GlobalHookManager.OnAutoDownloaderSettingsUpdated().Trigger(event)
settings = event.Settings
if ad == nil {
return
}
go func() {
ad.mu.Lock()
defer ad.mu.Unlock()
ad.settings = settings
// Update the provider if it's provided
if provider != "" {
ad.settings.Provider = provider
}
ad.settingsUpdatedCh <- struct{}{} // Notify that the settings have been updated
if ad.settings.Enabled {
ad.startCh <- struct{}{} // Start the auto downloader
} else if !ad.settings.Enabled {
ad.stopCh <- struct{}{} // Stop the auto downloader
}
}()
}
func (ad *AutoDownloader) SetAnimeCollection(ac *anilist.AnimeCollection) {
ad.animeCollection = mo.Some(ac)
}
func (ad *AutoDownloader) SetTorrentClientRepository(repo *torrent_client.Repository) {
defer util.HandlePanicInModuleThen("autodownloader/SetTorrentClientRepository", func() {})
if ad == nil {
return
}
ad.torrentClientRepository = repo
}
// Start will start the auto downloader in a goroutine
func (ad *AutoDownloader) Start() {
defer util.HandlePanicInModuleThen("autodownloader/Start", func() {})
if ad == nil {
return
}
go func() {
ad.mu.Lock()
if ad.settings.Enabled {
started := ad.torrentClientRepository.Start() // Start torrent client if it's not running
if !started {
ad.logger.Warn().Msg("autodownloader: Failed to start torrent client. Make sure it's running for the Auto Downloader to work.")
ad.mu.Unlock()
return
}
}
ad.mu.Unlock()
// Start the auto downloader
ad.start()
}()
}
func (ad *AutoDownloader) Run() {
defer util.HandlePanicInModuleThen("autodownloader/Run", func() {})
if ad == nil {
return
}
go func() {
ad.mu.Lock()
defer ad.mu.Unlock()
ad.startCh <- struct{}{}
ad.logger.Trace().Msg("autodownloader: Received start signal")
}()
}
// CleanUpDownloadedItems will clean up downloaded items from the database.
// This should be run after a scan is completed.
func (ad *AutoDownloader) CleanUpDownloadedItems() {
defer util.HandlePanicInModuleThen("autodownloader/CleanUpDownloadedItems", func() {})
if ad == nil {
return
}
ad.mu.Lock()
defer ad.mu.Unlock()
err := ad.database.DeleteDownloadedAutoDownloaderItems()
if err != nil {
return
}
}
func (ad *AutoDownloader) start() {
defer util.HandlePanicInModuleThen("autodownloader/start", func() {})
if ad.settings.Enabled {
ad.logger.Info().Msg("autodownloader: Module started")
}
for {
interval := 20
// Use the user-defined interval if it's greater or equal to 15
if ad.settings != nil && ad.settings.Interval > 0 && ad.settings.Interval >= 15 {
interval = ad.settings.Interval
}
ticker := time.NewTicker(time.Duration(interval) * time.Minute)
select {
case <-ad.settingsUpdatedCh:
break // Restart the loop
case <-ad.stopCh:
case <-ad.startCh:
if ad.settings.Enabled {
ad.logger.Debug().Msg("autodownloader: Auto Downloader started")
ad.checkForNewEpisodes()
}
case <-ticker.C:
if ad.settings.Enabled {
ad.checkForNewEpisodes()
}
}
ticker.Stop()
}
}
func (ad *AutoDownloader) checkForNewEpisodes() {
defer util.HandlePanicInModuleThen("autodownloader/checkForNewEpisodes", func() {})
if ad.isOffline != nil && *ad.isOffline {
ad.logger.Debug().Msg("autodownloader: Skipping check for new episodes. AutoDownloader is in offline mode.")
return
}
ad.mu.Lock()
if ad == nil || ad.torrentRepository == nil || !ad.settings.Enabled || ad.settings.Provider == "" || ad.settings.Provider == torrent.ProviderNone {
ad.logger.Warn().Msg("autodownloader: Could not check for new episodes. AutoDownloader is not enabled or provider is not set.")
ad.mu.Unlock()
return
}
// DEVNOTE: [checkForNewEpisodes] is called on startup, when the default anime provider extension has not yet been loaded.
providerExt, found := ad.torrentRepository.GetDefaultAnimeProviderExtension()
if !found {
//ad.logger.Warn().Msg("autodownloader: Could not check for new episodes. Default provider not found.")
ad.mu.Unlock()
return
}
if providerExt.GetProvider().GetSettings().Type != hibiketorrent.AnimeProviderTypeMain {
ad.logger.Warn().Msgf("autodownloader: Could not check for new episodes. Provider '%s' cannot be used for auto downloading.", providerExt.GetName())
ad.mu.Unlock()
return
}
ad.mu.Unlock()
torrents := make([]*NormalizedTorrent, 0)
// Get rules from the database
rules, err := db_bridge.GetAutoDownloaderRules(ad.database)
if err != nil {
ad.logger.Error().Err(err).Msg("autodownloader: Failed to fetch rules from the database")
return
}
// Filter out disabled rules
_filteredRules := make([]*anime.AutoDownloaderRule, 0)
for _, rule := range rules {
if rule.Enabled {
_filteredRules = append(_filteredRules, rule)
}
}
rules = _filteredRules
// Event
event := &AutoDownloaderRunStartedEvent{
Rules: rules,
}
_ = hook.GlobalHookManager.OnAutoDownloaderRunStarted().Trigger(event)
rules = event.Rules
// Default prevented, return
if event.DefaultPrevented {
return
}
// If there are no rules, return
if len(rules) == 0 {
ad.logger.Debug().Msg("autodownloader: No rules found")
return
}
// Get local files from the database
lfs, _, err := db_bridge.GetLocalFiles(ad.database)
if err != nil {
ad.logger.Error().Err(err).Msg("autodownloader: Failed to fetch local files from the database")
return
}
// Create a LocalFileWrapper
lfWrapper := anime.NewLocalFileWrapper(lfs)
// Get the latest torrents
torrents, err = ad.getLatestTorrents(rules)
if err != nil {
ad.logger.Error().Err(err).Msg("autodownloader: Failed to get latest torrents")
return
}
// Event
fetchedEvent := &AutoDownloaderTorrentsFetchedEvent{
Torrents: torrents,
}
_ = hook.GlobalHookManager.OnAutoDownloaderTorrentsFetched().Trigger(fetchedEvent)
torrents = fetchedEvent.Torrents
// // Try to start the torrent client if it's not running
// if ad.torrentClientRepository != nil {
// started := ad.torrentClientRepository.Start() // Start torrent client if it's not running
// if !started {
// ad.logger.Warn().Msg("autodownloader: Failed to start torrent client. Make sure it's running.")
// }
// }
// Get existing torrents
existingTorrents := make([]*torrent_client.Torrent, 0)
if ad.torrentClientRepository != nil {
existingTorrents, err = ad.torrentClientRepository.GetList()
if err != nil {
existingTorrents = make([]*torrent_client.Torrent, 0)
}
}
downloaded := 0
mu := sync.Mutex{}
// Going through each rule
p := pool.New()
for _, rule := range rules {
rule := rule
p.Go(func() {
if !rule.Enabled {
return // Skip rule
}
listEntry, found := ad.getRuleListEntry(rule)
// If the media is not found, skip the rule
if !found {
return // Skip rule
}
// DEVNOTE: This is bad, do not skip anime that are not releasing because dubs are delayed
// If the media is not releasing AND has more than one episode, skip the rule
// This is to avoid skipping movies and single-episode OVAs
//if *listEntry.GetMedia().GetStatus() != anilist.MediaStatusReleasing && listEntry.GetMedia().GetCurrentEpisodeCount() > 1 {
// return // Skip rule
//}
localEntry, _ := lfWrapper.GetLocalEntryById(listEntry.GetMedia().GetID())
// +---------------------+
// | Existing Item |
// +---------------------+
items, err := ad.database.GetAutoDownloaderItemByMediaId(listEntry.GetMedia().GetID())
if err != nil {
items = make([]*models.AutoDownloaderItem, 0)
}
// Get all torrents that follow the rule
torrentsToDownload := make([]*tmpTorrentToDownload, 0)
outer:
for _, t := range torrents {
// If the torrent is already added, skip it
for _, et := range existingTorrents {
if et.Hash == t.InfoHash {
continue outer // Skip the torrent
}
}
episode, ok := ad.torrentFollowsRule(t, rule, listEntry, localEntry, items)
event := &AutoDownloaderMatchVerifiedEvent{
Torrent: t,
Rule: rule,
ListEntry: listEntry,
LocalEntry: localEntry,
Episode: episode,
MatchFound: ok,
}
_ = hook.GlobalHookManager.OnAutoDownloaderMatchVerified().Trigger(event)
t = event.Torrent
rule = event.Rule
listEntry = event.ListEntry
localEntry = event.LocalEntry
episode = event.Episode
ok = event.MatchFound
// Default prevented, skip the torrent
if event.DefaultPrevented {
continue outer // Skip the torrent
}
if ok {
torrentsToDownload = append(torrentsToDownload, &tmpTorrentToDownload{
torrent: t,
episode: episode,
})
}
}
// Download the torrent if there's only one
if len(torrentsToDownload) == 1 {
t := torrentsToDownload[0]
ok := ad.downloadTorrent(t.torrent, rule, t.episode)
if ok {
downloaded++
}
return
}
// If there's more than one, we will group them by episode and sort them
// Make a map [episode]torrents
epMap := make(map[int][]*tmpTorrentToDownload)
for _, t := range torrentsToDownload {
if _, ok := epMap[t.episode]; !ok {
epMap[t.episode] = make([]*tmpTorrentToDownload, 0)
epMap[t.episode] = append(epMap[t.episode], t)
} else {
epMap[t.episode] = append(epMap[t.episode], t)
}
}
// Go through each episode group and download the best torrent (by resolution and seeders)
for ep, torrents := range epMap {
// If there's only one torrent for the episode, download it
if len(torrents) == 1 {
ok := ad.downloadTorrent(torrents[0].torrent, rule, ep)
if ok {
mu.Lock()
downloaded++
mu.Unlock()
}
continue
}
// If there are more than one
// Sort by resolution
sort.Slice(torrents, func(i, j int) bool {
qI := comparison.ExtractResolutionInt(torrents[i].torrent.ParsedData.VideoResolution)
qJ := comparison.ExtractResolutionInt(torrents[j].torrent.ParsedData.VideoResolution)
return qI > qJ
})
// Sort by seeds
sort.Slice(torrents, func(i, j int) bool {
return torrents[i].torrent.Seeders > torrents[j].torrent.Seeders
})
ok := ad.downloadTorrent(torrents[0].torrent, rule, ep)
if ok {
mu.Lock()
downloaded++
mu.Unlock()
}
}
})
}
p.Wait()
if downloaded > 0 {
if ad.settings.DownloadAutomatically {
notifier.GlobalNotifier.Notify(
notifier.AutoDownloader,
fmt.Sprintf("%d %s %s been downloaded.", downloaded, util.Pluralize(downloaded, "episode", "episodes"), util.Pluralize(downloaded, "has", "have")),
)
} else {
notifier.GlobalNotifier.Notify(
notifier.AutoDownloader,
fmt.Sprintf("%d %s %s been added to the queue.", downloaded, util.Pluralize(downloaded, "episode", "episodes"), util.Pluralize(downloaded, "has", "have")),
)
}
}
}
func (ad *AutoDownloader) torrentFollowsRule(
t *NormalizedTorrent,
rule *anime.AutoDownloaderRule,
listEntry *anilist.AnimeListEntry,
localEntry *anime.LocalFileWrapperEntry,
items []*models.AutoDownloaderItem,
) (int, bool) {
defer util.HandlePanicInModuleThen("autodownloader/torrentFollowsRule", func() {})
if ok := ad.isReleaseGroupMatch(t.ParsedData.ReleaseGroup, rule); !ok {
return -1, false
}
if ok := ad.isResolutionMatch(t.ParsedData.VideoResolution, rule); !ok {
return -1, false
}
if ok := ad.isTitleMatch(t.ParsedData, t.Name, rule, listEntry); !ok {
return -1, false
}
if ok := ad.isAdditionalTermsMatch(t.Name, rule); !ok {
return -1, false
}
episode, ok := ad.isSeasonAndEpisodeMatch(t.ParsedData, rule, listEntry, localEntry, items)
if !ok {
return -1, false
}
return episode, true
}
func (ad *AutoDownloader) downloadTorrent(t *NormalizedTorrent, rule *anime.AutoDownloaderRule, episode int) bool {
defer util.HandlePanicInModuleThen("autodownloader/downloadTorrent", func() {})
ad.mu.Lock()
defer ad.mu.Unlock()
// Double check that the episode hasn't been added while we have the lock
items, err := ad.database.GetAutoDownloaderItemByMediaId(rule.MediaId)
if err == nil {
for _, item := range items {
if item.Episode == episode {
return false // Skip, episode was added by another goroutine
}
}
}
// Event
beforeEvent := &AutoDownloaderBeforeDownloadTorrentEvent{
Torrent: t,
Rule: rule,
Items: items,
}
_ = hook.GlobalHookManager.OnAutoDownloaderBeforeDownloadTorrent().Trigger(beforeEvent)
t = beforeEvent.Torrent
rule = beforeEvent.Rule
_ = beforeEvent.Items
// Default prevented, return
if beforeEvent.DefaultPrevented {
return false
}
providerExtension, found := ad.torrentRepository.GetDefaultAnimeProviderExtension()
if !found {
ad.logger.Warn().Msg("autodownloader: Could not download torrent. Default provider not found")
return false
}
if ad.torrentClientRepository == nil {
ad.logger.Error().Msg("autodownloader: torrent client not found")
return false
}
useDebrid := false
if ad.settings.UseDebrid {
// Check if the debrid provider is enabled
if !ad.debridClientRepository.HasProvider() || !ad.debridClientRepository.GetSettings().Enabled {
ad.logger.Error().Msg("autodownloader: Debrid provider not found or not enabled")
// We return instead of falling back to torrent client
return false
}
useDebrid = true
}
// Get torrent magnet
magnet, err := t.GetMagnet(providerExtension.GetProvider())
if err != nil {
ad.logger.Error().Str("link", t.Link).Str("name", t.Name).Msg("autodownloader: Failed to get magnet link for torrent")
return false
}
downloaded := false
if useDebrid {
//
// Debrid
//
if ad.settings.DownloadAutomatically {
// Add the torrent to the debrid provider and queue it
_, err := ad.debridClientRepository.AddAndQueueTorrent(debrid.AddTorrentOptions{
MagnetLink: magnet,
SelectFileId: "all", // RD-only, select all files
}, rule.Destination, rule.MediaId)
if err != nil {
ad.logger.Error().Err(err).Str("link", t.Link).Str("name", t.Name).Msg("autodownloader: Failed to add torrent to debrid")
return false
}
} else {
debridProvider, err := ad.debridClientRepository.GetProvider()
if err != nil {
ad.logger.Error().Err(err).Msg("autodownloader: Failed to get debrid provider")
return false
}
// Add the torrent to the debrid provider
_, err = debridProvider.AddTorrent(debrid.AddTorrentOptions{
MagnetLink: magnet,
SelectFileId: "all", // RD-only, select all files
})
if err != nil {
ad.logger.Error().Err(err).Str("link", t.Link).Str("name", t.Name).Msg("autodownloader: Failed to add torrent to debrid")
return false
}
}
} else {
// Pause the torrent when it's added
if ad.settings.DownloadAutomatically {
//
// Torrent client
//
started := ad.torrentClientRepository.Start() // Start torrent client if it's not running
if !started {
ad.logger.Error().Str("link", t.Link).Str("name", t.Name).Msg("autodownloader: Failed to download torrent. torrent client is not running.")
return false
}
// Return if the torrent is already added
torrentExists := ad.torrentClientRepository.TorrentExists(t.InfoHash)
if torrentExists {
//ad.Logger.Debug().Str("name", t.Name).Msg("autodownloader: Torrent already added")
return false
}
ad.logger.Debug().Msgf("autodownloader: Downloading torrent: %s", t.Name)
// Add the torrent to torrent client
err := ad.torrentClientRepository.AddMagnets([]string{magnet}, rule.Destination)
if err != nil {
ad.logger.Error().Err(err).Str("link", t.Link).Str("name", t.Name).Msg("autodownloader: Failed to add torrent to torrent client")
return false
}
downloaded = true
}
}
ad.logger.Info().Str("name", t.Name).Msg("autodownloader: Added torrent")
ad.wsEventManager.SendEvent(events.AutoDownloaderItemAdded, t.Name)
// Add the torrent to the database
item := &models.AutoDownloaderItem{
RuleID: rule.DbID,
MediaID: rule.MediaId,
Episode: episode,
Link: t.Link,
Hash: t.InfoHash,
Magnet: magnet,
TorrentName: t.Name,
Downloaded: downloaded,
}
_ = ad.database.InsertAutoDownloaderItem(item)
// Event
afterEvent := &AutoDownloaderAfterDownloadTorrentEvent{
Torrent: t,
Rule: rule,
}
_ = hook.GlobalHookManager.OnAutoDownloaderAfterDownloadTorrent().Trigger(afterEvent)
return true
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (ad *AutoDownloader) isAdditionalTermsMatch(torrentName string, rule *anime.AutoDownloaderRule) (ok bool) {
defer util.HandlePanicInModuleThen("autodownloader/isAdditionalTermsMatch", func() {
ok = false
})
if len(rule.AdditionalTerms) == 0 {
return true
}
// Go through each additional term
for _, optionsText := range rule.AdditionalTerms {
// Split the options by comma
options := strings.Split(strings.TrimSpace(optionsText), ",")
// Check if the torrent name contains at least one of the options
foundOption := false
for _, option := range options {
option := strings.TrimSpace(option)
if strings.Contains(strings.ToLower(torrentName), strings.ToLower(option)) {
foundOption = true
}
}
// If the torrent name doesn't contain any of the options, return false
if !foundOption {
return false
}
}
// If all options are found, return true
return true
}
func (ad *AutoDownloader) isReleaseGroupMatch(releaseGroup string, rule *anime.AutoDownloaderRule) (ok bool) {
defer util.HandlePanicInModuleThen("autodownloader/isReleaseGroupMatch", func() {
ok = false
})
if len(rule.ReleaseGroups) == 0 {
return true
}
for _, rg := range rule.ReleaseGroups {
if strings.ToLower(rg) == strings.ToLower(releaseGroup) {
return true
}
}
return false
}
// isResolutionMatch
// DEVOTE: Improve this
func (ad *AutoDownloader) isResolutionMatch(quality string, rule *anime.AutoDownloaderRule) (ok bool) {
defer util.HandlePanicInModuleThen("autodownloader/isResolutionMatch", func() {
ok = false
})
if len(rule.Resolutions) == 0 {
return true
}
if quality == "" {
return false
}
for _, q := range rule.Resolutions {
qualityWithoutP := strings.TrimSuffix(quality, "p")
qWithoutP := strings.TrimSuffix(q, "p")
if quality == q || qualityWithoutP == qWithoutP {
return true
}
if strings.Contains(quality, qWithoutP) { // e.g. 1080 in 1920x1080
return true
}
}
return false
}
func (ad *AutoDownloader) isTitleMatch(torrentParsedData *habari.Metadata, torrentName string, rule *anime.AutoDownloaderRule, listEntry *anilist.AnimeListEntry) (ok bool) {
defer util.HandlePanicInModuleThen("autodownloader/isTitleMatch", func() {
ok = false
})
switch rule.TitleComparisonType {
case anime.AutoDownloaderRuleTitleComparisonContains:
// +---------------------+
// | Title "Contains" |
// +---------------------+
// Check if the torrent name contains the comparison title exactly
// This will fail for torrent titles that don't contain a season number if the comparison title has a season number
if strings.Contains(strings.ToLower(torrentParsedData.Title), strings.ToLower(rule.ComparisonTitle)) {
return true
}
if strings.Contains(strings.ToLower(torrentName), strings.ToLower(rule.ComparisonTitle)) {
return true
}
case anime.AutoDownloaderRuleTitleComparisonLikely:
// +---------------------+
// | Title "Likely" |
// +---------------------+
torrentTitle := torrentParsedData.Title
comparisonTitle := strings.ReplaceAll(strings.ReplaceAll(rule.ComparisonTitle, "[", ""), "]", "")
// 1. Use comparison title (without season number - if it exists)
// Remove season number from the torrent title if it exists
parsedComparisonTitle := habari.Parse(comparisonTitle)
if parsedComparisonTitle.Title != "" && len(parsedComparisonTitle.SeasonNumber) > 0 {
_comparisonTitle := parsedComparisonTitle.Title
if len(parsedComparisonTitle.ReleaseGroup) > 0 {
_comparisonTitle = fmt.Sprintf("%s %s", parsedComparisonTitle.ReleaseGroup, _comparisonTitle)
_comparisonTitle = strings.TrimSpace(_comparisonTitle)
}
// First, use comparison title, compare without season number
// e.g. Torrent: "[Seanime] Jujutsu Kaisen 2nd Season - 20 [...].mkv" -> "Jujutsu Kaisen"
// e.g. Comparison Title: "Jujutsu Kaisen 2nd Season" -> "Jujutsu Kaisen"
// DEVNOTE: isSeasonAndEpisodeMatch will handle the case where the torrent has a season number
// Make sure the distance is not too great
lev := metrics.NewLevenshtein()
lev.CaseSensitive = false
res := lev.Distance(torrentTitle, _comparisonTitle)
if res < 4 {
return true
}
}
// 2. Use media titles
// If we're here, it means that either
// - the comparison title doesn't have a season number
// - the comparison title (w/o season number) is not similar to the torrent title
torrentTitleVariations := []*string{&torrentTitle}
if len(torrentParsedData.SeasonNumber) > 0 {
season := util.StringToIntMust(torrentParsedData.SeasonNumber[0])
if season > 1 {
// If the torrent has a season number, add it to the variations
torrentTitleVariations = []*string{
lo.ToPtr(fmt.Sprintf("%s Season %s", torrentParsedData.Title, torrentParsedData.SeasonNumber[0])),
lo.ToPtr(fmt.Sprintf("%s S%s", torrentParsedData.Title, torrentParsedData.SeasonNumber[0])),
lo.ToPtr(fmt.Sprintf("%s %s Season", torrentParsedData.Title, util.IntegerToOrdinal(util.StringToIntMust(torrentParsedData.SeasonNumber[0])))),
}
}
}
// If the parsed comparison title doesn't match, compare the torrent title with media titles
mediaTitles := listEntry.GetMedia().GetAllTitles()
var compRes *comparison.SorensenDiceResult
for _, title := range torrentTitleVariations {
res, found := comparison.FindBestMatchWithSorensenDice(title, mediaTitles)
if found {
if compRes == nil || res.Rating > compRes.Rating {
compRes = res
}
}
}
// If the best match is not found
// /!\ This shouldn't happen since the media titles are always present
if compRes == nil {
// Compare using rule comparison title
sd := metrics.NewSorensenDice()
sd.CaseSensitive = false
res := sd.Compare(torrentTitle, comparisonTitle)
if res > ComparisonThreshold {
return true
}
return false
}
// If the best match is found
if compRes.Rating > ComparisonThreshold {
return true
}
return false
}
return false
}
func (ad *AutoDownloader) isSeasonAndEpisodeMatch(
parsedData *habari.Metadata,
rule *anime.AutoDownloaderRule,
listEntry *anilist.AnimeListEntry,
localEntry *anime.LocalFileWrapperEntry,
items []*models.AutoDownloaderItem,
) (a int, b bool) {
defer util.HandlePanicInModuleThen("autodownloader/isSeasonAndEpisodeMatch", func() {
b = false
})
if listEntry == nil {
return -1, false
}
episodes := parsedData.EpisodeNumber
// Skip if we parsed more than one episode number (e.g. "01-02")
// We can't handle this case since it might be a batch release
if len(episodes) > 1 {
return -1, false
}
var ok bool
episode := 1
if len(episodes) == 1 {
_episode, _ok := util.StringToInt(episodes[0])
if _ok {
episode = _episode
ok = true
}
}
// +---------------------+
// | No episode number |
// +---------------------+
// We can't parse the episode number
if !ok {
// Return true if the media (has only one episode or is a movie) AND (is not in the library)
if listEntry.GetMedia().GetCurrentEpisodeCount() == 1 || *listEntry.GetMedia().GetFormat() == anilist.MediaFormatMovie {
// Make sure it wasn't already added
for _, item := range items {
if item.Episode == 1 {
return -1, false // Skip, file already queued or downloaded
}
}
// Make sure it doesn't exist in the library
if localEntry != nil {
if _, found := localEntry.FindLocalFileWithEpisodeNumber(1); found {
return -1, false // Skip, file already exists
}
}
return 1, true // Good to go
}
return -1, false
}
// +---------------------+
// | Episode number |
// +---------------------+
hasAbsoluteEpisode := false
// Handle ABSOLUTE episode numbers
if listEntry.GetMedia().GetCurrentEpisodeCount() != -1 && episode > listEntry.GetMedia().GetCurrentEpisodeCount() {
// Fetch the Animap media in order to normalize the episode number
ad.mu.Lock()
animeMetadata, err := ad.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, listEntry.GetMedia().GetID())
// If the media is found and the offset is greater than 0
if err == nil && animeMetadata.GetOffset() > 0 {
hasAbsoluteEpisode = true
episode = episode - animeMetadata.GetOffset()
}
ad.mu.Unlock()
}
// Return false if the episode is already downloaded
for _, item := range items {
if item.Episode == episode {
return -1, false // Skip, file already queued or downloaded
}
}
// Return false if the episode is already in the library
if localEntry != nil {
if _, found := localEntry.FindLocalFileWithEpisodeNumber(episode); found {
return -1, false
}
}
// If there's no absolute episode number, check that the episode number is not greater than the current episode count
if !hasAbsoluteEpisode && episode > listEntry.GetMedia().GetCurrentEpisodeCount() {
return -1, false
}
// As a last check, make sure the seasons match ONLY if the episode number is not absolute
// We do this check only for "likely" title comparison type since the season numbers are not compared
if ad.settings.EnableSeasonCheck {
if !hasAbsoluteEpisode {
switch rule.TitleComparisonType {
case anime.AutoDownloaderRuleTitleComparisonLikely:
// If the title comparison type is "Likely", we will compare the season numbers
if len(parsedData.SeasonNumber) > 0 {
season, ok := util.StringToInt(parsedData.SeasonNumber[0])
if ok && season > 1 {
parsedComparisonTitle := habari.Parse(rule.ComparisonTitle)
if len(parsedComparisonTitle.SeasonNumber) == 0 {
return -1, false
}
if season != util.StringToIntMust(parsedComparisonTitle.SeasonNumber[0]) {
return -1, false
}
}
}
}
}
}
switch rule.EpisodeType {
case anime.AutoDownloaderRuleEpisodeRecent:
// +---------------------+
// | Episode "Recent" |
// +---------------------+
// Return false if the user has already watched the episode
if listEntry.Progress != nil && *listEntry.GetProgress() > episode {
return -1, false
}
return episode, true // Good to go
case anime.AutoDownloaderRuleEpisodeSelected:
// +---------------------+
// | Episode "Selected" |
// +---------------------+
// Return true if the episode is in the list of selected episodes
for _, ep := range rule.EpisodeNumbers {
if ep == episode {
return episode, true // Good to go
}
}
return -1, false
}
return -1, false
}
func (ad *AutoDownloader) getRuleListEntry(rule *anime.AutoDownloaderRule) (*anilist.AnimeListEntry, bool) {
if rule == nil || rule.MediaId == 0 || ad.animeCollection.IsAbsent() {
return nil, false
}
listEntry, found := ad.animeCollection.MustGet().GetListEntryFromAnimeId(rule.MediaId)
if !found {
return nil, false
}
return listEntry, true
}