package anime import ( "bytes" "fmt" "path/filepath" "seanime/internal/util" "seanime/internal/util/comparison" "slices" "strconv" "strings" "github.com/samber/lo" lop "github.com/samber/lo/parallel" ) //---------------------------------------------------------------------------------------------------------------------- func (f *LocalFile) IsParsedEpisodeValid() bool { if f == nil || f.ParsedData == nil { return false } return len(f.ParsedData.Episode) > 0 } // GetEpisodeNumber returns the metadata episode number. // This requires the LocalFile to be hydrated. func (f *LocalFile) GetEpisodeNumber() int { if f.Metadata == nil { return -1 } return f.Metadata.Episode } func (f *LocalFile) GetParsedEpisodeTitle() string { if f.ParsedData == nil { return "" } return f.ParsedData.EpisodeTitle } // HasBeenWatched returns whether the episode has been watched. // This only applies to main episodes. func (f *LocalFile) HasBeenWatched(progress int) bool { if f.Metadata == nil { return false } if f.GetEpisodeNumber() == 0 && progress == 0 { return false } return progress >= f.GetEpisodeNumber() } // GetType returns the metadata type. // This requires the LocalFile to be hydrated. func (f *LocalFile) GetType() LocalFileType { return f.Metadata.Type } // IsMain returns true if the metadata type is LocalFileTypeMain func (f *LocalFile) IsMain() bool { return f.Metadata.Type == LocalFileTypeMain } // GetMetadata returns the file metadata. // This requires the LocalFile to be hydrated. func (f *LocalFile) GetMetadata() *LocalFileMetadata { return f.Metadata } // GetAniDBEpisode returns the metadata AniDB episode number. // This requires the LocalFile to be hydrated. func (f *LocalFile) GetAniDBEpisode() string { return f.Metadata.AniDBEpisode } func (f *LocalFile) IsLocked() bool { return f.Locked } func (f *LocalFile) IsIgnored() bool { return f.Ignored } // GetNormalizedPath returns the lowercase path of the LocalFile. // Use this for comparison. func (f *LocalFile) GetNormalizedPath() string { return util.NormalizePath(f.Path) } func (f *LocalFile) GetPath() string { return f.Path } func (f *LocalFile) HasSamePath(path string) bool { return f.GetNormalizedPath() == util.NormalizePath(path) } // IsInDir returns true if the LocalFile is in the given directory. func (f *LocalFile) IsInDir(dirPath string) bool { dirPath = util.NormalizePath(dirPath) if !filepath.IsAbs(dirPath) { return false } return strings.HasPrefix(f.GetNormalizedPath(), dirPath) } // IsAtRootOf returns true if the LocalFile is at the root of the given directory. func (f *LocalFile) IsAtRootOf(dirPath string) bool { dirPath = strings.TrimSuffix(util.NormalizePath(dirPath), "/") return filepath.ToSlash(filepath.Dir(f.GetNormalizedPath())) == dirPath } func (f *LocalFile) Equals(lf *LocalFile) bool { return util.NormalizePath(f.Path) == util.NormalizePath(lf.Path) } func (f *LocalFile) IsIncluded(lfs []*LocalFile) bool { for _, lf := range lfs { if f.Equals(lf) { return true } } return false } //---------------------------------------------------------------------------------------------------------------------- // buildTitle concatenates the given strings into a single string. func buildTitle(vals ...string) string { buf := bytes.NewBuffer([]byte{}) for i, v := range vals { buf.WriteString(v) if i != len(vals)-1 { buf.WriteString(" ") } } return buf.String() } // GetUniqueAnimeTitlesFromLocalFiles returns all parsed anime titles without duplicates, from a slice of LocalFile's. func GetUniqueAnimeTitlesFromLocalFiles(lfs []*LocalFile) []string { // Concurrently get title from each local file titles := lop.Map(lfs, func(file *LocalFile, index int) string { title := file.GetParsedTitle() // Some rudimentary exclusions for _, i := range []string{"SPECIALS", "SPECIAL", "EXTRA", "NC", "OP", "MOVIE", "MOVIES"} { if strings.ToUpper(title) == i { return "" } } return title }) // Keep unique title and filter out empty ones titles = lo.Filter(lo.Uniq(titles), func(item string, index int) bool { return len(item) > 0 }) return titles } // GetMediaIdsFromLocalFiles returns all media ids from a slice of LocalFile's. func GetMediaIdsFromLocalFiles(lfs []*LocalFile) []int { // Group local files by media id groupedLfs := GroupLocalFilesByMediaID(lfs) // Get slice of media ids from local files mIds := make([]int, len(groupedLfs)) for key := range groupedLfs { if !slices.Contains(mIds, key) { mIds = append(mIds, key) } } return mIds } // GetLocalFilesFromMediaId returns all local files with the given media id. func GetLocalFilesFromMediaId(lfs []*LocalFile, mId int) []*LocalFile { return lo.Filter(lfs, func(item *LocalFile, _ int) bool { return item.MediaId == mId }) } // GroupLocalFilesByMediaID returns a map of media id to local files. func GroupLocalFilesByMediaID(lfs []*LocalFile) (groupedLfs map[int][]*LocalFile) { groupedLfs = lop.GroupBy(lfs, func(item *LocalFile) int { return item.MediaId }) return } // IsLocalFileGroupValidEntry checks if there are any main episodes with valid episodes func IsLocalFileGroupValidEntry(lfs []*LocalFile) bool { // Check if there are any main episodes with valid parsed data flag := false for _, lf := range lfs { if lf.GetType() == LocalFileTypeMain && lf.IsParsedEpisodeValid() { flag = true break } } return flag } // FindLatestLocalFileFromGroup returns the "main" episode with the highest episode number. // Returns false if there are no episodes. func FindLatestLocalFileFromGroup(lfs []*LocalFile) (*LocalFile, bool) { // Check if there are any main episodes with valid parsed data if !IsLocalFileGroupValidEntry(lfs) { return nil, false } if lfs == nil || len(lfs) == 0 { return nil, false } // Get the episode with the highest progress number latest, found := lo.Find(lfs, func(lf *LocalFile) bool { return lf.GetType() == LocalFileTypeMain && lf.IsParsedEpisodeValid() }) if !found { return nil, false } for _, lf := range lfs { if lf.GetType() == LocalFileTypeMain && lf.GetEpisodeNumber() > latest.GetEpisodeNumber() { latest = lf } } if latest == nil || latest.GetType() != LocalFileTypeMain { return nil, false } return latest, true } func (f *LocalFile) GetParsedData() *LocalFileParsedData { return f.ParsedData } // GetParsedTitle returns the parsed title of the LocalFile. Falls back to the folder title if the file title is empty. func (f *LocalFile) GetParsedTitle() string { if len(f.ParsedData.Title) > 0 { return f.ParsedData.Title } if len(f.GetFolderTitle()) > 0 { return f.GetFolderTitle() } return "" } func (f *LocalFile) GetFolderTitle() string { folderTitles := make([]string, 0) if f.ParsedFolderData != nil && len(f.ParsedFolderData) > 0 { // Go through each folder data and keep the ones with a title data := lo.Filter(f.ParsedFolderData, func(fpd *LocalFileParsedData, _ int) bool { return len(fpd.Title) > 0 }) if len(data) == 0 { return "" } // Get the titles for _, v := range data { folderTitles = append(folderTitles, v.Title) } // If there are multiple titles, return the one closest to the end return folderTitles[len(folderTitles)-1] } return "" } // GetTitleVariations is used for matching. func (f *LocalFile) GetTitleVariations() []*string { folderSeason := 0 // Get the season from the folder data if f.ParsedFolderData != nil && len(f.ParsedFolderData) > 0 { v, found := lo.Find(f.ParsedFolderData, func(fpd *LocalFileParsedData) bool { return len(fpd.Season) > 0 }) if found { if res, ok := util.StringToInt(v.Season); ok { folderSeason = res } } } // Get the season from the filename season := 0 if len(f.ParsedData.Season) > 0 { if res, ok := util.StringToInt(f.ParsedData.Season); ok { season = res } } part := 0 // Get the part from the folder data if f.ParsedFolderData != nil && len(f.ParsedFolderData) > 0 { v, found := lo.Find(f.ParsedFolderData, func(fpd *LocalFileParsedData) bool { return len(fpd.Part) > 0 }) if found { if res, ok := util.StringToInt(v.Season); ok { part = res } } } // Devnote: This causes issues when an episode title contains "Part" //// Get the part from the filename //if len(f.ParsedData.Part) > 0 { // if res, ok := util.StringToInt(f.ParsedData.Part); ok { // part = res // } //} folderTitle := f.GetFolderTitle() if comparison.ValueContainsIgnoredKeywords(folderTitle) { folderTitle = "" } if len(f.ParsedData.Title) == 0 && len(folderTitle) == 0 { return make([]*string, 0) } titleVariations := make([]string, 0) bothTitles := len(f.ParsedData.Title) > 0 && len(folderTitle) > 0 // Both titles are present (filename and folder) noSeasonsOrParts := folderSeason == 0 && season == 0 && part == 0 // No seasons or parts are present bothTitlesSimilar := bothTitles && strings.Contains(folderTitle, f.ParsedData.Title) // The folder title contains the filename title eitherSeason := folderSeason > 0 || season > 0 // Either season is present eitherSeasonFirst := folderSeason == 1 || season == 1 // Either season is 1 // Part if part > 0 { if len(folderTitle) > 0 { titleVariations = append(titleVariations, buildTitle(folderTitle, "Part", strconv.Itoa(part)), buildTitle(folderTitle, "Part", util.IntegerToOrdinal(part)), buildTitle(folderTitle, "Cour", strconv.Itoa(part)), buildTitle(folderTitle, "Cour", util.IntegerToOrdinal(part)), ) } if len(f.ParsedData.Title) > 0 { titleVariations = append(titleVariations, buildTitle(f.ParsedData.Title, "Part", strconv.Itoa(part)), buildTitle(f.ParsedData.Title, "Part", util.IntegerToOrdinal(part)), buildTitle(f.ParsedData.Title, "Cour", strconv.Itoa(part)), buildTitle(f.ParsedData.Title, "Cour", util.IntegerToOrdinal(part)), ) } } // Title, no seasons, no parts, or season 1 // e.g. "Bungou Stray Dogs" // e.g. "Bungou Stray Dogs Season 1" if noSeasonsOrParts || eitherSeasonFirst { if len(f.ParsedData.Title) > 0 { // Add filename title titleVariations = append(titleVariations, f.ParsedData.Title) } if len(folderTitle) > 0 { // Both titles are present and similar, add folder title titleVariations = append(titleVariations, folderTitle) } } // Part & Season // e.g. "Spy x Family Season 1 Part 2" if part > 0 && eitherSeason { if len(folderTitle) > 0 { if season > 0 { titleVariations = append(titleVariations, buildTitle(folderTitle, "Season", strconv.Itoa(season), "Part", strconv.Itoa(part)), ) } else if folderSeason > 0 { titleVariations = append(titleVariations, buildTitle(folderTitle, "Season", strconv.Itoa(folderSeason), "Part", strconv.Itoa(part)), ) } } if len(f.ParsedData.Title) > 0 { if season > 0 { titleVariations = append(titleVariations, buildTitle(f.ParsedData.Title, "Season", strconv.Itoa(season), "Part", strconv.Itoa(part)), ) } else if folderSeason > 0 { titleVariations = append(titleVariations, buildTitle(f.ParsedData.Title, "Season", strconv.Itoa(folderSeason), "Part", strconv.Itoa(part)), ) } } } // Season is present if eitherSeason { arr := make([]string, 0) seas := folderSeason // Default to folder parsed season if season > 0 { // Use filename parsed season if present seas = season } // Both titles are present if bothTitles { // Add both titles arr = append(arr, f.ParsedData.Title) arr = append(arr, folderTitle) if !bothTitlesSimilar { // Combine both titles if they are not similar arr = append(arr, fmt.Sprintf("%s %s", folderTitle, f.ParsedData.Title)) } } else if len(folderTitle) > 0 { // Only folder title is present arr = append(arr, folderTitle) } else if len(f.ParsedData.Title) > 0 { // Only filename title is present arr = append(arr, f.ParsedData.Title) } for _, t := range arr { titleVariations = append(titleVariations, buildTitle(t, "Season", strconv.Itoa(seas)), buildTitle(t, "S"+strconv.Itoa(seas)), buildTitle(t, util.IntegerToOrdinal(seas), "Season"), ) } } titleVariations = lo.Uniq(titleVariations) // If there are no title variations, use the folder title or the parsed title if len(titleVariations) == 0 { if len(folderTitle) > 0 { titleVariations = append(titleVariations, folderTitle) } if len(f.ParsedData.Title) > 0 { titleVariations = append(titleVariations, f.ParsedData.Title) } } return lo.ToSlicePtr(titleVariations) }