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

294 lines
7.3 KiB
Go

package torrent_analyzer
import (
"errors"
"path/filepath"
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/library/anime"
"seanime/internal/library/scanner"
"seanime/internal/platforms/platform"
"seanime/internal/util"
"seanime/internal/util/limiter"
"github.com/rs/zerolog"
lop "github.com/samber/lo/parallel"
)
type (
// Analyzer is a service similar to the scanner, but it is used to analyze torrent files.
// i.e. torrent files instead of local files.
Analyzer struct {
files []*File
media *anilist.CompleteAnime
platform platform.Platform
logger *zerolog.Logger
metadataProvider metadata.Provider
forceMatch bool
}
// Analysis contains the results of the analysis.
Analysis struct {
files []*File // Hydrated after scanFiles is called
selectedFiles []*File // Hydrated after findCorrespondingFiles is called
media *anilist.CompleteAnime
}
// File represents a torrent file and contains its metadata.
File struct {
index int
path string
localFile *anime.LocalFile
}
)
type (
NewAnalyzerOptions struct {
Logger *zerolog.Logger
Filepaths []string // Filepath of the torrent files
Media *anilist.CompleteAnime // The media to compare the files with
Platform platform.Platform
MetadataProvider metadata.Provider
// This basically skips the matching process and forces the media ID to be set.
// Used for the auto-select feature because the media is already known.
ForceMatch bool
}
)
func NewAnalyzer(opts *NewAnalyzerOptions) *Analyzer {
files := lop.Map(opts.Filepaths, func(filepath string, idx int) *File {
return newFile(idx, filepath)
})
return &Analyzer{
files: files,
media: opts.Media,
platform: opts.Platform,
logger: opts.Logger,
metadataProvider: opts.MetadataProvider,
forceMatch: opts.ForceMatch,
}
}
// AnalyzeTorrentFiles scans the files and returns an Analysis struct containing methods to get the results.
func (a *Analyzer) AnalyzeTorrentFiles() (*Analysis, error) {
if a.platform == nil {
return nil, errors.New("anilist client wrapper is nil")
}
if err := a.scanFiles(); err != nil {
return nil, err
}
analysis := &Analysis{
files: a.files,
media: a.media,
}
return analysis, nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (a *Analysis) GetCorrespondingFiles() map[int]*File {
ret, _ := a.getCorrespondingFiles(func(f *File) bool {
return true
})
return ret
}
func (a *Analysis) GetCorrespondingMainFiles() map[int]*File {
ret, _ := a.getCorrespondingFiles(func(f *File) bool {
return f.localFile.IsMain()
})
return ret
}
func (a *Analysis) GetMainFileByEpisode(episodeNumber int) (*File, bool) {
ret, _ := a.getCorrespondingFiles(func(f *File) bool {
return f.localFile.IsMain()
})
for _, f := range ret {
if f.localFile.Metadata.Episode == episodeNumber {
return f, true
}
}
return nil, false
}
func (a *Analysis) GetFileByAniDBEpisode(episode string) (*File, bool) {
for _, f := range a.files {
if f.localFile.Metadata.AniDBEpisode == episode {
return f, true
}
}
return nil, false
}
func (a *Analysis) GetUnselectedFiles() map[int]*File {
_, uRet := a.getCorrespondingFiles(func(f *File) bool {
return true
})
return uRet
}
func (a *Analysis) getCorrespondingFiles(filter func(f *File) bool) (map[int]*File, map[int]*File) {
ret := make(map[int]*File)
uRet := make(map[int]*File)
for _, af := range a.files {
if af.localFile.MediaId == a.media.ID {
if filter(af) {
ret[af.index] = af
} else {
uRet[af.index] = af
}
} else {
uRet[af.index] = af
}
}
return ret, uRet
}
// GetIndices returns the indices of the files.
//
// Example:
//
// selectedFilesMap := analysis.GetCorrespondingMainFiles()
// selectedIndices := analysis.GetIndices(selectedFilesMap)
func (a *Analysis) GetIndices(files map[int]*File) []int {
indices := make([]int, 0)
for i := range files {
indices = append(indices, i)
}
return indices
}
func (a *Analysis) GetFiles() []*File {
return a.files
}
// GetUnselectedIndices takes a map of selected files and returns the indices of the unselected files.
//
// Example:
//
// analysis, _ := analyzer.AnalyzeTorrentFiles()
// selectedFiles := analysis.GetCorrespondingMainFiles()
// indicesToRemove := analysis.GetUnselectedIndices(selectedFiles)
func (a *Analysis) GetUnselectedIndices(files map[int]*File) []int {
indices := make([]int, 0)
for i := range a.files {
if _, ok := files[i]; !ok {
indices = append(indices, i)
}
}
return indices
}
func (f *File) GetLocalFile() *anime.LocalFile {
return f.localFile
}
func (f *File) GetIndex() int {
return f.index
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// scanFiles scans the files and matches them with the media.
func (a *Analyzer) scanFiles() error {
completeAnimeCache := anilist.NewCompleteAnimeCache()
anilistRateLimiter := limiter.NewAnilistLimiter()
lfs := a.getLocalFiles() // Extract local files from the Files
// +---------------------+
// | MediaContainer |
// +---------------------+
tree := anilist.NewCompleteAnimeRelationTree()
if err := a.media.FetchMediaTree(anilist.FetchMediaTreeAll, a.platform.GetAnilistClient(), anilistRateLimiter, tree, completeAnimeCache); err != nil {
return err
}
allMedia := tree.Values()
mc := scanner.NewMediaContainer(&scanner.MediaContainerOptions{
AllMedia: allMedia,
})
//scanLogger, _ := scanner.NewScanLogger("./logs")
// +---------------------+
// | Matcher |
// +---------------------+
matcher := &scanner.Matcher{
LocalFiles: lfs,
MediaContainer: mc,
CompleteAnimeCache: completeAnimeCache,
Logger: util.NewLogger(),
ScanLogger: nil,
ScanSummaryLogger: nil,
}
err := matcher.MatchLocalFilesWithMedia()
if err != nil {
return err
}
if a.forceMatch {
for _, lf := range lfs {
lf.MediaId = a.media.GetID()
}
}
// +---------------------+
// | FileHydrator |
// +---------------------+
fh := &scanner.FileHydrator{
LocalFiles: lfs,
AllMedia: mc.NormalizedMedia,
CompleteAnimeCache: completeAnimeCache,
Platform: a.platform,
MetadataProvider: a.metadataProvider,
AnilistRateLimiter: anilistRateLimiter,
Logger: a.logger,
ScanLogger: nil,
ScanSummaryLogger: nil,
ForceMediaId: map[bool]int{true: a.media.GetID(), false: 0}[a.forceMatch],
}
fh.HydrateMetadata()
for _, af := range a.files {
for _, lf := range lfs {
if lf.Path == af.localFile.Path {
af.localFile = lf // Update the local file in the File
break
}
}
}
return nil
}
// newFile creates a new File from a file path.
func newFile(idx int, path string) *File {
path = filepath.ToSlash(path)
return &File{
index: idx,
path: path,
localFile: anime.NewLocalFile(path, ""),
}
}
func (a *Analyzer) getLocalFiles() []*anime.LocalFile {
files := make([]*anime.LocalFile, len(a.files))
for i, f := range a.files {
files[i] = f.localFile
}
return files
}