node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -0,0 +1,144 @@
package metadata
import (
"regexp"
"seanime/internal/api/anilist"
"seanime/internal/hook"
"seanime/internal/util"
"seanime/internal/util/filecache"
"strconv"
"github.com/rs/zerolog"
"github.com/samber/mo"
)
type (
AnimeWrapperImpl struct {
metadata mo.Option[*AnimeMetadata]
baseAnime *anilist.BaseAnime
fileCacher *filecache.Cacher
logger *zerolog.Logger
}
)
func (aw *AnimeWrapperImpl) GetEpisodeMetadata(epNum int) (ret EpisodeMetadata) {
if aw == nil || aw.baseAnime == nil {
return
}
ret = EpisodeMetadata{
AnidbId: 0,
TvdbId: 0,
Title: "",
Image: "",
AirDate: "",
Length: 0,
Summary: "",
Overview: "",
EpisodeNumber: epNum,
Episode: strconv.Itoa(epNum),
SeasonNumber: 0,
AbsoluteEpisodeNumber: 0,
AnidbEid: 0,
}
defer util.HandlePanicInModuleThen("api/metadata/GetEpisodeMetadata", func() {})
reqEvent := &AnimeEpisodeMetadataRequestedEvent{}
reqEvent.MediaId = aw.baseAnime.GetID()
reqEvent.EpisodeNumber = epNum
reqEvent.EpisodeMetadata = &ret
_ = hook.GlobalHookManager.OnAnimeEpisodeMetadataRequested().Trigger(reqEvent)
epNum = reqEvent.EpisodeNumber
// Default prevented by hook, return the metadata
if reqEvent.DefaultPrevented {
if reqEvent.EpisodeMetadata == nil {
return ret
}
return *reqEvent.EpisodeMetadata
}
//
// Process
//
episode := mo.None[*EpisodeMetadata]()
if aw.metadata.IsAbsent() {
ret.Image = aw.baseAnime.GetBannerImageSafe()
} else {
episodeF, found := aw.metadata.MustGet().FindEpisode(strconv.Itoa(epNum))
if found {
episode = mo.Some(episodeF)
}
}
// If we don't have Animap metadata, just return the metadata containing the image
if episode.IsAbsent() {
return ret
}
ret = *episode.MustGet()
// If TVDB image is not set, use Animap image, if that is not set, use the AniList banner image
if ret.Image == "" {
// Set Animap image if TVDB image is not set
if episode.MustGet().Image != "" {
ret.Image = episode.MustGet().Image
} else {
// If Animap image is not set, use the base media image
ret.Image = aw.baseAnime.GetBannerImageSafe()
}
}
// Event
event := &AnimeEpisodeMetadataEvent{
EpisodeMetadata: &ret,
EpisodeNumber: epNum,
MediaId: aw.baseAnime.GetID(),
}
_ = hook.GlobalHookManager.OnAnimeEpisodeMetadata().Trigger(event)
if event.EpisodeMetadata == nil {
return ret
}
ret = *event.EpisodeMetadata
return ret
}
func ExtractEpisodeInteger(s string) (int, bool) {
pattern := "[0-9]+"
regex := regexp.MustCompile(pattern)
// Find the first match in the input string.
match := regex.FindString(s)
if match != "" {
// Convert the matched string to an integer.
num, err := strconv.Atoi(match)
if err != nil {
return 0, false
}
return num, true
}
return 0, false
}
func OffsetAnidbEpisode(s string, offset int) string {
pattern := "([0-9]+)"
regex := regexp.MustCompile(pattern)
// Replace the first matched integer with the incremented value.
result := regex.ReplaceAllStringFunc(s, func(matched string) string {
num, err := strconv.Atoi(matched)
if err == nil {
num = num + offset
return strconv.Itoa(num)
} else {
return matched
}
})
return result
}

View File

@@ -0,0 +1,26 @@
package metadata
import (
"testing"
)
func TestOffsetEpisode(t *testing.T) {
cases := []struct {
input string
expected string
}{
{"S1", "S2"},
{"OP1", "OP2"},
{"1", "2"},
{"OP", "OP"},
}
for _, c := range cases {
actual := OffsetAnidbEpisode(c.input, 1)
if actual != c.expected {
t.Errorf("OffsetAnidbEpisode(%s, 1) == %s, expected %s", c.input, actual, c.expected)
}
}
}

View File

@@ -0,0 +1,47 @@
package metadata
import "seanime/internal/hook_resolver"
// AnimeMetadataRequestedEvent is triggered when anime metadata is requested and right before the metadata is processed.
// This event is followed by [AnimeMetadataEvent] which is triggered when the metadata is available.
// Prevent default to skip the default behavior and return the modified metadata.
// If the modified metadata is nil, an error will be returned.
type AnimeMetadataRequestedEvent struct {
hook_resolver.Event
MediaId int `json:"mediaId"`
// Empty metadata object, will be used if the hook prevents the default behavior
AnimeMetadata *AnimeMetadata `json:"animeMetadata"`
}
// AnimeMetadataEvent is triggered when anime metadata is available and is about to be returned.
// Anime metadata can be requested in many places, ranging from displaying the anime entry to starting a torrent stream.
// This event is triggered after [AnimeMetadataRequestedEvent].
// If the modified metadata is nil, an error will be returned.
type AnimeMetadataEvent struct {
hook_resolver.Event
MediaId int `json:"mediaId"`
AnimeMetadata *AnimeMetadata `json:"animeMetadata"`
}
// AnimeEpisodeMetadataRequestedEvent is triggered when anime episode metadata is requested.
// Prevent default to skip the default behavior and return the overridden metadata.
// This event is triggered before [AnimeEpisodeMetadataEvent].
// If the modified episode metadata is nil, an empty EpisodeMetadata object will be returned.
type AnimeEpisodeMetadataRequestedEvent struct {
hook_resolver.Event
// Empty metadata object, will be used if the hook prevents the default behavior
EpisodeMetadata *EpisodeMetadata `json:"animeEpisodeMetadata"`
EpisodeNumber int `json:"episodeNumber"`
MediaId int `json:"mediaId"`
}
// AnimeEpisodeMetadataEvent is triggered when anime episode metadata is available and is about to be returned.
// In the current implementation, episode metadata is requested for display purposes. It is used to get a more complete metadata object since the original AnimeMetadata object is not complete.
// This event is triggered after [AnimeEpisodeMetadataRequestedEvent].
// If the modified episode metadata is nil, an empty EpisodeMetadata object will be returned.
type AnimeEpisodeMetadataEvent struct {
hook_resolver.Event
EpisodeMetadata *EpisodeMetadata `json:"animeEpisodeMetadata"`
EpisodeNumber int `json:"episodeNumber"`
MediaId int `json:"mediaId"`
}

View File

@@ -0,0 +1,18 @@
package metadata
import (
"seanime/internal/util"
"seanime/internal/util/filecache"
"testing"
"github.com/stretchr/testify/require"
)
func GetMockProvider(t *testing.T) Provider {
filecacher, err := filecache.NewCacher(t.TempDir())
require.NoError(t, err)
return NewProvider(&NewProviderImplOptions{
Logger: util.NewLogger(),
FileCacher: filecacher,
})
}

View File

@@ -0,0 +1,212 @@
package metadata
import (
"errors"
"fmt"
"seanime/internal/api/anilist"
"seanime/internal/api/animap"
"seanime/internal/hook"
"seanime/internal/util/filecache"
"seanime/internal/util/result"
"strings"
"time"
"github.com/rs/zerolog"
"github.com/samber/mo"
"golang.org/x/sync/singleflight"
)
type (
ProviderImpl struct {
logger *zerolog.Logger
fileCacher *filecache.Cacher
animeMetadataCache *result.BoundedCache[string, *AnimeMetadata]
singleflight *singleflight.Group
}
NewProviderImplOptions struct {
Logger *zerolog.Logger
FileCacher *filecache.Cacher
}
)
func GetAnimeMetadataCacheKey(platform Platform, mId int) string {
return fmt.Sprintf("%s$%d", platform, mId)
}
// NewProvider creates a new metadata provider.
func NewProvider(options *NewProviderImplOptions) Provider {
return &ProviderImpl{
logger: options.Logger,
fileCacher: options.FileCacher,
animeMetadataCache: result.NewBoundedCache[string, *AnimeMetadata](100),
singleflight: &singleflight.Group{},
}
}
// GetCache returns the anime metadata cache.
func (p *ProviderImpl) GetCache() *result.BoundedCache[string, *AnimeMetadata] {
return p.animeMetadataCache
}
// GetAnimeMetadata fetches anime metadata from api.ani.zip.
func (p *ProviderImpl) GetAnimeMetadata(platform Platform, mId int) (ret *AnimeMetadata, err error) {
cacheKey := GetAnimeMetadataCacheKey(platform, mId)
if cached, ok := p.animeMetadataCache.Get(cacheKey); ok {
return cached, nil
}
res, err, _ := p.singleflight.Do(cacheKey, func() (interface{}, error) {
return p.fetchAnimeMetadata(platform, mId)
})
if err != nil {
return nil, err
}
return res.(*AnimeMetadata), nil
}
func (p *ProviderImpl) fetchAnimeMetadata(platform Platform, mId int) (*AnimeMetadata, error) {
ret := &AnimeMetadata{
Titles: make(map[string]string),
Episodes: make(map[string]*EpisodeMetadata),
EpisodeCount: 0,
SpecialCount: 0,
Mappings: &AnimeMappings{},
}
// Invoke AnimeMetadataRequested hook
reqEvent := &AnimeMetadataRequestedEvent{
MediaId: mId,
AnimeMetadata: ret,
}
err := hook.GlobalHookManager.OnAnimeMetadataRequested().Trigger(reqEvent)
if err != nil {
return nil, err
}
mId = reqEvent.MediaId
// Default prevented by hook, return the metadata
if reqEvent.DefaultPrevented {
// Override the metadata
ret = reqEvent.AnimeMetadata
// Trigger the event
event := &AnimeMetadataEvent{
MediaId: mId,
AnimeMetadata: ret,
}
err = hook.GlobalHookManager.OnAnimeMetadata().Trigger(event)
if err != nil {
return nil, err
}
ret = event.AnimeMetadata
mId = event.MediaId
if ret == nil {
return nil, errors.New("no metadata was returned")
}
p.animeMetadataCache.SetT(GetAnimeMetadataCacheKey(platform, mId), ret, 1*time.Hour)
return ret, nil
}
m, err := animap.FetchAnimapMedia(string(platform), mId)
if err != nil || m == nil {
//return p.AnizipFallback(platform, mId)
return nil, err
}
ret.Titles = m.Titles
ret.EpisodeCount = 0
ret.SpecialCount = 0
ret.Mappings.AnimeplanetId = m.Mappings.AnimePlanetID
ret.Mappings.KitsuId = m.Mappings.KitsuID
ret.Mappings.MalId = m.Mappings.MalID
ret.Mappings.Type = m.Mappings.Type
ret.Mappings.AnilistId = m.Mappings.AnilistID
ret.Mappings.AnisearchId = m.Mappings.AnisearchID
ret.Mappings.AnidbId = m.Mappings.AnidbID
ret.Mappings.NotifymoeId = m.Mappings.NotifyMoeID
ret.Mappings.LivechartId = m.Mappings.LivechartID
ret.Mappings.ThetvdbId = m.Mappings.TheTvdbID
ret.Mappings.ImdbId = ""
ret.Mappings.ThemoviedbId = m.Mappings.TheMovieDbID
for key, ep := range m.Episodes {
firstChar := key[0]
if firstChar == 'S' {
ret.SpecialCount++
} else {
if firstChar >= '0' && firstChar <= '9' {
ret.EpisodeCount++
}
}
em := &EpisodeMetadata{
AnidbId: ep.AnidbId,
TvdbId: ep.TvdbId,
Title: ep.AnidbTitle,
Image: ep.Image,
AirDate: ep.AirDate,
Length: ep.Runtime,
Summary: strings.ReplaceAll(ep.Overview, "`", "'"),
Overview: strings.ReplaceAll(ep.Overview, "`", "'"),
EpisodeNumber: ep.Number,
Episode: key,
SeasonNumber: ep.SeasonNumber,
AbsoluteEpisodeNumber: ep.AbsoluteNumber,
AnidbEid: ep.AnidbId,
HasImage: ep.Image != "",
}
if em.Length == 0 && ep.Runtime > 0 {
em.Length = ep.Runtime
}
if em.Summary == "" && ep.Overview != "" {
em.Summary = ep.Overview
}
if em.Overview == "" && ep.Overview != "" {
em.Overview = ep.Overview
}
if ep.TvdbTitle != "" && ep.AnidbTitle == "Episode "+ep.AnidbEpisode {
em.Title = ep.TvdbTitle
}
ret.Episodes[key] = em
}
// Event
event := &AnimeMetadataEvent{
MediaId: mId,
AnimeMetadata: ret,
}
err = hook.GlobalHookManager.OnAnimeMetadata().Trigger(event)
if err != nil {
return nil, err
}
ret = event.AnimeMetadata
mId = event.MediaId
p.animeMetadataCache.SetT(GetAnimeMetadataCacheKey(platform, mId), ret, 1*time.Hour)
return ret, nil
}
// GetAnimeMetadataWrapper creates a new anime wrapper.
//
// Example:
//
// metadataProvider.GetAnimeMetadataWrapper(media, metadata)
// metadataProvider.GetAnimeMetadataWrapper(media, nil)
func (p *ProviderImpl) GetAnimeMetadataWrapper(media *anilist.BaseAnime, metadata *AnimeMetadata) AnimeMetadataWrapper {
aw := &AnimeWrapperImpl{
metadata: mo.None[*AnimeMetadata](),
baseAnime: media,
fileCacher: p.fileCacher,
logger: p.logger,
}
if metadata != nil {
aw.metadata = mo.Some(metadata)
}
return aw
}

View File

@@ -0,0 +1,165 @@
package metadata
import (
"seanime/internal/api/anilist"
"seanime/internal/util/result"
"strings"
"time"
)
const (
AnilistPlatform Platform = "anilist"
MalPlatform Platform = "mal"
)
type (
Platform string
Provider interface {
// GetAnimeMetadata fetches anime metadata for the given platform from a source.
// In this case, the source is api.ani.zip.
GetAnimeMetadata(platform Platform, mId int) (*AnimeMetadata, error)
GetCache() *result.BoundedCache[string, *AnimeMetadata]
// GetAnimeMetadataWrapper creates a wrapper for anime metadata.
GetAnimeMetadataWrapper(anime *anilist.BaseAnime, metadata *AnimeMetadata) AnimeMetadataWrapper
}
// AnimeMetadataWrapper is a container for anime metadata.
// This wrapper is used to get a more complete metadata object by getting data from multiple sources in the Provider.
// The user can request metadata to be fetched from TVDB as well, which will be stored in the cache.
AnimeMetadataWrapper interface {
// GetEpisodeMetadata combines metadata from multiple sources to create a single EpisodeMetadata object.
GetEpisodeMetadata(episodeNumber int) EpisodeMetadata
}
)
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type (
AnimeMetadata struct {
Titles map[string]string `json:"titles"`
Episodes map[string]*EpisodeMetadata `json:"episodes"`
EpisodeCount int `json:"episodeCount"`
SpecialCount int `json:"specialCount"`
Mappings *AnimeMappings `json:"mappings"`
currentEpisodeCount int `json:"-"`
}
AnimeMappings struct {
AnimeplanetId string `json:"animeplanetId"`
KitsuId int `json:"kitsuId"`
MalId int `json:"malId"`
Type string `json:"type"`
AnilistId int `json:"anilistId"`
AnisearchId int `json:"anisearchId"`
AnidbId int `json:"anidbId"`
NotifymoeId string `json:"notifymoeId"`
LivechartId int `json:"livechartId"`
ThetvdbId int `json:"thetvdbId"`
ImdbId string `json:"imdbId"`
ThemoviedbId string `json:"themoviedbId"`
}
EpisodeMetadata struct {
AnidbId int `json:"anidbId"`
TvdbId int `json:"tvdbId"`
Title string `json:"title"`
Image string `json:"image"`
AirDate string `json:"airDate"`
Length int `json:"length"`
Summary string `json:"summary"`
Overview string `json:"overview"`
EpisodeNumber int `json:"episodeNumber"`
Episode string `json:"episode"`
SeasonNumber int `json:"seasonNumber"`
AbsoluteEpisodeNumber int `json:"absoluteEpisodeNumber"`
AnidbEid int `json:"anidbEid"`
HasImage bool `json:"hasImage"` // Indicates if the episode has a real image
}
)
func (m *AnimeMetadata) GetTitle() string {
if m == nil {
return ""
}
if len(m.Titles["en"]) > 0 {
return m.Titles["en"]
}
return m.Titles["ro"]
}
func (m *AnimeMetadata) GetMappings() *AnimeMappings {
if m == nil {
return &AnimeMappings{}
}
return m.Mappings
}
func (m *AnimeMetadata) FindEpisode(ep string) (*EpisodeMetadata, bool) {
if m.Episodes == nil {
return nil, false
}
episode, found := m.Episodes[ep]
if !found {
return nil, false
}
return episode, true
}
func (m *AnimeMetadata) GetMainEpisodeCount() int {
if m == nil {
return 0
}
return m.EpisodeCount
}
func (m *AnimeMetadata) GetCurrentEpisodeCount() int {
if m == nil {
return 0
}
if m.currentEpisodeCount > 0 {
return m.currentEpisodeCount
}
count := 0
for _, ep := range m.Episodes {
firstChar := ep.Episode[0]
if firstChar >= '0' && firstChar <= '9' {
// Check if aired
if ep.AirDate != "" {
date, err := time.Parse("2006-01-02", ep.AirDate)
if err == nil {
if date.Before(time.Now()) || date.Equal(time.Now()) {
count++
}
}
}
}
}
m.currentEpisodeCount = count
return count
}
// GetOffset returns the offset of the first episode relative to the absolute episode number.
// e.g, if the first episode's absolute number is 13, then the offset is 12.
func (m *AnimeMetadata) GetOffset() int {
if m == nil {
return 0
}
firstEp, found := m.FindEpisode("1")
if !found {
return 0
}
if firstEp.AbsoluteEpisodeNumber == 0 {
return 0
}
return firstEp.AbsoluteEpisodeNumber - 1
}
func (e *EpisodeMetadata) GetTitle() string {
if e == nil {
return ""
}
return strings.ReplaceAll(e.Title, "`", "'")
}