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

227 lines
7.9 KiB
Go

package scanner
import (
"errors"
"fmt"
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/util/limiter"
"sort"
"time"
"github.com/samber/lo"
"github.com/sourcegraph/conc/pool"
)
type (
MediaTreeAnalysisOptions struct {
tree *anilist.CompleteAnimeRelationTree
metadataProvider metadata.Provider
rateLimiter *limiter.Limiter
}
MediaTreeAnalysis struct {
branches []*MediaTreeAnalysisBranch
}
MediaTreeAnalysisBranch struct {
media *anilist.CompleteAnime
animeMetadata *metadata.AnimeMetadata
// The second absolute episode number of the first episode
// Sometimes, the metadata provider may have a 'true' absolute episode number and a 'part' absolute episode number
// 'part' absolute episode numbers might be used for "Part 2s" of a season
minPartAbsoluteEpisodeNumber int
maxPartAbsoluteEpisodeNumber int
minAbsoluteEpisode int
maxAbsoluteEpisode int
totalEpisodeCount int
noAbsoluteEpisodesFound bool
}
)
// NewMediaTreeAnalysis will analyze the media tree and create and store a MediaTreeAnalysisBranch for each media in the tree.
// Each MediaTreeAnalysisBranch will contain the min and max absolute episode number for the media.
// The min and max absolute episode numbers are used to get the relative episode number from an absolute episode number.
func NewMediaTreeAnalysis(opts *MediaTreeAnalysisOptions) (*MediaTreeAnalysis, error) {
relations := make([]*anilist.CompleteAnime, 0)
opts.tree.Range(func(key int, value *anilist.CompleteAnime) bool {
relations = append(relations, value)
return true
})
// Get Animap data for all related media in the tree
// With each Animap media, get the min and max absolute episode number
// Create new MediaTreeAnalysisBranch for each Animap media
p := pool.NewWithResults[*MediaTreeAnalysisBranch]().WithErrors()
for _, rel := range relations {
p.Go(func() (*MediaTreeAnalysisBranch, error) {
opts.rateLimiter.Wait()
animeMetadata, err := opts.metadataProvider.GetAnimeMetadata(metadata.AnilistPlatform, rel.ID)
if err != nil {
return nil, err
}
// Get the first episode
firstEp, ok := animeMetadata.Episodes["1"]
if !ok {
return nil, errors.New("no first episode")
}
// discrepancy: "seasonNumber":1,"episodeNumber":12,"absoluteEpisodeNumber":13,
// this happens when the media has a separate entry but is technically the same season
// when we detect this, we should use the "episodeNumber" as the absoluteEpisodeNumber
// this is a hacky fix, but it works for the cases I've seen so far
usePartEpisodeNumber := firstEp.EpisodeNumber > 1 && firstEp.AbsoluteEpisodeNumber-firstEp.EpisodeNumber > 1
partAbsoluteEpisodeNumber := 0
maxPartAbsoluteEpisodeNumber := 0
if usePartEpisodeNumber {
partAbsoluteEpisodeNumber = firstEp.EpisodeNumber
maxPartAbsoluteEpisodeNumber = partAbsoluteEpisodeNumber + animeMetadata.GetMainEpisodeCount() - 1
}
// If the first episode exists and has a valid absolute episode number, create a new MediaTreeAnalysisBranch
if animeMetadata.Episodes != nil {
return &MediaTreeAnalysisBranch{
media: rel,
animeMetadata: animeMetadata,
minPartAbsoluteEpisodeNumber: partAbsoluteEpisodeNumber,
maxPartAbsoluteEpisodeNumber: maxPartAbsoluteEpisodeNumber,
minAbsoluteEpisode: firstEp.AbsoluteEpisodeNumber,
// The max absolute episode number is the first episode's absolute episode number plus the total episode count minus 1
// We subtract 1 because the first episode's absolute episode number is already included in the total episode count
// e.g, if the first episode's absolute episode number is 13 and the total episode count is 12, the max absolute episode number is 24
maxAbsoluteEpisode: firstEp.AbsoluteEpisodeNumber + (animeMetadata.GetMainEpisodeCount() - 1),
totalEpisodeCount: animeMetadata.GetMainEpisodeCount(),
noAbsoluteEpisodesFound: firstEp.AbsoluteEpisodeNumber == 0,
}, nil
}
return nil, errors.New("could not analyze media tree branch")
})
}
branches, _ := p.Wait()
if branches == nil || len(branches) == 0 {
return nil, errors.New("no branches found")
}
return &MediaTreeAnalysis{branches: branches}, nil
}
// getRelativeEpisodeNumber uses the MediaTreeAnalysis to get the relative episode number for an absolute episode number
func (o *MediaTreeAnalysis) getRelativeEpisodeNumber(abs int) (relativeEp int, mediaId int, ok bool) {
isPartAbsolute := false
// Find the MediaTreeAnalysisBranch that contains the absolute episode number
branch, ok := lo.Find(o.branches, func(n *MediaTreeAnalysisBranch) bool {
// First check if the partAbsoluteEpisodeNumber is set
if n.minPartAbsoluteEpisodeNumber > 0 && n.maxPartAbsoluteEpisodeNumber > 0 {
// If it is, check if the absolute episode number given is the same as the partAbsoluteEpisodeNumber
// If it is, return true
if n.minPartAbsoluteEpisodeNumber <= abs && n.maxPartAbsoluteEpisodeNumber >= abs {
isPartAbsolute = true
return true
}
}
// Else, check if the absolute episode number given is within the min and max absolute episode numbers of the branch
if n.minAbsoluteEpisode <= abs && n.maxAbsoluteEpisode >= abs {
return true
}
return false
})
if !ok {
// Sort branches manually
type branchByFirstEpDate struct {
branch *MediaTreeAnalysisBranch
firstEpDate time.Time
minAbsoluteEpisode int
maxAbsoluteEpisode int
}
branches := make([]*branchByFirstEpDate, 0)
for _, b := range o.branches {
// Get the first episode date
firstEp, ok := b.animeMetadata.Episodes["1"]
if !ok {
continue
}
// parse date
t, err := time.Parse(time.DateOnly, firstEp.AirDate)
if err != nil {
continue
}
branches = append(branches, &branchByFirstEpDate{
branch: b,
firstEpDate: t,
})
}
// Sort branches by first episode date
// If the first episode date is not available, the branch will be placed at the end
sort.Slice(branches, func(i, j int) bool {
return branches[i].firstEpDate.Before(branches[j].firstEpDate)
})
// Hydrate branches with min and max absolute episode numbers
visited := make(map[int]*branchByFirstEpDate)
for idx, b := range branches {
visited[idx] = b
if v, ok := visited[idx-1]; ok {
b.minAbsoluteEpisode = v.maxAbsoluteEpisode + 1
b.maxAbsoluteEpisode = b.minAbsoluteEpisode + b.branch.totalEpisodeCount - 1
continue
}
b.minAbsoluteEpisode = 1
b.maxAbsoluteEpisode = b.minAbsoluteEpisode + b.branch.totalEpisodeCount - 1
}
for _, b := range branches {
if b.minAbsoluteEpisode <= abs && b.maxAbsoluteEpisode >= abs {
b.branch.minAbsoluteEpisode = b.minAbsoluteEpisode
b.branch.maxAbsoluteEpisode = b.maxAbsoluteEpisode
branch = b.branch
relativeEp = abs - (branch.minAbsoluteEpisode - 1)
mediaId = branch.media.ID
ok = true
return
}
}
return 0, 0, false
}
if isPartAbsolute {
// Let's say the media has 12 episodes and the file is "episode 13"
// If the [partAbsoluteEpisodeNumber] is 13, then the [relativeEp] will be 1, we can safely ignore the [absoluteEpisodeNumber]
// e.g. 13 - (13-1) = 1
relativeEp = abs - (branch.minPartAbsoluteEpisodeNumber - 1)
} else {
// Let's say the media has 12 episodes and the file is "episode 38"
// The [minAbsoluteEpisode] will be 38 and the [relativeEp] will be 1
// e.g. 38 - (38-1) = 1
relativeEp = abs - (branch.minAbsoluteEpisode - 1)
}
mediaId = branch.media.ID
return
}
func (o *MediaTreeAnalysis) printBranches() (str string) {
str = "["
for _, branch := range o.branches {
str += fmt.Sprintf("media: '%s', minAbsoluteEpisode: %d, maxAbsoluteEpisode: %d, totalEpisodeCount: %d; ", branch.media.GetTitleSafe(), branch.minAbsoluteEpisode, branch.maxAbsoluteEpisode, branch.totalEpisodeCount)
}
if len(o.branches) > 0 {
str = str[:len(str)-2]
}
str += "]"
return str
}