node build fixed

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

View File

@@ -0,0 +1,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)
}
}

View 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)
}

View 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")
}
}

View 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)
}

View 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
}

View 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
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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)
}

View 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
})
}

File diff suppressed because it is too large Load Diff

View 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)
})
}
}

View 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.

View 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")
}
}
}

View 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)
}
}

View 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,
})
}

View 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
}

View 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")},
})
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View 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"`
}

View 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()
})
}

View 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)
}

View 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()
}

View 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()
}

View 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()
}

View 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()
}

View 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)
}
}
}
}
}
}

View File

@@ -0,0 +1,11 @@
package plugin_ui
type WebviewManager struct {
ctx *Context
}
func NewWebviewManager(ctx *Context) *WebviewManager {
return &WebviewManager{
ctx: ctx,
}
}