node build fixed
This commit is contained in:
127
seanime-2.9.10/internal/plugin/anilist.go
Normal file
127
seanime-2.9.10/internal/plugin/anilist.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/extension"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Anilist struct {
|
||||
ctx *AppContextImpl
|
||||
ext *extension.Extension
|
||||
logger *zerolog.Logger
|
||||
}
|
||||
|
||||
// BindAnilist binds the anilist API to the Goja runtime.
|
||||
// Permissions need to be checked by the caller.
|
||||
// Permissions needed: anilist
|
||||
func (a *AppContextImpl) BindAnilist(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension) {
|
||||
anilistLogger := logger.With().Str("id", ext.ID).Logger()
|
||||
al := &Anilist{
|
||||
ctx: a,
|
||||
ext: ext,
|
||||
logger: &anilistLogger,
|
||||
}
|
||||
anilistObj := vm.NewObject()
|
||||
_ = anilistObj.Set("refreshAnimeCollection", al.RefreshAnimeCollection)
|
||||
_ = anilistObj.Set("refreshMangaCollection", al.RefreshMangaCollection)
|
||||
|
||||
// Bind anilist platform
|
||||
anilistPlatform, ok := a.anilistPlatform.Get()
|
||||
if ok {
|
||||
_ = anilistObj.Set("updateEntry", func(mediaID int, status *anilist.MediaListStatus, scoreRaw *int, progress *int, startedAt *anilist.FuzzyDateInput, completedAt *anilist.FuzzyDateInput) error {
|
||||
return anilistPlatform.UpdateEntry(context.Background(), mediaID, status, scoreRaw, progress, startedAt, completedAt)
|
||||
})
|
||||
_ = anilistObj.Set("updateEntryProgress", func(mediaID int, progress int, totalEpisodes *int) error {
|
||||
return anilistPlatform.UpdateEntryProgress(context.Background(), mediaID, progress, totalEpisodes)
|
||||
})
|
||||
_ = anilistObj.Set("updateEntryRepeat", func(mediaID int, repeat int) error {
|
||||
return anilistPlatform.UpdateEntryRepeat(context.Background(), mediaID, repeat)
|
||||
})
|
||||
_ = anilistObj.Set("deleteEntry", func(mediaID int) error {
|
||||
return anilistPlatform.DeleteEntry(context.Background(), mediaID)
|
||||
})
|
||||
_ = anilistObj.Set("getAnimeCollection", func(bypassCache bool) (*anilist.AnimeCollection, error) {
|
||||
return anilistPlatform.GetAnimeCollection(context.Background(), bypassCache)
|
||||
})
|
||||
_ = anilistObj.Set("getRawAnimeCollection", func(bypassCache bool) (*anilist.AnimeCollection, error) {
|
||||
return anilistPlatform.GetRawAnimeCollection(context.Background(), bypassCache)
|
||||
})
|
||||
_ = anilistObj.Set("getMangaCollection", func(bypassCache bool) (*anilist.MangaCollection, error) {
|
||||
return anilistPlatform.GetMangaCollection(context.Background(), bypassCache)
|
||||
})
|
||||
_ = anilistObj.Set("getRawMangaCollection", func(bypassCache bool) (*anilist.MangaCollection, error) {
|
||||
return anilistPlatform.GetRawMangaCollection(context.Background(), bypassCache)
|
||||
})
|
||||
_ = anilistObj.Set("getAnime", func(mediaID int) (*anilist.BaseAnime, error) {
|
||||
return anilistPlatform.GetAnime(context.Background(), mediaID)
|
||||
})
|
||||
_ = anilistObj.Set("getManga", func(mediaID int) (*anilist.BaseManga, error) {
|
||||
return anilistPlatform.GetManga(context.Background(), mediaID)
|
||||
})
|
||||
_ = anilistObj.Set("getAnimeDetails", func(mediaID int) (*anilist.AnimeDetailsById_Media, error) {
|
||||
return anilistPlatform.GetAnimeDetails(context.Background(), mediaID)
|
||||
})
|
||||
_ = anilistObj.Set("getMangaDetails", func(mediaID int) (*anilist.MangaDetailsById_Media, error) {
|
||||
return anilistPlatform.GetMangaDetails(context.Background(), mediaID)
|
||||
})
|
||||
_ = anilistObj.Set("getAnimeCollectionWithRelations", func() (*anilist.AnimeCollectionWithRelations, error) {
|
||||
return anilistPlatform.GetAnimeCollectionWithRelations(context.Background())
|
||||
})
|
||||
_ = anilistObj.Set("addMediaToCollection", func(mIds []int) error {
|
||||
return anilistPlatform.AddMediaToCollection(context.Background(), mIds)
|
||||
})
|
||||
_ = anilistObj.Set("getStudioDetails", func(studioID int) (*anilist.StudioDetails, error) {
|
||||
return anilistPlatform.GetStudioDetails(context.Background(), studioID)
|
||||
})
|
||||
|
||||
anilistClient := anilistPlatform.GetAnilistClient()
|
||||
_ = anilistObj.Set("listAnime", func(page *int, search *string, perPage *int, sort []*anilist.MediaSort, status []*anilist.MediaStatus, genres []*string, averageScoreGreater *int, season *anilist.MediaSeason, seasonYear *int, format *anilist.MediaFormat, isAdult *bool) (*anilist.ListAnime, error) {
|
||||
return anilistClient.ListAnime(context.Background(), page, search, perPage, sort, status, genres, averageScoreGreater, season, seasonYear, format, isAdult)
|
||||
})
|
||||
_ = anilistObj.Set("listManga", func(page *int, search *string, perPage *int, sort []*anilist.MediaSort, status []*anilist.MediaStatus, genres []*string, averageScoreGreater *int, startDateGreater *string, startDateLesser *string, format *anilist.MediaFormat, countryOfOrigin *string, isAdult *bool) (*anilist.ListManga, error) {
|
||||
return anilistClient.ListManga(context.Background(), page, search, perPage, sort, status, genres, averageScoreGreater, startDateGreater, startDateLesser, format, countryOfOrigin, isAdult)
|
||||
})
|
||||
_ = anilistObj.Set("listRecentAnime", func(page *int, perPage *int, airingAtGreater *int, airingAtLesser *int, notYetAired *bool) (*anilist.ListRecentAnime, error) {
|
||||
return anilistClient.ListRecentAnime(context.Background(), page, perPage, airingAtGreater, airingAtLesser, notYetAired)
|
||||
})
|
||||
_ = anilistObj.Set("customQuery", func(body map[string]interface{}, token string) (interface{}, error) {
|
||||
return anilist.CustomQuery(body, a.logger, token)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
_ = vm.Set("$anilist", anilistObj)
|
||||
}
|
||||
|
||||
func (a *Anilist) RefreshAnimeCollection() {
|
||||
a.logger.Trace().Msg("plugin: Refreshing anime collection")
|
||||
onRefreshAnilistAnimeCollection, ok := a.ctx.onRefreshAnilistAnimeCollection.Get()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
onRefreshAnilistAnimeCollection()
|
||||
wsEventManager, ok := a.ctx.wsEventManager.Get()
|
||||
if ok {
|
||||
wsEventManager.SendEvent(events.RefreshedAnilistAnimeCollection, nil)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Anilist) RefreshMangaCollection() {
|
||||
a.logger.Trace().Msg("plugin: Refreshing manga collection")
|
||||
onRefreshAnilistMangaCollection, ok := a.ctx.onRefreshAnilistMangaCollection.Get()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
onRefreshAnilistMangaCollection()
|
||||
wsEventManager, ok := a.ctx.wsEventManager.Get()
|
||||
if ok {
|
||||
wsEventManager.SendEvent(events.RefreshedAnilistMangaCollection, nil)
|
||||
}
|
||||
}
|
||||
127
seanime-2.9.10/internal/plugin/anime.go
Normal file
127
seanime-2.9.10/internal/plugin/anime.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"seanime/internal/database/db_bridge"
|
||||
"seanime/internal/extension"
|
||||
"seanime/internal/goja/goja_bindings"
|
||||
"seanime/internal/hook"
|
||||
"seanime/internal/library/anime"
|
||||
goja_util "seanime/internal/util/goja"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Anime struct {
|
||||
ctx *AppContextImpl
|
||||
vm *goja.Runtime
|
||||
logger *zerolog.Logger
|
||||
ext *extension.Extension
|
||||
scheduler *goja_util.Scheduler
|
||||
}
|
||||
|
||||
func (a *AppContextImpl) BindAnimeToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
|
||||
m := &Anime{
|
||||
ctx: a,
|
||||
vm: vm,
|
||||
logger: logger,
|
||||
ext: ext,
|
||||
scheduler: scheduler,
|
||||
}
|
||||
|
||||
animeObj := vm.NewObject()
|
||||
|
||||
// Get downloaded chapter containers
|
||||
_ = animeObj.Set("getAnimeEntry", m.getAnimeEntry)
|
||||
_ = obj.Set("anime", animeObj)
|
||||
}
|
||||
|
||||
func (m *Anime) getAnimeEntry(call goja.FunctionCall) goja.Value {
|
||||
promise, resolve, reject := m.vm.NewPromise()
|
||||
|
||||
mediaId := call.Argument(0).ToInteger()
|
||||
|
||||
if mediaId == 0 {
|
||||
_ = reject(goja_bindings.NewErrorString(m.vm, "anilist platform not found"))
|
||||
return m.vm.ToValue(promise)
|
||||
}
|
||||
|
||||
database, ok := m.ctx.database.Get()
|
||||
if !ok {
|
||||
_ = reject(goja_bindings.NewErrorString(m.vm, "database not found"))
|
||||
return m.vm.ToValue(promise)
|
||||
}
|
||||
|
||||
anilistPlatform, ok := m.ctx.anilistPlatform.Get()
|
||||
if !ok {
|
||||
_ = reject(goja_bindings.NewErrorString(m.vm, "anilist platform not found"))
|
||||
return m.vm.ToValue(promise)
|
||||
}
|
||||
|
||||
metadataProvider, ok := m.ctx.metadataProvider.Get()
|
||||
if !ok {
|
||||
_ = reject(goja_bindings.NewErrorString(m.vm, "metadata provider not found"))
|
||||
return m.vm.ToValue(promise)
|
||||
}
|
||||
|
||||
fillerManager, ok := m.ctx.fillerManager.Get()
|
||||
if !ok {
|
||||
_ = reject(goja_bindings.NewErrorString(m.vm, "filler manager not found"))
|
||||
return m.vm.ToValue(promise)
|
||||
}
|
||||
|
||||
go func() {
|
||||
// Get all the local files
|
||||
lfs, _, err := db_bridge.GetLocalFiles(database)
|
||||
if err != nil {
|
||||
_ = reject(m.vm.ToValue(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
// Get the user's anilist collection
|
||||
animeCollection, err := anilistPlatform.GetAnimeCollection(context.Background(), false)
|
||||
if err != nil {
|
||||
_ = reject(m.vm.ToValue(err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
if animeCollection == nil {
|
||||
_ = reject(goja_bindings.NewErrorString(m.vm, "anilist collection not found"))
|
||||
return
|
||||
}
|
||||
|
||||
// Create a new media entry
|
||||
entry, err := anime.NewEntry(context.Background(), &anime.NewEntryOptions{
|
||||
MediaId: int(mediaId),
|
||||
LocalFiles: lfs,
|
||||
AnimeCollection: animeCollection,
|
||||
Platform: anilistPlatform,
|
||||
MetadataProvider: metadataProvider,
|
||||
})
|
||||
if err != nil {
|
||||
_ = reject(goja_bindings.NewError(m.vm, err))
|
||||
return
|
||||
}
|
||||
|
||||
fillerEvent := new(anime.AnimeEntryFillerHydrationEvent)
|
||||
fillerEvent.Entry = entry
|
||||
err = hook.GlobalHookManager.OnAnimeEntryFillerHydration().Trigger(fillerEvent)
|
||||
if err != nil {
|
||||
_ = reject(goja_bindings.NewError(m.vm, err))
|
||||
return
|
||||
}
|
||||
entry = fillerEvent.Entry
|
||||
|
||||
if !fillerEvent.DefaultPrevented {
|
||||
fillerManager.HydrateFillerData(fillerEvent.Entry)
|
||||
}
|
||||
|
||||
m.scheduler.ScheduleAsync(func() error {
|
||||
_ = resolve(m.vm.ToValue(entry))
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
return m.vm.ToValue(promise)
|
||||
}
|
||||
316
seanime-2.9.10/internal/plugin/app_context.go
Normal file
316
seanime-2.9.10/internal/plugin/app_context.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"seanime/internal/api/metadata"
|
||||
"seanime/internal/continuity"
|
||||
"seanime/internal/database/db"
|
||||
"seanime/internal/database/models"
|
||||
discordrpc_presence "seanime/internal/discordrpc/presence"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/extension"
|
||||
"seanime/internal/library/autodownloader"
|
||||
"seanime/internal/library/autoscanner"
|
||||
"seanime/internal/library/fillermanager"
|
||||
"seanime/internal/library/playbackmanager"
|
||||
"seanime/internal/manga"
|
||||
"seanime/internal/mediaplayers/mediaplayer"
|
||||
"seanime/internal/mediastream"
|
||||
"seanime/internal/onlinestream"
|
||||
"seanime/internal/platforms/platform"
|
||||
"seanime/internal/torrent_clients/torrent_client"
|
||||
"seanime/internal/torrentstream"
|
||||
"seanime/internal/util/filecache"
|
||||
goja_util "seanime/internal/util/goja"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/rs/zerolog"
|
||||
"github.com/samber/mo"
|
||||
)
|
||||
|
||||
type AppContextModules struct {
|
||||
IsOffline *bool
|
||||
Database *db.Database
|
||||
AnimeLibraryPaths *[]string
|
||||
AnilistPlatform platform.Platform
|
||||
PlaybackManager *playbackmanager.PlaybackManager
|
||||
MediaPlayerRepository *mediaplayer.Repository
|
||||
MangaRepository *manga.Repository
|
||||
MetadataProvider metadata.Provider
|
||||
WSEventManager events.WSEventManagerInterface
|
||||
DiscordPresence *discordrpc_presence.Presence
|
||||
TorrentClientRepository *torrent_client.Repository
|
||||
ContinuityManager *continuity.Manager
|
||||
AutoScanner *autoscanner.AutoScanner
|
||||
AutoDownloader *autodownloader.AutoDownloader
|
||||
FileCacher *filecache.Cacher
|
||||
OnlinestreamRepository *onlinestream.Repository
|
||||
MediastreamRepository *mediastream.Repository
|
||||
TorrentstreamRepository *torrentstream.Repository
|
||||
FillerManager *fillermanager.FillerManager
|
||||
OnRefreshAnilistAnimeCollection func()
|
||||
OnRefreshAnilistMangaCollection func()
|
||||
}
|
||||
|
||||
// AppContext allows plugins to interact with core modules.
|
||||
// It binds JS APIs to the Goja runtimes for that purpose.
|
||||
type AppContext interface {
|
||||
// SetModulesPartial sets modules if they are not nil
|
||||
SetModulesPartial(AppContextModules)
|
||||
// SetLogger sets the logger for the context
|
||||
SetLogger(logger *zerolog.Logger)
|
||||
|
||||
Database() mo.Option[*db.Database]
|
||||
PlaybackManager() mo.Option[*playbackmanager.PlaybackManager]
|
||||
MediaPlayerRepository() mo.Option[*mediaplayer.Repository]
|
||||
AnilistPlatform() mo.Option[platform.Platform]
|
||||
WSEventManager() mo.Option[events.WSEventManagerInterface]
|
||||
|
||||
IsOffline() bool
|
||||
|
||||
BindApp(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension)
|
||||
// BindStorage binds $storage to the Goja runtime
|
||||
BindStorage(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) *Storage
|
||||
// BindAnilist binds $anilist to the Goja runtime
|
||||
BindAnilist(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension)
|
||||
// BindDatabase binds $database to the Goja runtime
|
||||
BindDatabase(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension)
|
||||
|
||||
// BindSystem binds $system to the Goja runtime
|
||||
BindSystem(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
|
||||
|
||||
// BindPlaybackToContextObj binds 'playback' to the UI context object
|
||||
BindPlaybackToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
|
||||
|
||||
// BindCronToContextObj binds 'cron' to the UI context object
|
||||
BindCronToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) *Cron
|
||||
|
||||
// BindDownloaderToContextObj binds 'downloader' to the UI context object
|
||||
BindDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
|
||||
|
||||
// BindMangaToContextObj binds 'manga' to the UI context object
|
||||
BindMangaToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
|
||||
|
||||
// BindAnimeToContextObj binds 'anime' to the UI context object
|
||||
BindAnimeToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
|
||||
|
||||
// BindDiscordToContextObj binds 'discord' to the UI context object
|
||||
BindDiscordToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
|
||||
|
||||
// BindContinuityToContextObj binds 'continuity' to the UI context object
|
||||
BindContinuityToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
|
||||
|
||||
// BindTorrentClientToContextObj binds 'torrentClient' to the UI context object
|
||||
BindTorrentClientToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
|
||||
|
||||
// BindTorrentstreamToContextObj binds 'torrentstream' to the UI context object
|
||||
BindTorrentstreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
|
||||
|
||||
// BindMediastreamToContextObj binds 'mediastream' to the UI context object
|
||||
BindMediastreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
|
||||
|
||||
// BindOnlinestreamToContextObj binds 'onlinestream' to the UI context object
|
||||
BindOnlinestreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
|
||||
|
||||
// BindFillerManagerToContextObj binds 'fillerManager' to the UI context object
|
||||
BindFillerManagerToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
|
||||
|
||||
// BindAutoDownloaderToContextObj binds 'autoDownloader' to the UI context object
|
||||
BindAutoDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
|
||||
|
||||
// BindAutoScannerToContextObj binds 'autoScanner' to the UI context object
|
||||
BindAutoScannerToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
|
||||
|
||||
// BindFileCacherToContextObj binds 'fileCacher' to the UI context object
|
||||
BindFileCacherToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
|
||||
|
||||
// BindExternalPlayerLinkToContextObj binds 'externalPlayerLink' to the UI context object
|
||||
BindExternalPlayerLinkToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler)
|
||||
|
||||
DropPluginData(extId string)
|
||||
}
|
||||
|
||||
var GlobalAppContext = NewAppContext()
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type AppContextImpl struct {
|
||||
logger *zerolog.Logger
|
||||
|
||||
animeLibraryPaths mo.Option[[]string]
|
||||
|
||||
wsEventManager mo.Option[events.WSEventManagerInterface]
|
||||
database mo.Option[*db.Database]
|
||||
playbackManager mo.Option[*playbackmanager.PlaybackManager]
|
||||
mediaplayerRepo mo.Option[*mediaplayer.Repository]
|
||||
mangaRepository mo.Option[*manga.Repository]
|
||||
anilistPlatform mo.Option[platform.Platform]
|
||||
discordPresence mo.Option[*discordrpc_presence.Presence]
|
||||
metadataProvider mo.Option[metadata.Provider]
|
||||
fillerManager mo.Option[*fillermanager.FillerManager]
|
||||
torrentClientRepository mo.Option[*torrent_client.Repository]
|
||||
torrentstreamRepository mo.Option[*torrentstream.Repository]
|
||||
mediastreamRepository mo.Option[*mediastream.Repository]
|
||||
onlinestreamRepository mo.Option[*onlinestream.Repository]
|
||||
continuityManager mo.Option[*continuity.Manager]
|
||||
autoScanner mo.Option[*autoscanner.AutoScanner]
|
||||
autoDownloader mo.Option[*autodownloader.AutoDownloader]
|
||||
fileCacher mo.Option[*filecache.Cacher]
|
||||
onRefreshAnilistAnimeCollection mo.Option[func()]
|
||||
onRefreshAnilistMangaCollection mo.Option[func()]
|
||||
isOffline bool
|
||||
}
|
||||
|
||||
func NewAppContext() AppContext {
|
||||
nopLogger := zerolog.Nop()
|
||||
appCtx := &AppContextImpl{
|
||||
logger: &nopLogger,
|
||||
database: mo.None[*db.Database](),
|
||||
playbackManager: mo.None[*playbackmanager.PlaybackManager](),
|
||||
mediaplayerRepo: mo.None[*mediaplayer.Repository](),
|
||||
anilistPlatform: mo.None[platform.Platform](),
|
||||
mangaRepository: mo.None[*manga.Repository](),
|
||||
metadataProvider: mo.None[metadata.Provider](),
|
||||
wsEventManager: mo.None[events.WSEventManagerInterface](),
|
||||
discordPresence: mo.None[*discordrpc_presence.Presence](),
|
||||
fillerManager: mo.None[*fillermanager.FillerManager](),
|
||||
torrentClientRepository: mo.None[*torrent_client.Repository](),
|
||||
torrentstreamRepository: mo.None[*torrentstream.Repository](),
|
||||
mediastreamRepository: mo.None[*mediastream.Repository](),
|
||||
onlinestreamRepository: mo.None[*onlinestream.Repository](),
|
||||
continuityManager: mo.None[*continuity.Manager](),
|
||||
autoScanner: mo.None[*autoscanner.AutoScanner](),
|
||||
autoDownloader: mo.None[*autodownloader.AutoDownloader](),
|
||||
fileCacher: mo.None[*filecache.Cacher](),
|
||||
onRefreshAnilistAnimeCollection: mo.None[func()](),
|
||||
onRefreshAnilistMangaCollection: mo.None[func()](),
|
||||
isOffline: false,
|
||||
}
|
||||
|
||||
return appCtx
|
||||
}
|
||||
|
||||
func (a *AppContextImpl) IsOffline() bool {
|
||||
return a.isOffline
|
||||
}
|
||||
|
||||
func (a *AppContextImpl) SetLogger(logger *zerolog.Logger) {
|
||||
a.logger = logger
|
||||
}
|
||||
|
||||
func (a *AppContextImpl) Database() mo.Option[*db.Database] {
|
||||
return a.database
|
||||
}
|
||||
|
||||
func (a *AppContextImpl) PlaybackManager() mo.Option[*playbackmanager.PlaybackManager] {
|
||||
return a.playbackManager
|
||||
}
|
||||
|
||||
func (a *AppContextImpl) MediaPlayerRepository() mo.Option[*mediaplayer.Repository] {
|
||||
return a.mediaplayerRepo
|
||||
}
|
||||
|
||||
func (a *AppContextImpl) AnilistPlatform() mo.Option[platform.Platform] {
|
||||
return a.anilistPlatform
|
||||
}
|
||||
|
||||
func (a *AppContextImpl) WSEventManager() mo.Option[events.WSEventManagerInterface] {
|
||||
return a.wsEventManager
|
||||
}
|
||||
|
||||
func (a *AppContextImpl) SetModulesPartial(modules AppContextModules) {
|
||||
if modules.IsOffline != nil {
|
||||
a.isOffline = *modules.IsOffline
|
||||
}
|
||||
|
||||
if modules.Database != nil {
|
||||
a.database = mo.Some(modules.Database)
|
||||
}
|
||||
|
||||
if modules.AnimeLibraryPaths != nil {
|
||||
a.animeLibraryPaths = mo.Some(*modules.AnimeLibraryPaths)
|
||||
}
|
||||
|
||||
if modules.MetadataProvider != nil {
|
||||
a.metadataProvider = mo.Some(modules.MetadataProvider)
|
||||
}
|
||||
|
||||
if modules.PlaybackManager != nil {
|
||||
a.playbackManager = mo.Some(modules.PlaybackManager)
|
||||
}
|
||||
|
||||
if modules.AnilistPlatform != nil {
|
||||
a.anilistPlatform = mo.Some(modules.AnilistPlatform)
|
||||
}
|
||||
|
||||
if modules.MediaPlayerRepository != nil {
|
||||
a.mediaplayerRepo = mo.Some(modules.MediaPlayerRepository)
|
||||
}
|
||||
|
||||
if modules.FillerManager != nil {
|
||||
a.fillerManager = mo.Some(modules.FillerManager)
|
||||
}
|
||||
|
||||
if modules.OnRefreshAnilistAnimeCollection != nil {
|
||||
a.onRefreshAnilistAnimeCollection = mo.Some(modules.OnRefreshAnilistAnimeCollection)
|
||||
}
|
||||
|
||||
if modules.OnRefreshAnilistMangaCollection != nil {
|
||||
a.onRefreshAnilistMangaCollection = mo.Some(modules.OnRefreshAnilistMangaCollection)
|
||||
}
|
||||
|
||||
if modules.MangaRepository != nil {
|
||||
a.mangaRepository = mo.Some(modules.MangaRepository)
|
||||
}
|
||||
|
||||
if modules.DiscordPresence != nil {
|
||||
a.discordPresence = mo.Some(modules.DiscordPresence)
|
||||
}
|
||||
|
||||
if modules.WSEventManager != nil {
|
||||
a.wsEventManager = mo.Some(modules.WSEventManager)
|
||||
}
|
||||
|
||||
if modules.ContinuityManager != nil {
|
||||
a.continuityManager = mo.Some(modules.ContinuityManager)
|
||||
}
|
||||
|
||||
if modules.TorrentClientRepository != nil {
|
||||
a.torrentClientRepository = mo.Some(modules.TorrentClientRepository)
|
||||
}
|
||||
|
||||
if modules.TorrentstreamRepository != nil {
|
||||
a.torrentstreamRepository = mo.Some(modules.TorrentstreamRepository)
|
||||
}
|
||||
|
||||
if modules.MediastreamRepository != nil {
|
||||
a.mediastreamRepository = mo.Some(modules.MediastreamRepository)
|
||||
}
|
||||
|
||||
if modules.OnlinestreamRepository != nil {
|
||||
a.onlinestreamRepository = mo.Some(modules.OnlinestreamRepository)
|
||||
}
|
||||
|
||||
if modules.AutoDownloader != nil {
|
||||
a.autoDownloader = mo.Some(modules.AutoDownloader)
|
||||
}
|
||||
|
||||
if modules.AutoScanner != nil {
|
||||
a.autoScanner = mo.Some(modules.AutoScanner)
|
||||
}
|
||||
|
||||
if modules.FileCacher != nil {
|
||||
a.fileCacher = mo.Some(modules.FileCacher)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AppContextImpl) DropPluginData(extId string) {
|
||||
db, ok := a.database.Get()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
err := db.Gorm().Where("plugin_id = ?", extId).Delete(&models.PluginData{}).Error
|
||||
if err != nil {
|
||||
a.logger.Error().Err(err).Msg("Failed to drop plugin data")
|
||||
}
|
||||
}
|
||||
62
seanime-2.9.10/internal/plugin/continuity.go
Normal file
62
seanime-2.9.10/internal/plugin/continuity.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"seanime/internal/continuity"
|
||||
"seanime/internal/extension"
|
||||
"seanime/internal/goja/goja_bindings"
|
||||
goja_util "seanime/internal/util/goja"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func (a *AppContextImpl) BindContinuityToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
|
||||
|
||||
continuityObj := vm.NewObject()
|
||||
|
||||
_ = continuityObj.Set("updateWatchHistoryItem", func(opts continuity.UpdateWatchHistoryItemOptions) goja.Value {
|
||||
manager, ok := a.continuityManager.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "continuity manager not set")
|
||||
}
|
||||
err := manager.UpdateWatchHistoryItem(&opts)
|
||||
if err != nil {
|
||||
goja_bindings.PanicThrowError(vm, err)
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
_ = continuityObj.Set("getWatchHistoryItem", func(mediaId int) goja.Value {
|
||||
manager, ok := a.continuityManager.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "continuity manager not set")
|
||||
}
|
||||
resp := manager.GetWatchHistoryItem(mediaId)
|
||||
if resp == nil || !resp.Found {
|
||||
return goja.Undefined()
|
||||
}
|
||||
return vm.ToValue(resp.Item)
|
||||
})
|
||||
|
||||
_ = continuityObj.Set("getWatchHistory", func() goja.Value {
|
||||
manager, ok := a.continuityManager.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "continuity manager not set")
|
||||
}
|
||||
return vm.ToValue(manager.GetWatchHistory())
|
||||
})
|
||||
|
||||
_ = continuityObj.Set("deleteWatchHistoryItem", func(mediaId int) goja.Value {
|
||||
manager, ok := a.continuityManager.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "continuity manager not set")
|
||||
}
|
||||
err := manager.DeleteWatchHistoryItem(mediaId)
|
||||
if err != nil {
|
||||
goja_bindings.PanicThrowError(vm, err)
|
||||
}
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
_ = obj.Set("continuity", continuityObj)
|
||||
}
|
||||
515
seanime-2.9.10/internal/plugin/cron.go
Normal file
515
seanime-2.9.10/internal/plugin/cron.go
Normal file
@@ -0,0 +1,515 @@
|
||||
// Package cron implements a crontab-like service to execute and schedule
|
||||
// repeative tasks/jobs.
|
||||
//
|
||||
// Example:
|
||||
//
|
||||
// c := cron.New()
|
||||
// c.MustAdd("dailyReport", "0 0 * * *", func() { ... })
|
||||
// c.Start()
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"seanime/internal/extension"
|
||||
goja_util "seanime/internal/util/goja"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// Cron is a crontab-like struct for tasks/jobs scheduling.
|
||||
type Cron struct {
|
||||
timezone *time.Location
|
||||
ticker *time.Ticker
|
||||
startTimer *time.Timer
|
||||
tickerDone chan bool
|
||||
jobs []*CronJob
|
||||
interval time.Duration
|
||||
mux sync.RWMutex
|
||||
scheduler *goja_util.Scheduler
|
||||
}
|
||||
|
||||
// New create a new Cron struct with default tick interval of 1 minute
|
||||
// and timezone in UTC.
|
||||
//
|
||||
// You can change the default tick interval with Cron.SetInterval().
|
||||
// You can change the default timezone with Cron.SetTimezone().
|
||||
func New(scheduler *goja_util.Scheduler) *Cron {
|
||||
return &Cron{
|
||||
interval: 1 * time.Minute,
|
||||
timezone: time.UTC,
|
||||
jobs: []*CronJob{},
|
||||
tickerDone: make(chan bool),
|
||||
scheduler: scheduler,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *AppContextImpl) BindCronToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) *Cron {
|
||||
cron := New(scheduler)
|
||||
cronObj := vm.NewObject()
|
||||
_ = cronObj.Set("add", cron.Add)
|
||||
_ = cronObj.Set("remove", cron.Remove)
|
||||
_ = cronObj.Set("removeAll", cron.RemoveAll)
|
||||
_ = cronObj.Set("total", cron.Total)
|
||||
_ = cronObj.Set("stop", cron.Stop)
|
||||
_ = cronObj.Set("start", cron.Start)
|
||||
_ = cronObj.Set("hasStarted", cron.HasStarted)
|
||||
_ = obj.Set("cron", cronObj)
|
||||
|
||||
return cron
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// SetInterval changes the current cron tick interval
|
||||
// (it usually should be >= 1 minute).
|
||||
func (c *Cron) SetInterval(d time.Duration) {
|
||||
// update interval
|
||||
c.mux.Lock()
|
||||
wasStarted := c.ticker != nil
|
||||
c.interval = d
|
||||
c.mux.Unlock()
|
||||
|
||||
// restart the ticker
|
||||
if wasStarted {
|
||||
c.Start()
|
||||
}
|
||||
}
|
||||
|
||||
// SetTimezone changes the current cron tick timezone.
|
||||
func (c *Cron) SetTimezone(l *time.Location) {
|
||||
c.mux.Lock()
|
||||
defer c.mux.Unlock()
|
||||
|
||||
c.timezone = l
|
||||
}
|
||||
|
||||
// MustAdd is similar to Add() but panic on failure.
|
||||
func (c *Cron) MustAdd(jobId string, cronExpr string, run func()) {
|
||||
if err := c.Add(jobId, cronExpr, run); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
}
|
||||
|
||||
// Add registers a single cron job.
|
||||
//
|
||||
// If there is already a job with the provided id, then the old job
|
||||
// will be replaced with the new one.
|
||||
//
|
||||
// cronExpr is a regular cron expression, eg. "0 */3 * * *" (aka. at minute 0 past every 3rd hour).
|
||||
// Check cron.NewSchedule() for the supported tokens.
|
||||
func (c *Cron) Add(jobId string, cronExpr string, fn func()) error {
|
||||
if fn == nil {
|
||||
return errors.New("failed to add new cron job: fn must be non-nil function")
|
||||
}
|
||||
|
||||
schedule, err := NewSchedule(cronExpr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to add new cron job: %w", err)
|
||||
}
|
||||
|
||||
c.mux.Lock()
|
||||
defer c.mux.Unlock()
|
||||
|
||||
// remove previous (if any)
|
||||
c.jobs = slices.DeleteFunc(c.jobs, func(j *CronJob) bool {
|
||||
return j.Id() == jobId
|
||||
})
|
||||
|
||||
// add new
|
||||
c.jobs = append(c.jobs, &CronJob{
|
||||
id: jobId,
|
||||
fn: fn,
|
||||
schedule: schedule,
|
||||
scheduler: c.scheduler,
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Remove removes a single cron job by its id.
|
||||
func (c *Cron) Remove(jobId string) {
|
||||
c.mux.Lock()
|
||||
defer c.mux.Unlock()
|
||||
|
||||
if c.jobs == nil {
|
||||
return // nothing to remove
|
||||
}
|
||||
|
||||
c.jobs = slices.DeleteFunc(c.jobs, func(j *CronJob) bool {
|
||||
return j.Id() == jobId
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveAll removes all registered cron jobs.
|
||||
func (c *Cron) RemoveAll() {
|
||||
c.mux.Lock()
|
||||
defer c.mux.Unlock()
|
||||
|
||||
c.jobs = []*CronJob{}
|
||||
}
|
||||
|
||||
// Total returns the current total number of registered cron jobs.
|
||||
func (c *Cron) Total() int {
|
||||
c.mux.RLock()
|
||||
defer c.mux.RUnlock()
|
||||
|
||||
return len(c.jobs)
|
||||
}
|
||||
|
||||
// Jobs returns a shallow copy of the currently registered cron jobs.
|
||||
func (c *Cron) Jobs() []*CronJob {
|
||||
c.mux.RLock()
|
||||
defer c.mux.RUnlock()
|
||||
|
||||
copy := make([]*CronJob, len(c.jobs))
|
||||
for i, j := range c.jobs {
|
||||
copy[i] = j
|
||||
}
|
||||
|
||||
return copy
|
||||
}
|
||||
|
||||
// Stop stops the current cron ticker (if not already).
|
||||
//
|
||||
// You can resume the ticker by calling Start().
|
||||
func (c *Cron) Stop() {
|
||||
c.mux.Lock()
|
||||
defer c.mux.Unlock()
|
||||
|
||||
if c.startTimer != nil {
|
||||
c.startTimer.Stop()
|
||||
c.startTimer = nil
|
||||
}
|
||||
|
||||
if c.ticker == nil {
|
||||
return // already stopped
|
||||
}
|
||||
|
||||
c.tickerDone <- true
|
||||
c.ticker.Stop()
|
||||
c.ticker = nil
|
||||
}
|
||||
|
||||
// Start starts the cron ticker.
|
||||
//
|
||||
// Calling Start() on already started cron will restart the ticker.
|
||||
func (c *Cron) Start() {
|
||||
c.Stop()
|
||||
|
||||
// delay the ticker to start at 00 of 1 c.interval duration
|
||||
now := time.Now()
|
||||
next := now.Add(c.interval).Truncate(c.interval)
|
||||
delay := next.Sub(now)
|
||||
|
||||
c.mux.Lock()
|
||||
c.startTimer = time.AfterFunc(delay, func() {
|
||||
c.mux.Lock()
|
||||
c.ticker = time.NewTicker(c.interval)
|
||||
c.mux.Unlock()
|
||||
|
||||
// run immediately at 00
|
||||
c.runDue(time.Now())
|
||||
|
||||
// run after each tick
|
||||
go func() {
|
||||
for {
|
||||
select {
|
||||
case <-c.tickerDone:
|
||||
return
|
||||
case t := <-c.ticker.C:
|
||||
c.runDue(t)
|
||||
}
|
||||
}
|
||||
}()
|
||||
})
|
||||
c.mux.Unlock()
|
||||
}
|
||||
|
||||
// HasStarted checks whether the current Cron ticker has been started.
|
||||
func (c *Cron) HasStarted() bool {
|
||||
c.mux.RLock()
|
||||
defer c.mux.RUnlock()
|
||||
|
||||
return c.ticker != nil
|
||||
}
|
||||
|
||||
// runDue runs all registered jobs that are scheduled for the provided time.
|
||||
func (c *Cron) runDue(t time.Time) {
|
||||
c.mux.RLock()
|
||||
defer c.mux.RUnlock()
|
||||
|
||||
moment := NewMoment(t.In(c.timezone))
|
||||
|
||||
for _, j := range c.jobs {
|
||||
if j.schedule.IsDue(moment) {
|
||||
go j.Run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////
|
||||
|
||||
// CronJob defines a single registered cron job.
|
||||
type CronJob struct {
|
||||
fn func()
|
||||
schedule *Schedule
|
||||
id string
|
||||
scheduler *goja_util.Scheduler
|
||||
}
|
||||
|
||||
// Id returns the cron job id.
|
||||
func (j *CronJob) Id() string {
|
||||
return j.id
|
||||
}
|
||||
|
||||
// Expression returns the plain cron job schedule expression.
|
||||
func (j *CronJob) Expression() string {
|
||||
return j.schedule.rawExpr
|
||||
}
|
||||
|
||||
// Run runs the cron job function.
|
||||
func (j *CronJob) Run() {
|
||||
if j.fn != nil {
|
||||
j.scheduler.ScheduleAsync(func() error {
|
||||
j.fn()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// MarshalJSON implements [json.Marshaler] and export the current
|
||||
// jobs data into valid JSON.
|
||||
func (j CronJob) MarshalJSON() ([]byte, error) {
|
||||
plain := struct {
|
||||
Id string `json:"id"`
|
||||
Expression string `json:"expression"`
|
||||
}{
|
||||
Id: j.Id(),
|
||||
Expression: j.Expression(),
|
||||
}
|
||||
|
||||
return json.Marshal(plain)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////
|
||||
|
||||
// Moment represents a parsed single time moment.
|
||||
type Moment struct {
|
||||
Minute int `json:"minute"`
|
||||
Hour int `json:"hour"`
|
||||
Day int `json:"day"`
|
||||
Month int `json:"month"`
|
||||
DayOfWeek int `json:"dayOfWeek"`
|
||||
}
|
||||
|
||||
// NewMoment creates a new Moment from the specified time.
|
||||
func NewMoment(t time.Time) *Moment {
|
||||
return &Moment{
|
||||
Minute: t.Minute(),
|
||||
Hour: t.Hour(),
|
||||
Day: t.Day(),
|
||||
Month: int(t.Month()),
|
||||
DayOfWeek: int(t.Weekday()),
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule stores parsed information for each time component when a cron job should run.
|
||||
type Schedule struct {
|
||||
Minutes map[int]struct{} `json:"minutes"`
|
||||
Hours map[int]struct{} `json:"hours"`
|
||||
Days map[int]struct{} `json:"days"`
|
||||
Months map[int]struct{} `json:"months"`
|
||||
DaysOfWeek map[int]struct{} `json:"daysOfWeek"`
|
||||
|
||||
rawExpr string
|
||||
}
|
||||
|
||||
// IsDue checks whether the provided Moment satisfies the current Schedule.
|
||||
func (s *Schedule) IsDue(m *Moment) bool {
|
||||
if _, ok := s.Minutes[m.Minute]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := s.Hours[m.Hour]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := s.Days[m.Day]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := s.DaysOfWeek[m.DayOfWeek]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
if _, ok := s.Months[m.Month]; !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
var macros = map[string]string{
|
||||
"@yearly": "0 0 1 1 *",
|
||||
"@annually": "0 0 1 1 *",
|
||||
"@monthly": "0 0 1 * *",
|
||||
"@weekly": "0 0 * * 0",
|
||||
"@daily": "0 0 * * *",
|
||||
"@midnight": "0 0 * * *",
|
||||
"@hourly": "0 * * * *",
|
||||
"@30min": "*/30 * * * *",
|
||||
"@15min": "*/15 * * * *",
|
||||
"@10min": "*/10 * * * *",
|
||||
"@5min": "*/5 * * * *",
|
||||
}
|
||||
|
||||
// NewSchedule creates a new Schedule from a cron expression.
|
||||
//
|
||||
// A cron expression could be a macro OR 5 segments separated by space,
|
||||
// representing: minute, hour, day of the month, month and day of the week.
|
||||
//
|
||||
// The following segment formats are supported:
|
||||
// - wildcard: *
|
||||
// - range: 1-30
|
||||
// - step: */n or 1-30/n
|
||||
// - list: 1,2,3,10-20/n
|
||||
//
|
||||
// The following macros are supported:
|
||||
// - @yearly (or @annually)
|
||||
// - @monthly
|
||||
// - @weekly
|
||||
// - @daily (or @midnight)
|
||||
// - @hourly
|
||||
func NewSchedule(cronExpr string) (*Schedule, error) {
|
||||
if v, ok := macros[cronExpr]; ok {
|
||||
cronExpr = v
|
||||
}
|
||||
|
||||
segments := strings.Split(cronExpr, " ")
|
||||
if len(segments) != 5 {
|
||||
return nil, errors.New("invalid cron expression - must be a valid macro or to have exactly 5 space separated segments")
|
||||
}
|
||||
|
||||
minutes, err := parseCronSegment(segments[0], 0, 59)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hours, err := parseCronSegment(segments[1], 0, 23)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
days, err := parseCronSegment(segments[2], 1, 31)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
months, err := parseCronSegment(segments[3], 1, 12)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
daysOfWeek, err := parseCronSegment(segments[4], 0, 6)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Schedule{
|
||||
Minutes: minutes,
|
||||
Hours: hours,
|
||||
Days: days,
|
||||
Months: months,
|
||||
DaysOfWeek: daysOfWeek,
|
||||
rawExpr: cronExpr,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// parseCronSegment parses a single cron expression segment and
|
||||
// returns its time schedule slots.
|
||||
func parseCronSegment(segment string, min int, max int) (map[int]struct{}, error) {
|
||||
slots := map[int]struct{}{}
|
||||
|
||||
list := strings.Split(segment, ",")
|
||||
for _, p := range list {
|
||||
stepParts := strings.Split(p, "/")
|
||||
|
||||
// step (*/n, 1-30/n)
|
||||
var step int
|
||||
switch len(stepParts) {
|
||||
case 1:
|
||||
step = 1
|
||||
case 2:
|
||||
parsedStep, err := strconv.Atoi(stepParts[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parsedStep < 1 || parsedStep > max {
|
||||
return nil, fmt.Errorf("invalid segment step boundary - the step must be between 1 and the %d", max)
|
||||
}
|
||||
step = parsedStep
|
||||
default:
|
||||
return nil, errors.New("invalid segment step format - must be in the format */n or 1-30/n")
|
||||
}
|
||||
|
||||
// find the min and max range of the segment part
|
||||
var rangeMin, rangeMax int
|
||||
if stepParts[0] == "*" {
|
||||
rangeMin = min
|
||||
rangeMax = max
|
||||
} else {
|
||||
// single digit (1) or range (1-30)
|
||||
rangeParts := strings.Split(stepParts[0], "-")
|
||||
switch len(rangeParts) {
|
||||
case 1:
|
||||
if step != 1 {
|
||||
return nil, errors.New("invalid segement step - step > 1 could be used only with the wildcard or range format")
|
||||
}
|
||||
parsed, err := strconv.Atoi(rangeParts[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parsed < min || parsed > max {
|
||||
return nil, errors.New("invalid segment value - must be between the min and max of the segment")
|
||||
}
|
||||
rangeMin = parsed
|
||||
rangeMax = rangeMin
|
||||
case 2:
|
||||
parsedMin, err := strconv.Atoi(rangeParts[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parsedMin < min || parsedMin > max {
|
||||
return nil, fmt.Errorf("invalid segment range minimum - must be between %d and %d", min, max)
|
||||
}
|
||||
rangeMin = parsedMin
|
||||
|
||||
parsedMax, err := strconv.Atoi(rangeParts[1])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parsedMax < parsedMin || parsedMax > max {
|
||||
return nil, fmt.Errorf("invalid segment range maximum - must be between %d and %d", rangeMin, max)
|
||||
}
|
||||
rangeMax = parsedMax
|
||||
default:
|
||||
return nil, errors.New("invalid segment range format - the range must have 1 or 2 parts")
|
||||
}
|
||||
}
|
||||
|
||||
// fill the slots
|
||||
for i := rangeMin; i <= rangeMax; i += step {
|
||||
slots[i] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
return slots, nil
|
||||
}
|
||||
513
seanime-2.9.10/internal/plugin/database.go
Normal file
513
seanime-2.9.10/internal/plugin/database.go
Normal file
@@ -0,0 +1,513 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"seanime/internal/database/db"
|
||||
"seanime/internal/database/db_bridge"
|
||||
"seanime/internal/database/models"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/extension"
|
||||
"seanime/internal/library/anime"
|
||||
util "seanime/internal/util"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/rs/zerolog"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
ctx *AppContextImpl
|
||||
logger *zerolog.Logger
|
||||
ext *extension.Extension
|
||||
}
|
||||
|
||||
// BindDatabase binds the database module to the Goja runtime.
|
||||
// Permissions needed: databases
|
||||
func (a *AppContextImpl) BindDatabase(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension) {
|
||||
dbLogger := logger.With().Str("id", ext.ID).Logger()
|
||||
db := &Database{
|
||||
ctx: a,
|
||||
logger: &dbLogger,
|
||||
ext: ext,
|
||||
}
|
||||
dbObj := vm.NewObject()
|
||||
|
||||
// Local files
|
||||
localFilesObj := vm.NewObject()
|
||||
_ = localFilesObj.Set("getAll", db.getAllLocalFiles)
|
||||
_ = localFilesObj.Set("findBy", db.findLocalFilesBy)
|
||||
_ = localFilesObj.Set("save", db.saveLocalFiles)
|
||||
_ = localFilesObj.Set("insert", db.insertLocalFiles)
|
||||
_ = dbObj.Set("localFiles", localFilesObj)
|
||||
|
||||
// Anilist
|
||||
anilistObj := vm.NewObject()
|
||||
_ = anilistObj.Set("getToken", db.getAnilistToken)
|
||||
_ = anilistObj.Set("getUsername", db.getAnilistUsername)
|
||||
_ = dbObj.Set("anilist", anilistObj)
|
||||
|
||||
// Auto downloader rules
|
||||
autoDownloaderRulesObj := vm.NewObject()
|
||||
_ = autoDownloaderRulesObj.Set("getAll", db.getAllAutoDownloaderRules)
|
||||
_ = autoDownloaderRulesObj.Set("get", db.getAutoDownloaderRule)
|
||||
_ = autoDownloaderRulesObj.Set("getByMediaId", db.getAutoDownloaderRulesByMediaId)
|
||||
_ = autoDownloaderRulesObj.Set("update", db.updateAutoDownloaderRule)
|
||||
_ = autoDownloaderRulesObj.Set("insert", db.insertAutoDownloaderRule)
|
||||
_ = autoDownloaderRulesObj.Set("remove", db.deleteAutoDownloaderRule)
|
||||
_ = dbObj.Set("autoDownloaderRules", autoDownloaderRulesObj)
|
||||
|
||||
// Auto downloader items
|
||||
autoDownloaderItemsObj := vm.NewObject()
|
||||
_ = autoDownloaderItemsObj.Set("getAll", db.getAllAutoDownloaderItems)
|
||||
_ = autoDownloaderItemsObj.Set("get", db.getAutoDownloaderItem)
|
||||
_ = autoDownloaderItemsObj.Set("getByMediaId", db.getAutoDownloaderItemsByMediaId)
|
||||
_ = autoDownloaderItemsObj.Set("insert", db.insertAutoDownloaderItem)
|
||||
_ = autoDownloaderItemsObj.Set("remove", db.deleteAutoDownloaderItem)
|
||||
_ = dbObj.Set("autoDownloaderItems", autoDownloaderItemsObj)
|
||||
|
||||
// Silenced media entries
|
||||
silencedMediaEntriesObj := vm.NewObject()
|
||||
_ = silencedMediaEntriesObj.Set("getAllIds", db.getAllSilencedMediaEntryIds)
|
||||
_ = silencedMediaEntriesObj.Set("isSilenced", db.isSilenced)
|
||||
_ = silencedMediaEntriesObj.Set("setSilenced", db.setSilenced)
|
||||
_ = dbObj.Set("silencedMediaEntries", silencedMediaEntriesObj)
|
||||
|
||||
// Media fillers
|
||||
mediaFillersObj := vm.NewObject()
|
||||
_ = mediaFillersObj.Set("getAll", db.getAllMediaFillers)
|
||||
_ = mediaFillersObj.Set("get", db.getMediaFiller)
|
||||
_ = mediaFillersObj.Set("insert", db.insertMediaFiller)
|
||||
_ = mediaFillersObj.Set("remove", db.deleteMediaFiller)
|
||||
_ = dbObj.Set("mediaFillers", mediaFillersObj)
|
||||
|
||||
_ = vm.Set("$database", dbObj)
|
||||
}
|
||||
|
||||
func (d *Database) getAllLocalFiles() ([]*anime.LocalFile, error) {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return nil, errors.New("database not initialized")
|
||||
}
|
||||
|
||||
files, _, err := db_bridge.GetLocalFiles(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func (d *Database) findLocalFilesBy(filterFn func(*anime.LocalFile) bool) ([]*anime.LocalFile, error) {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return nil, errors.New("database not initialized")
|
||||
}
|
||||
|
||||
files, _, err := db_bridge.GetLocalFiles(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filteredFiles := make([]*anime.LocalFile, 0)
|
||||
for _, file := range files {
|
||||
if filterFn(file) {
|
||||
filteredFiles = append(filteredFiles, file)
|
||||
}
|
||||
}
|
||||
return filteredFiles, nil
|
||||
}
|
||||
|
||||
func (d *Database) saveLocalFiles(filesToSave []*anime.LocalFile) error {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return errors.New("database not initialized")
|
||||
}
|
||||
|
||||
lfs, lfsId, err := db_bridge.GetLocalFiles(db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filesToSaveMap := make(map[string]*anime.LocalFile)
|
||||
for _, file := range filesToSave {
|
||||
filesToSaveMap[util.NormalizePath(file.Path)] = file
|
||||
}
|
||||
|
||||
for i := range lfs {
|
||||
if fileToSave, ok := filesToSaveMap[util.NormalizePath(lfs[i].Path)]; !ok {
|
||||
lfs[i] = fileToSave
|
||||
}
|
||||
}
|
||||
|
||||
_, err = db_bridge.SaveLocalFiles(db, lfsId, lfs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ws, ok := d.ctx.wsEventManager.Get()
|
||||
if ok {
|
||||
ws.SendEvent(events.InvalidateQueries, []string{events.GetLocalFilesEndpoint, events.GetAnimeEntryEndpoint, events.GetLibraryCollectionEndpoint, events.GetMissingEpisodesEndpoint})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) insertLocalFiles(files []*anime.LocalFile) ([]*anime.LocalFile, error) {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return nil, errors.New("database not initialized")
|
||||
}
|
||||
|
||||
lfs, err := db_bridge.InsertLocalFiles(db, files)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ws, ok := d.ctx.wsEventManager.Get()
|
||||
if ok {
|
||||
ws.SendEvent(events.InvalidateQueries, []string{events.GetLocalFilesEndpoint, events.GetAnimeEntryEndpoint, events.GetLibraryCollectionEndpoint, events.GetMissingEpisodesEndpoint})
|
||||
}
|
||||
|
||||
return lfs, nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (d *Database) getAnilistToken() (string, error) {
|
||||
if d.ext.Plugin == nil || len(d.ext.Plugin.Permissions.Scopes) == 0 {
|
||||
return "", errors.New("permission denied")
|
||||
}
|
||||
if !util.Contains(d.ext.Plugin.Permissions.Scopes, extension.PluginPermissionAnilistToken) {
|
||||
return "", errors.New("permission denied")
|
||||
}
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return "", errors.New("database not initialized")
|
||||
}
|
||||
return db.GetAnilistToken(), nil
|
||||
}
|
||||
|
||||
func (d *Database) getAnilistUsername() (string, error) {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return "", errors.New("database not initialized")
|
||||
}
|
||||
|
||||
acc, err := db.GetAccount()
|
||||
if err != nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
return acc.Username, nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (d *Database) getAllAutoDownloaderRules() ([]*anime.AutoDownloaderRule, error) {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return nil, errors.New("database not initialized")
|
||||
}
|
||||
|
||||
rules, err := db_bridge.GetAutoDownloaderRules(db)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func (d *Database) getAutoDownloaderRule(id uint) (*anime.AutoDownloaderRule, error) {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return nil, errors.New("database not initialized")
|
||||
}
|
||||
|
||||
rule, err := db_bridge.GetAutoDownloaderRule(db, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
func (d *Database) getAutoDownloaderRulesByMediaId(mediaId int) ([]*anime.AutoDownloaderRule, error) {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return nil, errors.New("database not initialized")
|
||||
}
|
||||
|
||||
rules := db_bridge.GetAutoDownloaderRulesByMediaId(db, mediaId)
|
||||
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
func (d *Database) updateAutoDownloaderRule(id uint, rule *anime.AutoDownloaderRule) error {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return errors.New("database not initialized")
|
||||
}
|
||||
|
||||
err := db_bridge.UpdateAutoDownloaderRule(db, id, rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ws, ok := d.ctx.wsEventManager.Get()
|
||||
if ok {
|
||||
ws.SendEvent(events.InvalidateQueries, []string{events.GetAutoDownloaderRulesEndpoint, events.GetAutoDownloaderItemsEndpoint, events.GetAnimeEntryEndpoint})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) insertAutoDownloaderRule(rule *anime.AutoDownloaderRule) error {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return errors.New("database not initialized")
|
||||
}
|
||||
|
||||
err := db_bridge.InsertAutoDownloaderRule(db, rule)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ws, ok := d.ctx.wsEventManager.Get()
|
||||
if ok {
|
||||
ws.SendEvent(events.InvalidateQueries, []string{events.GetAutoDownloaderRulesEndpoint, events.GetAutoDownloaderRulesByAnimeEndpoint, events.GetAutoDownloaderRuleEndpoint, events.GetAutoDownloaderItemsEndpoint, events.GetAnimeEntryEndpoint})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) deleteAutoDownloaderRule(id uint) error {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return errors.New("database not initialized")
|
||||
}
|
||||
|
||||
err := db_bridge.DeleteAutoDownloaderRule(db, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ws, ok := d.ctx.wsEventManager.Get()
|
||||
if ok {
|
||||
ws.SendEvent(events.InvalidateQueries, []string{events.GetAutoDownloaderRulesEndpoint, events.GetAutoDownloaderRulesByAnimeEndpoint, events.GetAutoDownloaderRuleEndpoint, events.GetAutoDownloaderItemsEndpoint, events.GetAnimeEntryEndpoint})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (d *Database) getAllAutoDownloaderItems() ([]*models.AutoDownloaderItem, error) {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return nil, errors.New("database not initialized")
|
||||
}
|
||||
|
||||
items, err := db.GetAutoDownloaderItems()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (d *Database) getAutoDownloaderItem(id uint) (*models.AutoDownloaderItem, error) {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return nil, errors.New("database not initialized")
|
||||
}
|
||||
|
||||
item, err := db.GetAutoDownloaderItem(id)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return item, nil
|
||||
}
|
||||
|
||||
func (d *Database) getAutoDownloaderItemsByMediaId(mediaId int) ([]*models.AutoDownloaderItem, error) {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return nil, errors.New("database not initialized")
|
||||
}
|
||||
|
||||
items, err := db.GetAutoDownloaderItemByMediaId(mediaId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (d *Database) insertAutoDownloaderItem(item *models.AutoDownloaderItem) error {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return errors.New("database not initialized")
|
||||
}
|
||||
|
||||
err := db.InsertAutoDownloaderItem(item)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ws, ok := d.ctx.wsEventManager.Get()
|
||||
if ok {
|
||||
ws.SendEvent(events.InvalidateQueries, []string{events.GetAutoDownloaderRulesEndpoint, events.GetAutoDownloaderRulesByAnimeEndpoint, events.GetAutoDownloaderRuleEndpoint, events.GetAutoDownloaderItemsEndpoint, events.GetAnimeEntryEndpoint})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) deleteAutoDownloaderItem(id uint) error {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return errors.New("database not initialized")
|
||||
}
|
||||
|
||||
err := db.DeleteAutoDownloaderItem(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ws, ok := d.ctx.wsEventManager.Get()
|
||||
if ok {
|
||||
ws.SendEvent(events.InvalidateQueries, []string{events.GetAutoDownloaderRulesEndpoint, events.GetAutoDownloaderRulesByAnimeEndpoint, events.GetAutoDownloaderRuleEndpoint, events.GetAutoDownloaderItemsEndpoint, events.GetAnimeEntryEndpoint})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (d *Database) getAllSilencedMediaEntryIds() ([]int, error) {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return nil, errors.New("database not initialized")
|
||||
}
|
||||
|
||||
ids, err := db.GetSilencedMediaEntryIds()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func (d *Database) isSilenced(mediaId int) (bool, error) {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return false, errors.New("database not initialized")
|
||||
}
|
||||
|
||||
entry, err := db.GetSilencedMediaEntry(uint(mediaId))
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
return entry != nil, nil
|
||||
}
|
||||
|
||||
func (d *Database) setSilenced(mediaId int, silenced bool) error {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return errors.New("database not initialized")
|
||||
}
|
||||
|
||||
if silenced {
|
||||
err := db.InsertSilencedMediaEntry(uint(mediaId))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
} else {
|
||||
err := db.DeleteSilencedMediaEntry(uint(mediaId))
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
ws, ok := d.ctx.wsEventManager.Get()
|
||||
if ok {
|
||||
ws.SendEvent(events.InvalidateQueries, []string{events.GetAnimeEntrySilenceStatusEndpoint})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (d *Database) getAllMediaFillers() (map[int]*db.MediaFillerItem, error) {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return nil, errors.New("database not initialized")
|
||||
}
|
||||
|
||||
fillers, err := db.GetCachedMediaFillers()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return fillers, nil
|
||||
}
|
||||
|
||||
func (d *Database) getMediaFiller(mediaId int) (*db.MediaFillerItem, error) {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return nil, errors.New("database not initialized")
|
||||
}
|
||||
|
||||
filler, ok := db.GetMediaFillerItem(mediaId)
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return filler, nil
|
||||
}
|
||||
|
||||
func (d *Database) insertMediaFiller(provider string, mediaId int, slug string, fillerEpisodes []string) error {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return errors.New("database not initialized")
|
||||
}
|
||||
|
||||
err := db.InsertMediaFiller(provider, mediaId, slug, time.Now(), fillerEpisodes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ws, ok := d.ctx.wsEventManager.Get()
|
||||
if ok {
|
||||
ws.SendEvent(events.InvalidateQueries, []string{events.GetAnimeEntryEndpoint})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) deleteMediaFiller(mediaId int) error {
|
||||
db, ok := d.ctx.database.Get()
|
||||
if !ok {
|
||||
return errors.New("database not initialized")
|
||||
}
|
||||
|
||||
err := db.DeleteMediaFiller(mediaId)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ws, ok := d.ctx.wsEventManager.Get()
|
||||
if ok {
|
||||
ws.SendEvent(events.InvalidateQueries, []string{events.GetAnimeEntryEndpoint})
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
57
seanime-2.9.10/internal/plugin/discord.go
Normal file
57
seanime-2.9.10/internal/plugin/discord.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
discordrpc_presence "seanime/internal/discordrpc/presence"
|
||||
"seanime/internal/extension"
|
||||
"seanime/internal/goja/goja_bindings"
|
||||
goja_util "seanime/internal/util/goja"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func (a *AppContextImpl) BindDiscordToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
|
||||
|
||||
discordObj := vm.NewObject()
|
||||
_ = discordObj.Set("setMangaActivity", func(opts discordrpc_presence.MangaActivity) goja.Value {
|
||||
presence, ok := a.discordPresence.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "discord rpc client not set")
|
||||
}
|
||||
presence.SetMangaActivity(&opts)
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = discordObj.Set("setAnimeActivity", func(opts discordrpc_presence.AnimeActivity) goja.Value {
|
||||
presence, ok := a.discordPresence.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "discord rpc client not set")
|
||||
}
|
||||
presence.SetAnimeActivity(&opts)
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = discordObj.Set("updateAnimeActivity", func(progress int, duration int, paused bool) goja.Value {
|
||||
presence, ok := a.discordPresence.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "discord rpc client not set")
|
||||
}
|
||||
presence.UpdateAnimeActivity(progress, duration, paused)
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = discordObj.Set("setLegacyAnimeActivity", func(opts discordrpc_presence.LegacyAnimeActivity) goja.Value {
|
||||
presence, ok := a.discordPresence.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "discord rpc client not set")
|
||||
}
|
||||
presence.LegacySetAnimeActivity(&opts)
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = discordObj.Set("cancelActivity", func() goja.Value {
|
||||
presence, ok := a.discordPresence.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "discord rpc client not set")
|
||||
}
|
||||
presence.Close()
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("discord", discordObj)
|
||||
}
|
||||
328
seanime-2.9.10/internal/plugin/downloader.go
Normal file
328
seanime-2.9.10/internal/plugin/downloader.go
Normal file
@@ -0,0 +1,328 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"seanime/internal/extension"
|
||||
goja_util "seanime/internal/util/goja"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type DownloadStatus string
|
||||
|
||||
const (
|
||||
DownloadStatusDownloading DownloadStatus = "downloading"
|
||||
DownloadStatusCompleted DownloadStatus = "completed"
|
||||
DownloadStatusCancelled DownloadStatus = "cancelled"
|
||||
DownloadStatusError DownloadStatus = "error"
|
||||
)
|
||||
|
||||
type DownloadProgress struct {
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
Destination string `json:"destination"`
|
||||
TotalBytes int64 `json:"totalBytes"`
|
||||
TotalSize int64 `json:"totalSize"`
|
||||
Speed int64 `json:"speed"`
|
||||
Percentage float64 `json:"percentage"`
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error,omitempty"`
|
||||
LastUpdateTime time.Time `json:"lastUpdate"`
|
||||
StartTime time.Time `json:"startTime"`
|
||||
|
||||
lastBytes int64 `json:"-"`
|
||||
}
|
||||
|
||||
// IsFinished returns true if the download has completed, errored, or been cancelled
|
||||
func (p *DownloadProgress) IsFinished() bool {
|
||||
return p.Status == string(DownloadStatusCompleted) || p.Status == string(DownloadStatusCancelled) || p.Status == string(DownloadStatusError)
|
||||
}
|
||||
|
||||
type progressSubscriber struct {
|
||||
ID string
|
||||
Channel chan map[string]interface{}
|
||||
Cancel context.CancelFunc
|
||||
LastSent time.Time
|
||||
}
|
||||
|
||||
func (a *AppContextImpl) BindDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
|
||||
downloadObj := vm.NewObject()
|
||||
|
||||
progressMap := sync.Map{}
|
||||
downloadCancels := sync.Map{}
|
||||
progressSubscribers := sync.Map{}
|
||||
|
||||
_ = downloadObj.Set("watch", func(downloadID string, callback goja.Callable) goja.Value {
|
||||
// Create cancellable context for the subscriber
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
// Create a new subscriber
|
||||
subscriber := &progressSubscriber{
|
||||
ID: downloadID,
|
||||
Channel: make(chan map[string]interface{}, 1),
|
||||
Cancel: cancel,
|
||||
LastSent: time.Now(),
|
||||
}
|
||||
|
||||
// Store the subscriber
|
||||
if existing, ok := progressSubscribers.Load(downloadID); ok {
|
||||
// Cancel existing subscriber if any
|
||||
existing.(*progressSubscriber).Cancel()
|
||||
}
|
||||
progressSubscribers.Store(downloadID, subscriber)
|
||||
|
||||
// Start watching for progress updates
|
||||
go func() {
|
||||
defer func() {
|
||||
close(subscriber.Channel)
|
||||
progressSubscribers.Delete(downloadID)
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
// If download is complete/cancelled/errored, send one last update and stop
|
||||
if progress, ok := progressMap.Load(downloadID); ok {
|
||||
p := progress.(*DownloadProgress)
|
||||
scheduler.ScheduleAsync(func() error {
|
||||
p.Speed = 0
|
||||
callback(goja.Undefined(), vm.ToValue(p))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
return
|
||||
case <-ticker.C:
|
||||
if progress, ok := progressMap.Load(downloadID); ok {
|
||||
p := progress.(*DownloadProgress)
|
||||
scheduler.ScheduleAsync(func() error {
|
||||
callback(goja.Undefined(), vm.ToValue(p))
|
||||
return nil
|
||||
})
|
||||
// If download is complete/cancelled/errored, send one last update and stop
|
||||
if p.IsFinished() {
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Download not found or already completed
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
// Return a function to cancel the watch
|
||||
return vm.ToValue(func() {
|
||||
if subscriber, ok := progressSubscribers.Load(downloadID); ok {
|
||||
subscriber.(*progressSubscriber).Cancel()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
_ = downloadObj.Set("download", func(url string, destination string, options map[string]interface{}) (string, error) {
|
||||
if !a.isAllowedPath(ext, destination, AllowPathWrite) {
|
||||
return "", ErrPathNotAuthorized
|
||||
}
|
||||
|
||||
// Generate unique download ID
|
||||
downloadID := uuid.New().String()
|
||||
|
||||
// Create context with optional timeout
|
||||
var ctx context.Context
|
||||
var cancel context.CancelFunc
|
||||
if timeout, ok := options["timeout"].(float64); ok {
|
||||
ctx, cancel = context.WithTimeout(context.Background(), time.Duration(timeout)*time.Second)
|
||||
} else {
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
}
|
||||
downloadCancels.Store(downloadID, cancel)
|
||||
|
||||
logger.Trace().Str("url", url).Str("destination", destination).Msg("plugin: Starting download")
|
||||
|
||||
// Initialize progress tracking
|
||||
now := time.Now()
|
||||
progress := &DownloadProgress{
|
||||
ID: downloadID,
|
||||
URL: url,
|
||||
Destination: destination,
|
||||
Status: string(DownloadStatusDownloading),
|
||||
LastUpdateTime: now,
|
||||
StartTime: now,
|
||||
}
|
||||
progressMap.Store(downloadID, progress)
|
||||
|
||||
// Start download in a goroutine
|
||||
go func() {
|
||||
defer downloadCancels.Delete(downloadID)
|
||||
defer func() {
|
||||
// Clean up subscriber if it exists
|
||||
if subscriber, ok := progressSubscribers.Load(downloadID); ok {
|
||||
subscriber.(*progressSubscriber).Cancel()
|
||||
}
|
||||
}()
|
||||
|
||||
// Create request
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
progress.Status = string(DownloadStatusError)
|
||||
progress.Error = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
// Add headers if provided
|
||||
if headers, ok := options["headers"].(map[string]interface{}); ok {
|
||||
for k, v := range headers {
|
||||
if strVal, ok := v.(string); ok {
|
||||
req.Header.Set(k, strVal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Execute request
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
progress.Status = string(DownloadStatusError)
|
||||
progress.Error = err.Error()
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
progress.Status = string(DownloadStatusError)
|
||||
progress.Error = fmt.Sprintf("server returned status code %d", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
// Update progress with content length
|
||||
progress.TotalSize = resp.ContentLength
|
||||
|
||||
// Create destination directory if it doesn't exist
|
||||
if err := os.MkdirAll(filepath.Dir(destination), 0755); err != nil {
|
||||
progress.Status = string(DownloadStatusError)
|
||||
progress.Error = err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
// Create destination file
|
||||
file, err := os.Create(destination)
|
||||
if err != nil {
|
||||
progress.Status = string(DownloadStatusError)
|
||||
progress.Error = err.Error()
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Create buffer for copying
|
||||
buffer := make([]byte, 32*1024)
|
||||
lastUpdateTime := now
|
||||
|
||||
logger.Trace().Str("url", url).Str("destination", destination).Msg("plugin: Download started")
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
progress.Status = string(DownloadStatusCancelled)
|
||||
logger.Trace().Str("url", url).Str("destination", destination).Msg("plugin: Download cancelled")
|
||||
return
|
||||
default:
|
||||
n, err := resp.Body.Read(buffer)
|
||||
if n > 0 {
|
||||
_, writeErr := file.Write(buffer[:n])
|
||||
if writeErr != nil {
|
||||
progress.Status = string(DownloadStatusError)
|
||||
progress.Error = writeErr.Error()
|
||||
return
|
||||
}
|
||||
|
||||
progress.TotalBytes += int64(n)
|
||||
if progress.TotalSize > 0 {
|
||||
progress.Percentage = float64(progress.TotalBytes) / float64(progress.TotalSize) * 100
|
||||
}
|
||||
|
||||
// Update speed every 500ms
|
||||
if time.Since(lastUpdateTime) > 500*time.Millisecond {
|
||||
elapsed := time.Since(lastUpdateTime).Seconds()
|
||||
bytesInPeriod := progress.TotalBytes - progress.lastBytes
|
||||
progress.Speed = int64(float64(bytesInPeriod) / elapsed)
|
||||
progress.lastBytes = progress.TotalBytes
|
||||
progress.LastUpdateTime = time.Now()
|
||||
lastUpdateTime = time.Now()
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if err == io.EOF {
|
||||
progress.Status = string(DownloadStatusCompleted)
|
||||
logger.Trace().Str("url", url).Str("destination", destination).Msg("plugin: Download completed")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, context.Canceled) {
|
||||
progress.Status = string(DownloadStatusCancelled)
|
||||
logger.Trace().Str("url", url).Str("destination", destination).Msg("plugin: Download cancelled")
|
||||
return
|
||||
}
|
||||
progress.Status = string(DownloadStatusError)
|
||||
progress.Error = err.Error()
|
||||
logger.Error().Err(err).Str("url", url).Str("destination", destination).Msg("plugin: Download error")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return downloadID, nil
|
||||
})
|
||||
|
||||
_ = downloadObj.Set("getProgress", func(downloadID string) *DownloadProgress {
|
||||
if progress, ok := progressMap.Load(downloadID); ok {
|
||||
return progress.(*DownloadProgress)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
_ = downloadObj.Set("listDownloads", func() []*DownloadProgress {
|
||||
downloads := make([]*DownloadProgress, 0)
|
||||
progressMap.Range(func(key, value interface{}) bool {
|
||||
downloads = append(downloads, value.(*DownloadProgress))
|
||||
return true
|
||||
})
|
||||
return downloads
|
||||
})
|
||||
|
||||
_ = downloadObj.Set("cancel", func(downloadID string) {
|
||||
if cancel, ok := downloadCancels.Load(downloadID); ok {
|
||||
if cancel == nil {
|
||||
return
|
||||
}
|
||||
logger.Trace().Str("downloadID", downloadID).Msg("plugin: Cancelling download")
|
||||
cancel.(context.CancelFunc)()
|
||||
}
|
||||
})
|
||||
|
||||
_ = downloadObj.Set("cancelAll", func() {
|
||||
logger.Trace().Msg("plugin: Cancelling all downloads")
|
||||
downloadCancels.Range(func(key, value interface{}) bool {
|
||||
if value == nil {
|
||||
return true
|
||||
}
|
||||
logger.Trace().Str("downloadID", key.(string)).Msg("plugin: Cancelling download")
|
||||
value.(context.CancelFunc)()
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
_ = obj.Set("downloader", downloadObj)
|
||||
}
|
||||
187
seanime-2.9.10/internal/plugin/manga.go
Normal file
187
seanime-2.9.10/internal/plugin/manga.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"seanime/internal/extension"
|
||||
"seanime/internal/goja/goja_bindings"
|
||||
"seanime/internal/manga"
|
||||
goja_util "seanime/internal/util/goja"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Manga struct {
|
||||
ctx *AppContextImpl
|
||||
vm *goja.Runtime
|
||||
logger *zerolog.Logger
|
||||
ext *extension.Extension
|
||||
scheduler *goja_util.Scheduler
|
||||
}
|
||||
|
||||
func (a *AppContextImpl) BindMangaToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
|
||||
m := &Manga{
|
||||
ctx: a,
|
||||
vm: vm,
|
||||
logger: logger,
|
||||
ext: ext,
|
||||
scheduler: scheduler,
|
||||
}
|
||||
|
||||
mangaObj := vm.NewObject()
|
||||
|
||||
// Get downloaded chapter containers
|
||||
_ = mangaObj.Set("getDownloadedChapters", m.getDownloadedChapterContainers)
|
||||
_ = mangaObj.Set("getCollection", m.getCollection)
|
||||
_ = mangaObj.Set("refreshChapters", m.refreshChapterContainers)
|
||||
_ = mangaObj.Set("emptyCache", m.emptyCache)
|
||||
_ = mangaObj.Set("getChapterContainer", m.getChapterContainer)
|
||||
_ = mangaObj.Set("getProviders", m.getProviders)
|
||||
_ = obj.Set("manga", mangaObj)
|
||||
}
|
||||
|
||||
func (m *Manga) getProviders() (map[string]string, error) {
|
||||
mangaRepo, ok := m.ctx.mangaRepository.Get()
|
||||
if !ok {
|
||||
return nil, errors.New("manga repository not found")
|
||||
}
|
||||
providers := make(map[string]string)
|
||||
extension.RangeExtensions(mangaRepo.GetProviderExtensionBank(), func(id string, ext extension.MangaProviderExtension) bool {
|
||||
providers[id] = ext.GetName()
|
||||
return true
|
||||
})
|
||||
return providers, nil
|
||||
}
|
||||
|
||||
type GetChapterContainerOptions struct {
|
||||
MediaId int
|
||||
Provider string
|
||||
Titles []*string
|
||||
Year int
|
||||
}
|
||||
|
||||
func (m *Manga) getChapterContainer(opts *GetChapterContainerOptions) goja.Value {
|
||||
promise, resolve, reject := m.vm.NewPromise()
|
||||
|
||||
mangaRepo, ok := m.ctx.mangaRepository.Get()
|
||||
if !ok {
|
||||
// reject(goja_bindings.NewErrorString(m.vm, "manga repository not set"))
|
||||
// return m.vm.ToValue(promise)
|
||||
goja_bindings.PanicThrowErrorString(m.vm, "manga repository not set")
|
||||
}
|
||||
|
||||
go func() {
|
||||
ret, err := mangaRepo.GetMangaChapterContainer(&manga.GetMangaChapterContainerOptions{
|
||||
MediaId: opts.MediaId,
|
||||
Provider: opts.Provider,
|
||||
Titles: opts.Titles,
|
||||
Year: opts.Year,
|
||||
})
|
||||
m.scheduler.ScheduleAsync(func() error {
|
||||
if err != nil {
|
||||
reject(err.Error())
|
||||
} else {
|
||||
resolve(ret)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
return m.vm.ToValue(promise)
|
||||
}
|
||||
|
||||
func (m *Manga) getDownloadedChapterContainers() ([]*manga.ChapterContainer, error) {
|
||||
mangaRepo, ok := m.ctx.mangaRepository.Get()
|
||||
if !ok {
|
||||
return nil, errors.New("manga repository not found")
|
||||
}
|
||||
anilistPlatform, foundAnilistPlatform := m.ctx.anilistPlatform.Get()
|
||||
if !foundAnilistPlatform {
|
||||
return nil, errors.New("anilist platform not found")
|
||||
}
|
||||
|
||||
mangaCollection, err := anilistPlatform.GetMangaCollection(context.Background(), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return mangaRepo.GetDownloadedChapterContainers(mangaCollection)
|
||||
}
|
||||
|
||||
func (m *Manga) getCollection() (*manga.Collection, error) {
|
||||
anilistPlatform, foundAnilistPlatform := m.ctx.anilistPlatform.Get()
|
||||
if !foundAnilistPlatform {
|
||||
return nil, errors.New("anilist platform not found")
|
||||
}
|
||||
|
||||
mangaCollection, err := anilistPlatform.GetMangaCollection(context.Background(), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return manga.NewCollection(&manga.NewCollectionOptions{
|
||||
MangaCollection: mangaCollection,
|
||||
Platform: anilistPlatform,
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Manga) refreshChapterContainers(selectedProviderMap map[int]string) goja.Value {
|
||||
promise, resolve, reject := m.vm.NewPromise()
|
||||
|
||||
mangaRepo, ok := m.ctx.mangaRepository.Get()
|
||||
if !ok {
|
||||
jsErr := m.vm.NewGoError(errors.New("manga repository not found"))
|
||||
_ = reject(jsErr)
|
||||
return m.vm.ToValue(promise)
|
||||
}
|
||||
anilistPlatform, foundAnilistPlatform := m.ctx.anilistPlatform.Get()
|
||||
if !foundAnilistPlatform {
|
||||
jsErr := m.vm.NewGoError(errors.New("anilist platform not found"))
|
||||
_ = reject(jsErr)
|
||||
return m.vm.ToValue(promise)
|
||||
}
|
||||
|
||||
mangaCollection, err := anilistPlatform.GetMangaCollection(context.Background(), false)
|
||||
if err != nil {
|
||||
reject(err.Error())
|
||||
return m.vm.ToValue(promise)
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := mangaRepo.RefreshChapterContainers(mangaCollection, selectedProviderMap)
|
||||
m.scheduler.ScheduleAsync(func() error {
|
||||
if err != nil {
|
||||
reject(err.Error())
|
||||
} else {
|
||||
resolve(nil)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
return m.vm.ToValue(promise)
|
||||
}
|
||||
|
||||
func (m *Manga) emptyCache(mediaId int) goja.Value {
|
||||
promise, resolve, reject := m.vm.NewPromise()
|
||||
|
||||
mangaRepo, ok := m.ctx.mangaRepository.Get()
|
||||
if !ok {
|
||||
// reject(goja_bindings.NewErrorString(m.vm, "manga repository not found"))
|
||||
// return m.vm.ToValue(promise)
|
||||
goja_bindings.PanicThrowErrorString(m.vm, "manga repository not found")
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := mangaRepo.EmptyMangaCache(mediaId)
|
||||
m.scheduler.ScheduleAsync(func() error {
|
||||
if err != nil {
|
||||
reject(err.Error())
|
||||
} else {
|
||||
resolve(nil)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
return m.vm.ToValue(promise)
|
||||
}
|
||||
347
seanime-2.9.10/internal/plugin/other.go
Normal file
347
seanime-2.9.10/internal/plugin/other.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/extension"
|
||||
"seanime/internal/goja/goja_bindings"
|
||||
"seanime/internal/library/anime"
|
||||
"seanime/internal/onlinestream"
|
||||
goja_util "seanime/internal/util/goja"
|
||||
"strconv"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
// BindTorrentstreamToContextObj binds 'torrentstream' to the UI context object
|
||||
func (a *AppContextImpl) BindTorrentstreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
|
||||
|
||||
}
|
||||
|
||||
// BindOnlinestreamToContextObj binds 'onlinestream' to the UI context object
|
||||
func (a *AppContextImpl) BindOnlinestreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
|
||||
|
||||
}
|
||||
|
||||
// BindMediastreamToContextObj binds 'mediastream' to the UI context object
|
||||
func (a *AppContextImpl) BindMediastreamToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
|
||||
|
||||
}
|
||||
|
||||
// BindTorrentClientToContextObj binds 'torrentClient' to the UI context object
|
||||
func (a *AppContextImpl) BindTorrentClientToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
|
||||
|
||||
torrentClientObj := vm.NewObject()
|
||||
_ = torrentClientObj.Set("getTorrents", func() goja.Value {
|
||||
promise, resolve, reject := vm.NewPromise()
|
||||
|
||||
torrentClient, ok := a.torrentClientRepository.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "torrentClient not set")
|
||||
}
|
||||
|
||||
go func() {
|
||||
torrents, err := torrentClient.GetList()
|
||||
scheduler.ScheduleAsync(func() error {
|
||||
if err != nil {
|
||||
reject(goja_bindings.NewErrorString(vm, "error getting torrents: "+err.Error()))
|
||||
return nil
|
||||
}
|
||||
resolve(vm.ToValue(torrents))
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
return vm.ToValue(promise)
|
||||
})
|
||||
|
||||
_ = torrentClientObj.Set("getActiveTorrents", func() goja.Value {
|
||||
promise, resolve, reject := vm.NewPromise()
|
||||
|
||||
torrentClient, ok := a.torrentClientRepository.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "torrentClient not set")
|
||||
}
|
||||
|
||||
go func() {
|
||||
activeTorrents, err := torrentClient.GetActiveTorrents()
|
||||
scheduler.ScheduleAsync(func() error {
|
||||
if err != nil {
|
||||
reject(goja_bindings.NewErrorString(vm, "error getting active torrents: "+err.Error()))
|
||||
return nil
|
||||
}
|
||||
resolve(vm.ToValue(activeTorrents))
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
return vm.ToValue(promise)
|
||||
})
|
||||
|
||||
_ = torrentClientObj.Set("addMagnets", func(magnets []string, dest string) goja.Value {
|
||||
promise, resolve, reject := vm.NewPromise()
|
||||
|
||||
torrentClient, ok := a.torrentClientRepository.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "torrentClient not set")
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := torrentClient.AddMagnets(magnets, dest)
|
||||
scheduler.ScheduleAsync(func() error {
|
||||
if err != nil {
|
||||
reject(goja_bindings.NewErrorString(vm, "error adding magnets: "+err.Error()))
|
||||
return nil
|
||||
}
|
||||
resolve(goja.Undefined())
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
return vm.ToValue(promise)
|
||||
})
|
||||
|
||||
_ = torrentClientObj.Set("removeTorrents", func(hashes []string) goja.Value {
|
||||
promise, resolve, reject := vm.NewPromise()
|
||||
|
||||
torrentClient, ok := a.torrentClientRepository.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "torrentClient not set")
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := torrentClient.RemoveTorrents(hashes)
|
||||
scheduler.ScheduleAsync(func() error {
|
||||
if err != nil {
|
||||
reject(goja_bindings.NewErrorString(vm, "error removing torrents: "+err.Error()))
|
||||
return nil
|
||||
}
|
||||
resolve(goja.Undefined())
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
return vm.ToValue(promise)
|
||||
})
|
||||
|
||||
_ = torrentClientObj.Set("pauseTorrents", func(hashes []string) goja.Value {
|
||||
promise, resolve, reject := vm.NewPromise()
|
||||
|
||||
torrentClient, ok := a.torrentClientRepository.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "torrentClient not set")
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := torrentClient.PauseTorrents(hashes)
|
||||
scheduler.ScheduleAsync(func() error {
|
||||
if err != nil {
|
||||
reject(goja_bindings.NewErrorString(vm, "error pausing torrents: "+err.Error()))
|
||||
return nil
|
||||
}
|
||||
resolve(goja.Undefined())
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
return vm.ToValue(promise)
|
||||
})
|
||||
|
||||
_ = torrentClientObj.Set("resumeTorrents", func(hashes []string) goja.Value {
|
||||
promise, resolve, reject := vm.NewPromise()
|
||||
|
||||
torrentClient, ok := a.torrentClientRepository.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "torrentClient not set")
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := torrentClient.ResumeTorrents(hashes)
|
||||
scheduler.ScheduleAsync(func() error {
|
||||
if err != nil {
|
||||
reject(goja_bindings.NewErrorString(vm, "error resuming torrents: "+err.Error()))
|
||||
return nil
|
||||
}
|
||||
resolve(goja.Undefined())
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
return vm.ToValue(promise)
|
||||
})
|
||||
|
||||
_ = torrentClientObj.Set("deselectFiles", func(hash string, indices []int) goja.Value {
|
||||
promise, resolve, reject := vm.NewPromise()
|
||||
|
||||
torrentClient, ok := a.torrentClientRepository.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "torrentClient not set")
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := torrentClient.DeselectFiles(hash, indices)
|
||||
scheduler.ScheduleAsync(func() error {
|
||||
if err != nil {
|
||||
reject(goja_bindings.NewErrorString(vm, "error deselecting files: "+err.Error()))
|
||||
return nil
|
||||
}
|
||||
resolve(goja.Undefined())
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
return vm.ToValue(promise)
|
||||
})
|
||||
|
||||
_ = torrentClientObj.Set("getFiles", func(hash string) goja.Value {
|
||||
promise, resolve, reject := vm.NewPromise()
|
||||
|
||||
torrentClient, ok := a.torrentClientRepository.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "torrentClient not set")
|
||||
}
|
||||
|
||||
go func() {
|
||||
files, err := torrentClient.GetFiles(hash)
|
||||
scheduler.ScheduleAsync(func() error {
|
||||
if err != nil {
|
||||
reject(goja_bindings.NewErrorString(vm, "error getting files: "+err.Error()))
|
||||
return nil
|
||||
}
|
||||
resolve(vm.ToValue(files))
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
return vm.ToValue(promise)
|
||||
})
|
||||
|
||||
_ = obj.Set("torrentClient", torrentClientObj)
|
||||
|
||||
}
|
||||
|
||||
// BindFillerManagerToContextObj binds 'fillerManager' to the UI context object
|
||||
func (a *AppContextImpl) BindFillerManagerToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
|
||||
|
||||
fillerManagerObj := vm.NewObject()
|
||||
_ = fillerManagerObj.Set("getFillerEpisodes", func(mediaId int) goja.Value {
|
||||
fillerManager, ok := a.fillerManager.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "fillerManager not set")
|
||||
}
|
||||
fillerEpisodes, ok := fillerManager.GetFillerEpisodes(mediaId)
|
||||
if !ok {
|
||||
return goja.Undefined()
|
||||
}
|
||||
return vm.ToValue(fillerEpisodes)
|
||||
})
|
||||
|
||||
_ = fillerManagerObj.Set("removeFillerData", func(mediaId int) goja.Value {
|
||||
fillerManager, ok := a.fillerManager.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "fillerManager not set")
|
||||
}
|
||||
fillerManager.RemoveFillerData(mediaId)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
_ = fillerManagerObj.Set("setFillerEpisodes", func(mediaId int, fillerEpisodes []string) goja.Value {
|
||||
fillerManager, ok := a.fillerManager.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "fillerManager not set")
|
||||
}
|
||||
fillerManager.StoreFillerData("plugin", strconv.Itoa(mediaId), mediaId, fillerEpisodes)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
_ = fillerManagerObj.Set("isEpisodeFiller", func(mediaId int, episodeNumber int) goja.Value {
|
||||
fillerManager, ok := a.fillerManager.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "fillerManager not set")
|
||||
}
|
||||
return vm.ToValue(fillerManager.IsEpisodeFiller(mediaId, episodeNumber))
|
||||
})
|
||||
|
||||
_ = fillerManagerObj.Set("hydrateFillerData", func(e *anime.Entry) goja.Value {
|
||||
fillerManager, ok := a.fillerManager.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "fillerManager not set")
|
||||
}
|
||||
fillerManager.HydrateFillerData(e)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
_ = fillerManagerObj.Set("hydrateOnlinestreamFillerData", func(mId int, episodes []*onlinestream.Episode) goja.Value {
|
||||
fillerManager, ok := a.fillerManager.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "fillerManager not set")
|
||||
}
|
||||
fillerManager.HydrateOnlinestreamFillerData(mId, episodes)
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
_ = obj.Set("fillerManager", fillerManagerObj)
|
||||
|
||||
}
|
||||
|
||||
// BindAutoDownloaderToContextObj binds 'autoDownloader' to the UI context object
|
||||
func (a *AppContextImpl) BindAutoDownloaderToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
|
||||
|
||||
autoDownloaderObj := vm.NewObject()
|
||||
_ = autoDownloaderObj.Set("run", func() goja.Value {
|
||||
autoDownloader, ok := a.autoDownloader.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "autoDownloader not set")
|
||||
}
|
||||
autoDownloader.Run()
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("autoDownloader", autoDownloaderObj)
|
||||
}
|
||||
|
||||
// BindAutoScannerToContextObj binds 'autoScanner' to the UI context object
|
||||
func (a *AppContextImpl) BindAutoScannerToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
|
||||
|
||||
autoScannerObj := vm.NewObject()
|
||||
_ = autoScannerObj.Set("notify", func() goja.Value {
|
||||
autoScanner, ok := a.autoScanner.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "autoScanner not set")
|
||||
}
|
||||
autoScanner.Notify()
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("autoScanner", autoScannerObj)
|
||||
|
||||
}
|
||||
|
||||
// BindFileCacherToContextObj binds 'fileCacher' to the UI context object
|
||||
func (a *AppContextImpl) BindFileCacherToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
|
||||
|
||||
}
|
||||
|
||||
// BindExternalPlayerLinkToContextObj binds 'externalPlayerLink' to the UI context object
|
||||
func (a *AppContextImpl) BindExternalPlayerLinkToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
|
||||
|
||||
externalPlayerLinkObj := vm.NewObject()
|
||||
_ = externalPlayerLinkObj.Set("open", func(url string, mediaId int, episodeNumber int, mediaTitle string) goja.Value {
|
||||
wsEventManager, ok := a.wsEventManager.Get()
|
||||
if !ok {
|
||||
goja_bindings.PanicThrowErrorString(vm, "wsEventManager not set")
|
||||
}
|
||||
// Send the external player link
|
||||
wsEventManager.SendEvent(events.ExternalPlayerOpenURL, struct {
|
||||
Url string `json:"url"`
|
||||
MediaId int `json:"mediaId"`
|
||||
EpisodeNumber int `json:"episodeNumber"`
|
||||
MediaTitle string `json:"mediaTitle"`
|
||||
}{
|
||||
Url: url,
|
||||
MediaId: mediaId,
|
||||
EpisodeNumber: episodeNumber,
|
||||
MediaTitle: mediaTitle,
|
||||
})
|
||||
return goja.Undefined()
|
||||
})
|
||||
_ = obj.Set("externalPlayerLink", externalPlayerLinkObj)
|
||||
}
|
||||
381
seanime-2.9.10/internal/plugin/playback.go
Normal file
381
seanime-2.9.10/internal/plugin/playback.go
Normal file
@@ -0,0 +1,381 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"seanime/internal/api/anilist"
|
||||
"seanime/internal/extension"
|
||||
"seanime/internal/library/playbackmanager"
|
||||
"seanime/internal/mediaplayers/mediaplayer"
|
||||
"seanime/internal/mediaplayers/mpv"
|
||||
"seanime/internal/mediaplayers/mpvipc"
|
||||
goja_util "seanime/internal/util/goja"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
type Playback struct {
|
||||
ctx *AppContextImpl
|
||||
vm *goja.Runtime
|
||||
logger *zerolog.Logger
|
||||
ext *extension.Extension
|
||||
scheduler *goja_util.Scheduler
|
||||
}
|
||||
|
||||
type PlaybackMPV struct {
|
||||
mpv *mpv.Mpv
|
||||
playback *Playback
|
||||
}
|
||||
|
||||
func (a *AppContextImpl) BindPlaybackToContextObj(vm *goja.Runtime, obj *goja.Object, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) {
|
||||
p := &Playback{
|
||||
ctx: a,
|
||||
vm: vm,
|
||||
logger: logger,
|
||||
ext: ext,
|
||||
scheduler: scheduler,
|
||||
}
|
||||
|
||||
playbackObj := vm.NewObject()
|
||||
_ = playbackObj.Set("playUsingMediaPlayer", p.playUsingMediaPlayer)
|
||||
_ = playbackObj.Set("streamUsingMediaPlayer", p.streamUsingMediaPlayer)
|
||||
_ = playbackObj.Set("registerEventListener", p.registerEventListener)
|
||||
_ = playbackObj.Set("pause", p.pause)
|
||||
_ = playbackObj.Set("resume", p.resume)
|
||||
_ = playbackObj.Set("seek", p.seek)
|
||||
_ = playbackObj.Set("cancel", p.cancel)
|
||||
_ = playbackObj.Set("getNextEpisode", p.getNextEpisode)
|
||||
_ = playbackObj.Set("playNextEpisode", p.playNextEpisode)
|
||||
_ = obj.Set("playback", playbackObj)
|
||||
|
||||
// MPV
|
||||
mpvObj := vm.NewObject()
|
||||
mpv := mpv.New(logger, "", "")
|
||||
playbackMPV := &PlaybackMPV{
|
||||
mpv: mpv,
|
||||
playback: p,
|
||||
}
|
||||
_ = mpvObj.Set("openAndPlay", playbackMPV.openAndPlay)
|
||||
_ = mpvObj.Set("onEvent", playbackMPV.onEvent)
|
||||
_ = mpvObj.Set("getConnection", playbackMPV.getConnection)
|
||||
_ = mpvObj.Set("stop", playbackMPV.stop)
|
||||
_ = obj.Set("mpv", mpvObj)
|
||||
}
|
||||
|
||||
type PlaybackEvent struct {
|
||||
IsVideoStarted bool `json:"isVideoStarted"`
|
||||
IsVideoStopped bool `json:"isVideoStopped"`
|
||||
IsVideoCompleted bool `json:"isVideoCompleted"`
|
||||
IsStreamStarted bool `json:"isStreamStarted"`
|
||||
IsStreamStopped bool `json:"isStreamStopped"`
|
||||
IsStreamCompleted bool `json:"isStreamCompleted"`
|
||||
StartedEvent *struct {
|
||||
Filename string `json:"filename"`
|
||||
} `json:"startedEvent"`
|
||||
StoppedEvent *struct {
|
||||
Reason string `json:"reason"`
|
||||
} `json:"stoppedEvent"`
|
||||
CompletedEvent *struct {
|
||||
Filename string `json:"filename"`
|
||||
} `json:"completedEvent"`
|
||||
State *playbackmanager.PlaybackState `json:"state"`
|
||||
Status *mediaplayer.PlaybackStatus `json:"status"`
|
||||
}
|
||||
|
||||
// playUsingMediaPlayer starts playback of a local file using the media player specified in the settings.
|
||||
func (p *Playback) playUsingMediaPlayer(payload string) error {
|
||||
playbackManager, ok := p.ctx.PlaybackManager().Get()
|
||||
if !ok {
|
||||
return errors.New("playback manager not found")
|
||||
}
|
||||
|
||||
return playbackManager.StartPlayingUsingMediaPlayer(&playbackmanager.StartPlayingOptions{
|
||||
Payload: payload,
|
||||
})
|
||||
}
|
||||
|
||||
// streamUsingMediaPlayer starts streaming a video using the media player specified in the settings.
|
||||
func (p *Playback) streamUsingMediaPlayer(windowTitle string, payload string, media *anilist.BaseAnime, aniDbEpisode string) error {
|
||||
playbackManager, ok := p.ctx.PlaybackManager().Get()
|
||||
if !ok {
|
||||
return errors.New("playback manager not found")
|
||||
}
|
||||
|
||||
return playbackManager.StartStreamingUsingMediaPlayer(windowTitle, &playbackmanager.StartPlayingOptions{
|
||||
Payload: payload,
|
||||
}, media, aniDbEpisode)
|
||||
}
|
||||
|
||||
////////////////////////////////////
|
||||
// MPV
|
||||
////////////////////////////////////
|
||||
|
||||
func (p *PlaybackMPV) openAndPlay(filePath string) goja.Value {
|
||||
promise, resolve, reject := p.playback.vm.NewPromise()
|
||||
|
||||
go func() {
|
||||
err := p.mpv.OpenAndPlay(filePath)
|
||||
p.playback.scheduler.ScheduleAsync(func() error {
|
||||
if err != nil {
|
||||
jsErr := p.playback.vm.NewGoError(err)
|
||||
reject(jsErr)
|
||||
} else {
|
||||
resolve(nil)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
return p.playback.vm.ToValue(promise)
|
||||
}
|
||||
|
||||
func (p *PlaybackMPV) onEvent(callback func(event *mpvipc.Event, closed bool)) (func(), error) {
|
||||
id := p.playback.ext.ID + "_mpv"
|
||||
sub := p.mpv.Subscribe(id)
|
||||
|
||||
go func() {
|
||||
for event := range sub.Events() {
|
||||
p.playback.scheduler.ScheduleAsync(func() error {
|
||||
callback(event, false)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for range sub.Closed() {
|
||||
p.playback.scheduler.ScheduleAsync(func() error {
|
||||
callback(nil, true)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
cancelFn := func() {
|
||||
p.mpv.Unsubscribe(id)
|
||||
}
|
||||
|
||||
return cancelFn, nil
|
||||
}
|
||||
|
||||
func (p *PlaybackMPV) stop() goja.Value {
|
||||
promise, resolve, _ := p.playback.vm.NewPromise()
|
||||
|
||||
go func() {
|
||||
p.mpv.CloseAll()
|
||||
p.playback.scheduler.ScheduleAsync(func() error {
|
||||
resolve(goja.Undefined())
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
return p.playback.vm.ToValue(promise)
|
||||
}
|
||||
|
||||
func (p *PlaybackMPV) getConnection() goja.Value {
|
||||
conn, err := p.mpv.GetOpenConnection()
|
||||
if err != nil {
|
||||
return goja.Undefined()
|
||||
}
|
||||
return p.playback.vm.ToValue(conn)
|
||||
}
|
||||
|
||||
// registerEventListener registers a subscriber for playback events.
|
||||
//
|
||||
// Example:
|
||||
// $playback.registerEventListener("mySubscriber", (event) => {
|
||||
// console.log(event)
|
||||
// });
|
||||
func (p *Playback) registerEventListener(callback func(event *PlaybackEvent)) (func(), error) {
|
||||
playbackManager, ok := p.ctx.PlaybackManager().Get()
|
||||
if !ok {
|
||||
return nil, errors.New("playback manager not found")
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
|
||||
subscriber := playbackManager.SubscribeToPlaybackStatus(id)
|
||||
|
||||
go func() {
|
||||
for event := range subscriber.EventCh {
|
||||
switch e := event.(type) {
|
||||
case playbackmanager.PlaybackStatusChangedEvent:
|
||||
p.scheduler.ScheduleAsync(func() error {
|
||||
callback(&PlaybackEvent{
|
||||
Status: &e.Status,
|
||||
State: &e.State,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
case playbackmanager.VideoStartedEvent:
|
||||
p.scheduler.ScheduleAsync(func() error {
|
||||
callback(&PlaybackEvent{
|
||||
IsVideoStarted: true,
|
||||
StartedEvent: &struct {
|
||||
Filename string `json:"filename"`
|
||||
}{
|
||||
Filename: e.Filename,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
})
|
||||
case playbackmanager.VideoStoppedEvent:
|
||||
p.scheduler.ScheduleAsync(func() error {
|
||||
callback(&PlaybackEvent{
|
||||
IsVideoStopped: true,
|
||||
StoppedEvent: &struct {
|
||||
Reason string `json:"reason"`
|
||||
}{
|
||||
Reason: e.Reason,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
})
|
||||
case playbackmanager.VideoCompletedEvent:
|
||||
p.scheduler.ScheduleAsync(func() error {
|
||||
callback(&PlaybackEvent{
|
||||
IsVideoCompleted: true,
|
||||
CompletedEvent: &struct {
|
||||
Filename string `json:"filename"`
|
||||
}{
|
||||
Filename: e.Filename,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
})
|
||||
case playbackmanager.StreamStateChangedEvent:
|
||||
p.scheduler.ScheduleAsync(func() error {
|
||||
callback(&PlaybackEvent{
|
||||
State: &e.State,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
case playbackmanager.StreamStatusChangedEvent:
|
||||
p.scheduler.ScheduleAsync(func() error {
|
||||
callback(&PlaybackEvent{
|
||||
Status: &e.Status,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
case playbackmanager.StreamStartedEvent:
|
||||
p.scheduler.ScheduleAsync(func() error {
|
||||
callback(&PlaybackEvent{
|
||||
IsStreamStarted: true,
|
||||
StartedEvent: &struct {
|
||||
Filename string `json:"filename"`
|
||||
}{
|
||||
Filename: e.Filename,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
})
|
||||
case playbackmanager.StreamStoppedEvent:
|
||||
p.scheduler.ScheduleAsync(func() error {
|
||||
callback(&PlaybackEvent{
|
||||
IsStreamStopped: true,
|
||||
StoppedEvent: &struct {
|
||||
Reason string `json:"reason"`
|
||||
}{
|
||||
Reason: e.Reason,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
})
|
||||
case playbackmanager.StreamCompletedEvent:
|
||||
p.scheduler.ScheduleAsync(func() error {
|
||||
callback(&PlaybackEvent{
|
||||
IsStreamCompleted: true,
|
||||
CompletedEvent: &struct {
|
||||
Filename string `json:"filename"`
|
||||
}{
|
||||
Filename: e.Filename,
|
||||
},
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
cancelFn := func() {
|
||||
playbackManager.UnsubscribeFromPlaybackStatus(id)
|
||||
}
|
||||
|
||||
return cancelFn, nil
|
||||
}
|
||||
|
||||
func (p *Playback) pause() error {
|
||||
playbackManager, ok := p.ctx.PlaybackManager().Get()
|
||||
if !ok {
|
||||
return errors.New("playback manager not found")
|
||||
}
|
||||
return playbackManager.Pause()
|
||||
}
|
||||
|
||||
func (p *Playback) resume() error {
|
||||
playbackManager, ok := p.ctx.PlaybackManager().Get()
|
||||
if !ok {
|
||||
return errors.New("playback manager not found")
|
||||
}
|
||||
return playbackManager.Resume()
|
||||
}
|
||||
|
||||
func (p *Playback) seek(seconds float64) error {
|
||||
playbackManager, ok := p.ctx.PlaybackManager().Get()
|
||||
if !ok {
|
||||
return errors.New("playback manager not found")
|
||||
}
|
||||
return playbackManager.Seek(seconds)
|
||||
}
|
||||
|
||||
func (p *Playback) cancel() error {
|
||||
playbackManager, ok := p.ctx.PlaybackManager().Get()
|
||||
if !ok {
|
||||
return errors.New("playback manager not found")
|
||||
}
|
||||
return playbackManager.Cancel()
|
||||
}
|
||||
|
||||
func (p *Playback) getNextEpisode() goja.Value {
|
||||
promise, resolve, reject := p.vm.NewPromise()
|
||||
|
||||
playbackManager, ok := p.ctx.PlaybackManager().Get()
|
||||
if !ok {
|
||||
reject(p.vm.NewGoError(errors.New("playback manager not found")))
|
||||
return p.vm.ToValue(promise)
|
||||
}
|
||||
|
||||
go func() {
|
||||
nextEpisode := playbackManager.GetNextEpisode()
|
||||
p.scheduler.ScheduleAsync(func() error {
|
||||
resolve(p.vm.ToValue(nextEpisode))
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
return p.vm.ToValue(promise)
|
||||
}
|
||||
|
||||
func (p *Playback) playNextEpisode() goja.Value {
|
||||
promise, resolve, reject := p.vm.NewPromise()
|
||||
|
||||
playbackManager, ok := p.ctx.PlaybackManager().Get()
|
||||
if !ok {
|
||||
reject(p.vm.NewGoError(errors.New("playback manager not found")))
|
||||
return p.vm.ToValue(promise)
|
||||
}
|
||||
|
||||
go func() {
|
||||
err := playbackManager.PlayNextEpisode()
|
||||
p.scheduler.ScheduleAsync(func() error {
|
||||
if err != nil {
|
||||
reject(p.vm.NewGoError(err))
|
||||
} else {
|
||||
resolve(goja.Undefined())
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}()
|
||||
|
||||
return p.vm.ToValue(promise)
|
||||
}
|
||||
26
seanime-2.9.10/internal/plugin/plugin_app.go
Normal file
26
seanime-2.9.10/internal/plugin/plugin_app.go
Normal file
@@ -0,0 +1,26 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"seanime/internal/constants"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/extension"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
func (a *AppContextImpl) BindApp(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension) {
|
||||
appObj := vm.NewObject()
|
||||
appObj.Set("getVersion", constants.Version)
|
||||
appObj.Set("getVersionName", constants.VersionName)
|
||||
|
||||
appObj.Set("invalidateClientQuery", func(keys []string) {
|
||||
wsEventManager, ok := a.wsEventManager.Get()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
wsEventManager.SendEvent(events.InvalidateQueries, keys)
|
||||
})
|
||||
|
||||
_ = vm.Set("$app", appObj)
|
||||
}
|
||||
631
seanime-2.9.10/internal/plugin/storage.go
Normal file
631
seanime-2.9.10/internal/plugin/storage.go
Normal file
@@ -0,0 +1,631 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"seanime/internal/database/models"
|
||||
"seanime/internal/extension"
|
||||
goja_util "seanime/internal/util/goja"
|
||||
"seanime/internal/util/result"
|
||||
"strings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/rs/zerolog"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Storage is used to store data for an extension.
|
||||
// A new instance is created for each extension.
|
||||
type Storage struct {
|
||||
ctx *AppContextImpl
|
||||
ext *extension.Extension
|
||||
logger *zerolog.Logger
|
||||
runtime *goja.Runtime
|
||||
pluginDataCache *result.Map[string, *models.PluginData] // Cache to avoid repeated database calls
|
||||
keyDataCache *result.Map[string, interface{}] // Cache to avoid repeated database calls
|
||||
keySubscribers *result.Map[string, []chan interface{}] // Subscribers for key changes
|
||||
scheduler *goja_util.Scheduler
|
||||
}
|
||||
|
||||
var (
|
||||
ErrDatabaseNotInitialized = errors.New("database is not initialized")
|
||||
)
|
||||
|
||||
// BindStorage binds the storage API to the Goja runtime.
|
||||
// Permissions need to be checked by the caller.
|
||||
// Permissions needed: storage
|
||||
func (a *AppContextImpl) BindStorage(vm *goja.Runtime, logger *zerolog.Logger, ext *extension.Extension, scheduler *goja_util.Scheduler) *Storage {
|
||||
storageLogger := logger.With().Str("id", ext.ID).Logger()
|
||||
storage := &Storage{
|
||||
ctx: a,
|
||||
ext: ext,
|
||||
logger: &storageLogger,
|
||||
runtime: vm,
|
||||
pluginDataCache: result.NewResultMap[string, *models.PluginData](),
|
||||
keyDataCache: result.NewResultMap[string, interface{}](),
|
||||
keySubscribers: result.NewResultMap[string, []chan interface{}](),
|
||||
scheduler: scheduler,
|
||||
}
|
||||
storageObj := vm.NewObject()
|
||||
_ = storageObj.Set("get", storage.Get)
|
||||
_ = storageObj.Set("set", storage.Set)
|
||||
_ = storageObj.Set("remove", storage.Delete)
|
||||
_ = storageObj.Set("drop", storage.Drop)
|
||||
_ = storageObj.Set("clear", storage.Clear)
|
||||
_ = storageObj.Set("keys", storage.Keys)
|
||||
_ = storageObj.Set("has", storage.Has)
|
||||
_ = storageObj.Set("watch", storage.Watch)
|
||||
_ = vm.Set("$storage", storageObj)
|
||||
|
||||
return storage
|
||||
}
|
||||
|
||||
// Stop closes all subscriber channels.
|
||||
func (s *Storage) Stop() {
|
||||
s.keySubscribers.Range(func(key string, subscribers []chan interface{}) bool {
|
||||
for _, ch := range subscribers {
|
||||
close(ch)
|
||||
}
|
||||
return true
|
||||
})
|
||||
s.keySubscribers.Clear()
|
||||
}
|
||||
|
||||
// getDB returns the database instance or an error if not initialized
|
||||
func (s *Storage) getDB() (*gorm.DB, error) {
|
||||
db, ok := s.ctx.database.Get()
|
||||
if !ok {
|
||||
return nil, ErrDatabaseNotInitialized
|
||||
}
|
||||
return db.Gorm(), nil
|
||||
}
|
||||
|
||||
// getPluginData retrieves the plugin data from the database
|
||||
// If createIfNotExists is true, it will create an empty record if none exists
|
||||
func (s *Storage) getPluginData(createIfNotExists bool) (*models.PluginData, error) {
|
||||
// Check cache first
|
||||
if cachedData, ok := s.pluginDataCache.Get(s.ext.ID); ok {
|
||||
return cachedData, nil
|
||||
}
|
||||
|
||||
db, err := s.getDB()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pluginData models.PluginData
|
||||
if err := db.Where("plugin_id = ?", s.ext.ID).First(&pluginData).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) && createIfNotExists {
|
||||
// Create empty data structure
|
||||
baseData := make(map[string]interface{})
|
||||
baseDataMarshaled, err := json.Marshal(baseData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newPluginData := &models.PluginData{
|
||||
PluginID: s.ext.ID,
|
||||
Data: baseDataMarshaled,
|
||||
}
|
||||
|
||||
if err := db.Create(newPluginData).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the new plugin data
|
||||
s.pluginDataCache.Set(s.ext.ID, newPluginData)
|
||||
return newPluginData, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Cache the plugin data
|
||||
s.pluginDataCache.Set(s.ext.ID, &pluginData)
|
||||
return &pluginData, nil
|
||||
}
|
||||
|
||||
// getDataMap unmarshals the plugin data into a map
|
||||
func (s *Storage) getDataMap(pluginData *models.PluginData) (map[string]interface{}, error) {
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(pluginData.Data, &data); err != nil {
|
||||
return make(map[string]interface{}), err
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
// saveDataMap marshals and saves the data map to the database
|
||||
func (s *Storage) saveDataMap(pluginData *models.PluginData, data map[string]interface{}) error {
|
||||
marshaled, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
pluginData.Data = marshaled
|
||||
|
||||
db, err := s.getDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = db.Save(pluginData).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Update the cache
|
||||
s.pluginDataCache.Set(s.ext.ID, pluginData)
|
||||
|
||||
s.keyDataCache.Clear()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getNestedValue retrieves a value from a nested map using dot notation
|
||||
func getNestedValue(data map[string]interface{}, path string) interface{} {
|
||||
if !strings.Contains(path, ".") {
|
||||
return data[path]
|
||||
}
|
||||
|
||||
parts := strings.Split(path, ".")
|
||||
current := data
|
||||
|
||||
// Navigate through all parts except the last one
|
||||
for i := 0; i < len(parts)-1; i++ {
|
||||
part := parts[i]
|
||||
next, ok := current[part]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Try to convert to map for next level
|
||||
nextMap, ok := next.(map[string]interface{})
|
||||
if !ok {
|
||||
// Try to convert from unmarshaled JSON
|
||||
jsonMap, ok := next.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
nextMap = jsonMap
|
||||
}
|
||||
|
||||
current = nextMap
|
||||
}
|
||||
|
||||
// Return the value at the final part
|
||||
return current[parts[len(parts)-1]]
|
||||
}
|
||||
|
||||
// setNestedValue sets a value in a nested map using dot notation
|
||||
// It creates intermediate maps as needed
|
||||
func setNestedValue(data map[string]interface{}, path string, value interface{}) {
|
||||
if !strings.Contains(path, ".") {
|
||||
data[path] = value
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.Split(path, ".")
|
||||
current := data
|
||||
|
||||
// Navigate and create intermediate maps as needed
|
||||
for i := 0; i < len(parts)-1; i++ {
|
||||
part := parts[i]
|
||||
next, ok := current[part]
|
||||
if !ok {
|
||||
// Create new map if key doesn't exist
|
||||
next = make(map[string]interface{})
|
||||
current[part] = next
|
||||
}
|
||||
|
||||
// Try to convert to map for next level
|
||||
nextMap, ok := next.(map[string]interface{})
|
||||
if !ok {
|
||||
// Try to convert from unmarshaled JSON
|
||||
jsonMap, ok := next.(map[string]interface{})
|
||||
if !ok {
|
||||
// Replace with a new map if not convertible
|
||||
nextMap = make(map[string]interface{})
|
||||
current[part] = nextMap
|
||||
} else {
|
||||
nextMap = jsonMap
|
||||
current[part] = nextMap
|
||||
}
|
||||
}
|
||||
|
||||
current = nextMap
|
||||
}
|
||||
|
||||
// Set the value at the final part
|
||||
current[parts[len(parts)-1]] = value
|
||||
}
|
||||
|
||||
// deleteNestedValue deletes a value from a nested map using dot notation
|
||||
// Returns true if the key was found and deleted
|
||||
func deleteNestedValue(data map[string]interface{}, path string) bool {
|
||||
if !strings.Contains(path, ".") {
|
||||
_, exists := data[path]
|
||||
if exists {
|
||||
delete(data, path)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
parts := strings.Split(path, ".")
|
||||
current := data
|
||||
|
||||
// Navigate through all parts except the last one
|
||||
for i := 0; i < len(parts)-1; i++ {
|
||||
part := parts[i]
|
||||
next, ok := current[part]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to convert to map for next level
|
||||
nextMap, ok := next.(map[string]interface{})
|
||||
if !ok {
|
||||
// Try to convert from unmarshaled JSON
|
||||
jsonMap, ok := next.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
nextMap = jsonMap
|
||||
}
|
||||
|
||||
current = nextMap
|
||||
}
|
||||
|
||||
// Delete the value at the final part
|
||||
lastPart := parts[len(parts)-1]
|
||||
_, exists := current[lastPart]
|
||||
if exists {
|
||||
delete(current, lastPart)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// hasNestedKey checks if a nested key exists using dot notation
|
||||
func hasNestedKey(data map[string]interface{}, path string) bool {
|
||||
if !strings.Contains(path, ".") {
|
||||
_, exists := data[path]
|
||||
return exists
|
||||
}
|
||||
|
||||
parts := strings.Split(path, ".")
|
||||
current := data
|
||||
|
||||
// Navigate through all parts except the last one
|
||||
for i := 0; i < len(parts)-1; i++ {
|
||||
part := parts[i]
|
||||
next, ok := current[part]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
|
||||
// Try to convert to map for next level
|
||||
nextMap, ok := next.(map[string]interface{})
|
||||
if !ok {
|
||||
// Try to convert from unmarshaled JSON
|
||||
jsonMap, ok := next.(map[string]interface{})
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
nextMap = jsonMap
|
||||
}
|
||||
|
||||
current = nextMap
|
||||
}
|
||||
|
||||
// Check if the final key exists
|
||||
_, exists := current[parts[len(parts)-1]]
|
||||
return exists
|
||||
}
|
||||
|
||||
// getAllKeys recursively gets all keys from a nested map using dot notation
|
||||
func getAllKeys(data map[string]interface{}, prefix string) []string {
|
||||
keys := make([]string, 0)
|
||||
|
||||
for key, value := range data {
|
||||
fullKey := key
|
||||
if prefix != "" {
|
||||
fullKey = prefix + "." + key
|
||||
}
|
||||
|
||||
keys = append(keys, fullKey)
|
||||
|
||||
// If value is a map, recursively get its keys
|
||||
if nestedMap, ok := value.(map[string]interface{}); ok {
|
||||
nestedKeys := getAllKeys(nestedMap, fullKey)
|
||||
keys = append(keys, nestedKeys...)
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
// notifyKeyAndParents sends notifications to subscribers of the given key and its parent keys
|
||||
// If the value is nil, it indicates the key was deleted
|
||||
func (s *Storage) notifyKeyAndParents(key string, value interface{}, data map[string]interface{}) {
|
||||
// Notify direct subscribers of this key
|
||||
if subscribers, ok := s.keySubscribers.Get(key); ok {
|
||||
for _, ch := range subscribers {
|
||||
// Non-blocking send to avoid deadlocks
|
||||
select {
|
||||
case ch <- value:
|
||||
default:
|
||||
// Channel is full or closed, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also notify parent key subscribers if this is a nested key
|
||||
if strings.Contains(key, ".") {
|
||||
parts := strings.Split(key, ".")
|
||||
for i := 1; i < len(parts); i++ {
|
||||
parentKey := strings.Join(parts[:i], ".")
|
||||
if subscribers, ok := s.keySubscribers.Get(parentKey); ok {
|
||||
// Get the current parent value
|
||||
parentValue := getNestedValue(data, parentKey)
|
||||
for _, ch := range subscribers {
|
||||
// Non-blocking send to avoid deadlocks
|
||||
select {
|
||||
case ch <- parentValue:
|
||||
default:
|
||||
// Channel is full or closed, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Storage) Watch(key string, callback goja.Callable) goja.Value {
|
||||
s.logger.Trace().Msgf("plugin: Watching key %s", key)
|
||||
|
||||
// Create a channel to receive updates
|
||||
updateCh := make(chan interface{}, 100)
|
||||
|
||||
// Add this channel to the subscribers for this key
|
||||
subscribers := []chan interface{}{}
|
||||
if existingSubscribers, ok := s.keySubscribers.Get(key); ok {
|
||||
subscribers = existingSubscribers
|
||||
}
|
||||
subscribers = append(subscribers, updateCh)
|
||||
s.keySubscribers.Set(key, subscribers)
|
||||
|
||||
// Start a goroutine to listen for updates
|
||||
go func() {
|
||||
for value := range updateCh {
|
||||
// Call the callback with the new value
|
||||
s.scheduler.ScheduleAsync(func() error {
|
||||
_, err := callback(goja.Undefined(), s.runtime.ToValue(value))
|
||||
if err != nil {
|
||||
s.logger.Error().Err(err).Msgf("plugin: Error calling watch callback for key %s", key)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
// Check if the key currently exists and immediately send its value
|
||||
// This allows watchers to get the current value right away
|
||||
currentValue, _ := s.Get(key)
|
||||
if currentValue != nil {
|
||||
// Use non-blocking send
|
||||
select {
|
||||
case updateCh <- currentValue:
|
||||
default:
|
||||
// Channel is full, skip
|
||||
}
|
||||
}
|
||||
|
||||
// Return a function that can be used to cancel the watch
|
||||
cancelFn := func() {
|
||||
close(updateCh)
|
||||
// Remove this specific channel from subscribers
|
||||
if existingSubscribers, ok := s.keySubscribers.Get(key); ok {
|
||||
newSubscribers := make([]chan interface{}, 0, len(existingSubscribers)-1)
|
||||
for _, ch := range existingSubscribers {
|
||||
if ch != updateCh {
|
||||
newSubscribers = append(newSubscribers, ch)
|
||||
}
|
||||
}
|
||||
|
||||
if len(newSubscribers) > 0 {
|
||||
s.keySubscribers.Set(key, newSubscribers)
|
||||
} else {
|
||||
s.keySubscribers.Delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s.runtime.ToValue(cancelFn)
|
||||
}
|
||||
|
||||
func (s *Storage) Delete(key string) error {
|
||||
s.logger.Trace().Msgf("plugin: Deleting key %s", key)
|
||||
|
||||
// Remove from key cache
|
||||
s.keyDataCache.Delete(key)
|
||||
|
||||
pluginData, err := s.getPluginData(false)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := s.getDataMap(pluginData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Notify subscribers that the key was deleted
|
||||
s.notifyKeyAndParents(key, nil, data)
|
||||
|
||||
if deleteNestedValue(data, key) {
|
||||
return s.saveDataMap(pluginData, data)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) Drop() error {
|
||||
s.logger.Trace().Msg("plugin: Dropping storage")
|
||||
|
||||
// // Close all subscriber channels
|
||||
// s.keySubscribers.Range(func(key string, subscribers []chan interface{}) bool {
|
||||
// for _, ch := range subscribers {
|
||||
// close(ch)
|
||||
// }
|
||||
// return true
|
||||
// })
|
||||
// s.keySubscribers.Clear()
|
||||
|
||||
// Clear caches
|
||||
s.pluginDataCache.Clear()
|
||||
s.keyDataCache.Clear()
|
||||
|
||||
db, err := s.getDB()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return db.Where("plugin_id = ?", s.ext.ID).Delete(&models.PluginData{}).Error
|
||||
}
|
||||
|
||||
func (s *Storage) Clear() error {
|
||||
s.logger.Trace().Msg("plugin: Clearing storage")
|
||||
|
||||
// Clear key cache
|
||||
s.keyDataCache.Clear()
|
||||
|
||||
pluginData, err := s.getPluginData(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get all keys before clearing
|
||||
data, err := s.getDataMap(pluginData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Get all keys to notify subscribers
|
||||
keys := getAllKeys(data, "")
|
||||
|
||||
// Create empty data map
|
||||
cleanData := make(map[string]interface{})
|
||||
|
||||
// Save the empty data first
|
||||
if err := s.saveDataMap(pluginData, cleanData); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Notify all subscribers that their keys were cleared
|
||||
for _, key := range keys {
|
||||
s.notifyKeyAndParents(key, nil, cleanData)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Storage) Keys() ([]string, error) {
|
||||
pluginData, err := s.getPluginData(false)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return []string{}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := s.getDataMap(pluginData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return getAllKeys(data, ""), nil
|
||||
}
|
||||
|
||||
func (s *Storage) Has(key string) (bool, error) {
|
||||
// Check key cache first
|
||||
if s.keyDataCache.Has(key) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
pluginData, err := s.getPluginData(false)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
data, err := s.getDataMap(pluginData)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
exists := hasNestedKey(data, key)
|
||||
|
||||
// If key exists, we can also cache its value for future Get calls
|
||||
if exists {
|
||||
value := getNestedValue(data, key)
|
||||
if value != nil {
|
||||
s.keyDataCache.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
func (s *Storage) Get(key string) (interface{}, error) {
|
||||
// Check key cache first
|
||||
if cachedValue, ok := s.keyDataCache.Get(key); ok {
|
||||
return cachedValue, nil
|
||||
}
|
||||
|
||||
pluginData, err := s.getPluginData(true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
data, err := s.getDataMap(pluginData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
value := getNestedValue(data, key)
|
||||
|
||||
// Cache the value
|
||||
if value != nil {
|
||||
s.keyDataCache.Set(key, value)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (s *Storage) Set(key string, value interface{}) error {
|
||||
s.logger.Trace().Msgf("plugin: Setting key %s", key)
|
||||
pluginData, err := s.getPluginData(true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := s.getDataMap(pluginData)
|
||||
if err != nil {
|
||||
data = make(map[string]interface{})
|
||||
}
|
||||
|
||||
setNestedValue(data, key, value)
|
||||
|
||||
// Update key cache
|
||||
s.keyDataCache.Set(key, value)
|
||||
|
||||
// Notify subscribers
|
||||
s.notifyKeyAndParents(key, value, data)
|
||||
|
||||
return s.saveDataMap(pluginData, data)
|
||||
}
|
||||
341
seanime-2.9.10/internal/plugin/store.go
Normal file
341
seanime-2.9.10/internal/plugin/store.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package plugin
|
||||
|
||||
// Source: PocketBase
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
goja_util "seanime/internal/util/goja"
|
||||
"seanime/internal/util/result"
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
// @todo remove after https://github.com/golang/go/issues/20135
|
||||
const ShrinkThreshold = 200 // the number is arbitrary chosen
|
||||
|
||||
// Store defines a concurrent safe in memory key-value data store.
|
||||
// A new instance is created for each extension.
|
||||
type Store[K comparable, T any] struct {
|
||||
data map[K]T
|
||||
mu sync.RWMutex
|
||||
keySubscribers *result.Map[K, []*StoreKeySubscriber[K, T]]
|
||||
deleted int64
|
||||
}
|
||||
|
||||
type StoreKeySubscriber[K comparable, T any] struct {
|
||||
Key K
|
||||
Channel chan T
|
||||
}
|
||||
|
||||
// New creates a new Store[T] instance with a shallow copy of the provided data (if any).
|
||||
func NewStore[K comparable, T any](data map[K]T) *Store[K, T] {
|
||||
s := &Store[K, T]{
|
||||
data: make(map[K]T),
|
||||
keySubscribers: result.NewResultMap[K, []*StoreKeySubscriber[K, T]](),
|
||||
deleted: 0,
|
||||
}
|
||||
|
||||
s.Reset(data)
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Stop closes all subscriber goroutines.
|
||||
func (s *Store[K, T]) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
s.keySubscribers.Range(func(key K, subscribers []*StoreKeySubscriber[K, T]) bool {
|
||||
for _, subscriber := range subscribers {
|
||||
close(subscriber.Channel)
|
||||
}
|
||||
return true
|
||||
})
|
||||
s.keySubscribers.Clear()
|
||||
}
|
||||
|
||||
func (s *Store[K, T]) Bind(vm *goja.Runtime, scheduler *goja_util.Scheduler) {
|
||||
// Create a new object for the store
|
||||
storeObj := vm.NewObject()
|
||||
_ = storeObj.Set("get", s.Get)
|
||||
_ = storeObj.Set("set", s.Set)
|
||||
_ = storeObj.Set("length", s.Length)
|
||||
_ = storeObj.Set("remove", s.Remove)
|
||||
_ = storeObj.Set("removeAll", s.RemoveAll)
|
||||
_ = storeObj.Set("getAll", s.GetAll)
|
||||
_ = storeObj.Set("has", s.Has)
|
||||
_ = storeObj.Set("getOrSet", s.GetOrSet)
|
||||
_ = storeObj.Set("setIfLessThanLimit", s.SetIfLessThanLimit)
|
||||
_ = storeObj.Set("unmarshalJSON", s.UnmarshalJSON)
|
||||
_ = storeObj.Set("marshalJSON", s.MarshalJSON)
|
||||
_ = storeObj.Set("reset", s.Reset)
|
||||
_ = storeObj.Set("values", s.Values)
|
||||
s.bindWatch(storeObj, vm, scheduler)
|
||||
_ = vm.Set("$store", storeObj)
|
||||
}
|
||||
|
||||
// BindWatch binds the watch method to the store object in the runtime.
|
||||
func (s *Store[K, T]) bindWatch(storeObj *goja.Object, vm *goja.Runtime, scheduler *goja_util.Scheduler) {
|
||||
|
||||
// Example:
|
||||
// store.watch("key", (value) => {
|
||||
// console.log(value)
|
||||
// })
|
||||
_ = storeObj.Set("watch", func(key K, callback goja.Callable) goja.Value {
|
||||
// Create a new subscriber
|
||||
subscriber := &StoreKeySubscriber[K, T]{
|
||||
Key: key,
|
||||
Channel: make(chan T),
|
||||
}
|
||||
s.keySubscribers.Set(key, []*StoreKeySubscriber[K, T]{subscriber})
|
||||
|
||||
// Listen for changes
|
||||
go func() {
|
||||
for value := range subscriber.Channel {
|
||||
// Schedule the callback when the value changes
|
||||
scheduler.ScheduleAsync(func() error {
|
||||
callback(goja.Undefined(), vm.ToValue(value))
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
cancelFn := func() {
|
||||
close(subscriber.Channel)
|
||||
s.keySubscribers.Delete(key)
|
||||
}
|
||||
return vm.ToValue(cancelFn)
|
||||
})
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// Reset clears the store and replaces the store data with a
|
||||
// shallow copy of the provided newData.
|
||||
func (s *Store[K, T]) Reset(newData map[K]T) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if len(newData) > 0 {
|
||||
s.data = make(map[K]T, len(newData))
|
||||
for k, v := range newData {
|
||||
s.data[k] = v
|
||||
}
|
||||
} else {
|
||||
s.data = make(map[K]T)
|
||||
}
|
||||
|
||||
s.deleted = 0
|
||||
}
|
||||
|
||||
// Length returns the current number of elements in the store.
|
||||
func (s *Store[K, T]) Length() int {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return len(s.data)
|
||||
}
|
||||
|
||||
// RemoveAll removes all the existing store entries.
|
||||
func (s *Store[K, T]) RemoveAll() {
|
||||
s.Reset(nil)
|
||||
}
|
||||
|
||||
// Remove removes a single entry from the store.
|
||||
//
|
||||
// Remove does nothing if key doesn't exist in the store.
|
||||
func (s *Store[K, T]) Remove(key K) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
delete(s.data, key)
|
||||
s.deleted++
|
||||
|
||||
// reassign to a new map so that the old one can be gc-ed because it doesn't shrink
|
||||
//
|
||||
// @todo remove after https://github.com/golang/go/issues/20135
|
||||
if s.deleted >= ShrinkThreshold {
|
||||
newData := make(map[K]T, len(s.data))
|
||||
for k, v := range s.data {
|
||||
newData[k] = v
|
||||
}
|
||||
s.data = newData
|
||||
s.deleted = 0
|
||||
}
|
||||
}
|
||||
|
||||
// Has checks if element with the specified key exist or not.
|
||||
func (s *Store[K, T]) Has(key K) bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
_, ok := s.data[key]
|
||||
|
||||
return ok
|
||||
}
|
||||
|
||||
// Get returns a single element value from the store.
|
||||
//
|
||||
// If key is not set, the zero T value is returned.
|
||||
func (s *Store[K, T]) Get(key K) T {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
return s.data[key]
|
||||
}
|
||||
|
||||
// GetOk is similar to Get but returns also a boolean indicating whether the key exists or not.
|
||||
func (s *Store[K, T]) GetOk(key K) (T, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
v, ok := s.data[key]
|
||||
|
||||
return v, ok
|
||||
}
|
||||
|
||||
// GetAll returns a shallow copy of the current store data.
|
||||
func (s *Store[K, T]) GetAll() map[K]T {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var clone = make(map[K]T, len(s.data))
|
||||
|
||||
for k, v := range s.data {
|
||||
clone[k] = v
|
||||
}
|
||||
|
||||
return clone
|
||||
}
|
||||
|
||||
// Values returns a slice with all of the current store values.
|
||||
func (s *Store[K, T]) Values() []T {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
var values = make([]T, 0, len(s.data))
|
||||
|
||||
for _, v := range s.data {
|
||||
values = append(values, v)
|
||||
}
|
||||
|
||||
return values
|
||||
}
|
||||
|
||||
// Set sets (or overwrite if already exist) a new value for key.
|
||||
func (s *Store[K, T]) Set(key K, value T) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.data == nil {
|
||||
s.data = make(map[K]T)
|
||||
}
|
||||
|
||||
s.data[key] = value
|
||||
|
||||
// Notify subscribers
|
||||
go s.notifySubscribers(key, value)
|
||||
}
|
||||
|
||||
// GetOrSet retrieves a single existing value for the provided key
|
||||
// or stores a new one if it doesn't exist.
|
||||
func (s *Store[K, T]) GetOrSet(key K, setFunc func() T) T {
|
||||
// lock only reads to minimize locks contention
|
||||
s.mu.RLock()
|
||||
v, ok := s.data[key]
|
||||
s.mu.RUnlock()
|
||||
|
||||
if !ok {
|
||||
s.mu.Lock()
|
||||
v = setFunc()
|
||||
if s.data == nil {
|
||||
s.data = make(map[K]T)
|
||||
}
|
||||
s.data[key] = v
|
||||
|
||||
// Notify subscribers
|
||||
go s.notifySubscribers(key, v)
|
||||
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// SetIfLessThanLimit sets (or overwrite if already exist) a new value for key.
|
||||
//
|
||||
// This method is similar to Set() but **it will skip adding new elements**
|
||||
// to the store if the store length has reached the specified limit.
|
||||
// false is returned if maxAllowedElements limit is reached.
|
||||
func (s *Store[K, T]) SetIfLessThanLimit(key K, value T, maxAllowedElements int) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.data == nil {
|
||||
s.data = make(map[K]T)
|
||||
}
|
||||
|
||||
// check for existing item
|
||||
_, ok := s.data[key]
|
||||
|
||||
if !ok && len(s.data) >= maxAllowedElements {
|
||||
// cannot add more items
|
||||
return false
|
||||
}
|
||||
|
||||
// add/overwrite item
|
||||
s.data[key] = value
|
||||
|
||||
// Notify subscribers
|
||||
go s.notifySubscribers(key, value)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// UnmarshalJSON implements [json.Unmarshaler] and imports the
|
||||
// provided JSON data into the store.
|
||||
//
|
||||
// The store entries that match with the ones from the data will be overwritten with the new value.
|
||||
func (s *Store[K, T]) UnmarshalJSON(data []byte) error {
|
||||
raw := map[K]T{}
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.data == nil {
|
||||
s.data = make(map[K]T)
|
||||
}
|
||||
|
||||
for k, v := range raw {
|
||||
s.data[k] = v
|
||||
|
||||
// Notify subscribers
|
||||
go s.notifySubscribers(k, v)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarshalJSON implements [json.Marshaler] and export the current
|
||||
// store data into valid JSON.
|
||||
func (s *Store[K, T]) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(s.GetAll())
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (s *Store[K, T]) notifySubscribers(key K, value T) {
|
||||
s.keySubscribers.Range(func(subscriberKey K, subscribers []*StoreKeySubscriber[K, T]) bool {
|
||||
if subscriberKey != key {
|
||||
return true
|
||||
}
|
||||
for _, subscriber := range subscribers {
|
||||
subscriber.Channel <- value
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
1017
seanime-2.9.10/internal/plugin/system.go
Normal file
1017
seanime-2.9.10/internal/plugin/system.go
Normal file
File diff suppressed because it is too large
Load Diff
412
seanime-2.9.10/internal/plugin/system_test.go
Normal file
412
seanime-2.9.10/internal/plugin/system_test.go
Normal file
@@ -0,0 +1,412 @@
|
||||
package plugin
|
||||
|
||||
import (
|
||||
"seanime/internal/extension"
|
||||
"testing"
|
||||
|
||||
"github.com/samber/mo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Mock AppContextImpl for testing
|
||||
type mockAppContext struct {
|
||||
AppContextImpl
|
||||
mockPaths map[string][]string
|
||||
}
|
||||
|
||||
// Create a new mock context with initialized fields
|
||||
func newMockAppContext(paths map[string][]string) *mockAppContext {
|
||||
ctx := &mockAppContext{
|
||||
mockPaths: paths,
|
||||
}
|
||||
// Initialize the animeLibraryPaths field with mock data
|
||||
if libraryPaths, ok := paths["SEANIME_ANIME_LIBRARY"]; ok {
|
||||
ctx.animeLibraryPaths = mo.Some(libraryPaths)
|
||||
} else {
|
||||
ctx.animeLibraryPaths = mo.Some([]string{})
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestIsAllowedPath(t *testing.T) {
|
||||
// Create mock context with predefined paths
|
||||
mockCtx := newMockAppContext(map[string][]string{
|
||||
"SEANIME_ANIME_LIBRARY": {"/anime/lib1", "/anime/lib2"},
|
||||
"HOME": {"/home/user"},
|
||||
"TEMP": {"/tmp"},
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ext *extension.Extension
|
||||
path string
|
||||
mode int
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil extension",
|
||||
ext: nil,
|
||||
path: "/some/path",
|
||||
mode: AllowPathRead,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "no patterns",
|
||||
ext: &extension.Extension{
|
||||
Plugin: &extension.PluginManifest{
|
||||
Permissions: extension.PluginPermissions{
|
||||
Scopes: []extension.PluginPermissionScope{
|
||||
extension.PluginPermissionSystem,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
path: "/some/path",
|
||||
mode: AllowPathRead,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "simple path match",
|
||||
ext: &extension.Extension{
|
||||
Plugin: &extension.PluginManifest{
|
||||
Permissions: extension.PluginPermissions{
|
||||
Scopes: []extension.PluginPermissionScope{
|
||||
extension.PluginPermissionSystem,
|
||||
},
|
||||
Allow: extension.PluginAllowlist{
|
||||
ReadPaths: []string{"/test/*.txt"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
path: "/test/file.txt",
|
||||
mode: AllowPathRead,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "multiple library paths - first match",
|
||||
ext: &extension.Extension{
|
||||
Plugin: &extension.PluginManifest{
|
||||
Permissions: extension.PluginPermissions{
|
||||
Scopes: []extension.PluginPermissionScope{
|
||||
extension.PluginPermissionSystem,
|
||||
},
|
||||
Allow: extension.PluginAllowlist{
|
||||
ReadPaths: []string{"$SEANIME_ANIME_LIBRARY/**"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
path: "/anime/lib1/file.txt",
|
||||
mode: AllowPathRead,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "multiple library paths - second match",
|
||||
ext: &extension.Extension{
|
||||
Plugin: &extension.PluginManifest{
|
||||
Permissions: extension.PluginPermissions{
|
||||
Scopes: []extension.PluginPermissionScope{
|
||||
extension.PluginPermissionSystem,
|
||||
},
|
||||
Allow: extension.PluginAllowlist{
|
||||
ReadPaths: []string{"$SEANIME_ANIME_LIBRARY/**"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
path: "/anime/lib2/file.txt",
|
||||
mode: AllowPathRead,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "write mode with read pattern",
|
||||
ext: &extension.Extension{
|
||||
Plugin: &extension.PluginManifest{
|
||||
Permissions: extension.PluginPermissions{
|
||||
Scopes: []extension.PluginPermissionScope{
|
||||
extension.PluginPermissionSystem,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
path: "/test/file.txt",
|
||||
mode: AllowPathWrite,
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "multiple patterns - match one",
|
||||
ext: &extension.Extension{
|
||||
Plugin: &extension.PluginManifest{
|
||||
Permissions: extension.PluginPermissions{
|
||||
Scopes: []extension.PluginPermissionScope{
|
||||
extension.PluginPermissionSystem,
|
||||
},
|
||||
Allow: extension.PluginAllowlist{
|
||||
ReadPaths: []string{"$SEANIME_ANIME_LIBRARY/**"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
path: "/anime/lib1/file.txt",
|
||||
mode: AllowPathRead,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "no matching pattern",
|
||||
ext: &extension.Extension{
|
||||
Plugin: &extension.PluginManifest{
|
||||
Permissions: extension.PluginPermissions{
|
||||
Scopes: []extension.PluginPermissionScope{
|
||||
extension.PluginPermissionSystem,
|
||||
},
|
||||
Allow: extension.PluginAllowlist{
|
||||
ReadPaths: []string{"$SEANIME_ANIME_LIBRARY/**"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
path: "/anime/lib1/file.txt",
|
||||
mode: AllowPathRead,
|
||||
expected: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := mockCtx.isAllowedPath(tt.ext, tt.path, tt.mode)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsAllowedCommand(t *testing.T) {
|
||||
// Create mock context
|
||||
mockCtx := newMockAppContext(map[string][]string{
|
||||
"HOME": {"/home/user"},
|
||||
"SEANIME_ANIME_LIBRARY": {}, // Empty but initialized
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
ext *extension.Extension
|
||||
cmd string
|
||||
args []string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil extension",
|
||||
ext: nil,
|
||||
cmd: "ls",
|
||||
args: []string{"-l"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "simple command no args",
|
||||
ext: &extension.Extension{
|
||||
Plugin: &extension.PluginManifest{
|
||||
Permissions: extension.PluginPermissions{
|
||||
Scopes: []extension.PluginPermissionScope{
|
||||
extension.PluginPermissionSystem,
|
||||
},
|
||||
Allow: extension.PluginAllowlist{
|
||||
CommandScopes: []extension.CommandScope{
|
||||
{
|
||||
Command: "ls",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cmd: "ls",
|
||||
args: []string{},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "command with fixed args - match",
|
||||
ext: &extension.Extension{
|
||||
Plugin: &extension.PluginManifest{
|
||||
Permissions: extension.PluginPermissions{
|
||||
Scopes: []extension.PluginPermissionScope{
|
||||
extension.PluginPermissionSystem,
|
||||
},
|
||||
Allow: extension.PluginAllowlist{
|
||||
CommandScopes: []extension.CommandScope{
|
||||
{
|
||||
Command: "git",
|
||||
Args: []extension.CommandArg{
|
||||
{Value: "pull"},
|
||||
{Value: "origin"},
|
||||
{Value: "main"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cmd: "git",
|
||||
args: []string{"pull", "origin", "main"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "command with fixed args - no match",
|
||||
ext: &extension.Extension{
|
||||
Plugin: &extension.PluginManifest{
|
||||
Permissions: extension.PluginPermissions{
|
||||
Scopes: []extension.PluginPermissionScope{
|
||||
extension.PluginPermissionSystem,
|
||||
},
|
||||
Allow: extension.PluginAllowlist{
|
||||
CommandScopes: []extension.CommandScope{
|
||||
{
|
||||
Command: "git",
|
||||
Args: []extension.CommandArg{
|
||||
{Value: "pull"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cmd: "git",
|
||||
args: []string{"push"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "command with $ARGS validator",
|
||||
ext: &extension.Extension{
|
||||
Plugin: &extension.PluginManifest{
|
||||
Permissions: extension.PluginPermissions{
|
||||
Scopes: []extension.PluginPermissionScope{
|
||||
extension.PluginPermissionSystem,
|
||||
},
|
||||
Allow: extension.PluginAllowlist{
|
||||
CommandScopes: []extension.CommandScope{
|
||||
{
|
||||
Command: "echo",
|
||||
Args: []extension.CommandArg{
|
||||
{Validator: "$ARGS"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cmd: "echo",
|
||||
args: []string{"hello", "world"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "command with regex validator - match",
|
||||
ext: &extension.Extension{
|
||||
Plugin: &extension.PluginManifest{
|
||||
Permissions: extension.PluginPermissions{
|
||||
Scopes: []extension.PluginPermissionScope{
|
||||
extension.PluginPermissionSystem,
|
||||
},
|
||||
Allow: extension.PluginAllowlist{
|
||||
CommandScopes: []extension.CommandScope{
|
||||
{
|
||||
Command: "open",
|
||||
Args: []extension.CommandArg{
|
||||
{Validator: "^https?://.*$"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cmd: "open",
|
||||
args: []string{"https://example.com"},
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "command with regex validator - no match",
|
||||
ext: &extension.Extension{
|
||||
Plugin: &extension.PluginManifest{
|
||||
Permissions: extension.PluginPermissions{
|
||||
Scopes: []extension.PluginPermissionScope{
|
||||
extension.PluginPermissionSystem,
|
||||
},
|
||||
Allow: extension.PluginAllowlist{
|
||||
CommandScopes: []extension.CommandScope{
|
||||
{
|
||||
Command: "open",
|
||||
Args: []extension.CommandArg{
|
||||
{Validator: "^https?://.*$"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cmd: "open",
|
||||
args: []string{"file://example.com"},
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "command with $PATH validator",
|
||||
ext: &extension.Extension{
|
||||
Plugin: &extension.PluginManifest{
|
||||
Permissions: extension.PluginPermissions{
|
||||
Scopes: []extension.PluginPermissionScope{
|
||||
extension.PluginPermissionSystem,
|
||||
},
|
||||
Allow: extension.PluginAllowlist{
|
||||
CommandScopes: []extension.CommandScope{
|
||||
{
|
||||
Command: "open",
|
||||
Args: []extension.CommandArg{
|
||||
{Validator: "$PATH"},
|
||||
},
|
||||
},
|
||||
},
|
||||
WritePaths: []string{"$SEANIME_ANIME_LIBRARY/**"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cmd: "open",
|
||||
args: []string{"/anime/lib1/test.txt"},
|
||||
expected: false, // Directory does not exist on the machine
|
||||
},
|
||||
{
|
||||
name: "too many args",
|
||||
ext: &extension.Extension{
|
||||
Plugin: &extension.PluginManifest{
|
||||
Permissions: extension.PluginPermissions{
|
||||
Scopes: []extension.PluginPermissionScope{
|
||||
extension.PluginPermissionSystem,
|
||||
},
|
||||
Allow: extension.PluginAllowlist{
|
||||
CommandScopes: []extension.CommandScope{
|
||||
{
|
||||
Command: "ls",
|
||||
Args: []extension.CommandArg{
|
||||
{Value: "-l"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
cmd: "ls",
|
||||
args: []string{"-l", "-a"},
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := mockCtx.isAllowedCommand(tt.ext, tt.cmd, tt.args...)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
87
seanime-2.9.10/internal/plugin/ui/DOCS.md
Normal file
87
seanime-2.9.10/internal/plugin/ui/DOCS.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Code
|
||||
|
||||
## Dev notes for the plugin UI
|
||||
|
||||
Avoid
|
||||
```go
|
||||
func (d *DOMManager) getElementChildren(elementID string) []*goja.Object {
|
||||
|
||||
// Listen for changes from the client
|
||||
eventListener := d.ctx.RegisterEventListener(ClientDOMElementUpdatedEvent)
|
||||
defer d.ctx.UnregisterEventListener(eventListener.ID)
|
||||
payload := ClientDOMElementUpdatedEventPayload{}
|
||||
|
||||
doneCh := make(chan []*goja.Object)
|
||||
|
||||
go func(eventListener *EventListener) {
|
||||
for event := range eventListener.Channel {
|
||||
if event.ParsePayloadAs(ClientDOMElementUpdatedEvent, &payload) {
|
||||
if payload.Action == "getChildren" && payload.ElementId == elementID {
|
||||
if v, ok := payload.Result.([]interface{}); ok {
|
||||
arr := make([]*goja.Object, 0, len(v))
|
||||
for _, elem := range v {
|
||||
if elemData, ok := elem.(map[string]interface{}); ok {
|
||||
arr = append(arr, d.createDOMElementObject(elemData))
|
||||
}
|
||||
}
|
||||
doneCh <- arr
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}(eventListener)
|
||||
|
||||
d.ctx.SendEventToClient(ServerDOMManipulateEvent, &ServerDOMManipulateEventPayload{
|
||||
ElementId: elementID,
|
||||
Action: "getChildren",
|
||||
Params: map[string]interface{}{},
|
||||
})
|
||||
timeout := time.After(4 * time.Second)
|
||||
|
||||
select {
|
||||
case <-timeout:
|
||||
return []*goja.Object{}
|
||||
case res := <-doneCh:
|
||||
return res
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In the above code
|
||||
```go
|
||||
arr = append(arr, d.createDOMElementObject(elemData))
|
||||
```
|
||||
Uses the VM so it should be scheduled.
|
||||
```go
|
||||
d.ctx.ScheduleAsync(func() error {
|
||||
arr := make([]*goja.Object, 0, len(v))
|
||||
for _, elem := range v {
|
||||
if elemData, ok := elem.(map[string]interface{}); ok {
|
||||
arr = append(arr, d.createDOMElementObject(elemData))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
```
|
||||
|
||||
However, getElementChildren() might be launched in a scheduled task.
|
||||
```ts
|
||||
ctx.registerEventHandler("test", () => {
|
||||
const el = ctx.dom.queryOne("#test")
|
||||
el.getChildren()
|
||||
})
|
||||
```
|
||||
|
||||
And since getElementChildren() is coded "synchronously" (without promises), it will block the task
|
||||
until the timeout and won't run its own task.
|
||||
You'll end up with something like this:
|
||||
|
||||
```txt
|
||||
> event received
|
||||
> timeout
|
||||
> processing scheduled task
|
||||
> sending task
|
||||
```
|
||||
|
||||
Conclusion: Prefer promises when possible. For synchronous functions, avoid scheduling tasks inside them.
|
||||
214
seanime-2.9.10/internal/plugin/ui/_scheduler.go
Normal file
214
seanime-2.9.10/internal/plugin/ui/_scheduler.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Job represents a task to be executed in the VM
|
||||
type Job struct {
|
||||
fn func() error
|
||||
resultCh chan error
|
||||
async bool // Flag to indicate if the job is async (doesn't need to wait for result)
|
||||
}
|
||||
|
||||
// Scheduler handles all VM operations added concurrently in a single goroutine
|
||||
// Any goroutine that needs to execute a VM operation must schedule it because the UI VM isn't thread safe
|
||||
type Scheduler struct {
|
||||
jobQueue chan *Job
|
||||
ctx context.Context
|
||||
context *Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
// Track the currently executing job to detect nested scheduling
|
||||
currentJob *Job
|
||||
currentJobLock sync.Mutex
|
||||
}
|
||||
|
||||
func NewScheduler(uiCtx *Context) *Scheduler {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
s := &Scheduler{
|
||||
jobQueue: make(chan *Job, 9999),
|
||||
ctx: ctx,
|
||||
context: uiCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
|
||||
s.start()
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Scheduler) start() {
|
||||
s.wg.Add(1)
|
||||
go func() {
|
||||
defer s.wg.Done()
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case job := <-s.jobQueue:
|
||||
// Set the current job before execution
|
||||
s.currentJobLock.Lock()
|
||||
s.currentJob = job
|
||||
s.currentJobLock.Unlock()
|
||||
|
||||
err := job.fn()
|
||||
|
||||
// Clear the current job after execution
|
||||
s.currentJobLock.Lock()
|
||||
s.currentJob = nil
|
||||
s.currentJobLock.Unlock()
|
||||
|
||||
// Only send result if the job is not async
|
||||
if !job.async {
|
||||
job.resultCh <- err
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
s.context.HandleException(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func (s *Scheduler) Stop() {
|
||||
s.cancel()
|
||||
s.wg.Wait()
|
||||
}
|
||||
|
||||
// Schedule adds a job to the queue and waits for its completion
|
||||
func (s *Scheduler) Schedule(fn func() error) error {
|
||||
resultCh := make(chan error, 1)
|
||||
job := &Job{
|
||||
fn: func() error {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
resultCh <- fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
},
|
||||
resultCh: resultCh,
|
||||
async: false,
|
||||
}
|
||||
|
||||
// Check if we're already in a job execution context
|
||||
s.currentJobLock.Lock()
|
||||
isNestedCall := s.currentJob != nil && !s.currentJob.async
|
||||
s.currentJobLock.Unlock()
|
||||
|
||||
// If this is a nested call from a synchronous job, we need to be careful
|
||||
// We can't execute directly because the VM isn't thread-safe
|
||||
// Instead, we'll queue it and use a separate goroutine to wait for the result
|
||||
if isNestedCall {
|
||||
// Queue the job
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return fmt.Errorf("scheduler stopped")
|
||||
case s.jobQueue <- job:
|
||||
// Create a separate goroutine to wait for the result
|
||||
// This prevents deadlock while still ensuring the job runs in the scheduler
|
||||
resultCh2 := make(chan error, 1)
|
||||
go func() {
|
||||
resultCh2 <- <-resultCh
|
||||
}()
|
||||
return <-resultCh2
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, queue the job normally
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return fmt.Errorf("scheduler stopped")
|
||||
case s.jobQueue <- job:
|
||||
return <-resultCh
|
||||
}
|
||||
}
|
||||
|
||||
// ScheduleAsync adds a job to the queue without waiting for completion
|
||||
// This is useful for fire-and-forget operations or when a job needs to schedule another job
|
||||
func (s *Scheduler) ScheduleAsync(fn func() error) {
|
||||
job := &Job{
|
||||
fn: func() error {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
s.context.HandleException(fmt.Errorf("panic in async job: %v", r))
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
},
|
||||
resultCh: nil, // No result channel needed
|
||||
async: true,
|
||||
}
|
||||
|
||||
// Queue the job without blocking
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
// Scheduler is stopped, just ignore
|
||||
return
|
||||
case s.jobQueue <- job:
|
||||
// Job queued successfully
|
||||
return
|
||||
default:
|
||||
// Queue is full, log an error
|
||||
s.context.HandleException(fmt.Errorf("async job queue is full"))
|
||||
}
|
||||
}
|
||||
|
||||
// ScheduleWithTimeout schedules a job with a timeout
|
||||
func (s *Scheduler) ScheduleWithTimeout(fn func() error, timeout time.Duration) error {
|
||||
resultCh := make(chan error, 1)
|
||||
job := &Job{
|
||||
fn: func() error {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
resultCh <- fmt.Errorf("panic: %v", r)
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
},
|
||||
resultCh: resultCh,
|
||||
async: false,
|
||||
}
|
||||
|
||||
// Check if we're already in a job execution context
|
||||
s.currentJobLock.Lock()
|
||||
isNestedCall := s.currentJob != nil && !s.currentJob.async
|
||||
s.currentJobLock.Unlock()
|
||||
|
||||
// If this is a nested call from a synchronous job, handle it specially
|
||||
if isNestedCall {
|
||||
// Queue the job
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return fmt.Errorf("scheduler stopped")
|
||||
case s.jobQueue <- job:
|
||||
// Create a separate goroutine to wait for the result with timeout
|
||||
resultCh2 := make(chan error, 1)
|
||||
go func() {
|
||||
select {
|
||||
case err := <-resultCh:
|
||||
resultCh2 <- err
|
||||
case <-time.After(timeout):
|
||||
resultCh2 <- fmt.Errorf("operation timed out")
|
||||
}
|
||||
}()
|
||||
return <-resultCh2
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return fmt.Errorf("scheduler stopped")
|
||||
case s.jobQueue <- job:
|
||||
select {
|
||||
case err := <-resultCh:
|
||||
return err
|
||||
case <-time.After(timeout):
|
||||
return fmt.Errorf("operation timed out")
|
||||
}
|
||||
}
|
||||
}
|
||||
683
seanime-2.9.10/internal/plugin/ui/action.go
Normal file
683
seanime-2.9.10/internal/plugin/ui/action.go
Normal file
@@ -0,0 +1,683 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"seanime/internal/util/result"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxActionsPerType = 3 // A plugin can only at most X actions of a certain type
|
||||
)
|
||||
|
||||
// ActionManager
|
||||
//
|
||||
// Actions are buttons, dropdown items, and context menu items that are displayed in certain places in the UI.
|
||||
// They are defined in the plugin code and are used to trigger events.
|
||||
//
|
||||
// The ActionManager is responsible for registering, rendering, and handling events for actions.
|
||||
type ActionManager struct {
|
||||
ctx *Context
|
||||
|
||||
animePageButtons *result.Map[string, *AnimePageButton]
|
||||
animePageDropdownItems *result.Map[string, *AnimePageDropdownMenuItem]
|
||||
animeLibraryDropdownItems *result.Map[string, *AnimeLibraryDropdownMenuItem]
|
||||
mangaPageButtons *result.Map[string, *MangaPageButton]
|
||||
mediaCardContextMenuItems *result.Map[string, *MediaCardContextMenuItem]
|
||||
episodeCardContextMenuItems *result.Map[string, *EpisodeCardContextMenuItem]
|
||||
episodeGridItemMenuItems *result.Map[string, *EpisodeGridItemMenuItem]
|
||||
}
|
||||
|
||||
type BaseActionProps struct {
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Style map[string]string `json:"style,omitempty"`
|
||||
}
|
||||
|
||||
// Base action struct that all action types embed
|
||||
type BaseAction struct {
|
||||
BaseActionProps
|
||||
}
|
||||
|
||||
// GetProps returns the base action properties
|
||||
func (a *BaseAction) GetProps() BaseActionProps {
|
||||
return a.BaseActionProps
|
||||
}
|
||||
|
||||
// SetProps sets the base action properties
|
||||
func (a *BaseAction) SetProps(props BaseActionProps) {
|
||||
a.BaseActionProps = props
|
||||
}
|
||||
|
||||
// UnmountAll unmounts all actions
|
||||
// It should be called when the plugin is unloaded
|
||||
func (a *ActionManager) UnmountAll() {
|
||||
|
||||
if a.animePageButtons.ClearN() > 0 {
|
||||
a.renderAnimePageButtons()
|
||||
}
|
||||
if a.animePageDropdownItems.ClearN() > 0 {
|
||||
a.renderAnimePageDropdownItems()
|
||||
}
|
||||
if a.animeLibraryDropdownItems.ClearN() > 0 {
|
||||
a.renderAnimeLibraryDropdownItems()
|
||||
}
|
||||
if a.mangaPageButtons.ClearN() > 0 {
|
||||
a.renderMangaPageButtons()
|
||||
}
|
||||
if a.mediaCardContextMenuItems.ClearN() > 0 {
|
||||
a.renderMediaCardContextMenuItems()
|
||||
}
|
||||
if a.episodeCardContextMenuItems.ClearN() > 0 {
|
||||
a.renderEpisodeCardContextMenuItems()
|
||||
}
|
||||
if a.episodeGridItemMenuItems.ClearN() > 0 {
|
||||
a.renderEpisodeGridItemMenuItems()
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type AnimePageButton struct {
|
||||
BaseAction
|
||||
Intent string `json:"intent,omitempty"`
|
||||
}
|
||||
|
||||
func (a *AnimePageButton) CreateObject(actionManager *ActionManager) *goja.Object {
|
||||
obj := actionManager.ctx.vm.NewObject()
|
||||
actionManager.bindSharedToObject(obj, a)
|
||||
|
||||
_ = obj.Set("setIntent", func(intent string) {
|
||||
a.Intent = intent
|
||||
})
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type EpisodeCardContextMenuItem struct {
|
||||
BaseAction
|
||||
}
|
||||
|
||||
func (a *EpisodeCardContextMenuItem) CreateObject(actionManager *ActionManager) *goja.Object {
|
||||
obj := actionManager.ctx.vm.NewObject()
|
||||
actionManager.bindSharedToObject(obj, a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type EpisodeGridItemMenuItem struct {
|
||||
BaseAction
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
func (a *EpisodeGridItemMenuItem) CreateObject(actionManager *ActionManager) *goja.Object {
|
||||
obj := actionManager.ctx.vm.NewObject()
|
||||
actionManager.bindSharedToObject(obj, a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type MangaPageButton struct {
|
||||
BaseAction
|
||||
Intent string `json:"intent,omitempty"`
|
||||
}
|
||||
|
||||
func (a *MangaPageButton) CreateObject(actionManager *ActionManager) *goja.Object {
|
||||
obj := actionManager.ctx.vm.NewObject()
|
||||
actionManager.bindSharedToObject(obj, a)
|
||||
|
||||
_ = obj.Set("setIntent", func(intent string) {
|
||||
a.Intent = intent
|
||||
})
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type AnimePageDropdownMenuItem struct {
|
||||
BaseAction
|
||||
}
|
||||
|
||||
func (a *AnimePageDropdownMenuItem) CreateObject(actionManager *ActionManager) *goja.Object {
|
||||
obj := actionManager.ctx.vm.NewObject()
|
||||
actionManager.bindSharedToObject(obj, a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type AnimeLibraryDropdownMenuItem struct {
|
||||
BaseAction
|
||||
}
|
||||
|
||||
func (a *AnimeLibraryDropdownMenuItem) CreateObject(actionManager *ActionManager) *goja.Object {
|
||||
obj := actionManager.ctx.vm.NewObject()
|
||||
actionManager.bindSharedToObject(obj, a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type MediaCardContextMenuItemFor string
|
||||
|
||||
const (
|
||||
MediaCardContextMenuItemForAnime MediaCardContextMenuItemFor = "anime"
|
||||
MediaCardContextMenuItemForManga MediaCardContextMenuItemFor = "manga"
|
||||
MediaCardContextMenuItemForBoth MediaCardContextMenuItemFor = "both"
|
||||
)
|
||||
|
||||
type MediaCardContextMenuItem struct {
|
||||
BaseAction
|
||||
For MediaCardContextMenuItemFor `json:"for"` // anime, manga, both
|
||||
}
|
||||
|
||||
func (a *MediaCardContextMenuItem) CreateObject(actionManager *ActionManager) *goja.Object {
|
||||
obj := actionManager.ctx.vm.NewObject()
|
||||
actionManager.bindSharedToObject(obj, a)
|
||||
|
||||
_ = obj.Set("setFor", func(_for MediaCardContextMenuItemFor) {
|
||||
a.For = _for
|
||||
})
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
// ///////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func NewActionManager(ctx *Context) *ActionManager {
|
||||
return &ActionManager{
|
||||
ctx: ctx,
|
||||
|
||||
animePageButtons: result.NewResultMap[string, *AnimePageButton](),
|
||||
animeLibraryDropdownItems: result.NewResultMap[string, *AnimeLibraryDropdownMenuItem](),
|
||||
animePageDropdownItems: result.NewResultMap[string, *AnimePageDropdownMenuItem](),
|
||||
mangaPageButtons: result.NewResultMap[string, *MangaPageButton](),
|
||||
mediaCardContextMenuItems: result.NewResultMap[string, *MediaCardContextMenuItem](),
|
||||
episodeCardContextMenuItems: result.NewResultMap[string, *EpisodeCardContextMenuItem](),
|
||||
episodeGridItemMenuItems: result.NewResultMap[string, *EpisodeGridItemMenuItem](),
|
||||
}
|
||||
}
|
||||
|
||||
// renderAnimePageButtons is called when the client requests the buttons to display on the anime page.
|
||||
func (a *ActionManager) renderAnimePageButtons() {
|
||||
buttons := make([]*AnimePageButton, 0)
|
||||
a.animePageButtons.Range(func(key string, value *AnimePageButton) bool {
|
||||
buttons = append(buttons, value)
|
||||
return true
|
||||
})
|
||||
|
||||
a.ctx.SendEventToClient(ServerActionRenderAnimePageButtonsEvent, ServerActionRenderAnimePageButtonsEventPayload{
|
||||
Buttons: buttons,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ActionManager) renderAnimePageDropdownItems() {
|
||||
items := make([]*AnimePageDropdownMenuItem, 0)
|
||||
a.animePageDropdownItems.Range(func(key string, value *AnimePageDropdownMenuItem) bool {
|
||||
items = append(items, value)
|
||||
return true
|
||||
})
|
||||
|
||||
a.ctx.SendEventToClient(ServerActionRenderAnimePageDropdownItemsEvent, ServerActionRenderAnimePageDropdownItemsEventPayload{
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ActionManager) renderAnimeLibraryDropdownItems() {
|
||||
items := make([]*AnimeLibraryDropdownMenuItem, 0)
|
||||
a.animeLibraryDropdownItems.Range(func(key string, value *AnimeLibraryDropdownMenuItem) bool {
|
||||
items = append(items, value)
|
||||
return true
|
||||
})
|
||||
|
||||
a.ctx.SendEventToClient(ServerActionRenderAnimeLibraryDropdownItemsEvent, ServerActionRenderAnimeLibraryDropdownItemsEventPayload{
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ActionManager) renderMangaPageButtons() {
|
||||
buttons := make([]*MangaPageButton, 0)
|
||||
a.mangaPageButtons.Range(func(key string, value *MangaPageButton) bool {
|
||||
buttons = append(buttons, value)
|
||||
return true
|
||||
})
|
||||
|
||||
a.ctx.SendEventToClient(ServerActionRenderMangaPageButtonsEvent, ServerActionRenderMangaPageButtonsEventPayload{
|
||||
Buttons: buttons,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ActionManager) renderMediaCardContextMenuItems() {
|
||||
items := make([]*MediaCardContextMenuItem, 0)
|
||||
a.mediaCardContextMenuItems.Range(func(key string, value *MediaCardContextMenuItem) bool {
|
||||
items = append(items, value)
|
||||
return true
|
||||
})
|
||||
|
||||
a.ctx.SendEventToClient(ServerActionRenderMediaCardContextMenuItemsEvent, ServerActionRenderMediaCardContextMenuItemsEventPayload{
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ActionManager) renderEpisodeCardContextMenuItems() {
|
||||
items := make([]*EpisodeCardContextMenuItem, 0)
|
||||
a.episodeCardContextMenuItems.Range(func(key string, value *EpisodeCardContextMenuItem) bool {
|
||||
items = append(items, value)
|
||||
return true
|
||||
})
|
||||
|
||||
a.ctx.SendEventToClient(ServerActionRenderEpisodeCardContextMenuItemsEvent, ServerActionRenderEpisodeCardContextMenuItemsEventPayload{
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *ActionManager) renderEpisodeGridItemMenuItems() {
|
||||
items := make([]*EpisodeGridItemMenuItem, 0)
|
||||
a.episodeGridItemMenuItems.Range(func(key string, value *EpisodeGridItemMenuItem) bool {
|
||||
items = append(items, value)
|
||||
return true
|
||||
})
|
||||
|
||||
a.ctx.SendEventToClient(ServerActionRenderEpisodeGridItemMenuItemsEvent, ServerActionRenderEpisodeGridItemMenuItemsEventPayload{
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
|
||||
// bind binds 'action' to the ctx object
|
||||
//
|
||||
// Example:
|
||||
// ctx.action.newAnimePageButton(...)
|
||||
func (a *ActionManager) bind(ctxObj *goja.Object) {
|
||||
actionObj := a.ctx.vm.NewObject()
|
||||
_ = actionObj.Set("newAnimePageButton", a.jsNewAnimePageButton)
|
||||
_ = actionObj.Set("newAnimePageDropdownItem", a.jsNewAnimePageDropdownItem)
|
||||
_ = actionObj.Set("newAnimeLibraryDropdownItem", a.jsNewAnimeLibraryDropdownItem)
|
||||
_ = actionObj.Set("newMediaCardContextMenuItem", a.jsNewMediaCardContextMenuItem)
|
||||
_ = actionObj.Set("newMangaPageButton", a.jsNewMangaPageButton)
|
||||
_ = actionObj.Set("newEpisodeCardContextMenuItem", a.jsNewEpisodeCardContextMenuItem)
|
||||
_ = actionObj.Set("newEpisodeGridItemMenuItem", a.jsNewEpisodeGridItemMenuItem)
|
||||
_ = ctxObj.Set("action", actionObj)
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Actions
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// jsNewEpisodeCardContextMenuItem
|
||||
//
|
||||
// Example:
|
||||
// const downloadButton = ctx.newEpisodeCardContextMenuItem({
|
||||
// label: "Download",
|
||||
// onClick: "download-button-clicked",
|
||||
// })
|
||||
func (a *ActionManager) jsNewEpisodeCardContextMenuItem(call goja.FunctionCall) goja.Value {
|
||||
// Create a new action
|
||||
action := &EpisodeCardContextMenuItem{}
|
||||
|
||||
// Get the props
|
||||
a.unmarshalProps(call, action)
|
||||
action.ID = uuid.New().String()
|
||||
|
||||
// Create the object
|
||||
obj := action.CreateObject(a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// jsNewEpisodeGridItemMenuItem
|
||||
//
|
||||
// Example:
|
||||
// const downloadButton = ctx.newEpisodeGridItemContextMenuItem({
|
||||
// label: "Download",
|
||||
// onClick: "download-button-clicked",
|
||||
// type: "library",
|
||||
// })
|
||||
func (a *ActionManager) jsNewEpisodeGridItemMenuItem(call goja.FunctionCall) goja.Value {
|
||||
// Create a new action
|
||||
action := &EpisodeGridItemMenuItem{}
|
||||
|
||||
// Get the props
|
||||
a.unmarshalProps(call, action)
|
||||
action.ID = uuid.New().String()
|
||||
|
||||
// Create the object
|
||||
obj := action.CreateObject(a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// jsNewAnimePageButton
|
||||
//
|
||||
// Example:
|
||||
// const downloadButton = ctx.newAnimePageButton({
|
||||
// label: "Download",
|
||||
// intent: "primary",
|
||||
// onClick: "download-button-clicked",
|
||||
// })
|
||||
func (a *ActionManager) jsNewAnimePageButton(call goja.FunctionCall) goja.Value {
|
||||
// Create a new action
|
||||
action := &AnimePageButton{}
|
||||
|
||||
// Get the props
|
||||
a.unmarshalProps(call, action)
|
||||
action.ID = uuid.New().String()
|
||||
|
||||
// Create the object
|
||||
obj := action.CreateObject(a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// jsNewAnimePageDropdownItem
|
||||
//
|
||||
// Example:
|
||||
// const downloadButton = ctx.newAnimePageDropdownItem({
|
||||
// label: "Download",
|
||||
// onClick: "download-button-clicked",
|
||||
// })
|
||||
func (a *ActionManager) jsNewAnimePageDropdownItem(call goja.FunctionCall) goja.Value {
|
||||
// Create a new action
|
||||
action := &AnimePageDropdownMenuItem{}
|
||||
|
||||
// Get the props
|
||||
a.unmarshalProps(call, action)
|
||||
action.ID = uuid.New().String()
|
||||
|
||||
// Create the object
|
||||
obj := action.CreateObject(a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// jsNewAnimeLibraryDropdownItem
|
||||
//
|
||||
// Example:
|
||||
// const downloadButton = ctx.newAnimeLibraryDropdownItem({
|
||||
// label: "Download",
|
||||
// onClick: "download-button-clicked",
|
||||
// })
|
||||
func (a *ActionManager) jsNewAnimeLibraryDropdownItem(call goja.FunctionCall) goja.Value {
|
||||
// Create a new action
|
||||
action := &AnimeLibraryDropdownMenuItem{}
|
||||
|
||||
// Get the props
|
||||
a.unmarshalProps(call, action)
|
||||
action.ID = uuid.New().String()
|
||||
|
||||
// Create the object
|
||||
obj := action.CreateObject(a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// jsNewMediaCardContextMenuItem
|
||||
//
|
||||
// Example:
|
||||
// const downloadButton = ctx.newMediaCardContextMenuItem({
|
||||
// label: "Download",
|
||||
// onClick: "download-button-clicked",
|
||||
// })
|
||||
func (a *ActionManager) jsNewMediaCardContextMenuItem(call goja.FunctionCall) goja.Value {
|
||||
// Create a new action
|
||||
action := &MediaCardContextMenuItem{}
|
||||
|
||||
// Get the props
|
||||
a.unmarshalProps(call, action)
|
||||
action.ID = uuid.New().String()
|
||||
|
||||
// Create the object
|
||||
obj := action.CreateObject(a)
|
||||
return obj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
// jsNewMangaPageButton
|
||||
//
|
||||
// Example:
|
||||
// const downloadButton = ctx.newMangaPageButton({
|
||||
// label: "Download",
|
||||
// onClick: "download-button-clicked",
|
||||
// })
|
||||
func (a *ActionManager) jsNewMangaPageButton(call goja.FunctionCall) goja.Value {
|
||||
// Create a new action
|
||||
action := &MangaPageButton{}
|
||||
|
||||
// Get the props
|
||||
a.unmarshalProps(call, action)
|
||||
action.ID = uuid.New().String()
|
||||
|
||||
// Create the object
|
||||
obj := action.CreateObject(a)
|
||||
return obj
|
||||
}
|
||||
|
||||
// ///////////////////////////////////////////////////////////////////////////////////
|
||||
// Shared
|
||||
// ///////////////////////////////////////////////////////////////////////////////////
|
||||
// bindSharedToObject binds shared methods to action objects
|
||||
//
|
||||
// Example:
|
||||
// const downloadButton = ctx.newAnimePageButton(...)
|
||||
// downloadButton.mount()
|
||||
// downloadButton.unmount()
|
||||
// downloadButton.setLabel("Downloading...")
|
||||
func (a *ActionManager) bindSharedToObject(obj *goja.Object, action interface{}) {
|
||||
var id string
|
||||
var props BaseActionProps
|
||||
var mapToUse interface{}
|
||||
|
||||
switch act := action.(type) {
|
||||
case *AnimePageButton:
|
||||
id = act.ID
|
||||
props = act.GetProps()
|
||||
mapToUse = a.animePageButtons
|
||||
case *MangaPageButton:
|
||||
id = act.ID
|
||||
props = act.GetProps()
|
||||
mapToUse = a.mangaPageButtons
|
||||
case *AnimePageDropdownMenuItem:
|
||||
id = act.ID
|
||||
props = act.GetProps()
|
||||
mapToUse = a.animePageDropdownItems
|
||||
case *AnimeLibraryDropdownMenuItem:
|
||||
id = act.ID
|
||||
props = act.GetProps()
|
||||
mapToUse = a.animeLibraryDropdownItems
|
||||
case *MediaCardContextMenuItem:
|
||||
id = act.ID
|
||||
props = act.GetProps()
|
||||
mapToUse = a.mediaCardContextMenuItems
|
||||
case *EpisodeCardContextMenuItem:
|
||||
id = act.ID
|
||||
props = act.GetProps()
|
||||
mapToUse = a.episodeCardContextMenuItems
|
||||
case *EpisodeGridItemMenuItem:
|
||||
id = act.ID
|
||||
props = act.GetProps()
|
||||
mapToUse = a.episodeGridItemMenuItems
|
||||
}
|
||||
|
||||
_ = obj.Set("mount", func() {
|
||||
switch m := mapToUse.(type) {
|
||||
case *result.Map[string, *AnimePageButton]:
|
||||
if btn, ok := action.(*AnimePageButton); ok {
|
||||
m.Set(id, btn)
|
||||
a.renderAnimePageButtons()
|
||||
}
|
||||
case *result.Map[string, *MangaPageButton]:
|
||||
if btn, ok := action.(*MangaPageButton); ok {
|
||||
m.Set(id, btn)
|
||||
a.renderMangaPageButtons()
|
||||
}
|
||||
case *result.Map[string, *AnimePageDropdownMenuItem]:
|
||||
if item, ok := action.(*AnimePageDropdownMenuItem); ok {
|
||||
m.Set(id, item)
|
||||
a.renderAnimePageDropdownItems()
|
||||
}
|
||||
case *result.Map[string, *AnimeLibraryDropdownMenuItem]:
|
||||
if item, ok := action.(*AnimeLibraryDropdownMenuItem); ok {
|
||||
m.Set(id, item)
|
||||
a.renderAnimeLibraryDropdownItems()
|
||||
}
|
||||
case *result.Map[string, *MediaCardContextMenuItem]:
|
||||
if item, ok := action.(*MediaCardContextMenuItem); ok {
|
||||
if item.For == "" {
|
||||
item.For = MediaCardContextMenuItemForBoth
|
||||
}
|
||||
m.Set(id, item)
|
||||
a.renderMediaCardContextMenuItems()
|
||||
}
|
||||
case *result.Map[string, *EpisodeCardContextMenuItem]:
|
||||
if item, ok := action.(*EpisodeCardContextMenuItem); ok {
|
||||
m.Set(id, item)
|
||||
a.renderEpisodeCardContextMenuItems()
|
||||
}
|
||||
case *result.Map[string, *EpisodeGridItemMenuItem]:
|
||||
if item, ok := action.(*EpisodeGridItemMenuItem); ok {
|
||||
m.Set(id, item)
|
||||
a.renderEpisodeGridItemMenuItems()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
_ = obj.Set("unmount", func() {
|
||||
switch m := mapToUse.(type) {
|
||||
case *result.Map[string, *AnimePageButton]:
|
||||
m.Delete(id)
|
||||
a.renderAnimePageButtons()
|
||||
case *result.Map[string, *MangaPageButton]:
|
||||
m.Delete(id)
|
||||
a.renderMangaPageButtons()
|
||||
case *result.Map[string, *AnimePageDropdownMenuItem]:
|
||||
m.Delete(id)
|
||||
a.renderAnimePageDropdownItems()
|
||||
case *result.Map[string, *AnimeLibraryDropdownMenuItem]:
|
||||
m.Delete(id)
|
||||
a.renderAnimeLibraryDropdownItems()
|
||||
case *result.Map[string, *MediaCardContextMenuItem]:
|
||||
m.Delete(id)
|
||||
a.renderMediaCardContextMenuItems()
|
||||
case *result.Map[string, *EpisodeCardContextMenuItem]:
|
||||
m.Delete(id)
|
||||
a.renderEpisodeCardContextMenuItems()
|
||||
case *result.Map[string, *EpisodeGridItemMenuItem]:
|
||||
m.Delete(id)
|
||||
a.renderEpisodeGridItemMenuItems()
|
||||
}
|
||||
})
|
||||
|
||||
_ = obj.Set("setLabel", func(label string) {
|
||||
newProps := props
|
||||
newProps.Label = label
|
||||
|
||||
switch act := action.(type) {
|
||||
case *AnimePageButton:
|
||||
act.SetProps(newProps)
|
||||
case *MangaPageButton:
|
||||
act.SetProps(newProps)
|
||||
case *AnimePageDropdownMenuItem:
|
||||
act.SetProps(newProps)
|
||||
case *AnimeLibraryDropdownMenuItem:
|
||||
act.SetProps(newProps)
|
||||
case *MediaCardContextMenuItem:
|
||||
act.SetProps(newProps)
|
||||
case *EpisodeCardContextMenuItem:
|
||||
act.SetProps(newProps)
|
||||
case *EpisodeGridItemMenuItem:
|
||||
act.SetProps(newProps)
|
||||
}
|
||||
|
||||
})
|
||||
|
||||
_ = obj.Set("setStyle", func(style map[string]string) {
|
||||
newProps := props
|
||||
newProps.Style = style
|
||||
|
||||
switch act := action.(type) {
|
||||
case *AnimePageButton:
|
||||
act.SetProps(newProps)
|
||||
a.renderAnimePageButtons()
|
||||
case *MangaPageButton:
|
||||
act.SetProps(newProps)
|
||||
a.renderMangaPageButtons()
|
||||
case *AnimePageDropdownMenuItem:
|
||||
act.SetProps(newProps)
|
||||
a.renderAnimePageDropdownItems()
|
||||
case *AnimeLibraryDropdownMenuItem:
|
||||
act.SetProps(newProps)
|
||||
a.renderAnimeLibraryDropdownItems()
|
||||
case *MediaCardContextMenuItem:
|
||||
act.SetProps(newProps)
|
||||
a.renderMediaCardContextMenuItems()
|
||||
case *EpisodeCardContextMenuItem:
|
||||
act.SetProps(newProps)
|
||||
a.renderEpisodeCardContextMenuItems()
|
||||
case *EpisodeGridItemMenuItem:
|
||||
act.SetProps(newProps)
|
||||
}
|
||||
})
|
||||
|
||||
_ = obj.Set("onClick", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
a.ctx.handleTypeError("onClick requires a callback function")
|
||||
}
|
||||
|
||||
callback, ok := goja.AssertFunction(call.Argument(0))
|
||||
if !ok {
|
||||
a.ctx.handleTypeError("onClick requires a callback function")
|
||||
}
|
||||
|
||||
eventListener := a.ctx.RegisterEventListener(ClientActionClickedEvent)
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
payload := ClientActionClickedEventPayload{}
|
||||
if event.ParsePayloadAs(ClientActionClickedEvent, &payload) && payload.ActionID == id {
|
||||
a.ctx.scheduler.ScheduleAsync(func() error {
|
||||
_, err := callback(goja.Undefined(), a.ctx.vm.ToValue(payload.Event))
|
||||
return err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return goja.Undefined()
|
||||
})
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// Utils
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (a *ActionManager) unmarshalProps(call goja.FunctionCall, ret interface{}) {
|
||||
if len(call.Arguments) < 1 {
|
||||
a.ctx.handleException(fmt.Errorf("expected 1 argument"))
|
||||
}
|
||||
|
||||
props := call.Arguments[0].Export()
|
||||
if props == nil {
|
||||
a.ctx.handleException(fmt.Errorf("expected props object"))
|
||||
}
|
||||
|
||||
marshaled, err := json.Marshal(props)
|
||||
if err != nil {
|
||||
a.ctx.handleException(err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(marshaled, ret)
|
||||
if err != nil {
|
||||
a.ctx.handleException(err)
|
||||
}
|
||||
}
|
||||
380
seanime-2.9.10/internal/plugin/ui/command.go
Normal file
380
seanime-2.9.10/internal/plugin/ui/command.go
Normal file
@@ -0,0 +1,380 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
goja_util "seanime/internal/util/goja"
|
||||
"seanime/internal/util/result"
|
||||
"slices"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// CommandPaletteManager is a manager for the command palette.
|
||||
// Unlike the Tray, command palette items are not reactive to state changes.
|
||||
// They are only rendered when the setItems function is called or the refresh function is called.
|
||||
type CommandPaletteManager struct {
|
||||
ctx *Context
|
||||
updateMutex sync.Mutex
|
||||
lastUpdated time.Time
|
||||
componentManager *ComponentManager
|
||||
|
||||
placeholder string
|
||||
keyboardShortcut string
|
||||
|
||||
// registered is true if the command palette has been registered
|
||||
registered bool
|
||||
|
||||
items *result.Map[string, *commandItem]
|
||||
renderedItems []*CommandItemJSON // Store rendered items when setItems is called
|
||||
}
|
||||
|
||||
type (
|
||||
commandItem struct {
|
||||
index int
|
||||
id string
|
||||
label string
|
||||
value string
|
||||
filterType string // "includes" or "startsWith" or ""
|
||||
heading string
|
||||
renderFunc func(goja.FunctionCall) goja.Value
|
||||
onSelectFunc func(goja.FunctionCall) goja.Value
|
||||
}
|
||||
|
||||
// CommandItemJSON is the JSON representation of a command item.
|
||||
// It is used to send the command item to the client.
|
||||
CommandItemJSON struct {
|
||||
Index int `json:"index"`
|
||||
ID string `json:"id"`
|
||||
Label string `json:"label"`
|
||||
Value string `json:"value"`
|
||||
FilterType string `json:"filterType"`
|
||||
Heading string `json:"heading"`
|
||||
Components interface{} `json:"components"`
|
||||
}
|
||||
)
|
||||
|
||||
func NewCommandPaletteManager(ctx *Context) *CommandPaletteManager {
|
||||
return &CommandPaletteManager{
|
||||
ctx: ctx,
|
||||
componentManager: &ComponentManager{ctx: ctx},
|
||||
items: result.NewResultMap[string, *commandItem](),
|
||||
renderedItems: make([]*CommandItemJSON, 0),
|
||||
}
|
||||
}
|
||||
|
||||
type NewCommandPaletteOptions struct {
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
KeyboardShortcut string `json:"keyboardShortcut,omitempty"`
|
||||
}
|
||||
|
||||
// sendInfoToClient sends the command palette info to the client after it's been requested.
|
||||
func (c *CommandPaletteManager) sendInfoToClient() {
|
||||
if c.registered {
|
||||
c.ctx.SendEventToClient(ServerCommandPaletteInfoEvent, ServerCommandPaletteInfoEventPayload{
|
||||
Placeholder: c.placeholder,
|
||||
KeyboardShortcut: c.keyboardShortcut,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CommandPaletteManager) jsNewCommandPalette(options NewCommandPaletteOptions) goja.Value {
|
||||
c.registered = true
|
||||
c.keyboardShortcut = options.KeyboardShortcut
|
||||
c.placeholder = options.Placeholder
|
||||
|
||||
cmdObj := c.ctx.vm.NewObject()
|
||||
|
||||
_ = cmdObj.Set("setItems", func(items []interface{}) {
|
||||
c.items.Clear()
|
||||
|
||||
for idx, item := range items {
|
||||
itemMap := item.(map[string]interface{})
|
||||
id := uuid.New().String()
|
||||
label, _ := itemMap["label"].(string)
|
||||
value, ok := itemMap["value"].(string)
|
||||
if !ok {
|
||||
c.ctx.handleTypeError("value must be a string")
|
||||
return
|
||||
}
|
||||
filterType, _ := itemMap["filterType"].(string)
|
||||
if filterType != "includes" && filterType != "startsWith" && filterType != "" {
|
||||
c.ctx.handleTypeError("filterType must be 'includes', 'startsWith'")
|
||||
return
|
||||
}
|
||||
heading, _ := itemMap["heading"].(string)
|
||||
renderFunc, ok := itemMap["render"].(func(goja.FunctionCall) goja.Value)
|
||||
if len(label) == 0 && !ok {
|
||||
c.ctx.handleTypeError("label or render function must be provided")
|
||||
return
|
||||
}
|
||||
onSelectFunc, ok := itemMap["onSelect"].(func(goja.FunctionCall) goja.Value)
|
||||
if !ok {
|
||||
c.ctx.handleTypeError("onSelect must be a function")
|
||||
return
|
||||
}
|
||||
|
||||
c.items.Set(id, &commandItem{
|
||||
index: idx,
|
||||
id: id,
|
||||
label: label,
|
||||
value: value,
|
||||
filterType: filterType,
|
||||
heading: heading,
|
||||
renderFunc: renderFunc,
|
||||
onSelectFunc: onSelectFunc,
|
||||
})
|
||||
}
|
||||
|
||||
// Convert the items to JSON
|
||||
itemsJSON := make([]*CommandItemJSON, 0)
|
||||
c.items.Range(func(key string, value *commandItem) bool {
|
||||
itemsJSON = append(itemsJSON, value.ToJSON(c.ctx, c.componentManager, c.ctx.scheduler))
|
||||
return true
|
||||
})
|
||||
// Store the converted items
|
||||
c.renderedItems = itemsJSON
|
||||
|
||||
c.renderCommandPaletteScheduled()
|
||||
})
|
||||
|
||||
_ = cmdObj.Set("refresh", func() {
|
||||
// Convert the items to JSON
|
||||
itemsJSON := make([]*CommandItemJSON, 0)
|
||||
c.items.Range(func(key string, value *commandItem) bool {
|
||||
itemsJSON = append(itemsJSON, value.ToJSON(c.ctx, c.componentManager, c.ctx.scheduler))
|
||||
return true
|
||||
})
|
||||
|
||||
c.renderedItems = itemsJSON
|
||||
|
||||
c.renderCommandPaletteScheduled()
|
||||
})
|
||||
|
||||
_ = cmdObj.Set("setPlaceholder", func(placeholder string) {
|
||||
c.placeholder = placeholder
|
||||
c.renderCommandPaletteScheduled()
|
||||
})
|
||||
|
||||
_ = cmdObj.Set("open", func() {
|
||||
c.ctx.SendEventToClient(ServerCommandPaletteOpenEvent, ServerCommandPaletteOpenEventPayload{})
|
||||
})
|
||||
|
||||
_ = cmdObj.Set("close", func() {
|
||||
c.ctx.SendEventToClient(ServerCommandPaletteCloseEvent, ServerCommandPaletteCloseEventPayload{})
|
||||
})
|
||||
|
||||
_ = cmdObj.Set("setInput", func(input string) {
|
||||
c.ctx.SendEventToClient(ServerCommandPaletteSetInputEvent, ServerCommandPaletteSetInputEventPayload{
|
||||
Value: input,
|
||||
})
|
||||
})
|
||||
|
||||
_ = cmdObj.Set("getInput", func() string {
|
||||
c.ctx.SendEventToClient(ServerCommandPaletteGetInputEvent, ServerCommandPaletteGetInputEventPayload{})
|
||||
|
||||
eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteInputEvent)
|
||||
defer c.ctx.UnregisterEventListener(eventListener.ID)
|
||||
|
||||
timeout := time.After(1500 * time.Millisecond)
|
||||
input := make(chan string)
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
payload := ClientCommandPaletteInputEventPayload{}
|
||||
if event.ParsePayloadAs(ClientCommandPaletteInputEvent, &payload) {
|
||||
input <- payload.Value
|
||||
}
|
||||
})
|
||||
|
||||
// go func() {
|
||||
// for event := range eventListener.Channel {
|
||||
// if event.ParsePayloadAs(ClientCommandPaletteInputEvent, &payload) {
|
||||
// input <- payload.Value
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
|
||||
select {
|
||||
case <-timeout:
|
||||
return ""
|
||||
case input := <-input:
|
||||
return input
|
||||
}
|
||||
})
|
||||
|
||||
// jsOnOpen
|
||||
//
|
||||
// Example:
|
||||
// commandPalette.onOpen(() => {
|
||||
// console.log("command palette opened by the user")
|
||||
// })
|
||||
_ = cmdObj.Set("onOpen", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
c.ctx.handleTypeError("onOpen requires a callback function")
|
||||
}
|
||||
|
||||
callback, ok := goja.AssertFunction(call.Argument(0))
|
||||
if !ok {
|
||||
c.ctx.handleTypeError("onOpen requires a callback function")
|
||||
}
|
||||
|
||||
eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteOpenedEvent)
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
payload := ClientCommandPaletteOpenedEventPayload{}
|
||||
if event.ParsePayloadAs(ClientCommandPaletteOpenedEvent, &payload) {
|
||||
c.ctx.scheduler.ScheduleAsync(func() error {
|
||||
_, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{}))
|
||||
return err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// go func() {
|
||||
// for event := range eventListener.Channel {
|
||||
// if event.ParsePayloadAs(ClientCommandPaletteOpenedEvent, &payload) {
|
||||
// c.ctx.scheduler.ScheduleAsync(func() error {
|
||||
// _, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{}))
|
||||
// if err != nil {
|
||||
// c.ctx.logger.Error().Err(err).Msg("plugin: Error running command palette open callback")
|
||||
// }
|
||||
// return err
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
// jsOnClose
|
||||
//
|
||||
// Example:
|
||||
// commandPalette.onClose(() => {
|
||||
// console.log("command palette closed by the user")
|
||||
// })
|
||||
_ = cmdObj.Set("onClose", func(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
c.ctx.handleTypeError("onClose requires a callback function")
|
||||
}
|
||||
|
||||
callback, ok := goja.AssertFunction(call.Argument(0))
|
||||
if !ok {
|
||||
c.ctx.handleTypeError("onClose requires a callback function")
|
||||
}
|
||||
|
||||
eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteClosedEvent)
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
payload := ClientCommandPaletteClosedEventPayload{}
|
||||
if event.ParsePayloadAs(ClientCommandPaletteClosedEvent, &payload) {
|
||||
c.ctx.scheduler.ScheduleAsync(func() error {
|
||||
_, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{}))
|
||||
return err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// go func() {
|
||||
// for event := range eventListener.Channel {
|
||||
// if event.ParsePayloadAs(ClientCommandPaletteClosedEvent, &payload) {
|
||||
// c.ctx.scheduler.ScheduleAsync(func() error {
|
||||
// _, err := callback(goja.Undefined(), c.ctx.vm.ToValue(map[string]interface{}{}))
|
||||
// if err != nil {
|
||||
// c.ctx.logger.Error().Err(err).Msg("plugin: Error running command palette close callback")
|
||||
// }
|
||||
// return err
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
return goja.Undefined()
|
||||
})
|
||||
|
||||
eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteItemSelectedEvent)
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
payload := ClientCommandPaletteItemSelectedEventPayload{}
|
||||
if event.ParsePayloadAs(ClientCommandPaletteItemSelectedEvent, &payload) {
|
||||
c.ctx.scheduler.ScheduleAsync(func() error {
|
||||
item, found := c.items.Get(payload.ItemID)
|
||||
if found {
|
||||
_ = item.onSelectFunc(goja.FunctionCall{})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
})
|
||||
// go func() {
|
||||
// eventListener := c.ctx.RegisterEventListener(ClientCommandPaletteItemSelectedEvent)
|
||||
// payload := ClientCommandPaletteItemSelectedEventPayload{}
|
||||
|
||||
// for event := range eventListener.Channel {
|
||||
// if event.ParsePayloadAs(ClientCommandPaletteItemSelectedEvent, &payload) {
|
||||
// item, found := c.items.Get(payload.ItemID)
|
||||
// if found {
|
||||
// c.ctx.scheduler.ScheduleAsync(func() error {
|
||||
// _ = item.onSelectFunc(goja.FunctionCall{})
|
||||
// return nil
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
|
||||
// Register components
|
||||
_ = cmdObj.Set("div", c.componentManager.jsDiv)
|
||||
_ = cmdObj.Set("flex", c.componentManager.jsFlex)
|
||||
_ = cmdObj.Set("stack", c.componentManager.jsStack)
|
||||
_ = cmdObj.Set("text", c.componentManager.jsText)
|
||||
_ = cmdObj.Set("button", c.componentManager.jsButton)
|
||||
_ = cmdObj.Set("anchor", c.componentManager.jsAnchor)
|
||||
|
||||
return cmdObj
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func (c *commandItem) ToJSON(ctx *Context, componentManager *ComponentManager, scheduler *goja_util.Scheduler) *CommandItemJSON {
|
||||
|
||||
var components interface{}
|
||||
if c.renderFunc != nil {
|
||||
var err error
|
||||
components, err = componentManager.renderComponents(c.renderFunc)
|
||||
if err != nil {
|
||||
ctx.logger.Error().Err(err).Msg("plugin: Failed to render command palette item")
|
||||
ctx.handleException(err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Reset the last rendered components, we don't care about diffing
|
||||
componentManager.lastRenderedComponents = nil
|
||||
|
||||
return &CommandItemJSON{
|
||||
Index: c.index,
|
||||
ID: c.id,
|
||||
Label: c.label,
|
||||
Value: c.value,
|
||||
FilterType: c.filterType,
|
||||
Heading: c.heading,
|
||||
Components: components,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *CommandPaletteManager) renderCommandPaletteScheduled() {
|
||||
c.updateMutex.Lock()
|
||||
defer c.updateMutex.Unlock()
|
||||
|
||||
if !c.registered {
|
||||
return
|
||||
}
|
||||
|
||||
slices.SortFunc(c.renderedItems, func(a, b *CommandItemJSON) int {
|
||||
return a.Index - b.Index
|
||||
})
|
||||
|
||||
c.ctx.SendEventToClient(ServerCommandPaletteUpdatedEvent, ServerCommandPaletteUpdatedEventPayload{
|
||||
Placeholder: c.placeholder,
|
||||
Items: c.renderedItems,
|
||||
})
|
||||
}
|
||||
374
seanime-2.9.10/internal/plugin/ui/component_utils.go
Normal file
374
seanime-2.9.10/internal/plugin/ui/component_utils.go
Normal file
@@ -0,0 +1,374 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/goccy/go-json"
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
func (c *ComponentManager) renderComponents(renderFunc func(goja.FunctionCall) goja.Value) (interface{}, error) {
|
||||
if renderFunc == nil {
|
||||
return nil, errors.New("render function is not set")
|
||||
}
|
||||
|
||||
// Get new components
|
||||
newComponents := c.getComponentsData(renderFunc)
|
||||
|
||||
// If we have previous components, perform diffing
|
||||
if c.lastRenderedComponents != nil {
|
||||
newComponents = c.componentDiff(c.lastRenderedComponents, newComponents)
|
||||
}
|
||||
|
||||
// Store the new components for next render
|
||||
c.lastRenderedComponents = newComponents
|
||||
|
||||
return newComponents, nil
|
||||
}
|
||||
|
||||
// getComponentsData calls the render function and returns the current state of the component tree
|
||||
func (c *ComponentManager) getComponentsData(renderFunc func(goja.FunctionCall) goja.Value) interface{} {
|
||||
// Call the render function
|
||||
value := renderFunc(goja.FunctionCall{})
|
||||
|
||||
// Convert the value to a JSON string
|
||||
v, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var ret interface{}
|
||||
err = json.Unmarshal(v, &ret)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
////
|
||||
|
||||
type ComponentProp struct {
|
||||
Name string // e.g. "label"
|
||||
Type string // e.g. "string"
|
||||
Default interface{} // Is set if the prop is not provided, if not set and required is false, the prop will not be included in the component
|
||||
Required bool // If true an no default value is provided, the component will throw a type error
|
||||
Validate func(value interface{}) error // Optional validation function
|
||||
OptionalFirstArg bool // If true, it can be the first argument to declaring the component as a shorthand (e.g. tray.button("Click me") instead of tray.button({label: "Click me"}))
|
||||
}
|
||||
|
||||
func defineComponent(vm *goja.Runtime, call goja.FunctionCall, t string, propDefs []ComponentProp) goja.Value {
|
||||
component := Component{
|
||||
ID: uuid.New().String(),
|
||||
Type: t,
|
||||
Props: make(map[string]interface{}),
|
||||
}
|
||||
|
||||
propsList := make(map[string]interface{})
|
||||
propDefsMap := make(map[string]*ComponentProp)
|
||||
|
||||
var shorthandProp *ComponentProp
|
||||
for _, propDef := range propDefs {
|
||||
|
||||
propDefsMap[propDef.Name] = &propDef
|
||||
|
||||
if propDef.OptionalFirstArg {
|
||||
shorthandProp = &propDef
|
||||
}
|
||||
}
|
||||
|
||||
if len(call.Arguments) > 0 {
|
||||
// Check if the first argument is the type of the shorthand
|
||||
hasShorthand := false
|
||||
if shorthandProp != nil {
|
||||
switch shorthandProp.Type {
|
||||
case "string":
|
||||
if _, ok := call.Argument(0).Export().(string); ok {
|
||||
propsList[shorthandProp.Name] = call.Argument(0).Export().(string)
|
||||
hasShorthand = true
|
||||
}
|
||||
case "boolean":
|
||||
if _, ok := call.Argument(0).Export().(bool); ok {
|
||||
propsList[shorthandProp.Name] = call.Argument(0).Export().(bool)
|
||||
hasShorthand = true
|
||||
}
|
||||
case "array":
|
||||
if _, ok := call.Argument(0).Export().([]interface{}); ok {
|
||||
propsList[shorthandProp.Name] = call.Argument(0).Export().([]interface{})
|
||||
hasShorthand = true
|
||||
}
|
||||
}
|
||||
if hasShorthand {
|
||||
// Get the rest of the props from the second argument
|
||||
if len(call.Arguments) > 1 {
|
||||
rest, ok := call.Argument(1).Export().(map[string]interface{})
|
||||
if ok {
|
||||
// Only add props that are defined in the propDefs
|
||||
for k, v := range rest {
|
||||
if _, ok := propDefsMap[k]; ok {
|
||||
propsList[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasShorthand {
|
||||
propsArg, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if ok {
|
||||
for k, v := range propsArg {
|
||||
if _, ok := propDefsMap[k]; ok {
|
||||
propsList[k] = v
|
||||
} else {
|
||||
// util.SpewMany(k, fmt.Sprintf("%T", v))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate props
|
||||
for _, propDef := range propDefs {
|
||||
// If a prop is required and no value is provided, panic
|
||||
if propDef.Required && len(propsList) == 0 {
|
||||
panic(vm.NewTypeError(fmt.Sprintf("%s is required", propDef.Name)))
|
||||
}
|
||||
|
||||
// Validate the prop if the prop is defined
|
||||
if propDef.Validate != nil {
|
||||
if val, ok := propsList[propDef.Name]; ok {
|
||||
err := propDef.Validate(val)
|
||||
if err != nil {
|
||||
log.Error().Msgf("Invalid prop value: %s", err.Error())
|
||||
panic(vm.NewTypeError(err.Error()))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set a default value if the prop is not provided
|
||||
if _, ok := propsList[propDef.Name]; !ok && propDef.Default != nil {
|
||||
propsList[propDef.Name] = propDef.Default
|
||||
}
|
||||
}
|
||||
|
||||
// Set the props
|
||||
for k, v := range propsList {
|
||||
component.Props[k] = v
|
||||
}
|
||||
|
||||
return vm.ToValue(component)
|
||||
}
|
||||
|
||||
// Helper function to create a validation function for a specific type
|
||||
func validateType(expectedType string) func(interface{}) error {
|
||||
return func(value interface{}) error {
|
||||
if value == nil {
|
||||
return fmt.Errorf("expected %s, got nil", expectedType)
|
||||
}
|
||||
switch expectedType {
|
||||
case "string":
|
||||
_, ok := value.(string)
|
||||
if !ok {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("expected string, got %T", value)
|
||||
}
|
||||
return nil
|
||||
case "number":
|
||||
_, ok := value.(float64)
|
||||
if !ok {
|
||||
_, ok := value.(int64)
|
||||
if !ok {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("expected number, got %T", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
case "boolean":
|
||||
_, ok := value.(bool)
|
||||
if !ok {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("expected boolean, got %T", value)
|
||||
}
|
||||
return nil
|
||||
case "array":
|
||||
_, ok := value.([]interface{})
|
||||
if !ok {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("expected array, got %T", value)
|
||||
}
|
||||
return nil
|
||||
case "object":
|
||||
_, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("expected object, got %T", value)
|
||||
}
|
||||
return nil
|
||||
case "function":
|
||||
_, ok := value.(func(goja.FunctionCall) goja.Value)
|
||||
if !ok {
|
||||
return fmt.Errorf("expected function, got %T", value)
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid type: %s", expectedType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// componentDiff compares two component trees and returns a new component tree that preserves the ID of old components that did not change.
|
||||
// It also recursively handles props and items arrays.
|
||||
//
|
||||
// This is important to preserve state between renders in React.
|
||||
func (c *ComponentManager) componentDiff(old, new interface{}) (ret interface{}) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
// If a panic occurs, return the new component tree
|
||||
ret = new
|
||||
}
|
||||
}()
|
||||
|
||||
if old == nil || new == nil {
|
||||
return new
|
||||
}
|
||||
|
||||
// Handle maps (components)
|
||||
if oldMap, ok := old.(map[string]interface{}); ok {
|
||||
if newMap, ok := new.(map[string]interface{}); ok {
|
||||
// If types match and it's a component (has "type" field), preserve ID
|
||||
if oldType, hasOldType := oldMap["type"]; hasOldType {
|
||||
if newType, hasNewType := newMap["type"]; hasNewType && oldType == newType {
|
||||
// Preserve the ID from the old component
|
||||
if oldID, hasOldID := oldMap["id"]; hasOldID {
|
||||
newMap["id"] = oldID
|
||||
}
|
||||
|
||||
// Recursively handle props
|
||||
if oldProps, hasOldProps := oldMap["props"].(map[string]interface{}); hasOldProps {
|
||||
if newProps, hasNewProps := newMap["props"].(map[string]interface{}); hasNewProps {
|
||||
// Special handling for items array in props
|
||||
if oldItems, ok := oldProps["items"].([]interface{}); ok {
|
||||
if newItems, ok := newProps["items"].([]interface{}); ok {
|
||||
newProps["items"] = c.componentDiff(oldItems, newItems)
|
||||
}
|
||||
}
|
||||
// Handle other props
|
||||
for k, v := range newProps {
|
||||
if k != "items" { // Skip items as we already handled it
|
||||
if oldV, exists := oldProps[k]; exists {
|
||||
newProps[k] = c.componentDiff(oldV, v)
|
||||
}
|
||||
}
|
||||
}
|
||||
newMap["props"] = newProps
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return newMap
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if oldArr, ok := old.([]interface{}); ok {
|
||||
if newArr, ok := new.([]interface{}); ok {
|
||||
// Create a new array to store the diffed components
|
||||
result := make([]interface{}, len(newArr))
|
||||
|
||||
// First, try to match components by key if available
|
||||
oldKeyMap := make(map[string]interface{})
|
||||
for _, oldComp := range oldArr {
|
||||
if oldMap, ok := oldComp.(map[string]interface{}); ok {
|
||||
if key, ok := oldMap["key"].(string); ok && key != "" {
|
||||
oldKeyMap[key] = oldComp
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process each new component
|
||||
for i, newComp := range newArr {
|
||||
matched := false
|
||||
|
||||
// Try to match by key first
|
||||
if newMap, ok := newComp.(map[string]interface{}); ok {
|
||||
if key, ok := newMap["key"].(string); ok && key != "" {
|
||||
if oldComp, exists := oldKeyMap[key]; exists {
|
||||
// Found a match by key
|
||||
result[i] = c.componentDiff(oldComp, newComp)
|
||||
matched = true
|
||||
// t.ctx.logger.Debug().
|
||||
// Str("key", key).
|
||||
// Str("type", fmt.Sprintf("%v", newMap["type"])).
|
||||
// Msg("Component matched by key")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no key match, try to match by position and type
|
||||
if !matched && i < len(oldArr) {
|
||||
oldComp := oldArr[i]
|
||||
oldType, newType := "", ""
|
||||
|
||||
if oldMap, ok := oldComp.(map[string]interface{}); ok {
|
||||
if t, ok := oldMap["type"].(string); ok {
|
||||
oldType = t
|
||||
}
|
||||
}
|
||||
if newMap, ok := newComp.(map[string]interface{}); ok {
|
||||
if t, ok := newMap["type"].(string); ok {
|
||||
newType = t
|
||||
}
|
||||
}
|
||||
|
||||
if oldType != "" && oldType == newType {
|
||||
result[i] = c.componentDiff(oldComp, newComp)
|
||||
matched = true
|
||||
// t.ctx.logger.Debug().
|
||||
// Str("type", oldType).
|
||||
// Msg("Component matched by type and position")
|
||||
}
|
||||
}
|
||||
|
||||
// If no match found, use the new component as is
|
||||
if !matched {
|
||||
result[i] = newComp
|
||||
// if newMap, ok := newComp.(map[string]interface{}); ok {
|
||||
// t.ctx.logger.Debug().
|
||||
// Str("type", fmt.Sprintf("%v", newMap["type"])).
|
||||
// Msg("New component added")
|
||||
// }
|
||||
}
|
||||
}
|
||||
|
||||
// Log removed components
|
||||
// if len(oldArr) > len(newArr) {
|
||||
// for i := len(newArr); i < len(oldArr); i++ {
|
||||
// if oldMap, ok := oldArr[i].(map[string]interface{}); ok {
|
||||
// t.ctx.logger.Debug().
|
||||
// Str("type", fmt.Sprintf("%v", oldMap["type"])).
|
||||
// Msg("Component removed")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
return new
|
||||
}
|
||||
280
seanime-2.9.10/internal/plugin/ui/components.go
Normal file
280
seanime-2.9.10/internal/plugin/ui/components.go
Normal file
@@ -0,0 +1,280 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/goccy/go-json"
|
||||
)
|
||||
|
||||
const (
|
||||
MAX_FIELD_REFS = 100
|
||||
)
|
||||
|
||||
// ComponentManager is used to register components.
|
||||
// Any higher-order UI system must use this to register components. (Tray)
|
||||
type ComponentManager struct {
|
||||
ctx *Context
|
||||
|
||||
// Last rendered components
|
||||
lastRenderedComponents interface{}
|
||||
}
|
||||
|
||||
// jsDiv
|
||||
//
|
||||
// Example:
|
||||
// const div = tray.div({
|
||||
// items: [
|
||||
// tray.text("Some text"),
|
||||
// ]
|
||||
// })
|
||||
func (c *ComponentManager) jsDiv(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "div", []ComponentProp{
|
||||
{Name: "items", Type: "array", Required: false, OptionalFirstArg: true},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
// jsFlex
|
||||
//
|
||||
// Example:
|
||||
// const flex = tray.flex({
|
||||
// items: [
|
||||
// tray.button({ label: "A button", onClick: "my-action" }),
|
||||
// true ? tray.text("Some text") : null,
|
||||
// ]
|
||||
// })
|
||||
// tray.render(() => flex)
|
||||
func (c *ComponentManager) jsFlex(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "flex", []ComponentProp{
|
||||
{Name: "items", Type: "array", Required: false, OptionalFirstArg: true},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "gap", Type: "number", Required: false, Default: 2, Validate: validateType("number")},
|
||||
{Name: "direction", Type: "string", Required: false, Default: "row", Validate: validateType("string")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
// jsStack
|
||||
//
|
||||
// Example:
|
||||
// const stack = tray.stack({
|
||||
// items: [
|
||||
// tray.text("Some text"),
|
||||
// ]
|
||||
// })
|
||||
func (c *ComponentManager) jsStack(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "stack", []ComponentProp{
|
||||
{Name: "items", Type: "array", Required: false, OptionalFirstArg: true},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "gap", Type: "number", Required: false, Default: 2, Validate: validateType("number")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
// jsText
|
||||
//
|
||||
// Example:
|
||||
// const text = tray.text("Some text")
|
||||
// // or
|
||||
// const text = tray.text({ text: "Some text" })
|
||||
func (c *ComponentManager) jsText(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "text", []ComponentProp{
|
||||
{Name: "text", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
// jsButton
|
||||
//
|
||||
// Example:
|
||||
// const button = tray.button("Click me")
|
||||
// // or
|
||||
// const button = tray.button({ label: "Click me", onClick: "my-action" })
|
||||
func (c *ComponentManager) jsButton(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "button", []ComponentProp{
|
||||
{Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
|
||||
{Name: "onClick", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "intent", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "loading", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
// jsAnchor
|
||||
//
|
||||
// Example:
|
||||
// const anchor = tray.anchor("Click here", { href: "https://example.com" })
|
||||
// // or
|
||||
// const anchor = tray.anchor({ text: "Click here", href: "https://example.com" })
|
||||
func (c *ComponentManager) jsAnchor(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "anchor", []ComponentProp{
|
||||
{Name: "text", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
|
||||
{Name: "href", Type: "string", Required: true, Validate: validateType("string")},
|
||||
{Name: "target", Type: "string", Required: false, Default: "_blank", Validate: validateType("string")},
|
||||
{Name: "onClick", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
////////////////////////////////////////////
|
||||
// Fields
|
||||
////////////////////////////////////////////
|
||||
|
||||
// jsInput
|
||||
//
|
||||
// Example:
|
||||
// const input = tray.input("Enter your name") // placeholder as shorthand
|
||||
// // or
|
||||
// const input = tray.input({
|
||||
// placeholder: "Enter your name",
|
||||
// value: "John",
|
||||
// onChange: "input-changed"
|
||||
// })
|
||||
func (c *ComponentManager) jsInput(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "input", []ComponentProp{
|
||||
{Name: "label", Type: "string", Required: false, OptionalFirstArg: true, Validate: validateType("string")},
|
||||
{Name: "placeholder", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "value", Type: "string", Required: false, Default: "", Validate: validateType("string")},
|
||||
{Name: "onChange", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "onSelect", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "textarea", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
func validateOptions(v interface{}) error {
|
||||
if v == nil {
|
||||
return errors.New("options must be an array of objects")
|
||||
}
|
||||
marshaled, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var arr []map[string]interface{}
|
||||
if err := json.Unmarshal(marshaled, &arr); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(arr) == 0 {
|
||||
return nil
|
||||
}
|
||||
for _, option := range arr {
|
||||
if _, ok := option["label"]; !ok {
|
||||
return errors.New("options must be an array of objects with a label property")
|
||||
}
|
||||
if _, ok := option["value"]; !ok {
|
||||
return errors.New("options must be an array of objects with a value property")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// jsSelect
|
||||
//
|
||||
// Example:
|
||||
// const select = tray.select("Select an item", {
|
||||
// options: [{ label: "Item 1", value: "item1" }, { label: "Item 2", value: "item2" }],
|
||||
// onChange: "select-changed"
|
||||
// })
|
||||
// // or
|
||||
// const select = tray.select({
|
||||
// placeholder: "Select an item",
|
||||
// options: [{ label: "Item 1", value: "item1" }, { label: "Item 2", value: "item2" }],
|
||||
// value: "Item 1",
|
||||
// onChange: "select-changed"
|
||||
// })
|
||||
func (c *ComponentManager) jsSelect(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "select", []ComponentProp{
|
||||
{Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
|
||||
{Name: "placeholder", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{
|
||||
Name: "options",
|
||||
Type: "array",
|
||||
Required: true,
|
||||
Validate: validateOptions,
|
||||
},
|
||||
{Name: "value", Type: "string", Required: false, Default: "", Validate: validateType("string")},
|
||||
{Name: "onChange", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
// jsCheckbox
|
||||
//
|
||||
// Example:
|
||||
// const checkbox = tray.checkbox("I agree to the terms and conditions")
|
||||
// // or
|
||||
// const checkbox = tray.checkbox({ label: "I agree to the terms and conditions", value: true })
|
||||
func (c *ComponentManager) jsCheckbox(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "checkbox", []ComponentProp{
|
||||
{Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
|
||||
{Name: "value", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "onChange", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
// jsRadioGroup
|
||||
//
|
||||
// Example:
|
||||
// const radioGroup = tray.radioGroup({
|
||||
// options: [{ label: "Item 1", value: "item1" }, { label: "Item 2", value: "item2" }],
|
||||
// onChange: "radio-group-changed"
|
||||
// })
|
||||
func (c *ComponentManager) jsRadioGroup(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "radio-group", []ComponentProp{
|
||||
{Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
|
||||
{Name: "value", Type: "string", Required: false, Default: "", Validate: validateType("string")},
|
||||
{
|
||||
Name: "options",
|
||||
Type: "array",
|
||||
Required: true,
|
||||
Validate: validateOptions,
|
||||
},
|
||||
{Name: "onChange", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
|
||||
// jsSwitch
|
||||
//
|
||||
// Example:
|
||||
// const switch = tray.switch({
|
||||
// label: "Toggle me",
|
||||
// value: true
|
||||
// })
|
||||
func (c *ComponentManager) jsSwitch(call goja.FunctionCall) goja.Value {
|
||||
return defineComponent(c.ctx.vm, call, "switch", []ComponentProp{
|
||||
{Name: "label", Type: "string", Required: true, OptionalFirstArg: true, Validate: validateType("string")},
|
||||
{Name: "value", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "onChange", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "fieldRef", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "style", Type: "object", Required: false, Validate: validateType("object")},
|
||||
{Name: "disabled", Type: "boolean", Required: false, Default: false, Validate: validateType("boolean")},
|
||||
{Name: "size", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "side", Type: "string", Required: false, Validate: validateType("string")},
|
||||
{Name: "className", Type: "string", Required: false, Validate: validateType("string")},
|
||||
})
|
||||
}
|
||||
1243
seanime-2.9.10/internal/plugin/ui/context.go
Normal file
1243
seanime-2.9.10/internal/plugin/ui/context.go
Normal file
File diff suppressed because it is too large
Load Diff
1610
seanime-2.9.10/internal/plugin/ui/dom.go
Normal file
1610
seanime-2.9.10/internal/plugin/ui/dom.go
Normal file
File diff suppressed because it is too large
Load Diff
397
seanime-2.9.10/internal/plugin/ui/events.go
Normal file
397
seanime-2.9.10/internal/plugin/ui/events.go
Normal file
@@ -0,0 +1,397 @@
|
||||
package plugin_ui
|
||||
|
||||
import "github.com/goccy/go-json"
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// Client to server
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type ClientEventType string
|
||||
|
||||
// ClientPluginEvent is an event received from the client
|
||||
type ClientPluginEvent struct {
|
||||
// ExtensionID is the "sent to"
|
||||
// If not set, the event is being sent to all plugins
|
||||
ExtensionID string `json:"extensionId,omitempty"`
|
||||
Type ClientEventType `json:"type"`
|
||||
Payload interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
const (
|
||||
ClientRenderTrayEvent ClientEventType = "tray:render" // Client wants to render the tray
|
||||
ClientListTrayIconsEvent ClientEventType = "tray:list-icons" // Client wants to list all icons from all plugins
|
||||
ClientTrayOpenedEvent ClientEventType = "tray:opened" // When the tray is opened
|
||||
ClientTrayClosedEvent ClientEventType = "tray:closed" // When the tray is closed
|
||||
ClientTrayClickedEvent ClientEventType = "tray:clicked" // When the tray is clicked
|
||||
ClientListCommandPalettesEvent ClientEventType = "command-palette:list" // When the client wants to list all command palettes
|
||||
ClientCommandPaletteOpenedEvent ClientEventType = "command-palette:opened" // When the client opens the command palette
|
||||
ClientCommandPaletteClosedEvent ClientEventType = "command-palette:closed" // When the client closes the command palette
|
||||
ClientRenderCommandPaletteEvent ClientEventType = "command-palette:render" // When the client requests the command palette to render
|
||||
ClientCommandPaletteInputEvent ClientEventType = "command-palette:input" // The client sends the current input of the command palette
|
||||
ClientCommandPaletteItemSelectedEvent ClientEventType = "command-palette:item-selected" // When the client selects an item from the command palette
|
||||
ClientActionRenderAnimePageButtonsEvent ClientEventType = "action:anime-page-buttons:render" // When the client requests the buttons to display on the anime page
|
||||
ClientActionRenderAnimePageDropdownItemsEvent ClientEventType = "action:anime-page-dropdown-items:render" // When the client requests the dropdown items to display on the anime page
|
||||
ClientActionRenderMangaPageButtonsEvent ClientEventType = "action:manga-page-buttons:render" // When the client requests the buttons to display on the manga page
|
||||
ClientActionRenderMediaCardContextMenuItemsEvent ClientEventType = "action:media-card-context-menu-items:render" // When the client requests the context menu items to display on the media card
|
||||
ClientActionRenderAnimeLibraryDropdownItemsEvent ClientEventType = "action:anime-library-dropdown-items:render" // When the client requests the dropdown items to display on the anime library
|
||||
ClientActionRenderEpisodeCardContextMenuItemsEvent ClientEventType = "action:episode-card-context-menu-items:render" // When the client requests the context menu items to display on the episode card
|
||||
ClientActionRenderEpisodeGridItemMenuItemsEvent ClientEventType = "action:episode-grid-item-menu-items:render" // When the client requests the context menu items to display on the episode grid item
|
||||
ClientActionClickedEvent ClientEventType = "action:clicked" // When the user clicks on an action
|
||||
ClientFormSubmittedEvent ClientEventType = "form:submitted" // When the form registered by the tray is submitted
|
||||
ClientScreenChangedEvent ClientEventType = "screen:changed" // When the current screen changes
|
||||
ClientEventHandlerTriggeredEvent ClientEventType = "handler:triggered" // When a custom event registered by the plugin is triggered
|
||||
ClientFieldRefSendValueEvent ClientEventType = "field-ref:send-value" // When the client sends the value of a field that has a ref
|
||||
|
||||
ClientDOMQueryResultEvent ClientEventType = "dom:query-result" // Result of a DOM query
|
||||
ClientDOMQueryOneResultEvent ClientEventType = "dom:query-one-result" // Result of a DOM query for one element
|
||||
ClientDOMObserveResultEvent ClientEventType = "dom:observe-result" // Result of a DOM observation
|
||||
ClientDOMStopObserveEvent ClientEventType = "dom:stop-observe" // Stop observing DOM elements
|
||||
ClientDOMCreateResultEvent ClientEventType = "dom:create-result" // Result of creating a DOM element
|
||||
ClientDOMElementUpdatedEvent ClientEventType = "dom:element-updated" // When a DOM element is updated
|
||||
ClientDOMEventTriggeredEvent ClientEventType = "dom:event-triggered" // When a DOM event is triggered
|
||||
ClientDOMReadyEvent ClientEventType = "dom:ready" // When a DOM element is ready
|
||||
)
|
||||
|
||||
type ClientRenderTrayEventPayload struct{}
|
||||
type ClientListTrayIconsEventPayload struct{}
|
||||
type ClientTrayOpenedEventPayload struct{}
|
||||
type ClientTrayClosedEventPayload struct{}
|
||||
type ClientTrayClickedEventPayload struct{}
|
||||
type ClientActionRenderAnimePageButtonsEventPayload struct{}
|
||||
type ClientActionRenderAnimePageDropdownItemsEventPayload struct{}
|
||||
type ClientActionRenderMangaPageButtonsEventPayload struct{}
|
||||
type ClientActionRenderMediaCardContextMenuItemsEventPayload struct{}
|
||||
type ClientActionRenderAnimeLibraryDropdownItemsEventPayload struct{}
|
||||
type ClientActionRenderEpisodeCardContextMenuItemsEventPayload struct{}
|
||||
type ClientActionRenderEpisodeGridItemMenuItemsEventPayload struct{}
|
||||
|
||||
type ClientListCommandPalettesEventPayload struct{}
|
||||
|
||||
type ClientCommandPaletteOpenedEventPayload struct{}
|
||||
|
||||
type ClientCommandPaletteClosedEventPayload struct{}
|
||||
|
||||
type ClientActionClickedEventPayload struct {
|
||||
ActionID string `json:"actionId"`
|
||||
Event map[string]interface{} `json:"event"`
|
||||
}
|
||||
|
||||
type ClientEventHandlerTriggeredEventPayload struct {
|
||||
HandlerName string `json:"handlerName"`
|
||||
Event map[string]interface{} `json:"event"`
|
||||
}
|
||||
|
||||
type ClientFormSubmittedEventPayload struct {
|
||||
FormName string `json:"formName"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type ClientScreenChangedEventPayload struct {
|
||||
Pathname string `json:"pathname"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
type ClientFieldRefSendValueEventPayload struct {
|
||||
FieldRef string `json:"fieldRef"`
|
||||
Value interface{} `json:"value"`
|
||||
}
|
||||
|
||||
type ClientRenderCommandPaletteEventPayload struct{}
|
||||
|
||||
type ClientCommandPaletteItemSelectedEventPayload struct {
|
||||
ItemID string `json:"itemId"`
|
||||
}
|
||||
|
||||
type ClientCommandPaletteInputEventPayload struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type ClientDOMEventTriggeredEventPayload struct {
|
||||
ElementId string `json:"elementId"`
|
||||
EventType string `json:"eventType"`
|
||||
Event map[string]interface{} `json:"event"`
|
||||
}
|
||||
|
||||
type ClientDOMQueryResultEventPayload struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Elements []interface{} `json:"elements"`
|
||||
}
|
||||
|
||||
type ClientDOMQueryOneResultEventPayload struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Element interface{} `json:"element"`
|
||||
}
|
||||
|
||||
type ClientDOMObserveResultEventPayload struct {
|
||||
ObserverId string `json:"observerId"`
|
||||
Elements []interface{} `json:"elements"`
|
||||
}
|
||||
|
||||
type ClientDOMCreateResultEventPayload struct {
|
||||
RequestID string `json:"requestId"`
|
||||
Element interface{} `json:"element"`
|
||||
}
|
||||
|
||||
type ClientDOMElementUpdatedEventPayload struct {
|
||||
ElementId string `json:"elementId"`
|
||||
Action string `json:"action"`
|
||||
Result interface{} `json:"result"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
type ClientDOMStopObserveEventPayload struct {
|
||||
ObserverId string `json:"observerId"`
|
||||
}
|
||||
|
||||
type ClientDOMReadyEventPayload struct {
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
// Server to client
|
||||
/////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type ServerEventType string
|
||||
|
||||
// ServerPluginEvent is an event sent to the client
|
||||
type ServerPluginEvent struct {
|
||||
ExtensionID string `json:"extensionId"` // Extension ID must be set
|
||||
Type ServerEventType `json:"type"`
|
||||
Payload interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
const (
|
||||
ServerTrayUpdatedEvent ServerEventType = "tray:updated" // When the trays are updated
|
||||
ServerTrayIconEvent ServerEventType = "tray:icon" // When the tray sends its icon to the client
|
||||
ServerTrayBadgeUpdatedEvent ServerEventType = "tray:badge-updated" // When the tray badge is updated
|
||||
ServerTrayOpenEvent ServerEventType = "tray:open" // When the tray is opened
|
||||
ServerTrayCloseEvent ServerEventType = "tray:close" // When the tray is closed
|
||||
ServerCommandPaletteInfoEvent ServerEventType = "command-palette:info" // When the command palette sends its state to the client
|
||||
ServerCommandPaletteUpdatedEvent ServerEventType = "command-palette:updated" // When the command palette is updated
|
||||
ServerCommandPaletteOpenEvent ServerEventType = "command-palette:open" // When the command palette is opened
|
||||
ServerCommandPaletteCloseEvent ServerEventType = "command-palette:close" // When the command palette is closed
|
||||
ServerCommandPaletteGetInputEvent ServerEventType = "command-palette:get-input" // When the command palette requests the input from the client
|
||||
ServerCommandPaletteSetInputEvent ServerEventType = "command-palette:set-input" // When the command palette sets the input
|
||||
ServerActionRenderAnimePageButtonsEvent ServerEventType = "action:anime-page-buttons:updated" // When the server renders the anime page buttons
|
||||
ServerActionRenderAnimePageDropdownItemsEvent ServerEventType = "action:anime-page-dropdown-items:updated" // When the server renders the anime page dropdown items
|
||||
ServerActionRenderMangaPageButtonsEvent ServerEventType = "action:manga-page-buttons:updated" // When the server renders the manga page buttons
|
||||
ServerActionRenderMediaCardContextMenuItemsEvent ServerEventType = "action:media-card-context-menu-items:updated" // When the server renders the media card context menu items
|
||||
ServerActionRenderEpisodeCardContextMenuItemsEvent ServerEventType = "action:episode-card-context-menu-items:updated" // When the server renders the episode card context menu items
|
||||
ServerActionRenderEpisodeGridItemMenuItemsEvent ServerEventType = "action:episode-grid-item-menu-items:updated" // When the server renders the episode grid item menu items
|
||||
ServerActionRenderAnimeLibraryDropdownItemsEvent ServerEventType = "action:anime-library-dropdown-items:updated" // When the server renders the anime library dropdown items
|
||||
ServerFormResetEvent ServerEventType = "form:reset"
|
||||
ServerFormSetValuesEvent ServerEventType = "form:set-values"
|
||||
ServerFieldRefSetValueEvent ServerEventType = "field-ref:set-value" // Set the value of a field (not in a form)
|
||||
ServerFatalErrorEvent ServerEventType = "fatal-error" // When the UI encounters a fatal error
|
||||
ServerScreenNavigateToEvent ServerEventType = "screen:navigate-to" // Navigate to a new screen
|
||||
ServerScreenReloadEvent ServerEventType = "screen:reload" // Reload the current screen
|
||||
ServerScreenGetCurrentEvent ServerEventType = "screen:get-current" // Get the current screen
|
||||
|
||||
ServerDOMQueryEvent ServerEventType = "dom:query" // When the server queries for DOM elements
|
||||
ServerDOMQueryOneEvent ServerEventType = "dom:query-one" // When the server queries for a single DOM element
|
||||
ServerDOMObserveEvent ServerEventType = "dom:observe" // When the server starts observing DOM elements
|
||||
ServerDOMStopObserveEvent ServerEventType = "dom:stop-observe" // When the server stops observing DOM elements
|
||||
ServerDOMCreateEvent ServerEventType = "dom:create" // When the server creates a DOM element
|
||||
ServerDOMManipulateEvent ServerEventType = "dom:manipulate" // When the server manipulates a DOM element
|
||||
ServerDOMObserveInViewEvent ServerEventType = "dom:observe-in-view"
|
||||
)
|
||||
|
||||
type ServerTrayUpdatedEventPayload struct {
|
||||
Components interface{} `json:"components"`
|
||||
}
|
||||
|
||||
type ServerCommandPaletteUpdatedEventPayload struct {
|
||||
Placeholder string `json:"placeholder"`
|
||||
Items interface{} `json:"items"`
|
||||
}
|
||||
|
||||
type ServerTrayOpenEventPayload struct {
|
||||
ExtensionID string `json:"extensionId"`
|
||||
}
|
||||
|
||||
type ServerTrayCloseEventPayload struct {
|
||||
ExtensionID string `json:"extensionId"`
|
||||
}
|
||||
|
||||
type ServerTrayIconEventPayload struct {
|
||||
ExtensionID string `json:"extensionId"`
|
||||
ExtensionName string `json:"extensionName"`
|
||||
IconURL string `json:"iconUrl"`
|
||||
WithContent bool `json:"withContent"`
|
||||
TooltipText string `json:"tooltipText"`
|
||||
BadgeNumber int `json:"badgeNumber"`
|
||||
BadgeIntent string `json:"badgeIntent"`
|
||||
Width string `json:"width,omitempty"`
|
||||
MinHeight string `json:"minHeight,omitempty"`
|
||||
}
|
||||
|
||||
type ServerTrayBadgeUpdatedEventPayload struct {
|
||||
BadgeNumber int `json:"badgeNumber"`
|
||||
BadgeIntent string `json:"badgeIntent"`
|
||||
}
|
||||
|
||||
type ServerFormResetEventPayload struct {
|
||||
FormName string `json:"formName"`
|
||||
FieldToReset string `json:"fieldToReset"` // If not set, the form will be reset
|
||||
}
|
||||
|
||||
type ServerFormSetValuesEventPayload struct {
|
||||
FormName string `json:"formName"`
|
||||
Data map[string]interface{} `json:"data"`
|
||||
}
|
||||
|
||||
type ServerFieldRefSetValueEventPayload struct {
|
||||
FieldRef string `json:"fieldRef"`
|
||||
Value interface{} `json:"value"`
|
||||
}
|
||||
|
||||
type ServerFieldRefGetValueEventPayload struct {
|
||||
FieldRef string `json:"fieldRef"`
|
||||
}
|
||||
|
||||
type ServerFatalErrorEventPayload struct {
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
type ServerScreenNavigateToEventPayload struct {
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
type ServerActionRenderAnimePageButtonsEventPayload struct {
|
||||
Buttons interface{} `json:"buttons"`
|
||||
}
|
||||
|
||||
type ServerActionRenderAnimePageDropdownItemsEventPayload struct {
|
||||
Items interface{} `json:"items"`
|
||||
}
|
||||
|
||||
type ServerActionRenderMangaPageButtonsEventPayload struct {
|
||||
Buttons interface{} `json:"buttons"`
|
||||
}
|
||||
|
||||
type ServerActionRenderMediaCardContextMenuItemsEventPayload struct {
|
||||
Items interface{} `json:"items"`
|
||||
}
|
||||
|
||||
type ServerActionRenderAnimeLibraryDropdownItemsEventPayload struct {
|
||||
Items interface{} `json:"items"`
|
||||
}
|
||||
|
||||
type ServerActionRenderEpisodeCardContextMenuItemsEventPayload struct {
|
||||
Items interface{} `json:"items"`
|
||||
}
|
||||
|
||||
type ServerActionRenderEpisodeGridItemMenuItemsEventPayload struct {
|
||||
Items interface{} `json:"items"`
|
||||
}
|
||||
|
||||
type ServerScreenReloadEventPayload struct{}
|
||||
|
||||
type ServerCommandPaletteInfoEventPayload struct {
|
||||
Placeholder string `json:"placeholder"`
|
||||
KeyboardShortcut string `json:"keyboardShortcut"`
|
||||
}
|
||||
|
||||
type ServerCommandPaletteOpenEventPayload struct{}
|
||||
|
||||
type ServerCommandPaletteCloseEventPayload struct{}
|
||||
|
||||
type ServerCommandPaletteGetInputEventPayload struct{}
|
||||
|
||||
type ServerCommandPaletteSetInputEventPayload struct {
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
type ServerScreenGetCurrentEventPayload struct{}
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
func NewClientPluginEvent(data map[string]interface{}) *ClientPluginEvent {
|
||||
extensionID, ok := data["extensionId"].(string)
|
||||
if !ok {
|
||||
extensionID = ""
|
||||
}
|
||||
|
||||
eventType, ok := data["type"].(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload, ok := data["payload"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ClientPluginEvent{
|
||||
ExtensionID: extensionID,
|
||||
Type: ClientEventType(eventType),
|
||||
Payload: payload,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *ClientPluginEvent) ParsePayload(ret interface{}) bool {
|
||||
data, err := json.Marshal(e.Payload)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
if err := json.Unmarshal(data, &ret); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (e *ClientPluginEvent) ParsePayloadAs(t ClientEventType, ret interface{}) bool {
|
||||
if e.Type != t {
|
||||
return false
|
||||
}
|
||||
return e.ParsePayload(ret)
|
||||
}
|
||||
|
||||
// Add DOM event payloads
|
||||
type ServerDOMQueryEventPayload struct {
|
||||
Selector string `json:"selector"`
|
||||
RequestID string `json:"requestId"`
|
||||
WithInnerHTML bool `json:"withInnerHTML"`
|
||||
WithOuterHTML bool `json:"withOuterHTML"`
|
||||
IdentifyChildren bool `json:"identifyChildren"`
|
||||
}
|
||||
|
||||
type ServerDOMQueryOneEventPayload struct {
|
||||
Selector string `json:"selector"`
|
||||
RequestID string `json:"requestId"`
|
||||
WithInnerHTML bool `json:"withInnerHTML"`
|
||||
WithOuterHTML bool `json:"withOuterHTML"`
|
||||
IdentifyChildren bool `json:"identifyChildren"`
|
||||
}
|
||||
|
||||
type ServerDOMObserveEventPayload struct {
|
||||
Selector string `json:"selector"`
|
||||
ObserverId string `json:"observerId"`
|
||||
WithInnerHTML bool `json:"withInnerHTML"`
|
||||
WithOuterHTML bool `json:"withOuterHTML"`
|
||||
IdentifyChildren bool `json:"identifyChildren"`
|
||||
}
|
||||
|
||||
type ServerDOMStopObserveEventPayload struct {
|
||||
ObserverId string `json:"observerId"`
|
||||
}
|
||||
|
||||
type ServerDOMCreateEventPayload struct {
|
||||
TagName string `json:"tagName"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
type ServerDOMManipulateEventPayload struct {
|
||||
ElementId string `json:"elementId"`
|
||||
Action string `json:"action"`
|
||||
Params map[string]interface{} `json:"params"`
|
||||
RequestID string `json:"requestId"`
|
||||
}
|
||||
|
||||
type ServerDOMObserveInViewEventPayload struct {
|
||||
Selector string `json:"selector"`
|
||||
ObserverId string `json:"observerId"`
|
||||
WithInnerHTML bool `json:"withInnerHTML"`
|
||||
WithOuterHTML bool `json:"withOuterHTML"`
|
||||
IdentifyChildren bool `json:"identifyChildren"`
|
||||
Margin string `json:"margin"`
|
||||
}
|
||||
27
seanime-2.9.10/internal/plugin/ui/fetch.go
Normal file
27
seanime-2.9.10/internal/plugin/ui/fetch.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"seanime/internal/goja/goja_bindings"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
func (c *Context) bindFetch(obj *goja.Object) {
|
||||
f := goja_bindings.NewFetch(c.vm)
|
||||
|
||||
_ = obj.Set("fetch", f.Fetch)
|
||||
|
||||
go func() {
|
||||
for fn := range f.ResponseChannel() {
|
||||
c.scheduler.ScheduleAsync(func() error {
|
||||
fn()
|
||||
return nil
|
||||
})
|
||||
}
|
||||
}()
|
||||
|
||||
c.registerOnCleanup(func() {
|
||||
c.logger.Debug().Msg("plugin: Terminating fetch")
|
||||
f.Close()
|
||||
})
|
||||
}
|
||||
337
seanime-2.9.10/internal/plugin/ui/form.go
Normal file
337
seanime-2.9.10/internal/plugin/ui/form.go
Normal file
@@ -0,0 +1,337 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"github.com/dop251/goja"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type FormManager struct {
|
||||
ctx *Context
|
||||
}
|
||||
|
||||
func NewFormManager(ctx *Context) *FormManager {
|
||||
return &FormManager{
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
type FormField struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Placeholder string `json:"placeholder,omitempty"`
|
||||
Value interface{} `json:"value,omitempty"`
|
||||
Options []FormFieldOption `json:"options,omitempty"`
|
||||
Props map[string]interface{} `json:"props,omitempty"`
|
||||
}
|
||||
|
||||
type FormFieldOption struct {
|
||||
Label string `json:"label"`
|
||||
Value interface{} `json:"value"`
|
||||
}
|
||||
|
||||
type Form struct {
|
||||
Name string `json:"name"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Props FormProps `json:"props"`
|
||||
manager *FormManager
|
||||
}
|
||||
|
||||
type FormProps struct {
|
||||
Name string `json:"name"`
|
||||
Fields []FormField `json:"fields"`
|
||||
}
|
||||
|
||||
// jsNewForm
|
||||
//
|
||||
// Example:
|
||||
// const form = tray.newForm("form-1")
|
||||
func (f *FormManager) jsNewForm(call goja.FunctionCall) goja.Value {
|
||||
name, ok := call.Argument(0).Export().(string)
|
||||
if !ok {
|
||||
f.ctx.handleTypeError("newForm requires a name")
|
||||
}
|
||||
|
||||
form := &Form{
|
||||
Name: name,
|
||||
ID: uuid.New().String(),
|
||||
Type: "form",
|
||||
Props: FormProps{Fields: make([]FormField, 0), Name: name},
|
||||
manager: f,
|
||||
}
|
||||
|
||||
formObj := f.ctx.vm.NewObject()
|
||||
|
||||
// Form methods
|
||||
formObj.Set("render", form.jsRender)
|
||||
formObj.Set("onSubmit", form.jsOnSubmit)
|
||||
|
||||
// Field creation methods
|
||||
formObj.Set("inputField", form.jsInputField)
|
||||
formObj.Set("numberField", form.jsNumberField)
|
||||
formObj.Set("selectField", form.jsSelectField)
|
||||
formObj.Set("checkboxField", form.jsCheckboxField)
|
||||
formObj.Set("radioField", form.jsRadioField)
|
||||
formObj.Set("dateField", form.jsDateField)
|
||||
formObj.Set("switchField", form.jsSwitchField)
|
||||
formObj.Set("submitButton", form.jsSubmitButton)
|
||||
formObj.Set("reset", form.jsReset)
|
||||
formObj.Set("setValues", form.jsSetValues)
|
||||
|
||||
return formObj
|
||||
}
|
||||
|
||||
func (f *Form) jsRender(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("render requires a config object")
|
||||
}
|
||||
|
||||
config, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("render requires a config object")
|
||||
}
|
||||
|
||||
if fields, ok := config["fields"].([]interface{}); ok {
|
||||
f.Props.Fields = make([]FormField, 0)
|
||||
for _, field := range fields {
|
||||
if fieldMap, ok := field.(FormField); ok {
|
||||
f.Props.Fields = append(f.Props.Fields, fieldMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return f.manager.ctx.vm.ToValue(f)
|
||||
}
|
||||
|
||||
func (f *Form) jsOnSubmit(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("onSubmit requires a callback function")
|
||||
}
|
||||
|
||||
callback, ok := goja.AssertFunction(call.Argument(0))
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("onSubmit requires a callback function")
|
||||
}
|
||||
|
||||
eventListener := f.manager.ctx.RegisterEventListener(ClientFormSubmittedEvent)
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
var payload ClientFormSubmittedEventPayload
|
||||
if event.ParsePayloadAs(ClientFormSubmittedEvent, &payload) && payload.FormName == f.Name {
|
||||
f.manager.ctx.scheduler.ScheduleAsync(func() error {
|
||||
_, err := callback(goja.Undefined(), f.manager.ctx.vm.ToValue(payload.Data))
|
||||
return err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// go func() {
|
||||
// for event := range eventListener.Channel {
|
||||
// if event.ParsePayloadAs(ClientFormSubmittedEvent, &payload) {
|
||||
// if payload.FormName == f.Name {
|
||||
// f.manager.ctx.scheduler.ScheduleAsync(func() error {
|
||||
// _, err := callback(goja.Undefined(), f.manager.ctx.vm.ToValue(payload.Data))
|
||||
// if err != nil {
|
||||
// f.manager.ctx.logger.Error().Err(err).Msg("error running form submit callback")
|
||||
// }
|
||||
// return err
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }()
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (f *Form) jsReset(call goja.FunctionCall) goja.Value {
|
||||
fieldToReset := ""
|
||||
if len(call.Arguments) > 0 {
|
||||
var ok bool
|
||||
fieldToReset, ok = call.Argument(0).Export().(string)
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("reset requires a field name")
|
||||
}
|
||||
}
|
||||
|
||||
f.manager.ctx.SendEventToClient(ServerFormResetEvent, ServerFormResetEventPayload{
|
||||
FormName: f.Name,
|
||||
FieldToReset: fieldToReset,
|
||||
})
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (f *Form) jsSetValues(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("setValues requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("setValues requires a config object")
|
||||
}
|
||||
|
||||
f.manager.ctx.SendEventToClient(ServerFormSetValuesEvent, ServerFormSetValuesEventPayload{
|
||||
FormName: f.Name,
|
||||
Data: props,
|
||||
})
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (f *Form) createField(fieldType string, props map[string]interface{}) goja.Value {
|
||||
nameRaw, ok := props["name"]
|
||||
name := ""
|
||||
if ok {
|
||||
name, ok = nameRaw.(string)
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("name must be a string")
|
||||
}
|
||||
}
|
||||
label := ""
|
||||
labelRaw, ok := props["label"]
|
||||
if ok {
|
||||
label, ok = labelRaw.(string)
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("label must be a string")
|
||||
}
|
||||
}
|
||||
placeholder, ok := props["placeholder"]
|
||||
if ok {
|
||||
placeholder, ok = placeholder.(string)
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("placeholder must be a string")
|
||||
}
|
||||
}
|
||||
field := FormField{
|
||||
ID: uuid.New().String(),
|
||||
Type: fieldType,
|
||||
Name: name,
|
||||
Label: label,
|
||||
Value: props["value"],
|
||||
Options: nil,
|
||||
}
|
||||
|
||||
// Handle options if present
|
||||
if options, ok := props["options"].([]interface{}); ok {
|
||||
fieldOptions := make([]FormFieldOption, len(options))
|
||||
for i, opt := range options {
|
||||
if optMap, ok := opt.(map[string]interface{}); ok {
|
||||
fieldOptions[i] = FormFieldOption{
|
||||
Label: optMap["label"].(string),
|
||||
Value: optMap["value"],
|
||||
}
|
||||
}
|
||||
}
|
||||
field.Options = fieldOptions
|
||||
}
|
||||
|
||||
return f.manager.ctx.vm.ToValue(field)
|
||||
}
|
||||
|
||||
func (f *Form) jsInputField(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("inputField requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("inputField requires a config object")
|
||||
}
|
||||
|
||||
return f.createField("input", props)
|
||||
}
|
||||
|
||||
func (f *Form) jsNumberField(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("numberField requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("numberField requires a config object")
|
||||
}
|
||||
|
||||
return f.createField("number", props)
|
||||
}
|
||||
|
||||
func (f *Form) jsSelectField(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("selectField requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("selectField requires a config object")
|
||||
}
|
||||
|
||||
return f.createField("select", props)
|
||||
}
|
||||
|
||||
func (f *Form) jsCheckboxField(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("checkboxField requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("checkboxField requires a config object")
|
||||
}
|
||||
|
||||
return f.createField("checkbox", props)
|
||||
}
|
||||
|
||||
func (f *Form) jsSwitchField(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("switchField requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("switchField requires a config object")
|
||||
}
|
||||
|
||||
return f.createField("switch", props)
|
||||
}
|
||||
|
||||
func (f *Form) jsRadioField(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("radioField requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("radioField requires a config object")
|
||||
}
|
||||
|
||||
return f.createField("radio", props)
|
||||
}
|
||||
|
||||
func (f *Form) jsDateField(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("dateField requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("dateField requires a config object")
|
||||
}
|
||||
|
||||
return f.createField("date", props)
|
||||
}
|
||||
|
||||
func (f *Form) jsSubmitButton(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
f.manager.ctx.handleTypeError("submitButton requires a config object")
|
||||
}
|
||||
|
||||
props, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
f.manager.ctx.handleTypeError("submitButton requires a config object")
|
||||
}
|
||||
|
||||
return f.createField("submit", props)
|
||||
}
|
||||
36
seanime-2.9.10/internal/plugin/ui/notification.go
Normal file
36
seanime-2.9.10/internal/plugin/ui/notification.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"seanime/internal/notifier"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type NotificationManager struct {
|
||||
ctx *Context
|
||||
}
|
||||
|
||||
func NewNotificationManager(ctx *Context) *NotificationManager {
|
||||
return &NotificationManager{
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (n *NotificationManager) bind(contextObj *goja.Object) {
|
||||
notificationObj := n.ctx.vm.NewObject()
|
||||
_ = notificationObj.Set("send", n.jsNotify)
|
||||
|
||||
_ = contextObj.Set("notification", notificationObj)
|
||||
}
|
||||
|
||||
func (n *NotificationManager) jsNotify(call goja.FunctionCall) goja.Value {
|
||||
message, ok := call.Argument(0).Export().(string)
|
||||
if !ok {
|
||||
n.ctx.handleTypeError("notification: notify requires a string message")
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
notifier.GlobalNotifier.Notify(notifier.Notification(n.ctx.ext.Name), message)
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
104
seanime-2.9.10/internal/plugin/ui/screen.go
Normal file
104
seanime-2.9.10/internal/plugin/ui/screen.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type ScreenManager struct {
|
||||
ctx *Context
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func NewScreenManager(ctx *Context) *ScreenManager {
|
||||
return &ScreenManager{
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
// bind binds 'screen' to the ctx object
|
||||
//
|
||||
// Example:
|
||||
// ctx.screen.navigateTo("/entry?id=21");
|
||||
func (s *ScreenManager) bind(ctxObj *goja.Object) {
|
||||
screenObj := s.ctx.vm.NewObject()
|
||||
_ = screenObj.Set("onNavigate", s.jsOnNavigate)
|
||||
_ = screenObj.Set("navigateTo", s.jsNavigateTo)
|
||||
_ = screenObj.Set("reload", s.jsReload)
|
||||
_ = screenObj.Set("loadCurrent", s.jsLoadCurrent)
|
||||
|
||||
_ = ctxObj.Set("screen", screenObj)
|
||||
}
|
||||
|
||||
// jsNavigateTo navigates to a new screen
|
||||
//
|
||||
// Example:
|
||||
// ctx.screen.navigateTo("/entry?id=21");
|
||||
func (s *ScreenManager) jsNavigateTo(path string, searchParams map[string]string) {
|
||||
if !strings.HasPrefix(path, "/") {
|
||||
path = "/" + path
|
||||
}
|
||||
|
||||
queryString := ""
|
||||
if len(searchParams) > 0 {
|
||||
query := url.Values{}
|
||||
for key, value := range searchParams {
|
||||
query.Add(key, value)
|
||||
}
|
||||
queryString = "?" + query.Encode()
|
||||
}
|
||||
|
||||
finalPath := path + queryString
|
||||
|
||||
s.ctx.SendEventToClient(ServerScreenNavigateToEvent, ServerScreenNavigateToEventPayload{
|
||||
Path: finalPath,
|
||||
})
|
||||
}
|
||||
|
||||
// jsReload reloads the current screen
|
||||
func (s *ScreenManager) jsReload() {
|
||||
s.ctx.SendEventToClient(ServerScreenReloadEvent, ServerScreenReloadEventPayload{})
|
||||
}
|
||||
|
||||
// jsLoadCurrent calls onNavigate with the current screen data
|
||||
func (s *ScreenManager) jsLoadCurrent() {
|
||||
s.ctx.SendEventToClient(ServerScreenGetCurrentEvent, ServerScreenGetCurrentEventPayload{})
|
||||
}
|
||||
|
||||
// jsOnNavigate registers a callback to be called when the current screen changes
|
||||
//
|
||||
// Example:
|
||||
// const onNavigate = (event) => {
|
||||
// console.log(event.screen);
|
||||
// };
|
||||
// ctx.screen.onNavigate(onNavigate);
|
||||
func (s *ScreenManager) jsOnNavigate(callback goja.Callable) goja.Value {
|
||||
eventListener := s.ctx.RegisterEventListener(ClientScreenChangedEvent)
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
var payload ClientScreenChangedEventPayload
|
||||
if event.ParsePayloadAs(ClientScreenChangedEvent, &payload) {
|
||||
s.ctx.scheduler.ScheduleAsync(func() error {
|
||||
|
||||
parsedQuery, _ := url.ParseQuery(strings.TrimPrefix(payload.Query, "?"))
|
||||
queryMap := make(map[string]string)
|
||||
for key, value := range parsedQuery {
|
||||
queryMap[key] = strings.Join(value, ",")
|
||||
}
|
||||
|
||||
ret := map[string]interface{}{
|
||||
"pathname": payload.Pathname,
|
||||
"searchParams": queryMap,
|
||||
}
|
||||
|
||||
_, err := callback(goja.Undefined(), s.ctx.vm.ToValue(ret))
|
||||
return err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
71
seanime-2.9.10/internal/plugin/ui/toast.go
Normal file
71
seanime-2.9.10/internal/plugin/ui/toast.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"seanime/internal/events"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
)
|
||||
|
||||
type ToastManager struct {
|
||||
ctx *Context
|
||||
}
|
||||
|
||||
func NewToastManager(ctx *Context) *ToastManager {
|
||||
return &ToastManager{
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
|
||||
func (t *ToastManager) bind(contextObj *goja.Object) {
|
||||
toastObj := t.ctx.vm.NewObject()
|
||||
_ = toastObj.Set("success", t.jsToastSuccess)
|
||||
_ = toastObj.Set("error", t.jsToastError)
|
||||
_ = toastObj.Set("info", t.jsToastInfo)
|
||||
_ = toastObj.Set("warning", t.jsToastWarning)
|
||||
|
||||
_ = contextObj.Set("toast", toastObj)
|
||||
}
|
||||
|
||||
func (t *ToastManager) jsToastSuccess(call goja.FunctionCall) goja.Value {
|
||||
message, ok := call.Argument(0).Export().(string)
|
||||
if !ok {
|
||||
t.ctx.handleTypeError("toast: success requires a string message")
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
t.ctx.wsEventManager.SendEvent(events.SuccessToast, message)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (t *ToastManager) jsToastError(call goja.FunctionCall) goja.Value {
|
||||
message, ok := call.Argument(0).Export().(string)
|
||||
if !ok {
|
||||
t.ctx.handleTypeError("toast: error requires a string message")
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
t.ctx.wsEventManager.SendEvent(events.ErrorToast, message)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (t *ToastManager) jsToastInfo(call goja.FunctionCall) goja.Value {
|
||||
message, ok := call.Argument(0).Export().(string)
|
||||
if !ok {
|
||||
t.ctx.handleTypeError("toast: info requires a string message")
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
t.ctx.wsEventManager.SendEvent(events.InfoToast, message)
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
func (t *ToastManager) jsToastWarning(call goja.FunctionCall) goja.Value {
|
||||
message, ok := call.Argument(0).Export().(string)
|
||||
if !ok {
|
||||
t.ctx.handleTypeError("toast: warning requires a string message")
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
t.ctx.wsEventManager.SendEvent(events.WarningToast, message)
|
||||
return goja.Undefined()
|
||||
}
|
||||
361
seanime-2.9.10/internal/plugin/ui/tray.go
Normal file
361
seanime-2.9.10/internal/plugin/ui/tray.go
Normal file
@@ -0,0 +1,361 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/samber/mo"
|
||||
)
|
||||
|
||||
type TrayManager struct {
|
||||
ctx *Context
|
||||
tray mo.Option[*Tray]
|
||||
lastUpdatedAt time.Time
|
||||
updateMutex sync.Mutex
|
||||
|
||||
componentManager *ComponentManager
|
||||
}
|
||||
|
||||
func NewTrayManager(ctx *Context) *TrayManager {
|
||||
return &TrayManager{
|
||||
ctx: ctx,
|
||||
tray: mo.None[*Tray](),
|
||||
componentManager: &ComponentManager{ctx: ctx},
|
||||
}
|
||||
}
|
||||
|
||||
// renderTrayScheduled renders the new component tree.
|
||||
// This function is unsafe because it is not thread-safe and should be scheduled.
|
||||
func (t *TrayManager) renderTrayScheduled() {
|
||||
t.updateMutex.Lock()
|
||||
defer t.updateMutex.Unlock()
|
||||
|
||||
tray, registered := t.tray.Get()
|
||||
if !registered {
|
||||
return
|
||||
}
|
||||
|
||||
if !tray.WithContent {
|
||||
return
|
||||
}
|
||||
|
||||
// Rate limit updates
|
||||
//if time.Since(t.lastUpdatedAt) < time.Millisecond*200 {
|
||||
// return
|
||||
//}
|
||||
|
||||
t.lastUpdatedAt = time.Now()
|
||||
|
||||
t.ctx.scheduler.ScheduleAsync(func() error {
|
||||
// t.ctx.logger.Trace().Msg("plugin: Rendering tray")
|
||||
newComponents, err := t.componentManager.renderComponents(tray.renderFunc)
|
||||
if err != nil {
|
||||
t.ctx.logger.Error().Err(err).Msg("plugin: Failed to render tray")
|
||||
t.ctx.handleException(err)
|
||||
return nil
|
||||
}
|
||||
|
||||
// t.ctx.logger.Trace().Msg("plugin: Sending tray update to client")
|
||||
// Send the JSON value to the client
|
||||
t.ctx.SendEventToClient(ServerTrayUpdatedEvent, ServerTrayUpdatedEventPayload{
|
||||
Components: newComponents,
|
||||
})
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// sendIconToClient sends the tray icon to the client after it's been requested.
|
||||
func (t *TrayManager) sendIconToClient() {
|
||||
if tray, registered := t.tray.Get(); registered {
|
||||
t.ctx.SendEventToClient(ServerTrayIconEvent, ServerTrayIconEventPayload{
|
||||
ExtensionID: t.ctx.ext.ID,
|
||||
ExtensionName: t.ctx.ext.Name,
|
||||
IconURL: tray.IconURL,
|
||||
WithContent: tray.WithContent,
|
||||
TooltipText: tray.TooltipText,
|
||||
BadgeNumber: tray.BadgeNumber,
|
||||
BadgeIntent: tray.BadgeIntent,
|
||||
Width: tray.Width,
|
||||
MinHeight: tray.MinHeight,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// Tray
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type Tray struct {
|
||||
// WithContent is used to determine if the tray has any content
|
||||
// If false, only the tray icon will be rendered and tray.render() will be ignored
|
||||
WithContent bool `json:"withContent"`
|
||||
|
||||
IconURL string `json:"iconUrl"`
|
||||
TooltipText string `json:"tooltipText"`
|
||||
BadgeNumber int `json:"badgeNumber"`
|
||||
BadgeIntent string `json:"badgeIntent"`
|
||||
Width string `json:"width,omitempty"`
|
||||
MinHeight string `json:"minHeight,omitempty"`
|
||||
|
||||
renderFunc func(goja.FunctionCall) goja.Value
|
||||
trayManager *TrayManager
|
||||
}
|
||||
|
||||
type Component struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Props map[string]interface{} `json:"props"`
|
||||
Key string `json:"key,omitempty"`
|
||||
}
|
||||
|
||||
// jsNewTray
|
||||
//
|
||||
// Example:
|
||||
// const tray = ctx.newTray()
|
||||
func (t *TrayManager) jsNewTray(call goja.FunctionCall) goja.Value {
|
||||
tray := &Tray{
|
||||
renderFunc: nil,
|
||||
trayManager: t,
|
||||
WithContent: true,
|
||||
}
|
||||
|
||||
props := call.Arguments
|
||||
if len(props) > 0 {
|
||||
propsObj := props[0].Export().(map[string]interface{})
|
||||
if propsObj["withContent"] != nil {
|
||||
tray.WithContent, _ = propsObj["withContent"].(bool)
|
||||
}
|
||||
if propsObj["iconUrl"] != nil {
|
||||
tray.IconURL, _ = propsObj["iconUrl"].(string)
|
||||
}
|
||||
if propsObj["tooltipText"] != nil {
|
||||
tray.TooltipText, _ = propsObj["tooltipText"].(string)
|
||||
}
|
||||
if propsObj["width"] != nil {
|
||||
tray.Width, _ = propsObj["width"].(string)
|
||||
}
|
||||
if propsObj["minHeight"] != nil {
|
||||
tray.MinHeight, _ = propsObj["minHeight"].(string)
|
||||
}
|
||||
}
|
||||
|
||||
t.tray = mo.Some(tray)
|
||||
|
||||
// Create a new tray object
|
||||
trayObj := t.ctx.vm.NewObject()
|
||||
_ = trayObj.Set("render", tray.jsRender)
|
||||
_ = trayObj.Set("update", tray.jsUpdate)
|
||||
_ = trayObj.Set("onOpen", tray.jsOnOpen)
|
||||
_ = trayObj.Set("onClose", tray.jsOnClose)
|
||||
_ = trayObj.Set("onClick", tray.jsOnClick)
|
||||
_ = trayObj.Set("open", tray.jsOpen)
|
||||
_ = trayObj.Set("close", tray.jsClose)
|
||||
_ = trayObj.Set("updateBadge", tray.jsUpdateBadge)
|
||||
|
||||
// Register components
|
||||
_ = trayObj.Set("div", t.componentManager.jsDiv)
|
||||
_ = trayObj.Set("flex", t.componentManager.jsFlex)
|
||||
_ = trayObj.Set("stack", t.componentManager.jsStack)
|
||||
_ = trayObj.Set("text", t.componentManager.jsText)
|
||||
_ = trayObj.Set("button", t.componentManager.jsButton)
|
||||
_ = trayObj.Set("anchor", t.componentManager.jsAnchor)
|
||||
_ = trayObj.Set("input", t.componentManager.jsInput)
|
||||
_ = trayObj.Set("radioGroup", t.componentManager.jsRadioGroup)
|
||||
_ = trayObj.Set("switch", t.componentManager.jsSwitch)
|
||||
_ = trayObj.Set("checkbox", t.componentManager.jsCheckbox)
|
||||
_ = trayObj.Set("select", t.componentManager.jsSelect)
|
||||
|
||||
return trayObj
|
||||
}
|
||||
|
||||
/////
|
||||
|
||||
// jsRender registers a function to be called when the tray is rendered/updated
|
||||
//
|
||||
// Example:
|
||||
// tray.render(() => flex)
|
||||
func (t *Tray) jsRender(call goja.FunctionCall) goja.Value {
|
||||
|
||||
funcRes, ok := call.Argument(0).Export().(func(goja.FunctionCall) goja.Value)
|
||||
if !ok {
|
||||
t.trayManager.ctx.handleTypeError("render requires a function")
|
||||
}
|
||||
|
||||
// Set the render function
|
||||
t.renderFunc = funcRes
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
// jsUpdate schedules a re-render on the client
|
||||
//
|
||||
// Example:
|
||||
// tray.update()
|
||||
func (t *Tray) jsUpdate(call goja.FunctionCall) goja.Value {
|
||||
// Update the context's lastUIUpdateAt to prevent duplicate updates
|
||||
t.trayManager.ctx.uiUpdateMu.Lock()
|
||||
t.trayManager.ctx.lastUIUpdateAt = time.Now()
|
||||
t.trayManager.ctx.uiUpdateMu.Unlock()
|
||||
|
||||
t.trayManager.renderTrayScheduled()
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
// jsOpen
|
||||
//
|
||||
// Example:
|
||||
// tray.open()
|
||||
func (t *Tray) jsOpen(call goja.FunctionCall) goja.Value {
|
||||
t.trayManager.ctx.SendEventToClient(ServerTrayOpenEvent, ServerTrayOpenEventPayload{
|
||||
ExtensionID: t.trayManager.ctx.ext.ID,
|
||||
})
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
// jsClose
|
||||
//
|
||||
// Example:
|
||||
// tray.close()
|
||||
func (t *Tray) jsClose(call goja.FunctionCall) goja.Value {
|
||||
t.trayManager.ctx.SendEventToClient(ServerTrayCloseEvent, ServerTrayCloseEventPayload{
|
||||
ExtensionID: t.trayManager.ctx.ext.ID,
|
||||
})
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
// jsUpdateBadge
|
||||
//
|
||||
// Example:
|
||||
// tray.updateBadge({ number: 1, intent: "success" })
|
||||
func (t *Tray) jsUpdateBadge(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
t.trayManager.ctx.handleTypeError("updateBadge requires a callback function")
|
||||
}
|
||||
|
||||
propsObj, ok := call.Argument(0).Export().(map[string]interface{})
|
||||
if !ok {
|
||||
t.trayManager.ctx.handleTypeError("updateBadge requires a callback function")
|
||||
}
|
||||
|
||||
number, ok := propsObj["number"].(int64)
|
||||
if !ok {
|
||||
t.trayManager.ctx.handleTypeError("updateBadge: number must be an integer")
|
||||
}
|
||||
|
||||
intent, ok := propsObj["intent"].(string)
|
||||
if !ok {
|
||||
intent = "info"
|
||||
}
|
||||
|
||||
t.BadgeNumber = int(number)
|
||||
t.BadgeIntent = intent
|
||||
|
||||
t.trayManager.ctx.SendEventToClient(ServerTrayBadgeUpdatedEvent, ServerTrayBadgeUpdatedEventPayload{
|
||||
BadgeNumber: t.BadgeNumber,
|
||||
BadgeIntent: t.BadgeIntent,
|
||||
})
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
// jsOnOpen
|
||||
//
|
||||
// Example:
|
||||
// tray.onOpen(() => {
|
||||
// console.log("tray opened by the user")
|
||||
// })
|
||||
func (t *Tray) jsOnOpen(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
t.trayManager.ctx.handleTypeError("onOpen requires a callback function")
|
||||
}
|
||||
|
||||
callback, ok := goja.AssertFunction(call.Argument(0))
|
||||
if !ok {
|
||||
t.trayManager.ctx.handleTypeError("onOpen requires a callback function")
|
||||
}
|
||||
|
||||
eventListener := t.trayManager.ctx.RegisterEventListener(ClientTrayOpenedEvent)
|
||||
payload := ClientTrayOpenedEventPayload{}
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
if event.ParsePayloadAs(ClientTrayOpenedEvent, &payload) {
|
||||
t.trayManager.ctx.scheduler.ScheduleAsync(func() error {
|
||||
_, err := callback(goja.Undefined(), t.trayManager.ctx.vm.ToValue(map[string]interface{}{}))
|
||||
if err != nil {
|
||||
t.trayManager.ctx.logger.Error().Err(err).Msg("plugin: Error running tray open callback")
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
// jsOnClick
|
||||
//
|
||||
// Example:
|
||||
// tray.onClick(() => {
|
||||
// console.log("tray clicked by the user")
|
||||
// })
|
||||
func (t *Tray) jsOnClick(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
t.trayManager.ctx.handleTypeError("onClick requires a callback function")
|
||||
}
|
||||
|
||||
callback, ok := goja.AssertFunction(call.Argument(0))
|
||||
if !ok {
|
||||
t.trayManager.ctx.handleTypeError("onClick requires a callback function")
|
||||
}
|
||||
|
||||
eventListener := t.trayManager.ctx.RegisterEventListener(ClientTrayClickedEvent)
|
||||
payload := ClientTrayClickedEventPayload{}
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
if event.ParsePayloadAs(ClientTrayClickedEvent, &payload) {
|
||||
t.trayManager.ctx.scheduler.ScheduleAsync(func() error {
|
||||
_, err := callback(goja.Undefined(), t.trayManager.ctx.vm.ToValue(map[string]interface{}{}))
|
||||
if err != nil {
|
||||
t.trayManager.ctx.logger.Error().Err(err).Msg("plugin: Error running tray click callback")
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
|
||||
// jsOnClose
|
||||
//
|
||||
// Example:
|
||||
// tray.onClose(() => {
|
||||
// console.log("tray closed by the user")
|
||||
// })
|
||||
func (t *Tray) jsOnClose(call goja.FunctionCall) goja.Value {
|
||||
if len(call.Arguments) < 1 {
|
||||
t.trayManager.ctx.handleTypeError("onClose requires a callback function")
|
||||
}
|
||||
|
||||
callback, ok := goja.AssertFunction(call.Argument(0))
|
||||
if !ok {
|
||||
t.trayManager.ctx.handleTypeError("onClose requires a callback function")
|
||||
}
|
||||
|
||||
eventListener := t.trayManager.ctx.RegisterEventListener(ClientTrayClosedEvent)
|
||||
payload := ClientTrayClosedEventPayload{}
|
||||
|
||||
eventListener.SetCallback(func(event *ClientPluginEvent) {
|
||||
if event.ParsePayloadAs(ClientTrayClosedEvent, &payload) {
|
||||
t.trayManager.ctx.scheduler.ScheduleAsync(func() error {
|
||||
_, err := callback(goja.Undefined(), t.trayManager.ctx.vm.ToValue(map[string]interface{}{}))
|
||||
if err != nil {
|
||||
t.trayManager.ctx.logger.Error().Err(err).Msg("plugin: Error running tray close callback")
|
||||
}
|
||||
return err
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return goja.Undefined()
|
||||
}
|
||||
325
seanime-2.9.10/internal/plugin/ui/ui.go
Normal file
325
seanime-2.9.10/internal/plugin/ui/ui.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package plugin_ui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"seanime/internal/database/db"
|
||||
"seanime/internal/events"
|
||||
"seanime/internal/extension"
|
||||
"seanime/internal/plugin"
|
||||
"seanime/internal/util"
|
||||
goja_util "seanime/internal/util/goja"
|
||||
"sync"
|
||||
|
||||
"github.com/dop251/goja"
|
||||
"github.com/rs/zerolog"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTooManyExceptions = errors.New("plugin: Too many exceptions")
|
||||
ErrFatalError = errors.New("plugin: Fatal error")
|
||||
)
|
||||
|
||||
const (
|
||||
MaxExceptions = 5 // Maximum number of exceptions that can be thrown before the UI is interrupted
|
||||
MaxConcurrentFetchRequests = 10 // Maximum number of concurrent fetch requests
|
||||
MaxEffectCallsPerWindow = 100 // Maximum number of effect calls allowed in time window
|
||||
EffectTimeWindow = 1000 // Time window in milliseconds to track effect calls
|
||||
StateUpdateBatchInterval = 10 // Time in milliseconds to batch state updates
|
||||
UIUpdateRateLimit = 120 // Time in milliseconds to rate limit UI updates
|
||||
)
|
||||
|
||||
// UI registry, unique to a plugin and VM
|
||||
type UI struct {
|
||||
ext *extension.Extension
|
||||
context *Context
|
||||
mu sync.RWMutex
|
||||
vm *goja.Runtime // VM executing the UI
|
||||
logger *zerolog.Logger
|
||||
wsEventManager events.WSEventManagerInterface
|
||||
appContext plugin.AppContext
|
||||
scheduler *goja_util.Scheduler
|
||||
|
||||
lastException string
|
||||
|
||||
// Channel to signal the UI has been unloaded
|
||||
// This is used to interrupt the Plugin when the UI is stopped
|
||||
destroyedCh chan struct{}
|
||||
destroyed bool
|
||||
}
|
||||
|
||||
type NewUIOptions struct {
|
||||
Logger *zerolog.Logger
|
||||
VM *goja.Runtime
|
||||
WSManager events.WSEventManagerInterface
|
||||
Database *db.Database
|
||||
Scheduler *goja_util.Scheduler
|
||||
Extension *extension.Extension
|
||||
}
|
||||
|
||||
func NewUI(options NewUIOptions) *UI {
|
||||
ui := &UI{
|
||||
ext: options.Extension,
|
||||
vm: options.VM,
|
||||
logger: options.Logger,
|
||||
wsEventManager: options.WSManager,
|
||||
appContext: plugin.GlobalAppContext, // Get the app context from the global hook manager
|
||||
scheduler: options.Scheduler,
|
||||
destroyedCh: make(chan struct{}),
|
||||
}
|
||||
ui.context = NewContext(ui)
|
||||
ui.context.scheduler.SetOnException(func(err error) {
|
||||
ui.logger.Error().Err(err).Msg("plugin: Encountered exception in asynchronous task")
|
||||
ui.context.handleException(err)
|
||||
})
|
||||
|
||||
return ui
|
||||
}
|
||||
|
||||
// Called by the Plugin when it's being unloaded
|
||||
func (u *UI) Unload(signalDestroyed bool) {
|
||||
u.logger.Debug().Msg("plugin: Stopping UI")
|
||||
|
||||
u.UnloadFromInside(signalDestroyed)
|
||||
|
||||
u.logger.Debug().Msg("plugin: Stopped UI")
|
||||
}
|
||||
|
||||
// UnloadFromInside is called by the UI module itself when it's being unloaded
|
||||
func (u *UI) UnloadFromInside(signalDestroyed bool) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
|
||||
if u.destroyed {
|
||||
return
|
||||
}
|
||||
// Stop the VM
|
||||
u.vm.ClearInterrupt()
|
||||
// Unsubscribe from client all events
|
||||
if u.context.wsSubscriber != nil {
|
||||
u.wsEventManager.UnsubscribeFromClientEvents("plugin-" + u.ext.ID)
|
||||
}
|
||||
// Clean up the context (all modules)
|
||||
if u.context != nil {
|
||||
u.context.Stop()
|
||||
}
|
||||
|
||||
// Send the plugin unloaded event to the client
|
||||
u.wsEventManager.SendEvent(events.PluginUnloaded, u.ext.ID)
|
||||
|
||||
if signalDestroyed {
|
||||
u.signalDestroyed()
|
||||
}
|
||||
}
|
||||
|
||||
// Destroyed returns a channel that is closed when the UI is destroyed
|
||||
func (u *UI) Destroyed() <-chan struct{} {
|
||||
return u.destroyedCh
|
||||
}
|
||||
|
||||
// signalDestroyed tells the plugin that the UI has been destroyed.
|
||||
// This is used to interrupt the Plugin when the UI is stopped
|
||||
// TODO: FIX
|
||||
func (u *UI) signalDestroyed() {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
u.logger.Error().Msgf("plugin: Panic in signalDestroyed: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
if u.destroyed {
|
||||
return
|
||||
}
|
||||
u.destroyed = true
|
||||
close(u.destroyedCh)
|
||||
}
|
||||
|
||||
// Register a UI
|
||||
// This is the main entry point for the UI
|
||||
// - It is called once when the plugin is loaded and registers all necessary modules
|
||||
func (u *UI) Register(callback string) error {
|
||||
defer util.HandlePanicInModuleThen("plugin_ui/Register", func() {
|
||||
u.logger.Error().Msg("plugin: Panic in Register")
|
||||
})
|
||||
|
||||
u.mu.Lock()
|
||||
|
||||
// Create a wrapper JavaScript function that calls the provided callback
|
||||
callback = `function(ctx) { return (` + callback + `).call(undefined, ctx); }`
|
||||
// Compile the callback into a Goja program
|
||||
// pr := goja.MustCompile("", "{("+callback+").apply(undefined, __ctx)}", true)
|
||||
|
||||
// Subscribe the plugin to client events
|
||||
u.context.wsSubscriber = u.wsEventManager.SubscribeToClientEvents("plugin-" + u.ext.ID)
|
||||
|
||||
u.logger.Debug().Msg("plugin: Registering UI")
|
||||
|
||||
// Listen for client events and send them to the event listeners
|
||||
go func() {
|
||||
for event := range u.context.wsSubscriber.Channel {
|
||||
//u.logger.Trace().Msgf("Received event %s", event.Type)
|
||||
u.HandleWSEvent(event)
|
||||
}
|
||||
u.logger.Debug().Msg("plugin: Event goroutine stopped")
|
||||
}()
|
||||
|
||||
u.context.createAndBindContextObject(u.vm)
|
||||
|
||||
// Execute the callback
|
||||
_, err := u.vm.RunString(`(` + callback + `).call(undefined, __ctx)`)
|
||||
if err != nil {
|
||||
u.mu.Unlock()
|
||||
u.logger.Error().Err(err).Msg("plugin: Encountered exception in UI handler, unloading plugin")
|
||||
u.wsEventManager.SendEvent(events.ErrorToast, fmt.Sprintf("plugin(%s): Encountered exception in UI handler: %s", u.ext.ID, err.Error()))
|
||||
u.wsEventManager.SendEvent(events.ConsoleLog, fmt.Sprintf("plugin(%s): Encountered exception in UI handler: %s", u.ext.ID, err.Error()))
|
||||
// Unload the UI and signal the Plugin that it's been terminated
|
||||
u.UnloadFromInside(true)
|
||||
return fmt.Errorf("plugin: Encountered exception in UI handler: %w", err)
|
||||
}
|
||||
|
||||
// Send events to the client
|
||||
u.context.trayManager.renderTrayScheduled()
|
||||
u.context.trayManager.sendIconToClient()
|
||||
u.context.actionManager.renderAnimePageButtons()
|
||||
u.context.actionManager.renderAnimePageDropdownItems()
|
||||
u.context.actionManager.renderAnimeLibraryDropdownItems()
|
||||
u.context.actionManager.renderMangaPageButtons()
|
||||
u.context.actionManager.renderMediaCardContextMenuItems()
|
||||
u.context.actionManager.renderEpisodeCardContextMenuItems()
|
||||
u.context.actionManager.renderEpisodeGridItemMenuItems()
|
||||
u.context.commandPaletteManager.renderCommandPaletteScheduled()
|
||||
u.context.commandPaletteManager.sendInfoToClient()
|
||||
|
||||
u.wsEventManager.SendEvent(events.PluginLoaded, u.ext.ID)
|
||||
|
||||
u.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add this new type to handle batched events from the client
|
||||
type BatchedClientEvents struct {
|
||||
Events []map[string]interface{} `json:"events"`
|
||||
}
|
||||
|
||||
// HandleWSEvent handles a websocket event from the client
|
||||
func (u *UI) HandleWSEvent(event *events.WebsocketClientEvent) {
|
||||
defer util.HandlePanicInModuleThen("plugin/HandleWSEvent", func() {})
|
||||
|
||||
u.mu.RLock()
|
||||
defer u.mu.RUnlock()
|
||||
|
||||
// Ignore if UI is destroyed
|
||||
if u.destroyed {
|
||||
return
|
||||
}
|
||||
|
||||
if event.Type == events.PluginEvent {
|
||||
// Extract the event payload
|
||||
payload, ok := event.Payload.(map[string]interface{})
|
||||
if !ok {
|
||||
u.logger.Error().Str("payload", fmt.Sprintf("%+v", event.Payload)).Msg("plugin/ui: Failed to parse plugin event payload")
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a batch event
|
||||
eventType, _ := payload["type"].(string)
|
||||
if eventType == "client:batch-events" {
|
||||
u.handleBatchedClientEvents(event.ClientID, payload)
|
||||
return
|
||||
}
|
||||
|
||||
// Process normal event
|
||||
clientEvent := NewClientPluginEvent(payload)
|
||||
if clientEvent == nil {
|
||||
u.logger.Error().Interface("payload", payload).Msg("plugin/ui: Failed to create client plugin event")
|
||||
return
|
||||
}
|
||||
|
||||
// If the event is for this plugin
|
||||
if clientEvent.ExtensionID == u.ext.ID || clientEvent.ExtensionID == "" {
|
||||
// Process the event based on type
|
||||
u.dispatchClientEvent(clientEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchClientEvent handles a client event based on its type
|
||||
func (u *UI) dispatchClientEvent(clientEvent *ClientPluginEvent) {
|
||||
switch clientEvent.Type {
|
||||
case ClientRenderTrayEvent: // Client wants to render the tray
|
||||
u.context.trayManager.renderTrayScheduled()
|
||||
|
||||
case ClientListTrayIconsEvent: // Client wants to list all tray icons from all plugins
|
||||
u.context.trayManager.sendIconToClient()
|
||||
|
||||
case ClientActionRenderAnimePageButtonsEvent: // Client wants to update the anime page buttons
|
||||
u.context.actionManager.renderAnimePageButtons()
|
||||
|
||||
case ClientActionRenderAnimePageDropdownItemsEvent: // Client wants to update the anime page dropdown items
|
||||
u.context.actionManager.renderAnimePageDropdownItems()
|
||||
|
||||
case ClientActionRenderAnimeLibraryDropdownItemsEvent: // Client wants to update the anime library dropdown items
|
||||
u.context.actionManager.renderAnimeLibraryDropdownItems()
|
||||
|
||||
case ClientActionRenderMangaPageButtonsEvent: // Client wants to update the manga page buttons
|
||||
u.context.actionManager.renderMangaPageButtons()
|
||||
|
||||
case ClientActionRenderMediaCardContextMenuItemsEvent: // Client wants to update the media card context menu items
|
||||
u.context.actionManager.renderMediaCardContextMenuItems()
|
||||
|
||||
case ClientActionRenderEpisodeCardContextMenuItemsEvent: // Client wants to update the episode card context menu items
|
||||
u.context.actionManager.renderEpisodeCardContextMenuItems()
|
||||
|
||||
case ClientActionRenderEpisodeGridItemMenuItemsEvent: // Client wants to update the episode grid item menu items
|
||||
u.context.actionManager.renderEpisodeGridItemMenuItems()
|
||||
|
||||
case ClientRenderCommandPaletteEvent: // Client wants to render the command palette
|
||||
u.context.commandPaletteManager.renderCommandPaletteScheduled()
|
||||
|
||||
case ClientListCommandPalettesEvent: // Client wants to list all command palettes
|
||||
u.context.commandPaletteManager.sendInfoToClient()
|
||||
|
||||
default:
|
||||
// Send to registered event listeners
|
||||
eventListeners, ok := u.context.eventBus.Get(clientEvent.Type)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
eventListeners.Range(func(key string, listener *EventListener) bool {
|
||||
listener.Send(clientEvent)
|
||||
return true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// handleBatchedClientEvents processes a batch of client events
|
||||
func (u *UI) handleBatchedClientEvents(clientID string, payload map[string]interface{}) {
|
||||
if eventPayload, ok := payload["payload"].(map[string]interface{}); ok {
|
||||
if eventsRaw, ok := eventPayload["events"].([]interface{}); ok {
|
||||
// Process each event in the batch
|
||||
for _, eventRaw := range eventsRaw {
|
||||
if eventMap, ok := eventRaw.(map[string]interface{}); ok {
|
||||
// Create a synthetic event object
|
||||
syntheticPayload := map[string]interface{}{
|
||||
"type": eventMap["type"],
|
||||
"extensionId": eventMap["extensionId"],
|
||||
"payload": eventMap["payload"],
|
||||
}
|
||||
|
||||
// Create and dispatch the event
|
||||
clientEvent := NewClientPluginEvent(syntheticPayload)
|
||||
if clientEvent == nil {
|
||||
u.logger.Error().Interface("payload", syntheticPayload).Msg("plugin/ui: Failed to create client plugin event from batch")
|
||||
continue
|
||||
}
|
||||
|
||||
// If the event is for this plugin
|
||||
if clientEvent.ExtensionID == u.ext.ID || clientEvent.ExtensionID == "" {
|
||||
// Process the event
|
||||
u.dispatchClientEvent(clientEvent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
11
seanime-2.9.10/internal/plugin/ui/webview.go
Normal file
11
seanime-2.9.10/internal/plugin/ui/webview.go
Normal file
@@ -0,0 +1,11 @@
|
||||
package plugin_ui
|
||||
|
||||
type WebviewManager struct {
|
||||
ctx *Context
|
||||
}
|
||||
|
||||
func NewWebviewManager(ctx *Context) *WebviewManager {
|
||||
return &WebviewManager{
|
||||
ctx: ctx,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user