Files
seanime-docker/seanime-2.9.10/internal/extension_repo/external.go
2025-09-20 14:08:38 +01:00

673 lines
22 KiB
Go

package extension_repo
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"runtime"
"seanime/internal/constants"
"seanime/internal/events"
"seanime/internal/extension"
"seanime/internal/util"
"sync"
"time"
"github.com/Masterminds/semver/v3"
"github.com/google/uuid"
"github.com/samber/lo"
)
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// External extensions
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (r *Repository) FetchExternalExtensionData(manifestURI string) (*extension.Extension, error) {
return r.fetchExternalExtensionData(manifestURI)
}
// fetchExternalExtensionData fetches the extension data from the manifest URI.
// noPayloadDownload is an optional argument to skip downloading the payload from the payload URI if it exists (e.g when checking for updates)
func (r *Repository) fetchExternalExtensionData(manifestURI string, noPayloadDownload ...bool) (*extension.Extension, error) {
// Fetch the manifest file
client := &http.Client{}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, manifestURI, nil)
if err != nil {
r.logger.Error().Err(err).Str("uri", manifestURI).Msg("extensions: Failed to create HTTP request")
return nil, fmt.Errorf("failed to create HTTP request, %w", err)
}
resp, err := client.Do(req)
if err != nil {
r.logger.Error().Err(err).Str("uri", manifestURI).Msg("extensions: Failed to fetch extension manifest")
return nil, fmt.Errorf("failed to fetch extension manifest, %w", err)
}
defer resp.Body.Close()
// Parse the response
var ext extension.Extension
err = json.NewDecoder(resp.Body).Decode(&ext)
if err != nil {
r.logger.Error().Err(err).Str("uri", manifestURI).Msg("extensions: Failed to parse extension manifest")
return nil, fmt.Errorf("failed to parse extension manifest, %w", err)
}
// Before sanity check, fetch the payload if needed
if ext.PayloadURI != "" && !lo.Contains(noPayloadDownload, true) {
r.logger.Debug().Str("id", ext.ID).Msg("extensions: Downloading payload")
payloadFromURI, err := r.downloadPayload(ext.PayloadURI)
if err != nil {
r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to download payload")
return nil, fmt.Errorf("failed to download payload, %w", err)
}
if payloadFromURI == "" {
r.logger.Error().Str("id", ext.ID).Msg("extensions: Downloaded payload is empty")
return nil, fmt.Errorf("downloaded payload is empty")
}
ext.Payload = payloadFromURI
}
// Check manifest
if err = manifestSanityCheck(&ext); err != nil {
r.logger.Error().Err(err).Str("uri", manifestURI).Msg("extensions: Failed sanity check")
return nil, fmt.Errorf("failed sanity check, %w", err)
}
// Check if the extension is development mode
if ext.IsDevelopment {
r.logger.Error().Str("id", ext.ID).Msg("extensions: Development mode enabled, cannot install development mode extensions for security reasons")
return nil, fmt.Errorf("cannot install development mode extensions for security reasons")
}
return &ext, nil
}
func (r *Repository) downloadPayload(uri string) (string, error) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
client := &http.Client{}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, uri, nil)
if err != nil {
return "", fmt.Errorf("failed to create HTTP request, %w", err)
}
// Download the payload
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("failed to download payload, %w", err)
}
defer resp.Body.Close()
// Read the payload
payload, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("failed to read payload, %w", err)
}
return string(payload), nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Install external extension
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type ExtensionInstallResponse struct {
Message string `json:"message"`
}
func (r *Repository) InstallExternalExtension(manifestURI string) (*ExtensionInstallResponse, error) {
ext, err := r.fetchExternalExtensionData(manifestURI)
if err != nil {
r.logger.Error().Err(err).Str("uri", manifestURI).Msg("extensions: Failed to fetch extension data")
return nil, fmt.Errorf("failed to fetch extension data, %w", err)
}
filename := filepath.Join(r.extensionDir, ext.ID+".json")
update := false
// Check if the extension is already installed
// i.e. a file with the same ID exists
if _, err := os.Stat(filename); err == nil {
r.logger.Debug().Str("id", ext.ID).Msg("extensions: Updating extension")
// Delete the old extension
err := os.Remove(filename)
if err != nil {
r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to remove old extension")
return nil, fmt.Errorf("failed to remove old extension, %w", err)
}
update = true
}
// Add the extension as a json file
file, err := os.Create(filename)
if err != nil {
r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to create extension file")
return nil, fmt.Errorf("failed to create extension file, %w", err)
}
defer file.Close()
// Write the extension to the file
enc := json.NewEncoder(file)
err = enc.Encode(ext)
if err != nil {
r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to write extension to file")
return nil, fmt.Errorf("failed to write extension to file, %w", err)
}
// Reload the extensions
//r.loadExternalExtensions()
r.reloadExtension(ext.ID)
if update {
r.updateDataMu.Lock()
r.updateData = lo.Filter(r.updateData, func(item UpdateData, _ int) bool {
return item.ExtensionID != ext.ID
})
r.updateDataMu.Unlock()
return &ExtensionInstallResponse{
Message: fmt.Sprintf("Successfully updated %s", ext.Name),
}, nil
}
return &ExtensionInstallResponse{
Message: fmt.Sprintf("Successfully installed %s", ext.Name),
}, nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Uninstall external extension
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (r *Repository) UninstallExternalExtension(id string) error {
// Check if the extension exists
// Parse the ext
ext, err := extractExtensionFromFile(filepath.Join(r.extensionDir, id+".json"))
if err != nil {
r.logger.Error().Err(err).Str("filepath", filepath.Join(r.extensionDir, id+".json")).Msg("extensions: Failed to read extension file")
return fmt.Errorf("failed to read extension file, %w", err)
}
// Uninstall the extension
err = os.Remove(filepath.Join(r.extensionDir, id+".json"))
if err != nil {
r.logger.Error().Err(err).Str("id", id).Msg("extensions: Failed to uninstall extension")
return fmt.Errorf("failed to uninstall extension, %w", err)
}
// Reload the extensions
//r.loadExternalExtensions()
go func() {
_ = r.deleteExtensionUserConfig(id)
// Delete the plugin data if it was a plugin
if ext.Type == extension.TypePlugin {
r.deletePluginData(id)
r.removePluginFromStoredSettings(id)
}
}()
r.reloadExtension(id)
return nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Check for updates
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// checkForUpdates checks all extensions for updates by querying their respective repositories.
// It returns a list of extension update data containing IDs and versions.
func (r *Repository) checkForUpdates() (ret []UpdateData) {
wg := sync.WaitGroup{}
mu := sync.Mutex{}
r.logger.Trace().Msg("extensions: Checking for updates")
// Check for updates for all extensions
r.extensionBank.Range(func(key string, ext extension.BaseExtension) bool {
wg.Add(1)
go func(ext extension.BaseExtension) {
defer wg.Done()
// Skip built-in extensions
if ext.GetManifestURI() == "builtin" || ext.GetManifestURI() == "" {
return
}
// Get the extension data from the repository
extFromRepo, err := r.fetchExternalExtensionData(ext.GetManifestURI(), true)
if err != nil {
r.logger.Error().Err(err).Str("id", ext.GetID()).Str("url", ext.GetManifestURI()).Msg("extensions: Failed to fetch extension data while checking for update")
return
}
// Sanity check, this checks for the version too
if err = manifestSanityCheck(extFromRepo); err != nil {
r.logger.Error().Err(err).Str("id", ext.GetID()).Str("url", ext.GetManifestURI()).Msg("extensions: Failed sanity check while checking for update")
return
}
if extFromRepo.ID != ext.GetID() {
r.logger.Warn().Str("id", ext.GetID()).Str("newID", extFromRepo.ID).Str("url", ext.GetManifestURI()).Msg("extensions: Extension ID changed while checking for update")
return
}
// If there's an update, send the update data to the channel
if extFromRepo.Version != ext.GetVersion() {
mu.Lock()
ret = append(ret, UpdateData{
ExtensionID: extFromRepo.ID,
Version: extFromRepo.Version,
ManifestURI: extFromRepo.ManifestURI,
})
mu.Unlock()
}
}(ext)
return true
})
wg.Wait()
r.logger.Debug().Int("haveUpdates", len(ret)).Msg("extensions: Retrieved update info")
return
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Update extension code
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// UpdateExtensionCode updates the code of an external application
func (r *Repository) UpdateExtensionCode(id string, payload string) error {
if id == "" {
r.logger.Error().Msg("extensions: ID is empty")
return fmt.Errorf("id is empty")
}
if payload == "" {
r.logger.Error().Msg("extensions: Payload is empty")
return fmt.Errorf("payload is empty")
}
// We don't check if the extension existed in "loaded" extensions since the extension might be invalid
// We check if the file exists
filename := id + ".json"
extensionFilepath := filepath.Join(r.extensionDir, filename)
if _, err := os.Stat(extensionFilepath); err != nil {
r.logger.Error().Err(err).Str("id", id).Msg("extensions: Extension not found")
return fmt.Errorf("extension not found")
}
ext, err := extractExtensionFromFile(extensionFilepath)
if err != nil {
r.logger.Error().Err(err).Str("id", id).Msg("extensions: Failed to read extension file")
return fmt.Errorf("failed to read extension file, %w", err)
}
// Update the payload
ext.Payload = payload
// Write the extension to the file
file, err := os.Create(extensionFilepath)
if err != nil {
r.logger.Error().Err(err).Str("id", id).Msg("extensions: Failed to create extension file")
return fmt.Errorf("failed to create extension file, %w", err)
}
defer file.Close()
enc := json.NewEncoder(file)
err = enc.Encode(ext)
if err != nil {
r.logger.Error().Err(err).Str("id", id).Msg("extensions: Failed to write extension to file")
return fmt.Errorf("failed to write extension to file, %w", err)
}
// Call reload extension to unload it
r.reloadExtension(id)
return nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Loading/Reloading external extensions
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (r *Repository) ReloadExternalExtensions() {
r.loadExternalExtensions()
}
func (r *Repository) ReloadExternalExtension(id string) {
r.reloadExtension(id)
runtime.GC()
}
// interruptExternalGojaExtensionVMs kills all VMs from currently loaded external Goja extensions & clears the Goja extensions map.
func (r *Repository) interruptExternalGojaExtensionVMs() {
defer util.HandlePanicInModuleThen("extension_repo/interruptExternalGojaExtensionVMs", func() {})
r.logger.Trace().Msg("extensions: Interrupting Goja VMs")
count := 0
// Remove external extensions from the Goja extensions map
//r.gojaExtensions.Clear()
for _, key := range r.gojaExtensions.Keys() {
if gojaExt, ok := r.gojaExtensions.Get(key); ok {
if gojaExt.GetExtension().ManifestURI != "builtin" {
gojaExt.ClearInterrupt()
r.gojaExtensions.Delete(key)
count++
}
}
}
r.logger.Debug().Int("count", count).Msg("extensions: Killed Goja VMs")
}
// unloadExternalExtensions unloads all external extensions from the extension banks.
func (r *Repository) unloadExternalExtensions() {
r.logger.Trace().Msg("extensions: Unloading external extensions")
// We also clear the invalid extensions list, assuming the extensions are reloaded
//r.invalidExtensions.Clear()
count := 0
for _, key := range r.invalidExtensions.Keys() {
if invalidExt, ok := r.invalidExtensions.Get(key); ok {
if invalidExt.Extension.ManifestURI != "builtin" {
r.invalidExtensions.Delete(key)
count++
}
}
}
r.extensionBank.RemoveExternalExtensions()
r.logger.Debug().Int("count", count).Msg("extensions: Unloaded external extensions")
}
// loadExternalExtensions loads all external extensions from the extension directory.
// This should be called after the built-in extensions are loaded.
func (r *Repository) loadExternalExtensions() {
r.logger.Trace().Msg("extensions: Loading external extensions")
// Interrupt all Goja VMs
r.interruptExternalGojaExtensionVMs()
// Unload all external extensions
r.unloadExternalExtensions()
//
// Load external extensions
//
err := filepath.WalkDir(r.extensionDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
r.logger.Error().Err(err).Msg("extensions: Failed to walk directory")
return err
}
if d.IsDir() {
return nil
}
// Check if the file is a .json file
// If it is, parse the json and install the extension
if filepath.Ext(path) != ".json" {
return nil
}
r.loadExternalExtension(path)
return nil
})
if err != nil {
r.logger.Error().Err(err).Msg("extensions: Failed to load extensions")
return
}
r.logger.Debug().Msg("extensions: Loaded external extensions")
if r.firstExternalExtensionLoadedFunc != nil {
r.firstExternalExtensionLoadedFunc()
}
r.wsEventManager.SendEvent(events.ExtensionsReloaded, nil)
}
// Loads an external extension from a file path
func (r *Repository) loadExternalExtension(filePath string) {
// Parse the ext
ext, err := extractExtensionFromFile(filePath)
if err != nil {
r.logger.Error().Err(err).Str("filepath", filePath).Msg("extensions: Failed to read extension file")
return
}
ext.Lang = extension.GetExtensionLang(ext.Lang)
var manifestError error
// +
// | Manifest sanity check
// +
// Sanity check
if err = r.extensionSanityCheck(ext); err != nil {
r.logger.Error().Err(err).Str("filepath", filePath).Msg("extensions: Failed sanity check")
manifestError = err
}
invalidExtensionID := ext.ID
if invalidExtensionID == "" {
invalidExtensionID = uuid.NewString()
}
// If there was an error with the manifest, skip loading the extension,
// add the extension to the InvalidExtensions list and return
// The extension should be added to the InvalidExtensions list with an auto-generated ID.
if manifestError != nil {
r.invalidExtensions.Set(invalidExtensionID, &extension.InvalidExtension{
ID: invalidExtensionID,
Reason: manifestError.Error(),
Path: filePath,
Code: extension.InvalidExtensionManifestError,
Extension: *ext,
})
r.logger.Error().Err(manifestError).Str("filepath", filePath).Msg("extensions: Failed to load extension, manifest error")
return
}
if ext.SemverConstraint != "" {
c, err := semver.NewConstraint(ext.SemverConstraint)
v, _ := semver.NewVersion(constants.Version)
if err == nil {
if !c.Check(v) {
r.invalidExtensions.Set(invalidExtensionID, &extension.InvalidExtension{
ID: invalidExtensionID,
Reason: fmt.Sprintf("Incompatible with this version of Seanime (%s): %s", constants.Version, ext.SemverConstraint),
Path: filePath,
Code: extension.InvalidExtensionSemverConstraintError,
Extension: *ext,
})
r.logger.Error().Str("id", ext.ID).Msg("extensions: Failed to load extension, semver constraint error")
return
}
}
}
var loadingErr error
// +
// | Load payload
// +
// Load the payload URI if the extension is development mode.
// The payload URI is a path to the payload file.
if ext.IsDevelopment && ext.PayloadURI != "" {
if _, err := os.Stat(ext.PayloadURI); errors.Is(err, os.ErrNotExist) {
r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to read payload file")
return
}
payload, err := os.ReadFile(ext.PayloadURI)
if err != nil {
r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to read payload file")
return
}
ext.Payload = string(payload)
r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded payload from file")
}
// +
// | Check plugin permissions
// +
if ext.Type == extension.TypePlugin && !ext.IsDevelopment {
if ext.Plugin == nil { // Shouldn't happen because of sanity check, but just in case
r.logger.Error().Str("id", ext.ID).Msg("extensions: Plugin manifest is missing plugin object")
return
}
permissionErr := r.checkPluginPermissions(ext)
if permissionErr != nil {
r.invalidExtensions.Set(invalidExtensionID, &extension.InvalidExtension{
ID: invalidExtensionID,
Reason: permissionErr.Error(),
Path: filePath,
Code: extension.InvalidExtensionPluginPermissionsNotGranted,
Extension: *ext,
PluginPermissionDescription: ext.Plugin.Permissions.GetDescription(),
})
r.logger.Warn().Err(permissionErr).Str("id", ext.ID).Msg("extensions: Plugin permissions not granted. Please grant the permissions in the extension page.")
return
}
}
// +
// | Load user config
// +
// Load user config
configErr := r.loadUserConfig(ext)
// If there was an error loading the user config, we add it to the InvalidExtensions list
// BUT we still load the extension
// DEVNOTE: Failure to load the user config is not a critical error
if configErr != nil {
r.invalidExtensions.Set(invalidExtensionID, &extension.InvalidExtension{
ID: invalidExtensionID,
Reason: configErr.Error(),
Path: filePath,
Code: extension.InvalidExtensionUserConfigError,
Extension: *ext,
})
r.logger.Warn().Err(configErr).Str("id", invalidExtensionID).Msg("extensions: Failed to load user config")
}
// +
// | Load extension
// +
// Load extension
switch ext.Type {
case extension.TypeMangaProvider:
// Load manga provider
loadingErr = r.loadExternalMangaExtension(ext)
case extension.TypeOnlinestreamProvider:
// Load online streaming provider
loadingErr = r.loadExternalOnlinestreamProviderExtension(ext)
case extension.TypeAnimeTorrentProvider:
// Load torrent provider
loadingErr = r.loadExternalAnimeTorrentProviderExtension(ext)
case extension.TypePlugin:
// Load plugin
loadingErr = r.loadPlugin(ext)
default:
r.logger.Error().Str("type", string(ext.Type)).Msg("extensions: Extension type not supported")
loadingErr = fmt.Errorf("extension type not supported")
}
// If there was an error loading the extension, skip adding it to the extension bank
// and add the extension to the InvalidExtensions list
if loadingErr != nil {
r.invalidExtensions.Set(invalidExtensionID, &extension.InvalidExtension{
ID: invalidExtensionID,
Reason: loadingErr.Error(),
Path: filePath,
Code: extension.InvalidExtensionPayloadError,
Extension: *ext,
})
r.logger.Error().Err(loadingErr).Str("filepath", filePath).Msg("extensions: Failed to load extension")
return
}
r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded external extension")
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Reload specific extension
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (r *Repository) reloadExtension(id string) {
r.logger.Trace().Str("id", id).Msg("extensions: Reloading extension")
// 1. Unload the extension
// Remove extension from bank
r.extensionBank.Delete(id)
// Kill Goja VM if it exists
gojaExtension, ok := r.gojaExtensions.Get(id)
if ok {
// Interrupt the extension's runtime and running processed before unloading
gojaExtension.ClearInterrupt()
r.logger.Trace().Str("id", id).Msg("extensions: Killed extension's runtime")
r.gojaExtensions.Delete(id)
}
// Remove from invalid extensions
r.invalidExtensions.Delete(id)
time.Sleep(200 * time.Millisecond)
// 2. Load the extension back
// Load the extension from the file
extensionFilepath := filepath.Join(r.extensionDir, id+".json")
// Check if the extension still exists
if _, err := os.Stat(extensionFilepath); err != nil {
// If the extension doesn't exist anymore, return silently - it was uninstalled
r.wsEventManager.SendEvent(events.ExtensionsReloaded, nil)
r.logger.Debug().Str("id", id).Msg("extensions: Extension removed")
return
}
// If the extension still exist, load it back
r.loadExternalExtension(extensionFilepath)
r.logger.Debug().Str("id", id).Msg("extensions: Reloaded extension")
r.wsEventManager.SendEvent(events.ExtensionsReloaded, nil)
}