1015 lines
30 KiB
Go
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
|
|
}
|