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