362 lines
13 KiB
Go
362 lines
13 KiB
Go
package anime
|
|
|
|
import (
|
|
"seanime/internal/api/anilist"
|
|
"seanime/internal/api/metadata"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
type (
|
|
// Episode represents a single episode of a media entry.
|
|
Episode struct {
|
|
Type LocalFileType `json:"type"`
|
|
DisplayTitle string `json:"displayTitle"` // e.g, Show: "Episode 1", Movie: "Violet Evergarden The Movie"
|
|
EpisodeTitle string `json:"episodeTitle"` // e.g, "Shibuya Incident - Gate, Open"
|
|
EpisodeNumber int `json:"episodeNumber"`
|
|
AniDBEpisode string `json:"aniDBEpisode,omitempty"` // AniDB episode number
|
|
AbsoluteEpisodeNumber int `json:"absoluteEpisodeNumber"`
|
|
ProgressNumber int `json:"progressNumber"` // Usually the same as EpisodeNumber, unless there is a discrepancy between AniList and AniDB
|
|
LocalFile *LocalFile `json:"localFile"`
|
|
IsDownloaded bool `json:"isDownloaded"` // Is in the local files
|
|
EpisodeMetadata *EpisodeMetadata `json:"episodeMetadata"` // (image, airDate, length, summary, overview)
|
|
FileMetadata *LocalFileMetadata `json:"fileMetadata"` // (episode, aniDBEpisode, type...)
|
|
IsInvalid bool `json:"isInvalid"` // No AniDB data
|
|
MetadataIssue string `json:"metadataIssue,omitempty"` // Alerts the user that there is a discrepancy between AniList and AniDB
|
|
BaseAnime *anilist.BaseAnime `json:"baseAnime,omitempty"`
|
|
// IsNakamaEpisode indicates that this episode is from the Nakama host's anime library.
|
|
IsNakamaEpisode bool `json:"_isNakamaEpisode"`
|
|
}
|
|
|
|
// EpisodeMetadata represents the metadata of an Episode.
|
|
// Metadata is fetched from Animap (AniDB) and, optionally, AniList (if Animap is not available).
|
|
EpisodeMetadata struct {
|
|
AnidbId int `json:"anidbId,omitempty"`
|
|
Image string `json:"image,omitempty"`
|
|
AirDate string `json:"airDate,omitempty"`
|
|
Length int `json:"length,omitempty"`
|
|
Summary string `json:"summary,omitempty"`
|
|
Overview string `json:"overview,omitempty"`
|
|
IsFiller bool `json:"isFiller,omitempty"`
|
|
HasImage bool `json:"hasImage,omitempty"` // Indicates if the episode has a real image
|
|
}
|
|
)
|
|
|
|
type (
|
|
// NewEpisodeOptions hold data used to create a new Episode.
|
|
NewEpisodeOptions struct {
|
|
LocalFile *LocalFile
|
|
AnimeMetadata *metadata.AnimeMetadata // optional
|
|
Media *anilist.BaseAnime
|
|
OptionalAniDBEpisode string
|
|
// ProgressOffset will offset the ProgressNumber for a specific MAIN file
|
|
// This is used when there is a discrepancy between AniList and AniDB
|
|
// When this is -1, it means that a re-mapping of AniDB Episode is needed
|
|
ProgressOffset int
|
|
IsDownloaded bool
|
|
MetadataProvider metadata.Provider // optional
|
|
}
|
|
|
|
// NewSimpleEpisodeOptions hold data used to create a new Episode.
|
|
// Unlike NewEpisodeOptions, this struct does not require Animap data. It is used to list episodes without AniDB metadata.
|
|
NewSimpleEpisodeOptions struct {
|
|
LocalFile *LocalFile
|
|
Media *anilist.BaseAnime
|
|
IsDownloaded bool
|
|
}
|
|
)
|
|
|
|
// NewEpisode creates a new episode entity.
|
|
//
|
|
// It is used to list existing local files as episodes
|
|
// OR list non-downloaded episodes by passing the `OptionalAniDBEpisode` parameter.
|
|
//
|
|
// `AnimeMetadata` should be defined, but this is not always the case.
|
|
// `LocalFile` is optional.
|
|
func NewEpisode(opts *NewEpisodeOptions) *Episode {
|
|
entryEp := new(Episode)
|
|
entryEp.BaseAnime = opts.Media
|
|
entryEp.DisplayTitle = ""
|
|
entryEp.EpisodeTitle = ""
|
|
|
|
hydrated := false
|
|
|
|
// LocalFile exists
|
|
if opts.LocalFile != nil {
|
|
|
|
aniDBEp := opts.LocalFile.Metadata.AniDBEpisode
|
|
|
|
// ProgressOffset is -1, meaning the hydrator mistakenly set AniDB episode to "S1" (due to torrent name) because the episode number is 0
|
|
// The hydrator ASSUMES that AniDB will not include episode 0 as part of main episodes.
|
|
// We will remap "S1" to "1" and offset other AniDB episodes by 1
|
|
// e.g, ["S1", "1", "2", "3",...,"12"] -> ["1", "2", "3", "4",...,"13"]
|
|
if opts.ProgressOffset == -1 && opts.LocalFile.GetType() == LocalFileTypeMain {
|
|
if aniDBEp == "S1" {
|
|
aniDBEp = "1"
|
|
opts.ProgressOffset = 0
|
|
} else {
|
|
// e.g, "1" -> "2" etc...
|
|
aniDBEp = metadata.OffsetAnidbEpisode(aniDBEp, opts.ProgressOffset)
|
|
}
|
|
entryEp.MetadataIssue = "forced_remapping"
|
|
}
|
|
|
|
// Get the Animap episode
|
|
foundAnimapEpisode := false
|
|
var episodeMetadata *metadata.EpisodeMetadata
|
|
if opts.AnimeMetadata != nil {
|
|
episodeMetadata, foundAnimapEpisode = opts.AnimeMetadata.FindEpisode(aniDBEp)
|
|
}
|
|
|
|
entryEp.IsDownloaded = true
|
|
entryEp.FileMetadata = opts.LocalFile.GetMetadata()
|
|
entryEp.Type = opts.LocalFile.GetType()
|
|
entryEp.LocalFile = opts.LocalFile
|
|
|
|
// Set episode number and progress number
|
|
switch opts.LocalFile.Metadata.Type {
|
|
case LocalFileTypeMain:
|
|
entryEp.EpisodeNumber = opts.LocalFile.GetEpisodeNumber()
|
|
entryEp.ProgressNumber = opts.LocalFile.GetEpisodeNumber() + opts.ProgressOffset
|
|
if foundAnimapEpisode {
|
|
entryEp.AniDBEpisode = aniDBEp
|
|
entryEp.AbsoluteEpisodeNumber = entryEp.EpisodeNumber + opts.AnimeMetadata.GetOffset()
|
|
}
|
|
case LocalFileTypeSpecial:
|
|
entryEp.EpisodeNumber = opts.LocalFile.GetEpisodeNumber()
|
|
entryEp.ProgressNumber = 0
|
|
case LocalFileTypeNC:
|
|
entryEp.EpisodeNumber = 0
|
|
entryEp.ProgressNumber = 0
|
|
}
|
|
|
|
// Set titles
|
|
if len(entryEp.DisplayTitle) == 0 {
|
|
switch opts.LocalFile.Metadata.Type {
|
|
case LocalFileTypeMain:
|
|
if foundAnimapEpisode {
|
|
entryEp.AniDBEpisode = aniDBEp
|
|
if *opts.Media.GetFormat() == anilist.MediaFormatMovie {
|
|
entryEp.DisplayTitle = opts.Media.GetPreferredTitle()
|
|
entryEp.EpisodeTitle = "Complete Movie"
|
|
} else {
|
|
entryEp.DisplayTitle = "Episode " + strconv.Itoa(opts.LocalFile.GetEpisodeNumber())
|
|
entryEp.EpisodeTitle = episodeMetadata.GetTitle()
|
|
}
|
|
} else {
|
|
if *opts.Media.GetFormat() == anilist.MediaFormatMovie {
|
|
entryEp.DisplayTitle = opts.Media.GetPreferredTitle()
|
|
entryEp.EpisodeTitle = "Complete Movie"
|
|
} else {
|
|
entryEp.DisplayTitle = "Episode " + strconv.Itoa(opts.LocalFile.GetEpisodeNumber())
|
|
entryEp.EpisodeTitle = opts.LocalFile.GetParsedEpisodeTitle()
|
|
}
|
|
}
|
|
hydrated = true // Hydrated
|
|
case LocalFileTypeSpecial:
|
|
if foundAnimapEpisode {
|
|
entryEp.AniDBEpisode = aniDBEp
|
|
episodeInt, found := metadata.ExtractEpisodeInteger(aniDBEp)
|
|
if found {
|
|
entryEp.DisplayTitle = "Special " + strconv.Itoa(episodeInt)
|
|
} else {
|
|
entryEp.DisplayTitle = "Special " + aniDBEp
|
|
}
|
|
entryEp.EpisodeTitle = episodeMetadata.GetTitle()
|
|
} else {
|
|
entryEp.DisplayTitle = "Special " + strconv.Itoa(opts.LocalFile.GetEpisodeNumber())
|
|
}
|
|
hydrated = true // Hydrated
|
|
case LocalFileTypeNC:
|
|
if foundAnimapEpisode {
|
|
entryEp.AniDBEpisode = aniDBEp
|
|
entryEp.DisplayTitle = episodeMetadata.GetTitle()
|
|
entryEp.EpisodeTitle = ""
|
|
} else {
|
|
entryEp.DisplayTitle = opts.LocalFile.GetParsedTitle()
|
|
entryEp.EpisodeTitle = ""
|
|
}
|
|
hydrated = true // Hydrated
|
|
}
|
|
} else {
|
|
hydrated = true // Hydrated
|
|
}
|
|
|
|
// Set episode metadata
|
|
entryEp.EpisodeMetadata = NewEpisodeMetadata(opts.AnimeMetadata, episodeMetadata, opts.Media, opts.MetadataProvider)
|
|
|
|
} else if len(opts.OptionalAniDBEpisode) > 0 && opts.AnimeMetadata != nil {
|
|
// No LocalFile, but AniDB episode is provided
|
|
|
|
// Get the Animap episode
|
|
if episodeMetadata, foundAnimapEpisode := opts.AnimeMetadata.FindEpisode(opts.OptionalAniDBEpisode); foundAnimapEpisode {
|
|
|
|
entryEp.IsDownloaded = false
|
|
entryEp.Type = LocalFileTypeMain
|
|
if strings.HasPrefix(opts.OptionalAniDBEpisode, "S") {
|
|
entryEp.Type = LocalFileTypeSpecial
|
|
} else if strings.HasPrefix(opts.OptionalAniDBEpisode, "OP") || strings.HasPrefix(opts.OptionalAniDBEpisode, "ED") {
|
|
entryEp.Type = LocalFileTypeNC
|
|
}
|
|
entryEp.EpisodeNumber = 0
|
|
entryEp.ProgressNumber = 0
|
|
|
|
if episodeInt, ok := metadata.ExtractEpisodeInteger(opts.OptionalAniDBEpisode); ok {
|
|
entryEp.EpisodeNumber = episodeInt
|
|
entryEp.ProgressNumber = episodeInt + opts.ProgressOffset
|
|
entryEp.AniDBEpisode = opts.OptionalAniDBEpisode
|
|
entryEp.AbsoluteEpisodeNumber = entryEp.EpisodeNumber + opts.AnimeMetadata.GetOffset()
|
|
switch entryEp.Type {
|
|
case LocalFileTypeMain:
|
|
if *opts.Media.GetFormat() == anilist.MediaFormatMovie {
|
|
entryEp.DisplayTitle = opts.Media.GetPreferredTitle()
|
|
entryEp.EpisodeTitle = "Complete Movie"
|
|
} else {
|
|
entryEp.DisplayTitle = "Episode " + strconv.Itoa(episodeInt)
|
|
entryEp.EpisodeTitle = episodeMetadata.GetTitle()
|
|
}
|
|
case LocalFileTypeSpecial:
|
|
entryEp.DisplayTitle = "Special " + strconv.Itoa(episodeInt)
|
|
entryEp.EpisodeTitle = episodeMetadata.GetTitle()
|
|
case LocalFileTypeNC:
|
|
entryEp.DisplayTitle = opts.OptionalAniDBEpisode
|
|
entryEp.EpisodeTitle = ""
|
|
}
|
|
hydrated = true
|
|
}
|
|
|
|
// Set episode metadata
|
|
entryEp.EpisodeMetadata = NewEpisodeMetadata(opts.AnimeMetadata, episodeMetadata, opts.Media, opts.MetadataProvider)
|
|
} else {
|
|
// No Local file, no Animap data
|
|
// DEVNOTE: Non-downloaded, without any AniDB data. Don't handle this case.
|
|
// Non-downloaded episodes are determined from AniDB data either way.
|
|
}
|
|
|
|
}
|
|
|
|
// If for some reason the episode is not hydrated, set it as invalid
|
|
if !hydrated {
|
|
if opts.LocalFile != nil {
|
|
entryEp.DisplayTitle = opts.LocalFile.GetParsedTitle()
|
|
}
|
|
entryEp.EpisodeTitle = ""
|
|
entryEp.IsInvalid = true
|
|
return entryEp
|
|
}
|
|
|
|
return entryEp
|
|
}
|
|
|
|
// NewEpisodeMetadata creates a new EpisodeMetadata from an Animap episode and AniList media.
|
|
// If the Animap episode is nil, it will just set the image from the media.
|
|
func NewEpisodeMetadata(
|
|
animeMetadata *metadata.AnimeMetadata,
|
|
episode *metadata.EpisodeMetadata,
|
|
media *anilist.BaseAnime,
|
|
metadataProvider metadata.Provider,
|
|
) *EpisodeMetadata {
|
|
md := new(EpisodeMetadata)
|
|
|
|
// No Animap data
|
|
if episode == nil {
|
|
md.Image = media.GetCoverImageSafe()
|
|
return md
|
|
}
|
|
epInt, err := strconv.Atoi(episode.Episode)
|
|
|
|
if err == nil {
|
|
aw := metadataProvider.GetAnimeMetadataWrapper(media, animeMetadata)
|
|
epMetadata := aw.GetEpisodeMetadata(epInt)
|
|
md.AnidbId = epMetadata.AnidbId
|
|
md.Image = epMetadata.Image
|
|
md.AirDate = epMetadata.AirDate
|
|
md.Length = epMetadata.Length
|
|
md.Summary = epMetadata.Summary
|
|
md.Overview = epMetadata.Overview
|
|
md.HasImage = epMetadata.HasImage
|
|
md.IsFiller = false
|
|
} else {
|
|
md.Image = media.GetBannerImageSafe()
|
|
}
|
|
|
|
return md
|
|
}
|
|
|
|
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
|
|
|
// NewSimpleEpisode creates a Episode without AniDB metadata.
|
|
func NewSimpleEpisode(opts *NewSimpleEpisodeOptions) *Episode {
|
|
entryEp := new(Episode)
|
|
entryEp.BaseAnime = opts.Media
|
|
entryEp.DisplayTitle = ""
|
|
entryEp.EpisodeTitle = ""
|
|
entryEp.EpisodeMetadata = new(EpisodeMetadata)
|
|
|
|
hydrated := false
|
|
|
|
// LocalFile exists
|
|
if opts.LocalFile != nil {
|
|
|
|
entryEp.IsDownloaded = true
|
|
entryEp.FileMetadata = opts.LocalFile.GetMetadata()
|
|
entryEp.Type = opts.LocalFile.GetType()
|
|
entryEp.LocalFile = opts.LocalFile
|
|
|
|
// Set episode number and progress number
|
|
switch opts.LocalFile.Metadata.Type {
|
|
case LocalFileTypeMain:
|
|
entryEp.EpisodeNumber = opts.LocalFile.GetEpisodeNumber()
|
|
entryEp.ProgressNumber = opts.LocalFile.GetEpisodeNumber()
|
|
hydrated = true // Hydrated
|
|
case LocalFileTypeSpecial:
|
|
entryEp.EpisodeNumber = opts.LocalFile.GetEpisodeNumber()
|
|
entryEp.ProgressNumber = 0
|
|
hydrated = true // Hydrated
|
|
case LocalFileTypeNC:
|
|
entryEp.EpisodeNumber = 0
|
|
entryEp.ProgressNumber = 0
|
|
hydrated = true // Hydrated
|
|
}
|
|
|
|
// Set titles
|
|
if len(entryEp.DisplayTitle) == 0 {
|
|
switch opts.LocalFile.Metadata.Type {
|
|
case LocalFileTypeMain:
|
|
if *opts.Media.GetFormat() == anilist.MediaFormatMovie {
|
|
entryEp.DisplayTitle = opts.Media.GetPreferredTitle()
|
|
entryEp.EpisodeTitle = "Complete Movie"
|
|
} else {
|
|
entryEp.DisplayTitle = "Episode " + strconv.Itoa(opts.LocalFile.GetEpisodeNumber())
|
|
entryEp.EpisodeTitle = opts.LocalFile.GetParsedEpisodeTitle()
|
|
}
|
|
|
|
hydrated = true // Hydrated
|
|
case LocalFileTypeSpecial:
|
|
entryEp.DisplayTitle = "Special " + strconv.Itoa(opts.LocalFile.GetEpisodeNumber())
|
|
hydrated = true // Hydrated
|
|
case LocalFileTypeNC:
|
|
entryEp.DisplayTitle = opts.LocalFile.GetParsedTitle()
|
|
entryEp.EpisodeTitle = ""
|
|
hydrated = true // Hydrated
|
|
}
|
|
}
|
|
|
|
entryEp.EpisodeMetadata.Image = opts.Media.GetCoverImageSafe()
|
|
|
|
}
|
|
|
|
if !hydrated {
|
|
if opts.LocalFile != nil {
|
|
entryEp.DisplayTitle = opts.LocalFile.GetParsedTitle()
|
|
}
|
|
entryEp.EpisodeTitle = ""
|
|
entryEp.IsInvalid = true
|
|
entryEp.MetadataIssue = "no_anidb_data"
|
|
return entryEp
|
|
}
|
|
|
|
entryEp.MetadataIssue = "no_anidb_data"
|
|
return entryEp
|
|
}
|