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,12 @@
## Add packages to Go interpreter
1. Extract package symbols using `yaegi` CLI tool
```bash
cd internal/yaegi_interp
```
```bash
yaegi extract "github.com/5rahim/hibike/a/b"
yaegi extract "github.com/a/b/c"
```

View File

@@ -0,0 +1,149 @@
package extension_repo
import (
"seanime/internal/events"
"seanime/internal/extension"
hibikemanga "seanime/internal/extension/hibike/manga"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
hibiketorrent "seanime/internal/extension/hibike/torrent"
)
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Built-in extensions
// - Built-in extensions are loaded once, on application startup
// - The "manifestURI" field is set to "builtin" to indicate that the extension is not external
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (r *Repository) ReloadBuiltInExtension(ext extension.Extension, provider interface{}) {
r.reloadBuiltInExtension(ext, provider)
}
func (r *Repository) reloadBuiltInExtension(ext extension.Extension, provider interface{}) {
// Unload the extension
// Remove extension from bank
r.extensionBank.Delete(ext.ID)
// Kill Goja VM if it exists
gojaExtension, ok := r.gojaExtensions.Get(ext.ID)
if ok {
// Interrupt the extension's runtime and running processed before unloading
gojaExtension.ClearInterrupt()
r.logger.Trace().Str("id", ext.ID).Msg("extensions: Killed built-in extension's runtime")
r.gojaExtensions.Delete(ext.ID)
}
// Remove from invalid extensions
r.invalidExtensions.Delete(ext.ID)
// Load the extension
r.loadBuiltInExtension(ext, provider)
}
func saveUserConfigInProvider(ext *extension.Extension, provider interface{}) {
if provider == nil {
return
}
if ext.SavedUserConfig == nil {
return
}
if configurableProvider, ok := provider.(extension.Configurable); ok {
configurableProvider.SetSavedUserConfig(*ext.SavedUserConfig)
}
}
func (r *Repository) loadBuiltInExtension(ext extension.Extension, provider interface{}) {
r.builtinExtensions.Set(ext.ID, &builtinExtension{
Extension: ext,
provider: provider,
})
// Load user config in the struct
configErr := r.loadUserConfig(&ext)
if configErr != nil {
r.invalidExtensions.Set(ext.ID, &extension.InvalidExtension{
ID: ext.ID,
Reason: configErr.Error(),
Path: "",
Code: extension.InvalidExtensionUserConfigError,
Extension: ext,
})
r.logger.Warn().Err(configErr).Str("id", ext.ID).Msg("extensions: Failed to load user config")
}
switch ext.Type {
case extension.TypeMangaProvider:
switch ext.Language {
// Go
case extension.LanguageGo:
if provider == nil {
r.logger.Error().Str("id", ext.ID).Msg("extensions: Built-in manga provider extension requires a provider")
return
}
saveUserConfigInProvider(&ext, provider)
if mangaProvider, ok := provider.(hibikemanga.Provider); ok {
r.loadBuiltInMangaProviderExtension(ext, mangaProvider)
}
}
case extension.TypeAnimeTorrentProvider:
switch ext.Language {
// Go
case extension.LanguageGo:
if provider == nil {
r.logger.Error().Str("id", ext.ID).Msg("extensions: Built-in anime torrent provider extension requires a provider")
return
}
saveUserConfigInProvider(&ext, provider)
if animeProvider, ok := provider.(hibiketorrent.AnimeProvider); ok {
r.loadBuiltInAnimeTorrentProviderExtension(ext, animeProvider)
}
}
case extension.TypeOnlinestreamProvider:
switch ext.Language {
// Go
case extension.LanguageGo:
if provider == nil {
r.logger.Error().Str("id", ext.ID).Msg("extensions: Built-in onlinestream provider extension requires a provider")
return
}
saveUserConfigInProvider(&ext, provider)
if onlinestreamProvider, ok := provider.(hibikeonlinestream.Provider); ok {
r.loadBuiltInOnlinestreamProviderExtension(ext, onlinestreamProvider)
}
case extension.LanguageJavascript, extension.LanguageTypescript:
r.loadBuiltInOnlinestreamProviderExtensionJS(ext)
}
case extension.TypePlugin:
// TODO: Implement
}
r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded built-in extension")
r.wsEventManager.SendEvent(events.ExtensionsReloaded, nil)
}
func (r *Repository) loadBuiltInMangaProviderExtension(ext extension.Extension, provider hibikemanga.Provider) {
r.extensionBank.Set(ext.ID, extension.NewMangaProviderExtension(&ext, provider))
r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded built-in manga provider extension")
}
func (r *Repository) loadBuiltInAnimeTorrentProviderExtension(ext extension.Extension, provider hibiketorrent.AnimeProvider) {
r.extensionBank.Set(ext.ID, extension.NewAnimeTorrentProviderExtension(&ext, provider))
r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded built-in anime torrent provider extension")
}
func (r *Repository) loadBuiltInOnlinestreamProviderExtension(ext extension.Extension, provider hibikeonlinestream.Provider) {
r.extensionBank.Set(ext.ID, extension.NewOnlinestreamProviderExtension(&ext, provider))
r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded built-in onlinestream provider extension")
}
func (r *Repository) loadBuiltInOnlinestreamProviderExtensionJS(ext extension.Extension) {
// Load the extension as if it was an external extension
err := r.loadExternalOnlinestreamExtensionJS(&ext, ext.Language)
if err != nil {
r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to load built-in JS onlinestream provider extension")
return
}
r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded built-in onlinestream provider extension")
}

View File

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

View File

@@ -0,0 +1,41 @@
package extension_repo
import (
"fmt"
"seanime/internal/extension"
"seanime/internal/util"
)
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Anime Torrent provider
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (r *Repository) loadExternalAnimeTorrentProviderExtension(ext *extension.Extension) (err error) {
defer util.HandlePanicInModuleWithError("extension_repo/loadExternalAnimeTorrentProviderExtension", &err)
switch ext.Language {
case extension.LanguageJavascript, extension.LanguageTypescript:
err = r.loadExternalAnimeTorrentProviderExtensionJS(ext, ext.Language)
default:
err = fmt.Errorf("unsupported language: %v", ext.Language)
}
if err != nil {
return
}
return
}
func (r *Repository) loadExternalAnimeTorrentProviderExtensionJS(ext *extension.Extension, language extension.Language) error {
provider, gojaExt, err := NewGojaAnimeTorrentProvider(ext, language, r.logger, r.gojaRuntimeManager)
if err != nil {
return err
}
// Add the extension to the map
retExt := extension.NewAnimeTorrentProviderExtension(ext, provider)
r.extensionBank.Set(ext.ID, retExt)
r.gojaExtensions.Set(ext.ID, gojaExt)
return nil
}

View File

@@ -0,0 +1,24 @@
package extension_repo
import (
"github.com/goccy/go-json"
"os"
"seanime/internal/extension"
)
func extractExtensionFromFile(filepath string) (ext *extension.Extension, err error) {
// Get the content of the file
fileContent, err := os.ReadFile(filepath)
if err != nil {
return
}
err = json.Unmarshal(fileContent, &ext)
if err != nil {
// If the manifest data is corrupted or not a valid manifest, skip loading the extension.
// We don't add it to the InvalidExtensions list because there's not enough information to
return
}
return
}

View File

@@ -0,0 +1,38 @@
package extension_repo
import (
"seanime/internal/extension"
"seanime/internal/util"
)
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Manga
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (r *Repository) loadExternalMangaExtension(ext *extension.Extension) (err error) {
defer util.HandlePanicInModuleWithError("extension_repo/loadExternalMangaExtension", &err)
switch ext.Language {
case extension.LanguageJavascript, extension.LanguageTypescript:
err = r.loadExternalMangaExtensionJS(ext, ext.Language)
}
if err != nil {
return
}
return
}
func (r *Repository) loadExternalMangaExtensionJS(ext *extension.Extension, language extension.Language) error {
provider, gojaExt, err := NewGojaMangaProvider(ext, language, r.logger, r.gojaRuntimeManager)
if err != nil {
return err
}
// Add the extension to the map
retExt := extension.NewMangaProviderExtension(ext, provider)
r.extensionBank.Set(ext.ID, retExt)
r.gojaExtensions.Set(ext.ID, gojaExt)
return nil
}

View File

@@ -0,0 +1,41 @@
package extension_repo
import (
"fmt"
"seanime/internal/extension"
"seanime/internal/util"
)
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Online streaming
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (r *Repository) loadExternalOnlinestreamProviderExtension(ext *extension.Extension) (err error) {
defer util.HandlePanicInModuleWithError("extension_repo/loadExternalOnlinestreamProviderExtension", &err)
switch ext.Language {
case extension.LanguageJavascript, extension.LanguageTypescript:
err = r.loadExternalOnlinestreamExtensionJS(ext, ext.Language)
default:
err = fmt.Errorf("unsupported language: %v", ext.Language)
}
if err != nil {
return
}
return
}
func (r *Repository) loadExternalOnlinestreamExtensionJS(ext *extension.Extension, language extension.Language) error {
provider, gojaExt, err := NewGojaOnlinestreamProvider(ext, language, r.logger, r.gojaRuntimeManager)
if err != nil {
return err
}
// Add the extension to the map
retExt := extension.NewOnlinestreamProviderExtension(ext, provider)
r.extensionBank.Set(ext.ID, retExt)
r.gojaExtensions.Set(ext.ID, gojaExt)
return nil
}

View File

@@ -0,0 +1,148 @@
package extension_repo
import (
"fmt"
"path/filepath"
"slices"
"seanime/internal/extension"
"seanime/internal/util"
"seanime/internal/util/filecache"
"github.com/samber/lo"
)
const PluginSettingsKey = "1"
const PluginSettingsBucket = "plugin-settings"
var (
ErrPluginPermissionsNotGranted = fmt.Errorf("plugin: permissions not granted")
)
type (
StoredPluginSettingsData struct {
PinnedTrayPluginIds []string `json:"pinnedTrayPluginIds"`
PluginGrantedPermissions map[string]string `json:"pluginGrantedPermissions"` // Extension ID -> Permission Hash
}
)
var DefaultStoredPluginSettingsData = StoredPluginSettingsData{
PinnedTrayPluginIds: []string{},
PluginGrantedPermissions: map[string]string{},
}
// GetPluginSettings returns the stored plugin settings.
// If no settings are found, it will return the default settings.
func (r *Repository) GetPluginSettings() *StoredPluginSettingsData {
bucket := filecache.NewPermanentBucket(PluginSettingsBucket)
var settings StoredPluginSettingsData
found, _ := r.fileCacher.GetPerm(bucket, PluginSettingsKey, &settings)
if !found {
r.fileCacher.SetPerm(bucket, PluginSettingsKey, DefaultStoredPluginSettingsData)
return &DefaultStoredPluginSettingsData
}
return &settings
}
// SetPluginSettingsPinnedTrays sets the pinned tray plugin IDs.
func (r *Repository) SetPluginSettingsPinnedTrays(pinnedTrayPluginIds []string) {
bucket := filecache.NewPermanentBucket(PluginSettingsBucket)
settings := r.GetPluginSettings()
settings.PinnedTrayPluginIds = pinnedTrayPluginIds
r.fileCacher.SetPerm(bucket, PluginSettingsKey, settings)
}
func (r *Repository) GrantPluginPermissions(pluginId string) {
// Parse the ext
ext, err := extractExtensionFromFile(filepath.Join(r.extensionDir, pluginId+".json"))
if err != nil {
r.logger.Error().Err(err).Str("filepath", filepath.Join(r.extensionDir, pluginId+".json")).Msg("extensions: Failed to read extension file")
return
}
// Check if the extension is a plugin
if ext.Type != extension.TypePlugin {
r.logger.Error().Str("id", pluginId).Msg("extensions: Extension is not a plugin")
return
}
// Grant the plugin permissions
permissionHash := ext.Plugin.Permissions.GetHash()
r.setPluginGrantedPermissions(pluginId, permissionHash)
r.logger.Debug().Str("id", pluginId).Msg("extensions: Granted plugin permissions")
// Reload the extension
r.reloadExtension(pluginId)
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// setPluginGrantedPermissions sets the granted permissions for a plugin.
func (r *Repository) setPluginGrantedPermissions(pluginId string, permissionHash string) {
bucket := filecache.NewPermanentBucket(PluginSettingsBucket)
settings := r.GetPluginSettings()
if settings.PluginGrantedPermissions == nil {
settings.PluginGrantedPermissions = make(map[string]string)
}
settings.PluginGrantedPermissions[pluginId] = permissionHash
r.fileCacher.SetPerm(bucket, PluginSettingsKey, settings)
}
// removePluginFromStoredSettings removes a plugin from the stored settings.
func (r *Repository) removePluginFromStoredSettings(pluginId string) {
bucket := filecache.NewPermanentBucket(PluginSettingsBucket)
settings := r.GetPluginSettings()
delete(settings.PluginGrantedPermissions, pluginId)
if slices.Contains(settings.PinnedTrayPluginIds, pluginId) {
settings.PinnedTrayPluginIds = lo.Filter(settings.PinnedTrayPluginIds, func(id string, _ int) bool {
return id != pluginId
})
}
r.fileCacher.SetPerm(bucket, PluginSettingsKey, settings)
}
func (r *Repository) checkPluginPermissions(ext *extension.Extension) (err error) {
defer util.HandlePanicInModuleWithError("extension_repo/checkPluginPermissions", &err)
if ext.Type != extension.TypePlugin {
return nil
}
if ext.Plugin == nil {
return nil
}
// Get current plugin permission hash
pluginPermissionHash := ext.Plugin.Permissions.GetHash()
// If the plugin has no permissions, skip the check
if pluginPermissionHash == "" {
return nil
}
// Get stored plugin permission hash
permissionMap := r.GetPluginSettings().PluginGrantedPermissions
// Check if the plugin has been granted the required permissions
granted, found := permissionMap[ext.ID]
if !found {
return ErrPluginPermissionsNotGranted
}
if granted != pluginPermissionHash {
return ErrPluginPermissionsNotGranted
}
return nil
}

View File

@@ -0,0 +1,276 @@
package extension_repo
import (
"context"
"encoding/json"
"fmt"
"io"
"reflect"
"seanime/internal/extension"
goja_bindings "seanime/internal/goja/goja_bindings"
"seanime/internal/library/anime"
"seanime/internal/plugin"
"sync"
"time"
"github.com/5rahim/habari"
"github.com/dop251/goja"
gojabuffer "github.com/dop251/goja_nodejs/buffer"
gojarequire "github.com/dop251/goja_nodejs/require"
gojaurl "github.com/dop251/goja_nodejs/url"
"github.com/evanw/esbuild/pkg/api"
"github.com/rs/zerolog"
"github.com/spf13/cast"
)
// GojaExtension is stored in the repository extension map, giving access to the VMs.
// Current use: Kill the VM when the extension is unloaded.
type GojaExtension interface {
PutVM(*goja.Runtime)
ClearInterrupt()
GetExtension() *extension.Extension
}
var cachedArrayOfTypes = plugin.NewStore[reflect.Type, reflect.Type](nil)
func BindUserConfig(vm *goja.Runtime, ext *extension.Extension, logger *zerolog.Logger) {
vm.Set("$getUserPreference", func(call goja.FunctionCall) goja.Value {
if ext.SavedUserConfig == nil {
return goja.Undefined()
}
key := call.Argument(0).String()
value, ok := ext.SavedUserConfig.Values[key]
if !ok {
// Check if the field has a default value
for _, field := range ext.UserConfig.Fields {
if field.Name == key && field.Default != "" {
return vm.ToValue(field.Default)
}
}
return goja.Undefined()
}
return vm.ToValue(value)
})
}
// ShareBinds binds the shared bindings to the VM
// This is called once per VM
func ShareBinds(vm *goja.Runtime, logger *zerolog.Logger) {
registry := new(gojarequire.Registry)
registry.Enable(vm)
fm := goja_bindings.DefaultFieldMapper{}
vm.SetFieldNameMapper(fm)
// goja.TagFieldNameMapper("json", true)
bindings := []struct {
name string
fn func(*goja.Runtime) error
}{
{"url", func(vm *goja.Runtime) error { gojaurl.Enable(vm); return nil }},
{"buffer", func(vm *goja.Runtime) error { gojabuffer.Enable(vm); return nil }},
{"fetch", func(vm *goja.Runtime) error { goja_bindings.BindFetch(vm); return nil }},
{"console", func(vm *goja.Runtime) error { goja_bindings.BindConsole(vm, logger); return nil }},
{"formData", func(vm *goja.Runtime) error { goja_bindings.BindFormData(vm); return nil }},
{"document", func(vm *goja.Runtime) error { goja_bindings.BindDocument(vm); return nil }},
{"crypto", func(vm *goja.Runtime) error { goja_bindings.BindCrypto(vm); return nil }},
{"torrentUtils", func(vm *goja.Runtime) error { goja_bindings.BindTorrentUtils(vm); return nil }},
}
for _, binding := range bindings {
if err := binding.fn(vm); err != nil {
logger.Error().Err(err).Str("name", binding.name).Msg("failed to bind")
}
}
vm.Set("__isOffline__", plugin.GlobalAppContext.IsOffline())
vm.Set("$toString", func(raw any, maxReaderBytes int) (string, error) {
switch v := raw.(type) {
case io.Reader:
if maxReaderBytes == 0 {
maxReaderBytes = 32 << 20 // 32 MB
}
limitReader := io.LimitReader(v, int64(maxReaderBytes))
bodyBytes, readErr := io.ReadAll(limitReader)
if readErr != nil {
return "", readErr
}
return string(bodyBytes), nil
default:
str, err := cast.ToStringE(v)
if err == nil {
return str, nil
}
// as a last attempt try to json encode the value
rawBytes, _ := json.Marshal(raw)
return string(rawBytes), nil
}
})
vm.Set("$toBytes", func(raw any) ([]byte, error) {
switch v := raw.(type) {
case io.Reader:
bodyBytes, readErr := io.ReadAll(v)
if readErr != nil {
return nil, readErr
}
return bodyBytes, nil
case string:
return []byte(v), nil
case []byte:
return v, nil
case []rune:
return []byte(string(v)), nil
default:
// as a last attempt try to json encode the value
rawBytes, _ := json.Marshal(raw)
return rawBytes, nil
}
})
vm.Set("$toError", func(raw any) error {
if err, ok := raw.(error); ok {
return err
}
return fmt.Errorf("%v", raw)
})
vm.Set("$sleep", func(milliseconds int64) {
time.Sleep(time.Duration(milliseconds) * time.Millisecond)
})
vm.Set("$arrayOf", func(model any) any {
mt := reflect.TypeOf(model)
st := cachedArrayOfTypes.GetOrSet(mt, func() reflect.Type {
return reflect.SliceOf(mt)
})
return reflect.New(st).Elem().Addr().Interface()
})
vm.Set("$unmarshal", func(data, dst any) error {
raw, err := json.Marshal(data)
if err != nil {
return err
}
return json.Unmarshal(raw, &dst)
})
vm.Set("$toPointer", func(data interface{}) interface{} {
if data == nil {
return nil
}
v := data
return &v
})
vm.Set("$Context", func(call goja.ConstructorCall) *goja.Object {
var instance context.Context
oldCtx, ok := call.Argument(0).Export().(context.Context)
if ok {
instance = oldCtx
} else {
instance = context.Background()
}
key := call.Argument(1).Export()
if key != nil {
instance = context.WithValue(instance, key, call.Argument(2).Export())
}
instanceValue := vm.ToValue(instance).(*goja.Object)
instanceValue.SetPrototype(call.This.Prototype())
return instanceValue
})
//
// Habari
//
habariObj := vm.NewObject()
_ = habariObj.Set("parse", func(filename string) *habari.Metadata {
return habari.Parse(filename)
})
vm.Set("$habari", habariObj)
//
// Anime Utils
//
animeUtilsObj := vm.NewObject()
_ = animeUtilsObj.Set("newLocalFileWrapper", func(lfs []*anime.LocalFile) *anime.LocalFileWrapper {
return anime.NewLocalFileWrapper(lfs)
})
vm.Set("$animeUtils", animeUtilsObj)
vm.Set("$waitGroup", func() *sync.WaitGroup {
return &sync.WaitGroup{}
})
// Run a function in a new goroutine
// The Goja runtime is not thread safe, so nothing related to the VM should be done in this goroutine
// You can use the $waitGroup to wait for multiple goroutines to finish
// You can use $store to communicate with the main thread
vm.Set("$unsafeGoroutine", func(fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
logger.Error().Err(fmt.Errorf("%v", r)).Msg("goroutine panic")
}
}()
fn()
}()
})
}
// JSVMTypescriptToJS converts typescript to javascript
func JSVMTypescriptToJS(ts string) (string, error) {
result := api.Transform(ts, api.TransformOptions{
Target: api.ES2018,
Loader: api.LoaderTS,
Format: api.FormatDefault,
MinifyWhitespace: true,
MinifySyntax: true,
Sourcemap: api.SourceMapNone,
})
if len(result.Errors) > 0 {
var errMsgs []string
for _, err := range result.Errors {
errMsgs = append(errMsgs, err.Text)
}
return "", fmt.Errorf("typescript compilation errors: %v", errMsgs)
}
return string(result.Code), nil
}
// structToMap converts a struct to "JSON-like" map for Goja extensions
// This is used to pass structs to Goja extensions
func structToMap(obj interface{}) map[string]interface{} {
// Convert the struct to a map
jsonData, err := json.Marshal(obj)
if err != nil {
return nil
}
var data map[string]interface{}
err = json.Unmarshal(jsonData, &data)
if err != nil {
return nil
}
return data
}

View File

@@ -0,0 +1,152 @@
package extension_repo
import (
"context"
"seanime/internal/extension"
hibiketorrent "seanime/internal/extension/hibike/torrent"
"seanime/internal/goja/goja_runtime"
"seanime/internal/util"
"github.com/rs/zerolog"
)
type GojaAnimeTorrentProvider struct {
*gojaProviderBase
}
func NewGojaAnimeTorrentProvider(ext *extension.Extension, language extension.Language, logger *zerolog.Logger, runtimeManager *goja_runtime.Manager) (hibiketorrent.AnimeProvider, *GojaAnimeTorrentProvider, error) {
base, err := initializeProviderBase(ext, language, logger, runtimeManager)
if err != nil {
return nil, nil, err
}
provider := &GojaAnimeTorrentProvider{
gojaProviderBase: base,
}
return provider, provider, nil
}
func (g *GojaAnimeTorrentProvider) Search(opts hibiketorrent.AnimeSearchOptions) (ret []*hibiketorrent.AnimeTorrent, err error) {
defer util.HandlePanicInModuleWithError(g.ext.ID+".Search", &err)
method, err := g.callClassMethod(context.Background(), "search", structToMap(opts))
promiseRes, err := g.waitForPromise(method)
if err != nil {
return nil, err
}
err = g.unmarshalValue(promiseRes, &ret)
if err != nil {
return nil, err
}
for i := range ret {
ret[i].Provider = g.ext.ID
}
return
}
func (g *GojaAnimeTorrentProvider) SmartSearch(opts hibiketorrent.AnimeSmartSearchOptions) (ret []*hibiketorrent.AnimeTorrent, err error) {
defer util.HandlePanicInModuleWithError(g.ext.ID+".SmartSearch", &err)
method, err := g.callClassMethod(context.Background(), "smartSearch", structToMap(opts))
promiseRes, err := g.waitForPromise(method)
if err != nil {
return nil, err
}
err = g.unmarshalValue(promiseRes, &ret)
if err != nil {
return nil, err
}
for i := range ret {
ret[i].Provider = g.ext.ID
}
return
}
func (g *GojaAnimeTorrentProvider) GetTorrentInfoHash(torrent *hibiketorrent.AnimeTorrent) (ret string, err error) {
defer util.HandlePanicInModuleWithError(g.ext.ID+".GetTorrentInfoHash", &err)
res, err := g.callClassMethod(context.Background(), "getTorrentInfoHash", structToMap(torrent))
if err != nil {
return "", err
}
promiseRes, err := g.waitForPromise(res)
if err != nil {
return "", err
}
err = g.unmarshalValue(promiseRes, &ret)
if err != nil {
return "", err
}
return
}
func (g *GojaAnimeTorrentProvider) GetTorrentMagnetLink(torrent *hibiketorrent.AnimeTorrent) (ret string, err error) {
defer util.HandlePanicInModuleWithError(g.ext.ID+".GetTorrentMagnetLink", &err)
res, err := g.callClassMethod(context.Background(), "getTorrentMagnetLink", structToMap(torrent))
if err != nil {
return "", err
}
promiseRes, err := g.waitForPromise(res)
if err != nil {
return "", err
}
err = g.unmarshalValue(promiseRes, &ret)
if err != nil {
return "", err
}
return
}
func (g *GojaAnimeTorrentProvider) GetLatest() (ret []*hibiketorrent.AnimeTorrent, err error) {
defer util.HandlePanicInModuleWithError(g.ext.ID+".GetLatest", &err)
method, err := g.callClassMethod(context.Background(), "getLatest")
if err != nil {
return nil, err
}
promiseRes, err := g.waitForPromise(method)
if err != nil {
return nil, err
}
err = g.unmarshalValue(promiseRes, &ret)
if err != nil {
return nil, err
}
return
}
func (g *GojaAnimeTorrentProvider) GetSettings() (ret hibiketorrent.AnimeProviderSettings) {
defer util.HandlePanicInModuleThen(g.ext.ID+".GetSettings", func() {
ret = hibiketorrent.AnimeProviderSettings{}
})
res, err := g.callClassMethod(context.Background(), "getSettings")
if err != nil {
return
}
err = g.unmarshalValue(res, &ret)
if err != nil {
return
}
return
}

View File

@@ -0,0 +1,258 @@
/// <reference path="../goja_onlinestream_test/onlinestream-provider.d.ts" />
/// <reference path="../goja_plugin_types/core.d.ts" />
type EpisodeData = {
id: number; episode: number; title: string; snapshot: string; filler: number; session: string; created_at?: string
}
type AnimeData = {
id: number; title: string; type: string; year: number; poster: string; session: string
}
class Provider {
api = "https://animepahe.ru"
headers = { Referer: "https://kwik.si" }
getSettings(): Settings {
return {
episodeServers: ["kwik"],
supportsDub: false,
}
}
async search(opts: SearchOptions): Promise<SearchResult[]> {
const req = await fetch(`${this.api}/api?m=search&q=${encodeURIComponent(opts.query)}`, {
headers: {
Cookie: "__ddg1_=;__ddg2_=;",
},
})
if (!req.ok) {
return []
}
const data = (await req.json()) as { data: AnimeData[] }
const results: SearchResult[] = []
if (!data?.data) {
return []
}
data.data.map((item: AnimeData) => {
results.push({
subOrDub: "sub",
id: item.session,
title: item.title,
url: "",
})
})
return results
}
async findEpisodes(id: string): Promise<EpisodeDetails[]> {
let episodes: EpisodeDetails[] = []
const req =
await fetch(
`${this.api}${id.includes("-") ? `/anime/${id}` : `/a/${id}`}`,
{
headers: {
Cookie: "__ddg1_=;__ddg2_=;",
},
},
)
const html = await req.text()
function pushData(data: EpisodeData[]) {
for (const item of data) {
episodes.push({
id: item.session + "$" + id,
number: item.episode,
title: item.title && item.title.length > 0 ? item.title : "Episode " + item.episode,
url: req.url,
})
}
}
const $ = LoadDoc(html)
const tempId = $("head > meta[property='og:url']").attr("content")!.split("/").pop()!
const { last_page, data } = (await (
await fetch(`${this.api}/api?m=release&id=${tempId}&sort=episode_asc&page=1`, {
headers: {
Cookie: "__ddg1_=;__ddg2_=;",
},
})
).json()) as {
last_page: number;
data: EpisodeData[]
}
pushData(data)
const pageNumbers = Array.from({ length: last_page - 1 }, (_, i) => i + 2)
const promises = pageNumbers.map((pageNumber) =>
fetch(`${this.api}/api?m=release&id=${tempId}&sort=episode_asc&page=${pageNumber}`, {
headers: {
Cookie: "__ddg1_=;__ddg2_=;",
},
}).then((res) => res.json()),
)
const results = (await Promise.all(promises)) as {
data: EpisodeData[]
}[]
results.forEach((showData) => {
for (const data of showData.data) {
if (data) {
pushData([data])
}
}
});
(data as any[]).sort((a, b) => a.number - b.number)
if (episodes.length === 0) {
throw new Error("No episodes found.")
}
const lowest = episodes[0].number
if (lowest > 1) {
for (let i = 0; i < episodes.length; i++) {
episodes[i].number = episodes[i].number - lowest + 1
}
}
// Remove decimal episode numbers
episodes = episodes.filter((episode) => Number.isInteger(episode.number))
// for (let i = 0; i < episodes.length; i++) {
// // If an episode number is a decimal, round it up to the nearest whole number
// if (Number.isInteger(episodes[i].number)) {
// continue
// }
// const original = episodes[i].number
// episodes[i].number = Math.floor(episodes[i].number)
// episodes[i].title = `Episode ${episodes[i].number} [{${original}}]`
// }
return episodes
}
async findEpisodeServer(episode: EpisodeDetails, _server: string): Promise<EpisodeServer> {
const episodeId = episode.id.split("$")[0]
const animeId = episode.id.split("$")[1]
console.log(`${this.api}/play/${animeId}/${episodeId}`)
const req = await fetch(
`${this.api}/play/${animeId}/${episodeId}`,
{
headers: {
Cookie: "__ddg1_=;__ddg2_=;",
},
},
)
const html = await req.text()
const regex = /https:\/\/kwik\.si\/e\/\w+/g
const matches = html.match(regex)
if (matches === null) {
throw new Error("Failed to fetch episode server.")
}
const $ = LoadDoc(html)
const result: EpisodeServer = {
videoSources: [],
headers: this.headers ?? {},
server: "kwik",
}
$("button[data-src]").each(async (_, el) => {
let videoSource: VideoSource = {
url: "",
type: "m3u8",
quality: "",
subtitles: [],
}
videoSource.url = el.data("src")!
if (!videoSource.url) {
return
}
const fansub = el.data("fansub")!
const quality = el.data("resolution")!
videoSource.quality = `${quality}p - ${fansub}`
if (el.data("audio") === "eng") {
videoSource.quality += " (Eng)"
}
if (videoSource.url === matches[0]) {
videoSource.quality += " (default)"
}
result.videoSources.push(videoSource)
})
const queries = result.videoSources.map(async (videoSource) => {
try {
const src_req = await fetch(videoSource.url, {
headers: {
Referer: this.headers.Referer,
"user-agent":
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.56",
},
})
const src_html = await src_req.text()
const scripts = src_html.match(/eval\(f.+?\}\)\)/g)
if (!scripts) {
return
}
for (const _script of scripts) {
const scriptMatch = _script.match(/eval(.+)/)
if (!scriptMatch || !scriptMatch[1]) {
continue
}
try {
const decoded = eval(scriptMatch[1])
const link = decoded.match(/source='(.+?)'/)
if (!link || !link[1]) {
continue
}
videoSource.url = link[1]
}
catch (e) {
console.error("Failed to extract kwik link", e)
}
}
}
catch (e) {
console.error("Failed to fetch kwik link", e)
}
})
await Promise.all(queries)
return result
}
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"esnext",
"dom"
],
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"downlevelIteration": true
}
}

View File

@@ -0,0 +1,201 @@
package extension_repo
import (
"context"
"encoding/json"
"fmt"
"seanime/internal/extension"
"seanime/internal/goja/goja_runtime"
"time"
"github.com/dop251/goja"
"github.com/dop251/goja/parser"
"github.com/rs/zerolog"
)
type gojaProviderBase struct {
ext *extension.Extension
logger *zerolog.Logger
pool *goja_runtime.Pool
program *goja.Program
source string
runtimeManager *goja_runtime.Manager
}
func initializeProviderBase(ext *extension.Extension, language extension.Language, logger *zerolog.Logger, runtimeManager *goja_runtime.Manager) (*gojaProviderBase, error) {
// initFn, pr, err := SetupGojaExtensionVM(ext, language, logger)
// if err != nil {
// return nil, err
// }
source := ext.Payload
if language == extension.LanguageTypescript {
var err error
source, err = JSVMTypescriptToJS(ext.Payload)
if err != nil {
logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to convert typescript")
return nil, err
}
}
// Compile the program once, to be reused by all VMs
program, err := goja.Compile("", source, false)
if err != nil {
logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to compile program")
return nil, fmt.Errorf("compilation failed: %w", err)
}
initFn := func() *goja.Runtime {
vm := goja.New()
vm.SetParserOptions(parser.WithDisableSourceMaps)
// Bind the shared bindings
ShareBinds(vm, logger)
BindUserConfig(vm, ext, logger)
return vm
}
pool, err := runtimeManager.GetOrCreatePrivatePool(ext.ID, initFn)
if err != nil {
return nil, err
}
return &gojaProviderBase{
ext: ext,
logger: logger,
pool: pool,
program: program,
source: source,
runtimeManager: runtimeManager,
}, nil
}
func (g *gojaProviderBase) GetExtension() *extension.Extension {
return g.ext
}
func (g *gojaProviderBase) callClassMethod(ctx context.Context, methodName string, args ...interface{}) (goja.Value, error) {
if ctx == nil {
ctx = context.Background()
}
vm, err := g.pool.Get(ctx)
if err != nil {
g.logger.Error().Err(err).Str("id", g.ext.ID).Msg("extension: Failed to get VM")
return nil, fmt.Errorf("failed to get VM: %w", err)
}
defer func() {
g.pool.Put(vm)
}()
// Ensure the Provider class is defined only once per VM
providerType, err := vm.RunString("typeof Provider")
if err != nil {
g.logger.Error().Err(err).Str("id", g.ext.ID).Msg("extension: Failed to check Provider existence")
return nil, fmt.Errorf("failed to check Provider existence: %w", err)
}
if providerType.String() == "undefined" {
_, err = vm.RunProgram(g.program)
if err != nil {
g.logger.Error().Err(err).Str("id", g.ext.ID).Msg("extension: Failed to run program")
return nil, fmt.Errorf("failed to run program: %w", err)
}
}
// Create a new instance of the Provider class
providerInstance, err := vm.RunString("new Provider()")
if err != nil {
g.logger.Error().Err(err).Str("id", g.ext.ID).Msg("extension: Failed to create Provider instance")
return nil, fmt.Errorf("failed to create Provider instance: %w", err)
}
if providerInstance == nil {
g.logger.Error().Str("id", g.ext.ID).Msg("extension: Provider constructor returned nil")
return nil, fmt.Errorf("provider constructor returned nil")
}
// Get the method from the instance
method, ok := goja.AssertFunction(providerInstance.ToObject(vm).Get(methodName))
if !ok {
g.logger.Error().Str("id", g.ext.ID).Str("method", methodName).Msg("extension: Method not found or not a function")
return nil, fmt.Errorf("method %s not found or not a function", methodName)
}
// Convert arguments to Goja values
gojaArgs := make([]goja.Value, len(args))
for i, arg := range args {
gojaArgs[i] = vm.ToValue(arg)
}
// Call the method
result, err := method(providerInstance, gojaArgs...)
if err != nil {
g.logger.Error().Err(err).Str("id", g.ext.ID).Str("method", methodName).Msg("extension: Method execution failed")
return nil, fmt.Errorf("method %s execution failed: %w", methodName, err)
}
// g.runtimeManager.PrintBasePoolMetrics()
return result, nil
}
// unmarshalValue unmarshals a Goja value to a target interface
// This is used to convert the result of a method call to a struct
func (g *gojaProviderBase) unmarshalValue(value goja.Value, target interface{}) error {
if value == nil {
return fmt.Errorf("cannot unmarshal nil value")
}
exported := value.Export()
if exported == nil {
return fmt.Errorf("exported value is nil")
}
data, err := json.Marshal(exported)
if err != nil {
return fmt.Errorf("failed to marshal value: %w", err)
}
return json.Unmarshal(data, target)
}
// waitForPromise waits for a promise to resolve and returns the result
func (g *gojaProviderBase) waitForPromise(value goja.Value) (goja.Value, error) {
if value == nil {
return nil, fmt.Errorf("cannot wait for nil promise")
}
// If the value is a promise, wait for it to resolve
if promise, ok := value.Export().(*goja.Promise); ok {
doneCh := make(chan struct{})
// Wait for the promise to resolve
go func() {
for promise.State() == goja.PromiseStatePending {
time.Sleep(10 * time.Millisecond)
}
close(doneCh)
}()
<-doneCh
// If the promise is rejected, return the error
if promise.State() == goja.PromiseStateRejected {
err := promise.Result()
return nil, fmt.Errorf("promise rejected: %v", err)
}
// If the promise is fulfilled, return the result
res := promise.Result()
return res, nil
}
// If the value is not a promise, return it as is
return value, nil
}
func (g *gojaProviderBase) PutVM(vm *goja.Runtime) {
g.pool.Put(vm)
}
func (g *gojaProviderBase) ClearInterrupt() {
// no-op
}

View File

@@ -0,0 +1,400 @@
package extension_repo
import (
"seanime/internal/api/anilist"
"seanime/internal/hook"
"seanime/internal/platforms/anilist_platform"
"seanime/internal/util"
"testing"
"github.com/stretchr/testify/require"
)
func TestNewGojaPlugin(t *testing.T) {
payload := `
function init() {
$app.onGetAnime((e) => {
if(e.anime.id === 178022) {
e.anime.id = 21;
e.anime.idMal = 21;
$replace(e.anime.id, 22)
$replace(e.anime.title, { "english": "The One Piece is Real" })
// e.anime.title = { "english": "The One Piece is Real" }
// $replace(e.anime.synonyms, ["The One Piece is Real"])
e.anime.synonyms = ["The One Piece is Real"]
// e.anime.synonyms[0] = "The One Piece is Real"
// $replace(e.anime.synonyms[0], "The One Piece is Real")
}
e.next();
});
$app.onGetAnime((e) => {
console.log("$app.onGetAnime(2) fired")
console.log(e.anime.id)
console.log(e.anime.idMal)
console.log(e.anime.synonyms[0])
console.log(e.anime.title)
});
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
_, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
m, err := anilistPlatform.GetAnime(t.Context(), 178022)
if err != nil {
t.Fatalf("GetAnime returned error: %v", err)
}
util.Spew(m.Title)
util.Spew(m.Synonyms)
// m, err = anilistPlatform.GetAnime(177709)
// if err != nil {
// t.Fatalf("GetAnime returned error: %v", err)
// }
// util.Spew(m.Title)
manager.PrintPluginPoolMetrics(opts.ID)
}
func BenchmarkAllHooks(b *testing.B) {
b.Run("BaselineNoHook", BenchmarkBaselineNoHook)
b.Run("HookInvocation", BenchmarkHookInvocation)
b.Run("HookInvocationParallel", BenchmarkHookInvocationParallel)
b.Run("HookInvocationWithWork", BenchmarkHookInvocationWithWork)
b.Run("HookInvocationWithWorkParallel", BenchmarkHookInvocationWithWorkParallel)
b.Run("NoHookInvocation", BenchmarkNoHookInvocation)
b.Run("NoHookInvocationParallel", BenchmarkNoHookInvocationParallel)
b.Run("NoHookInvocationWithWork", BenchmarkNoHookInvocationWithWork)
}
func BenchmarkHookInvocation(b *testing.B) {
b.ReportAllocs()
// Dummy extension payload that registers a hook
payload := `
function init() {
$app.onGetAnime(function(e) {
e.next();
});
}
`
opts := DefaultTestPluginOptions()
opts.ID = "dummy-hook-benchmark"
opts.Payload = payload
opts.SetupHooks = true
_, _, runtimeManager, _, _, err := InitTestPlugin(b, opts)
require.NoError(b, err)
// Create a dummy anime event that we'll reuse
title := "Test Anime"
dummyEvent := &anilist_platform.GetAnimeEvent{
Anime: &anilist.BaseAnime{
ID: 1234,
Title: &anilist.BaseAnime_Title{
English: &title,
},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := hook.GlobalHookManager.OnGetAnime().Trigger(dummyEvent); err != nil {
b.Fatal(err)
}
}
runtimeManager.PrintPluginPoolMetrics(opts.ID)
}
func BenchmarkNoHookInvocation(b *testing.B) {
b.ReportAllocs()
// Dummy extension payload that registers a hook
payload := `
function init() {
$app.onMissingEpisodes(function(e) {
e.next();
});
}
`
opts := DefaultTestPluginOptions()
opts.ID = "dummy-hook-benchmark"
opts.Payload = payload
opts.SetupHooks = true
_, _, runtimeManager, _, _, err := InitTestPlugin(b, opts)
require.NoError(b, err)
// Create a dummy anime event that we'll reuse
title := "Test Anime"
dummyEvent := &anilist_platform.GetAnimeEvent{
Anime: &anilist.BaseAnime{
ID: 1234,
Title: &anilist.BaseAnime_Title{
English: &title,
},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := hook.GlobalHookManager.OnGetAnime().Trigger(dummyEvent); err != nil {
b.Fatal(err)
}
}
runtimeManager.PrintPluginPoolMetrics(opts.ID)
}
// Add a parallel version to see how it performs under concurrent load
func BenchmarkHookInvocationParallel(b *testing.B) {
b.ReportAllocs()
payload := `
function init() {
$app.onGetAnime(function(e) {
e.next();
});
}
`
opts := DefaultTestPluginOptions()
opts.ID = "dummy-hook-benchmark"
opts.Payload = payload
opts.SetupHooks = true
_, _, runtimeManager, _, _, err := InitTestPlugin(b, opts)
require.NoError(b, err)
title := "Test Anime"
event := &anilist_platform.GetAnimeEvent{
Anime: &anilist.BaseAnime{
ID: 1234,
Title: &anilist.BaseAnime_Title{
English: &title,
},
},
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if err := hook.GlobalHookManager.OnGetAnime().Trigger(event); err != nil {
b.Fatal(err)
}
}
})
runtimeManager.PrintPluginPoolMetrics(opts.ID)
}
func BenchmarkNoHookInvocationParallel(b *testing.B) {
b.ReportAllocs()
payload := `
function init() {
$app.onMissingEpisodes(function(e) {
e.next();
});
}
`
opts := DefaultTestPluginOptions()
opts.ID = "dummy-hook-benchmark"
opts.Payload = payload
opts.SetupHooks = true
_, _, runtimeManager, _, _, err := InitTestPlugin(b, opts)
require.NoError(b, err)
title := "Test Anime"
event := &anilist_platform.GetAnimeEvent{
Anime: &anilist.BaseAnime{
ID: 1234,
Title: &anilist.BaseAnime_Title{
English: &title,
},
},
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if err := hook.GlobalHookManager.OnGetAnime().Trigger(event); err != nil {
b.Fatal(err)
}
}
})
runtimeManager.PrintPluginPoolMetrics(opts.ID)
}
// BenchmarkBaselineNoHook measures the baseline performance without any hooks
func BenchmarkBaselineNoHook(b *testing.B) {
b.ReportAllocs()
title := "Test Anime"
dummyEvent := &anilist_platform.GetAnimeEvent{
Anime: &anilist.BaseAnime{
ID: 1234,
Title: &anilist.BaseAnime_Title{
English: &title,
},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = dummyEvent.Next()
}
}
// BenchmarkHookInvocationWithWork measures performance with a hook that does some actual work
func BenchmarkHookInvocationWithWork(b *testing.B) {
b.ReportAllocs()
payload := `
function init() {
$app.onGetAnime(function(e) {
// Do some work
if (e.anime.id === 1234) {
e.anime.id = 5678;
e.anime.title.english = "Modified Title";
e.anime.idMal = 9012;
}
e.next();
});
}
`
opts := DefaultTestPluginOptions()
opts.ID = "dummy-hook-benchmark"
opts.Payload = payload
opts.SetupHooks = true
_, _, runtimeManager, _, _, err := InitTestPlugin(b, opts)
require.NoError(b, err)
title := "Test Anime"
dummyEvent := &anilist_platform.GetAnimeEvent{
Anime: &anilist.BaseAnime{
ID: 1234,
Title: &anilist.BaseAnime_Title{
English: &title,
},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := hook.GlobalHookManager.OnGetAnime().Trigger(dummyEvent); err != nil {
b.Fatal(err)
}
}
runtimeManager.PrintPluginPoolMetrics(opts.ID)
}
// BenchmarkHookParallel measures parallel performance with a hook that does some work
func BenchmarkHookInvocationWithWorkParallel(b *testing.B) {
b.ReportAllocs()
payload := `
function init() {
$app.onGetAnime(function(e) {
// Do some work
if (e.anime.id === 1234) {
e.anime.id = 5678;
e.anime.title.english = "Modified Title";
e.anime.idMal = 9012;
}
e.next();
});
}
`
opts := DefaultTestPluginOptions()
opts.ID = "dummy-hook-benchmark"
opts.Payload = payload
opts.SetupHooks = true
_, _, runtimeManager, _, _, err := InitTestPlugin(b, opts)
require.NoError(b, err)
title := "Test Anime"
dummyEvent := &anilist_platform.GetAnimeEvent{
Anime: &anilist.BaseAnime{
ID: 1234,
Title: &anilist.BaseAnime_Title{
English: &title,
},
},
}
b.ResetTimer()
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if err := hook.GlobalHookManager.OnGetAnime().Trigger(dummyEvent); err != nil {
b.Fatal(err)
}
}
})
runtimeManager.PrintPluginPoolMetrics(opts.ID)
}
func BenchmarkNoHookInvocationWithWork(b *testing.B) {
b.ReportAllocs()
payload := `
function init() {
$app.onMissingEpisodes(function(e) {
// Do some work
if (e.anime.id === 1234) {
e.anime.id = 5678;
e.anime.title.english = "Modified Title";
e.anime.idMal = 9012;
}
e.next();
});
}
`
opts := DefaultTestPluginOptions()
opts.ID = "dummy-hook-benchmark"
opts.Payload = payload
opts.SetupHooks = true
_, _, runtimeManager, _, _, err := InitTestPlugin(b, opts)
require.NoError(b, err)
title := "Test Anime"
dummyEvent := &anilist_platform.GetAnimeEvent{
Anime: &anilist.BaseAnime{
ID: 1234,
Title: &anilist.BaseAnime_Title{
English: &title,
},
},
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := hook.GlobalHookManager.OnGetAnime().Trigger(dummyEvent); err != nil {
b.Fatal(err)
}
}
runtimeManager.PrintPluginPoolMetrics(opts.ID)
}

View File

@@ -0,0 +1,162 @@
package extension_repo_test
import (
"os"
"seanime/internal/extension"
hibikemanga "seanime/internal/extension/hibike/manga"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
"seanime/internal/extension_repo"
"seanime/internal/goja/goja_runtime"
"seanime/internal/util"
"testing"
"github.com/davecgh/go-spew/spew"
"github.com/stretchr/testify/require"
)
func TestGojaWithExtension(t *testing.T) {
runtimeManager := goja_runtime.NewManager(util.NewLogger())
// Get the script
filepath := "./goja_manga_test/my-manga-provider.ts"
fileB, err := os.ReadFile(filepath)
if err != nil {
t.Fatal(err)
}
ext := &extension.Extension{
ID: "my-manga-provider",
Name: "MyMangaProvider",
Version: "0.1.0",
ManifestURI: "",
Language: extension.LanguageTypescript,
Type: extension.TypeMangaProvider,
Description: "",
Author: "",
Payload: string(fileB),
}
// Create the provider
provider, _, err := extension_repo.NewGojaMangaProvider(ext, ext.Language, util.NewLogger(), runtimeManager)
require.NoError(t, err)
// Test the search function
searchResult, err := provider.Search(hibikemanga.SearchOptions{Query: "dandadan"})
require.NoError(t, err)
spew.Dump(searchResult)
// Should have a result with rating of 1
var dandadanRes *hibikemanga.SearchResult
for _, res := range searchResult {
if res.SearchRating == 1 {
dandadanRes = res
break
}
}
require.NotNil(t, dandadanRes)
spew.Dump(dandadanRes)
// Test the search function again
searchResult, err = provider.Search(hibikemanga.SearchOptions{Query: "boku no kokoro no yaibai"})
require.NoError(t, err)
require.GreaterOrEqual(t, len(searchResult), 1)
t.Logf("Search results: %d", len(searchResult))
// Test the findChapters function
chapters, err := provider.FindChapters("pYN47sZm") // Boku no Kokoro no Yabai Yatsu
require.NoError(t, err)
require.GreaterOrEqual(t, len(chapters), 100)
t.Logf("Chapters: %d", len(chapters))
// Test the findChapterPages function
pages, err := provider.FindChapterPages("WLxnx") // Boku no Kokoro no Yabai Yatsu - Chapter 1
require.NoError(t, err)
require.GreaterOrEqual(t, len(pages), 10)
for _, page := range pages {
t.Logf("Page: %s, Index: %d\n", page.URL, page.Index)
}
}
func TestGojaOnlinestreamExtension(t *testing.T) {
runtimeManager := goja_runtime.NewManager(util.NewLogger())
// Get the script
filepath := "./goja_animepahe/animepahe.ts"
fileB, err := os.ReadFile(filepath)
if err != nil {
t.Fatal(err)
}
ext := &extension.Extension{
ID: "animepahe",
Name: "Animepahe",
Version: "0.1.0",
ManifestURI: "",
Language: extension.LanguageTypescript,
Type: extension.TypeOnlinestreamProvider,
Description: "",
Author: "",
Payload: string(fileB),
}
// Create the provider
provider, _, err := extension_repo.NewGojaOnlinestreamProvider(ext, ext.Language, util.NewLogger(), runtimeManager)
require.NoError(t, err)
// Test the search function
searchResult, err := provider.Search(hibikeonlinestream.SearchOptions{Query: "dandadan"})
require.NoError(t, err)
spew.Dump(searchResult)
// Should have a result with rating of 1
var dandadanRes *hibikeonlinestream.SearchResult
dandadanRes = searchResult[0]
require.NotNil(t, dandadanRes)
// Test find episodes
episodes, err := provider.FindEpisodes(dandadanRes.ID)
require.NoError(t, err)
util.Spew(episodes)
}
func TestGojaOnlinestreamExtension2(t *testing.T) {
runtimeManager := goja_runtime.NewManager(util.NewLogger())
// Get the script
filepath := "./goja_animepahe/animepahe.ts"
fileB, err := os.ReadFile(filepath)
if err != nil {
t.Fatal(err)
}
ext := &extension.Extension{
ID: "animepahe",
Name: "Animepahe",
Version: "0.1.0",
ManifestURI: "",
Language: extension.LanguageTypescript,
Type: extension.TypeOnlinestreamProvider,
Description: "",
Author: "",
Payload: string(fileB),
}
// Create the provider
provider, _, err := extension_repo.NewGojaOnlinestreamProvider(ext, ext.Language, util.NewLogger(), runtimeManager)
require.NoError(t, err)
// Find first episode server
server, err := provider.FindEpisodeServer(&hibikeonlinestream.EpisodeDetails{
Provider: "animepahe",
ID: "0ba8e30b98b1be6d19c8ac73ae11372911e62424ef454f05052ef6af8f01f13b$269b021d-a893-4471-04e7-b8933d81bda1",
Number: 1,
URL: "",
Title: "",
}, "kwik")
require.NoError(t, err)
spew.Dump(server)
}

View File

@@ -0,0 +1,130 @@
package extension_repo
import (
"context"
"seanime/internal/extension"
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/goja/goja_runtime"
"seanime/internal/util"
"seanime/internal/util/comparison"
"github.com/rs/zerolog"
)
type GojaMangaProvider struct {
*gojaProviderBase
}
func NewGojaMangaProvider(ext *extension.Extension, language extension.Language, logger *zerolog.Logger, runtimeManager *goja_runtime.Manager) (hibikemanga.Provider, *GojaMangaProvider, error) {
base, err := initializeProviderBase(ext, language, logger, runtimeManager)
if err != nil {
return nil, nil, err
}
provider := &GojaMangaProvider{
gojaProviderBase: base,
}
return provider, provider, nil
}
func (g *GojaMangaProvider) GetSettings() (ret hibikemanga.Settings) {
defer util.HandlePanicInModuleThen(g.ext.ID+".GetSettings", func() {
ret = hibikemanga.Settings{}
})
method, err := g.callClassMethod(context.Background(), "getSettings")
if err != nil {
return
}
err = g.unmarshalValue(method, &ret)
if err != nil {
return
}
return
}
func (g *GojaMangaProvider) Search(opts hibikemanga.SearchOptions) (ret []*hibikemanga.SearchResult, err error) {
defer util.HandlePanicInModuleWithError(g.ext.ID+".Search", &err)
method, err := g.callClassMethod(context.Background(), "search", structToMap(opts))
promiseRes, err := g.waitForPromise(method)
if err != nil {
return nil, err
}
err = g.unmarshalValue(promiseRes, &ret)
if err != nil {
return nil, err
}
// Set the provider & search rating
for i := range ret {
ret[i].Provider = g.ext.ID
synonyms := ret[i].Synonyms
if synonyms == nil {
continue
}
compTitles := []*string{&ret[i].Title}
for _, syn := range synonyms {
compTitles = append(compTitles, &syn)
}
compRes, ok := comparison.FindBestMatchWithSorensenDice(&opts.Query, compTitles)
if ok {
ret[i].SearchRating = compRes.Rating
}
}
return ret, nil
}
func (g *GojaMangaProvider) FindChapters(id string) (ret []*hibikemanga.ChapterDetails, err error) {
defer util.HandlePanicInModuleWithError(g.ext.ID+".FindChapters", &err)
method, err := g.callClassMethod(context.Background(), "findChapters", id)
promiseRes, err := g.waitForPromise(method)
if err != nil {
return nil, err
}
err = g.unmarshalValue(promiseRes, &ret)
if err != nil {
return nil, err
}
// Set the provider
for i := range ret {
ret[i].Provider = g.ext.ID
}
return ret, nil
}
func (g *GojaMangaProvider) FindChapterPages(id string) (ret []*hibikemanga.ChapterPage, err error) {
defer util.HandlePanicInModuleWithError(g.ext.ID+".FindChapterPages", &err)
method, err := g.callClassMethod(context.Background(), "findChapterPages", id)
promiseRes, err := g.waitForPromise(method)
if err != nil {
return nil, err
}
err = g.unmarshalValue(promiseRes, &ret)
if err != nil {
return nil, err
}
// Set the provider
for i := range ret {
ret[i].Provider = g.ext.ID
}
return ret, nil
}

View File

@@ -0,0 +1,43 @@
declare type SearchResult = {
id: string
title: string
synonyms?: string[]
year?: number
image?: string
}
declare type ChapterDetails = {
id: string
url: string
title: string
chapter: string
index: number
scanlator?: string
language?: string
rating?: number
updatedAt?: string
}
declare type ChapterPage = {
url: string
index: number
headers: { [key: string]: string }
}
declare type QueryOptions = {
query: string
year?: number
}
declare type Settings = {
supportsMultiLanguage?: boolean
supportsMultiScanlator?: boolean
}
declare abstract class MangaProvider {
search(opts: QueryOptions): Promise<SearchResult[]>
findChapters(id: string): Promise<ChapterDetails[]>
findChapterPages(id: string): Promise<ChapterPage[]>
getSettings(): Settings
}

View File

@@ -0,0 +1,222 @@
/// <reference path="./manga-provider.d.ts" />
class Provider {
private api = "https://api.comick.fun"
getSettings(): Settings {
return {
supportsMultiLanguage: true,
supportsMultiScanlator: false,
}
}
async search(opts: QueryOptions): Promise<SearchResult[]> {
console.log(this.api, opts.query)
const requestRes = await fetch(`${this.api}/v1.0/search?q=${encodeURIComponent(opts.query)}&limit=25&page=1`, {
method: "get",
})
const comickRes = await requestRes.json() as ComickSearchResult[]
const ret: SearchResult[] = []
for (const res of comickRes) {
let cover: any = res.md_covers ? res.md_covers[0] : null
if (cover && cover.b2key != undefined) {
cover = "https://meo.comick.pictures/" + cover.b2key
}
ret.push({
id: res.hid,
title: res.title ?? res.slug,
synonyms: res.md_titles?.map(t => t.title) ?? {},
year: res.year ?? 0,
image: cover,
})
}
console.log(ret[0])
console.error("test", ret[0].id)
return ret
}
async findChapters(id: string): Promise<ChapterDetails[]> {
console.log("Fetching chapters", id)
const chapterList: ChapterDetails[] = []
const data = (await (await fetch(`${this.api}/comic/${id}/chapters?lang=en&page=0&limit=1000000`))?.json()) as { chapters: ComickChapter[] }
const chapters: ChapterDetails[] = []
for (const chapter of data.chapters) {
if (!chapter.chap) {
continue
}
let title = "Chapter " + this.padNum(chapter.chap, 2) + " "
if (title.length === 0) {
if (!chapter.title) {
title = "Oneshot"
} else {
title = chapter.title
}
}
let canPush = true
for (let i = 0; i < chapters.length; i++) {
if (chapters[i].title?.trim() === title?.trim()) {
canPush = false
}
}
if (canPush) {
if (chapter.lang === "en") {
chapters.push({
url: `${this.api}/comic/${id}/chapter/${chapter.hid}`,
index: 0,
id: chapter.hid,
title: title?.trim(),
chapter: chapter.chap,
rating: chapter.up_count - chapter.down_count,
updatedAt: chapter.updated_at,
})
}
}
}
chapters.reverse()
for (let i = 0; i < chapters.length; i++) {
chapters[i].index = i
}
console.log(chapters.map(c => c.chapter))
return chapters
}
async findChapterPages(id: string): Promise<ChapterPage[]> {
const data = (await (await fetch(`${this.api}/chapter/${id}`))?.json()) as {
chapter: { md_images: { vol: any; w: number; h: number; b2key: string }[] }
}
const pages: ChapterPage[] = []
data.chapter.md_images.map((image, index: number) => {
pages.push({
url: `https://meo.comick.pictures/${image.b2key}?width=${image.w}`,
index: index,
headers: {},
})
})
return pages
}
padNum(number: string, places: number): string {
let range = number.split("-")
range = range.map((chapter) => {
chapter = chapter.trim()
const digits = chapter.split(".")[0].length
return "0".repeat(Math.max(0, places - digits)) + chapter
})
return range.join("-")
}
}
interface ComickSearchResult {
title: string;
id: number;
hid: string;
slug: string;
year?: number;
rating: string;
rating_count: number;
follow_count: number;
user_follow_count: number;
content_rating: string;
created_at: string;
demographic: number;
md_titles: { title: string }[];
md_covers: { vol: any; w: number; h: number; b2key: string }[];
highlight: string;
}
interface Comic {
id: number;
hid: string;
title: string;
country: string;
status: number;
links: {
al: string;
ap: string;
bw: string;
kt: string;
mu: string;
amz: string;
cdj: string;
ebj: string;
mal: string;
raw: string;
};
last_chapter: any;
chapter_count: number;
demographic: number;
hentai: boolean;
user_follow_count: number;
follow_rank: number;
comment_count: number;
follow_count: number;
desc: string;
parsed: string;
slug: string;
mismatch: any;
year: number;
bayesian_rating: any;
rating_count: number;
content_rating: string;
translation_completed: boolean;
relate_from: Array<any>;
mies: any;
md_titles: { title: string }[];
md_comic_md_genres: { md_genres: { name: string; type: string | null; slug: string; group: string } }[];
mu_comics: {
licensed_in_english: any;
mu_comic_categories: {
mu_categories: { title: string; slug: string };
positive_vote: number;
negative_vote: number;
}[];
};
md_covers: { vol: any; w: number; h: number; b2key: string }[];
iso639_1: string;
lang_name: string;
lang_native: string;
}
interface ComickChapter {
id: number;
chap: string;
title: string;
vol: string | null;
lang: string;
created_at: string;
updated_at: string;
up_count: number;
down_count: number;
group_name: any;
hid: string;
identities: any;
md_chapter_groups: { md_groups: { title: string; slug: string } }[];
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"es2015",
"dom"
],
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}

View File

@@ -0,0 +1,128 @@
package extension_repo
import (
"context"
"fmt"
"seanime/internal/extension"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
"seanime/internal/goja/goja_runtime"
"seanime/internal/util"
"github.com/rs/zerolog"
)
type GojaOnlinestreamProvider struct {
*gojaProviderBase
}
func NewGojaOnlinestreamProvider(ext *extension.Extension, language extension.Language, logger *zerolog.Logger, runtimeManager *goja_runtime.Manager) (hibikeonlinestream.Provider, *GojaOnlinestreamProvider, error) {
base, err := initializeProviderBase(ext, language, logger, runtimeManager)
if err != nil {
return nil, nil, err
}
provider := &GojaOnlinestreamProvider{
gojaProviderBase: base,
}
return provider, provider, nil
}
func (g *GojaOnlinestreamProvider) GetEpisodeServers() (ret []string) {
ret = make([]string, 0)
method, err := g.callClassMethod(context.Background(), "getEpisodeServers")
promiseRes, err := g.waitForPromise(method)
if err != nil {
return
}
err = g.unmarshalValue(promiseRes, &ret)
if err != nil {
return
}
return
}
func (g *GojaOnlinestreamProvider) Search(opts hibikeonlinestream.SearchOptions) (ret []*hibikeonlinestream.SearchResult, err error) {
defer util.HandlePanicInModuleWithError(g.ext.ID+".Search", &err)
method, err := g.callClassMethod(context.Background(), "search", structToMap(opts))
if err != nil {
return nil, fmt.Errorf("failed to call search method: %w", err)
}
promiseRes, err := g.waitForPromise(method)
if err != nil {
return nil, fmt.Errorf("failed to wait for promise: %w", err)
}
ret = make([]*hibikeonlinestream.SearchResult, 0)
err = g.unmarshalValue(promiseRes, &ret)
if err != nil {
return nil, fmt.Errorf("failed to unmarshal search results: %w", err)
}
return ret, nil
}
func (g *GojaOnlinestreamProvider) FindEpisodes(id string) (ret []*hibikeonlinestream.EpisodeDetails, err error) {
defer util.HandlePanicInModuleWithError(g.ext.ID+".FindEpisodes", &err)
method, err := g.callClassMethod(context.Background(), "findEpisodes", id)
promiseRes, err := g.waitForPromise(method)
if err != nil {
return nil, err
}
err = g.unmarshalValue(promiseRes, &ret)
if err != nil {
return nil, err
}
for _, episode := range ret {
episode.Provider = g.ext.ID
}
return
}
func (g *GojaOnlinestreamProvider) FindEpisodeServer(episode *hibikeonlinestream.EpisodeDetails, server string) (ret *hibikeonlinestream.EpisodeServer, err error) {
defer util.HandlePanicInModuleWithError(g.ext.ID+".FindEpisodeServer", &err)
method, err := g.callClassMethod(context.Background(), "findEpisodeServer", structToMap(episode), server)
promiseRes, err := g.waitForPromise(method)
if err != nil {
return nil, err
}
err = g.unmarshalValue(promiseRes, &ret)
if err != nil {
return nil, err
}
ret.Provider = g.ext.ID
return
}
func (g *GojaOnlinestreamProvider) GetSettings() (ret hibikeonlinestream.Settings) {
defer util.HandlePanicInModuleThen(g.ext.ID+".GetSettings", func() {
ret = hibikeonlinestream.Settings{}
})
method, err := g.callClassMethod(context.Background(), "getSettings")
if err != nil {
return
}
err = g.unmarshalValue(method, &ret)
if err != nil {
return
}
return
}

View File

@@ -0,0 +1,250 @@
/// <reference path="./onlinestream-provider.d.ts" />
/// <reference path="../../goja/goja_bindings/js/core.d.ts" />
class ProviderN {
api = "https://anitaku.to"
ajaxURL = "https://ajax.gogocdn.net"
getSettings(): Settings {
return {
episodeServers: ["gogocdn", "vidstreaming", "streamsb"],
supportsDub: true,
}
}
async search(opts: SearchOptions): Promise<SearchResult[]> {
const request = await fetch(`${this.api}/search.html?keyword=${encodeURIComponent(opts.query)}`)
if (!request.ok) {
return []
}
const data = await request.text()
const results: SearchResult[] = []
const $ = LoadDoc(data)
$("ul.items > li").each((_, el) => {
const title = el.find("p.name a").text().trim()
const id = el.find("div.img a").attr("href")
if (!id) {
return
}
results.push({
id: id,
title: title,
url: id,
subOrDub: "sub",
})
})
return results
}
async findEpisodes(id: string): Promise<EpisodeDetails[]> {
const episodes: EpisodeDetails[] = []
const data = await (await fetch(`${this.api}${id}`)).text()
const $ = LoadDoc(data)
const epStart = $("#episode_page > li").first().find("a").attr("ep_start")
const epEnd = $("#episode_page > li").last().find("a").attr("ep_end")
const movieId = $("#movie_id").attr("value")
const alias = $("#alias_anime").attr("value")
const req = await (await fetch(`${this.ajaxURL}/ajax/load-list-episode?ep_start=${epStart}&ep_end=${epEnd}&id=${movieId}&default_ep=${0}&alias=${alias}`)).text()
const $$ = LoadDoc(req)
$$("#episode_related > li").each((i, el) => {
episodes?.push({
id: el.find("a").attr("href")?.trim() ?? "",
url: el.find("a").attr("href")?.trim() ?? "",
number: parseFloat(el.find(`div.name`).text().replace("EP ", "")),
title: el.find(`div.name`).text(),
})
})
return episodes.reverse()
}
async findEpisodeServer(episode: EpisodeDetails, _server: string): Promise<EpisodeServer> {
let server = "gogocdn"
if (_server !== "default") {
server = _server
}
const episodeServer: EpisodeServer = {
server: server,
headers: {},
videoSources: [],
}
if (episode.id.startsWith("http")) {
const serverURL = episode.id
try {
const es = await new Extractor(serverURL, episodeServer).extract(server)
if (es) {
return es
}
}
catch (e) {
console.error(e)
return episodeServer
}
return episodeServer
}
const data = await (await fetch(`${this.api}${episode.id}`)).text()
const $ = LoadDoc(data)
let serverURL: string
switch (server) {
case "gogocdn":
serverURL = `${$("#load_anime > div > div > iframe").attr("src")}`
break
case "vidstreaming":
serverURL = `${$("div.anime_video_body > div.anime_muti_link > ul > li.vidcdn > a").attr("data-video")}`
break
case "streamsb":
serverURL = $("div.anime_video_body > div.anime_muti_link > ul > li.streamsb > a").attr("data-video")!
break
default:
serverURL = `${$("#load_anime > div > div > iframe").attr("src")}`
break
}
episode.id = serverURL
return await this.findEpisodeServer(episode, server)
}
}
class Extractor {
private url: string
private result: EpisodeServer
constructor(url: string, result: EpisodeServer) {
this.url = url
this.result = result
}
async extract(server: string): Promise<EpisodeServer | undefined> {
try {
switch (server) {
case "gogocdn":
console.log("GogoCDN extraction")
return await this.extractGogoCDN(this.url, this.result)
case "vidstreaming":
return await this.extractGogoCDN(this.url, this.result)
default:
return undefined
}
}
catch (e) {
console.error(e)
return undefined
}
}
public async extractGogoCDN(url: string, result: EpisodeServer): Promise<EpisodeServer> {
const keys = {
key: CryptoJS.enc.Utf8.parse("37911490979715163134003223491201"),
secondKey: CryptoJS.enc.Utf8.parse("54674138327930866480207815084989"),
iv: CryptoJS.enc.Utf8.parse("3134003223491201"),
}
function generateEncryptedAjaxParams(id: string) {
const encryptedKey = CryptoJS.AES.encrypt(id, keys.key, {
iv: keys.iv,
})
const scriptValue = $("script[data-name='episode']").data("value")!
const decryptedToken = CryptoJS.AES.decrypt(scriptValue, keys.key, {
iv: keys.iv,
}).toString(CryptoJS.enc.Utf8)
return `id=${encryptedKey.toString(CryptoJS.enc.Base64)}&alias=${id}&${decryptedToken}`
}
function decryptAjaxData(encryptedData: string) {
const decryptedData = CryptoJS.AES.decrypt(encryptedData, keys.secondKey, {
iv: keys.iv,
}).toString(CryptoJS.enc.Utf8)
return JSON.parse(decryptedData)
}
const req = await fetch(url)
const $ = LoadDoc(await req.text())
const encryptedParams = generateEncryptedAjaxParams(new URL(url).searchParams.get("id") ?? "")
const xmlHttpUrl = `${new URL(url).protocol}//${new URL(url).hostname}/encrypt-ajax.php?${encryptedParams}`
const encryptedData = await fetch(xmlHttpUrl, {
headers: {
"X-Requested-With": "XMLHttpRequest",
},
})
const decryptedData = await decryptAjaxData(((await encryptedData.json()) as { data: any })?.data)
if (!decryptedData.source) throw new Error("No source found. Try a different server.")
if (decryptedData.source[0].file.includes(".m3u8")) {
const resResult = await fetch(decryptedData.source[0].file.toString())
const resolutions = (await resResult.text()).match(/(RESOLUTION=)(.*)(\s*?)(\s*.*)/g)
resolutions?.forEach((res: string) => {
const index = decryptedData.source[0].file.lastIndexOf("/")
const quality = res.split("\n")[0].split("x")[1].split(",")[0]
const url = decryptedData.source[0].file.slice(0, index)
result.videoSources.push({
url: url + "/" + res.split("\n")[1],
quality: quality + "p",
subtitles: [],
type: "m3u8",
})
})
decryptedData.source.forEach((source: any) => {
result.videoSources.push({
url: source.file,
quality: "default",
subtitles: [],
type: "m3u8",
})
})
} else {
decryptedData.source.forEach((source: any) => {
result.videoSources.push({
url: source.file,
quality: source.label.split(" ")[0] + "p",
subtitles: [],
type: "m3u8",
})
})
decryptedData.source_bk.forEach((source: any) => {
result.videoSources.push({
url: source.file,
quality: "backup",
subtitles: [],
type: "m3u8",
})
})
}
return result
}
}

View File

@@ -0,0 +1,79 @@
declare type SearchResult = {
id: string
title: string
url: string
subOrDub: SubOrDub
}
declare type SubOrDub = "sub" | "dub" | "both"
declare type EpisodeDetails = {
id: string
number: number
url: string
title?: string
}
declare type EpisodeServer = {
server: string
headers: { [key: string]: string }
videoSources: VideoSource[]
}
declare type VideoSourceType = "mp4" | "m3u8"
declare type VideoSource = {
url: string
type: VideoSourceType
quality: string
subtitles: VideoSubtitle[]
}
declare type VideoSubtitle = {
id: string
url: string
language: string
isDefault: boolean
}
declare interface Media {
id: number
idMal?: number
status?: string
format?: string
englishTitle?: string
romajiTitle?: string
episodeCount?: number
absoluteSeasonOffset?: number
synonyms: string[]
isAdult: boolean
startDate?: FuzzyDate
}
declare interface FuzzyDate {
year: number
month?: number
day?: number
}
declare type SearchOptions = {
media: Media
query: string
dub: boolean
year?: number
}
declare type Settings = {
episodeServers: string[]
supportsDub: boolean
}
declare abstract class AnimeProvider {
search(opts: SearchOptions): Promise<SearchResult[]>
findEpisodes(id: string): Promise<EpisodeDetails[]>
findEpisodeServer(episode: EpisodeDetails, server: string): Promise<EpisodeServer>
getSettings(): Settings
}

View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"esnext",
"dom"
],
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"downlevelIteration": true
}
}

View File

@@ -0,0 +1,419 @@
package extension_repo
import (
"context"
"fmt"
"reflect"
"seanime/internal/events"
"seanime/internal/extension"
goja_bindings "seanime/internal/goja/goja_bindings"
"seanime/internal/goja/goja_runtime"
"seanime/internal/hook"
"seanime/internal/plugin"
plugin_ui "seanime/internal/plugin/ui"
"seanime/internal/util"
goja_util "seanime/internal/util/goja"
"slices"
"strings"
"github.com/dop251/goja"
"github.com/dop251/goja/parser"
"github.com/rs/zerolog"
"github.com/samber/lo"
)
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Load Plugin
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (r *Repository) loadPluginExtension(ext *extension.Extension) (err error) {
defer util.HandlePanicInModuleWithError("extension_repo/loadPluginExtension", &err)
_, gojaExt, err := NewGojaPlugin(ext, ext.Language, r.logger, r.gojaRuntimeManager, r.wsEventManager)
if err != nil {
return err
}
// Add the extension to the map
retExt := extension.NewPluginExtension(ext)
r.extensionBank.Set(ext.ID, retExt)
r.gojaExtensions.Set(ext.ID, gojaExt)
return
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Plugin
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type GojaPlugin struct {
ext *extension.Extension
logger *zerolog.Logger
pool *goja_runtime.Pool
runtimeManager *goja_runtime.Manager
store *plugin.Store[string, any]
storage *plugin.Storage
ui *plugin_ui.UI
scheduler *goja_util.Scheduler
loader *goja.Runtime
unbindHookFuncs []func()
interrupted bool
wsEventManager events.WSEventManagerInterface
}
func (p *GojaPlugin) GetExtension() *extension.Extension {
return p.ext
}
func (p *GojaPlugin) PutVM(vm *goja.Runtime) {
p.pool.Put(vm)
}
// ClearInterrupt stops the UI VM and other modules.
// It is called when the extension is unloaded.
func (p *GojaPlugin) ClearInterrupt() {
if p.interrupted {
return
}
p.interrupted = true
p.logger.Debug().Msg("plugin: Interrupting plugin")
// Unload the UI
if p.ui != nil {
p.ui.Unload(false)
}
// Clear the interrupt
if p.loader != nil {
p.loader.ClearInterrupt()
}
// Stop the store
if p.store != nil {
p.store.Stop()
}
// Stop the storage
if p.storage != nil {
p.storage.Stop()
}
// Delete the plugin pool
if p.runtimeManager != nil {
p.runtimeManager.DeletePluginPool(p.ext.ID)
}
p.logger.Debug().Msgf("plugin: Unbinding hooks (%d)", len(p.unbindHookFuncs))
// Unbind all hooks
for _, unbindHookFunc := range p.unbindHookFuncs {
unbindHookFunc()
}
// Run garbage collection
// runtime.GC()
p.logger.Debug().Msg("plugin: Interrupted plugin")
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func NewGojaPlugin(
ext *extension.Extension,
language extension.Language,
mLogger *zerolog.Logger,
runtimeManager *goja_runtime.Manager,
wsEventManager events.WSEventManagerInterface,
) (*GojaPlugin, GojaExtension, error) {
logger := lo.ToPtr(mLogger.With().Str("id", ext.ID).Logger())
defer util.HandlePanicInModuleThen("extension_repo/NewGojaPlugin", func() {
logger.Error().Msg("extensions: Failed to create Goja plugin")
})
logger.Trace().Msg("extensions: Loading plugin")
// 1. Create a new plugin instance
p := &GojaPlugin{
ext: ext,
logger: logger,
runtimeManager: runtimeManager,
store: plugin.NewStore[string, any](nil), // Create a store (must be stopped when unloading)
scheduler: goja_util.NewScheduler(), // Create a scheduler (must be stopped when unloading)
ui: nil, // To be initialized
loader: goja.New(), // To be initialized
unbindHookFuncs: []func(){},
wsEventManager: wsEventManager,
}
// 2. Create a new loader for the plugin
// Bind shared APIs to the loader
ShareBinds(p.loader, logger)
BindUserConfig(p.loader, ext, logger)
// Bind hooks to the loader
p.bindHooks()
// 3. Convert the payload to JavaScript if necessary
source := ext.Payload
if language == extension.LanguageTypescript {
var err error
source, err = JSVMTypescriptToJS(ext.Payload)
if err != nil {
logger.Error().Err(err).Msg("extensions: Failed to convert typescript")
return nil, nil, err
}
}
// 4. Create a new pool for the plugin hooks (must be deleted when unloading)
var err error
p.pool, err = runtimeManager.GetOrCreatePrivatePool(ext.ID, func() *goja.Runtime {
runtime := goja.New()
ShareBinds(runtime, logger)
BindUserConfig(runtime, ext, logger)
p.BindPluginAPIs(runtime, logger)
return runtime
})
if err != nil {
return nil, nil, err
}
//////// UI
// 5. Create a new VM for the UI (The UI uses a single VM instead of a pool in order to share state)
// (must be interrupted when unloading)
uiVM := goja.New()
uiVM.SetParserOptions(parser.WithDisableSourceMaps)
// Bind shared APIs
ShareBinds(uiVM, logger)
BindUserConfig(uiVM, ext, logger)
// Bind the store to the UI VM
p.BindPluginAPIs(uiVM, logger)
// Create a new UI instance
p.ui = plugin_ui.NewUI(plugin_ui.NewUIOptions{
Extension: ext,
Logger: logger,
VM: uiVM,
WSManager: wsEventManager,
Scheduler: p.scheduler,
})
go func() {
<-p.ui.Destroyed()
p.logger.Warn().Msg("plugin: UI interrupted, interrupting plugin")
p.ClearInterrupt()
}()
// 6. Bind the UI API to the loader so the plugin can register a new UI
// $ui.register(callback)
uiObj := p.loader.NewObject()
_ = uiObj.Set("register", p.ui.Register)
_ = p.loader.Set("$ui", uiObj)
// 7. Load the plugin source code in the VM (nothing will execute)
_, err = p.loader.RunString(source)
if err != nil {
logger.Error().Err(err).Msg("extensions: Failed to load plugin")
return nil, nil, err
}
// 8. Get and call the init function to actually run the plugin
if initFunc := p.loader.Get("init"); initFunc != nil && initFunc != goja.Undefined() {
_, err = p.loader.RunString("init();")
if err != nil {
logger.Error().Err(err).Msg("extensions: Failed to run plugin")
return nil, nil, fmt.Errorf("failed to run plugin: %w", err)
}
logger.Debug().Msg("extensions: Plugin initialized")
}
return p, p, nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// BindPluginAPIs adds plugin-specific APIs
func (p *GojaPlugin) BindPluginAPIs(vm *goja.Runtime, logger *zerolog.Logger) {
// Bind the app context
//_ = vm.Set("$ctx", hook.GlobalHookManager.AppContext())
fm := FieldMapper{}
vm.SetFieldNameMapper(fm)
// Bind the store
p.store.Bind(vm, p.scheduler)
// Bind mutable bindings
goja_util.BindMutable(vm)
// Bind await bindings
goja_util.BindAwait(vm)
// Bind console bindings
_ = goja_bindings.BindConsoleWithWS(p.ext, vm, logger, p.wsEventManager)
// Bind the app context
plugin.GlobalAppContext.BindApp(vm, logger, p.ext)
// Bind permission-specific APIs
if p.ext.Plugin != nil {
for _, permission := range p.ext.Plugin.Permissions.Scopes {
switch permission {
case extension.PluginPermissionStorage: // Storage
p.storage = plugin.GlobalAppContext.BindStorage(vm, logger, p.ext, p.scheduler)
case extension.PluginPermissionAnilist: // Anilist
plugin.GlobalAppContext.BindAnilist(vm, logger, p.ext)
case extension.PluginPermissionDatabase: // Database
plugin.GlobalAppContext.BindDatabase(vm, logger, p.ext)
case extension.PluginPermissionSystem: // System
plugin.GlobalAppContext.BindSystem(vm, logger, p.ext, p.scheduler)
}
}
}
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// bindHooks sets up hooks for the Goja runtime
func (p *GojaPlugin) bindHooks() {
// Create a FieldMapper instance for method name mapping
fm := FieldMapper{}
// Get the type of the global hook manager
appType := reflect.TypeOf(hook.GlobalHookManager)
// Get the value of the global hook manager
appValue := reflect.ValueOf(hook.GlobalHookManager)
// Get the total number of methods in the global hook manager
// i.e. OnGetAnime, OnGetAnimeDetails, etc.
totalMethods := appType.NumMethod()
// Define methods to exclude from binding
excludeHooks := []string{"OnServe", ""}
// Create a new JavaScript object to hold the hooks ($app)
appObj := p.loader.NewObject()
// Iterate through all methods of the global hook manager
// i.e. OnGetAnime, OnGetAnimeDetails, etc.
for i := 0; i < totalMethods; i++ {
// Get the method at the current index
method := appType.Method(i)
// Check that the method name starts with "On" and is not excluded
if !strings.HasPrefix(method.Name, "On") || slices.Contains(excludeHooks, method.Name) {
continue // Skip to the next method if not a hook or excluded
}
// Map the method name to a JavaScript-friendly name
// e.g. OnGetAnime -> onGetAnime
jsName := fm.MethodName(appType, method)
// Set the method on the app object with a callback function
// e.g. $app.onGetAnime(callback, "tag1", "tag2")
appObj.Set(jsName, func(callback string, tags ...string) {
// Create a wrapper JavaScript function that calls the provided callback
// This is necessary because the callback will be called with the provided args
callback = `function(e) { return (` + callback + `).call(undefined, e); }`
// Compile the callback into a Goja program
pr := goja.MustCompile("", "{("+callback+").apply(undefined, __args)}", true)
// Prepare the tags as reflect.Values for method invocation
tagsAsValues := make([]reflect.Value, len(tags))
for i, tag := range tags {
tagsAsValues[i] = reflect.ValueOf(tag)
}
// Get the hook function from the global hook manager and invokes it with the provided tags
// The invokation returns a hook instance
// i.e. OnTaggedHook(tags...) -> TaggedHook / OnHook() -> Hook
hookInstance := appValue.MethodByName(method.Name).Call(tagsAsValues)[0]
// Get the BindFunc method from the hook instance
hookBindFunc := hookInstance.MethodByName("BindFunc")
unbindHookFunc := hookInstance.MethodByName("Unbind")
// Get the expected handler type for the hook
// i.e. func(e *hook_resolver.Resolver) error
handlerType := hookBindFunc.Type().In(0)
// Create a new handler function for the hook
// - returns a new handler of the given handlerType that wraps the function
handler := reflect.MakeFunc(handlerType, func(args []reflect.Value) (results []reflect.Value) {
// Prepare arguments for the handler
handlerArgs := make([]any, len(args))
// var err error
// if p.interrupted {
// return []reflect.Value{reflect.ValueOf(&err).Elem()}
// }
// Run the handler in an isolated "executor" runtime for concurrency
err := p.runtimeManager.Run(context.Background(), p.ext.ID, func(executor *goja.Runtime) error {
// Set the field name mapper for the executor
executor.SetFieldNameMapper(fm)
// Convert each argument (event property) to the appropriate type
for i, arg := range args {
handlerArgs[i] = arg.Interface()
}
// Set the global variable $ctx in the executor
// executor.Set("$$app", plugin.GlobalAppContext)
executor.Set("__args", handlerArgs)
// Execute the handler program
res, err := executor.RunProgram(pr)
// Clear the __args variable for this executor
executor.Set("__args", goja.Undefined())
// executor.Set("$ctx", goja.Undefined())
// Check for returned Go error value
if res != nil {
if resErr, ok := res.Export().(error); ok {
return resErr
}
}
return normalizeException(err)
})
// Return the error as a reflect.Value
return []reflect.Value{reflect.ValueOf(&err).Elem()}
})
// Bind the hook if the plugin is not interrupted
if p.interrupted {
return
}
// Register the wrapped hook handler
callRet := hookBindFunc.Call([]reflect.Value{handler})
// Get the ID from the return value
id, ok := callRet[0].Interface().(string)
if ok {
p.unbindHookFuncs = append(p.unbindHookFuncs, func() {
p.logger.Trace().Str("id", p.ext.ID).Msgf("plugin: Unbinding hook %s", id)
unbindHookFunc.Call([]reflect.Value{reflect.ValueOf(id)})
})
}
})
}
// Set the $app object in the loader for JavaScript access
p.loader.Set("$app", appObj)
}
// normalizeException checks if the provided error is a goja.Exception
// and attempts to return its underlying Go error.
//
// note: using just goja.Exception.Unwrap() is insufficient and may falsely result in nil.
func normalizeException(err error) error {
if err == nil {
return nil
}
jsException, ok := err.(*goja.Exception)
if !ok {
return err // no exception
}
switch v := jsException.Value().Export().(type) {
case error:
err = v
case map[string]any: // goja.GoError
if vErr, ok := v["value"].(error); ok {
err = vErr
}
}
return err
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,798 @@
package extension_repo
import (
"fmt"
"net/http"
"net/http/httptest"
"seanime/internal/api/anilist"
"seanime/internal/api/metadata"
"seanime/internal/continuity"
"seanime/internal/database/db"
"seanime/internal/events"
"seanime/internal/extension"
"seanime/internal/goja/goja_runtime"
"seanime/internal/hook"
"seanime/internal/library/fillermanager"
"seanime/internal/library/playbackmanager"
"seanime/internal/mediaplayers/mediaplayer"
"seanime/internal/mediaplayers/mpv"
"seanime/internal/platforms/anilist_platform"
"seanime/internal/plugin"
"seanime/internal/test_utils"
"seanime/internal/util"
"seanime/internal/util/filecache"
"testing"
"time"
"github.com/rs/zerolog"
"github.com/samber/lo"
"github.com/stretchr/testify/require"
)
var (
testDocumentsDir = "/Users/rahim/Documents"
testDocumentCollectionDir = "/Users/rahim/Documents/collection"
testVideoPath = "/Users/rahim/Documents/collection/Bocchi the Rock/[ASW] Bocchi the Rock! - 01 [1080p HEVC][EDC91675].mkv"
tempTestDir = "$TEMP/test"
)
// TestPluginOptions contains options for initializing a test plugin
type TestPluginOptions struct {
ID string
Payload string
Language extension.Language
Permissions extension.PluginPermissions
PoolSize int
SetupHooks bool
}
// DefaultTestPluginOptions returns default options for a test plugin
func DefaultTestPluginOptions() TestPluginOptions {
return TestPluginOptions{
ID: "dummy-plugin",
Payload: "",
Language: extension.LanguageJavascript,
Permissions: extension.PluginPermissions{},
PoolSize: 15,
SetupHooks: true,
}
}
// InitTestPlugin initializes a test plugin with the given options
func InitTestPlugin(t testing.TB, opts TestPluginOptions) (*GojaPlugin, *zerolog.Logger, *goja_runtime.Manager, *anilist_platform.AnilistPlatform, events.WSEventManagerInterface, error) {
if opts.SetupHooks {
test_utils.SetTwoLevelDeep()
if tPtr, ok := t.(*testing.T); ok {
test_utils.InitTestProvider(tPtr, test_utils.Anilist())
}
}
ext := &extension.Extension{
ID: opts.ID,
Payload: opts.Payload,
Language: opts.Language,
Plugin: &extension.PluginManifest{},
}
if len(opts.Permissions.Scopes) > 0 {
ext.Plugin = &extension.PluginManifest{
Permissions: opts.Permissions,
}
}
ext.Plugin.Permissions.Allow = opts.Permissions.Allow
logger := util.NewLogger()
wsEventManager := events.NewMockWSEventManager(logger)
anilistClient := anilist.NewMockAnilistClient()
anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger).(*anilist_platform.AnilistPlatform)
// Initialize hook manager if needed
if opts.SetupHooks {
hm := hook.NewHookManager(hook.NewHookManagerOptions{Logger: logger})
hook.SetGlobalHookManager(hm)
}
manager := goja_runtime.NewManager(logger)
database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger)
require.NoError(t, err)
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
Database: database,
AnilistPlatform: anilistPlatform,
WSEventManager: wsEventManager,
AnimeLibraryPaths: &[]string{},
PlaybackManager: &playbackmanager.PlaybackManager{},
})
plugin, _, err := NewGojaPlugin(ext, opts.Language, logger, manager, wsEventManager)
return plugin, logger, manager, anilistPlatform, wsEventManager, err
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaPluginAnime(t *testing.T) {
payload := `
function init() {
$ui.register(async (ctx) => {
try {
console.log("Fetching anime entry");
console.log(typeof ctx.anime.getAnimeEntry)
ctx.anime.getAnimeEntry(21)
//ctx.anime.getAnimeEntry(21).then((anime) => {
// console.log("Anime", anime)
//}).catch((e) => {
// console.error("Error fetching anime entry", e)
//})
} catch (e) {
console.error("Error fetching anime entry", e)
}
})
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
opts.Permissions = extension.PluginPermissions{
Scopes: []extension.PluginPermissionScope{
extension.PluginPermissionAnilist,
extension.PluginPermissionDatabase,
},
}
logger := util.NewLogger()
database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger)
require.NoError(t, err)
metadataProvider := metadata.NewProvider(&metadata.NewProviderImplOptions{
FileCacher: lo.Must(filecache.NewCacher(t.TempDir())),
Logger: logger,
})
fillerManager := fillermanager.New(&fillermanager.NewFillerManagerOptions{
Logger: logger,
DB: database,
})
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
Database: database,
MetadataProvider: metadataProvider,
FillerManager: fillerManager,
})
_, logger, manager, _, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
manager.PrintPluginPoolMetrics(opts.ID)
time.Sleep(3 * time.Second)
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaPluginMpv(t *testing.T) {
payload := fmt.Sprintf(`
function init() {
$ui.register(async (ctx) => {
console.log("Testing MPV");
await ctx.mpv.openAndPlay("%s")
const cancel = ctx.mpv.onEvent((event) => {
console.log("Event received", event)
})
ctx.setTimeout(() => {
const conn = ctx.mpv.getConnection()
if (conn) {
conn.call("set_property", "pause", true)
}
}, 3000)
ctx.setTimeout(async () => {
console.log("Cancelling event listener")
cancel()
await ctx.mpv.stop()
}, 5000)
});
}
`, testVideoPath)
playbackManager, _, err := getPlaybackManager(t)
require.NoError(t, err)
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
PlaybackManager: playbackManager,
})
opts := DefaultTestPluginOptions()
opts.Payload = payload
opts.Permissions = extension.PluginPermissions{
Scopes: []extension.PluginPermissionScope{
extension.PluginPermissionPlayback,
},
}
_, _, manager, _, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
manager.PrintPluginPoolMetrics(opts.ID)
time.Sleep(8 * time.Second)
}
/////////////////////////////////////////////////////////////////////////////////////////////
// Test that the plugin cannot access paths that are not allowed
// $os.readDir should throw an error
func TestGojaPluginPathNotAllowed(t *testing.T) {
payload := fmt.Sprintf(`
function init() {
$ui.register((ctx) => {
const tempDir = $os.tempDir();
console.log("Temp dir", tempDir);
const dirPath = "%s";
const entries = $os.readDir(dirPath);
});
}
`, testDocumentCollectionDir)
opts := DefaultTestPluginOptions()
opts.Payload = payload
opts.Permissions = extension.PluginPermissions{
Scopes: []extension.PluginPermissionScope{
extension.PluginPermissionSystem,
},
Allow: extension.PluginAllowlist{
ReadPaths: []string{"$TEMP/*", testDocumentsDir},
WritePaths: []string{"$TEMP/*"},
},
}
_, _, manager, _, _, err := InitTestPlugin(t, opts)
require.Error(t, err)
manager.PrintPluginPoolMetrics(opts.ID)
}
/////////////////////////////////////////////////////////////////////////////////////////////
// Test that the plugin can play a video and listen to events
func TestGojaPluginPlaybackEvents(t *testing.T) {
payload := fmt.Sprintf(`
function init() {
$ui.register((ctx) => {
console.log("Testing Playback");
const cancel = ctx.playback.registerEventListener("mySubscriber", (event) => {
console.log("Event received", event)
})
ctx.playback.playUsingMediaPlayer("%s")
ctx.setTimeout(() => {
console.log("Cancelling event listener")
cancel()
}, 15000)
});
}
`, testVideoPath)
playbackManager, _, err := getPlaybackManager(t)
require.NoError(t, err)
plugin.GlobalAppContext.SetModulesPartial(plugin.AppContextModules{
PlaybackManager: playbackManager,
})
opts := DefaultTestPluginOptions()
opts.Payload = payload
opts.Permissions = extension.PluginPermissions{
Scopes: []extension.PluginPermissionScope{
extension.PluginPermissionPlayback,
},
}
_, _, manager, _, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
manager.PrintPluginPoolMetrics(opts.ID)
time.Sleep(16 * time.Second)
}
/////////////////////////////////////////////////////////////////////////////////////////////
// Tests that we can register hooks and the UI handler.
// Tests that the state updates correctly and effects run as expected.
// Tests that we can fetch data from an external source.
func TestGojaPluginUIAndHooks(t *testing.T) {
// Create a test server
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
time.Sleep(1000 * time.Millisecond)
fmt.Fprint(w, `{"test": "data"}`)
}))
defer server.Close()
payload := fmt.Sprintf(`
function init() {
$app.onGetAnime(async (e) => {
const url = "%s"
// const res = $await(fetch(url))
const res = await fetch(url)
const data = res.json()
console.log("fetched results in hook", data)
$store.set("data", data)
console.log("first hook fired");
e.next();
});
$app.onGetAnime(async (e) => {
console.log("results from first hook", $store.get("data"));
e.next();
});
$ui.register((ctx) => {
const url = "%s"
console.log("this is the start");
const count = ctx.state(0)
ctx.effect(async () => {
console.log("running effect that takes 1s")
ctx.setTimeout(() => {
console.log("1s elapsed since first effect called")
}, 1000)
const [a, b, c, d, e, f] = await Promise.all([
ctx.fetch("https://jsonplaceholder.typicode.com/todos/1"),
ctx.fetch("https://jsonplaceholder.typicode.com/todos/2"),
ctx.fetch("https://jsonplaceholder.typicode.com/todos/3"),
ctx.fetch("https://jsonplaceholder.typicode.com/todos/3"),
ctx.fetch("https://jsonplaceholder.typicode.com/todos/3"),
ctx.fetch(url),
])
console.log("fetch results", a.json(), b.json(), c.json(), d.json(), e.json(), f.json())
}, [count])
ctx.effect(() => {
console.log("running effect that runs fast ran second")
}, [count])
count.set(p => p+1)
console.log("this is the end");
});
}
`, server.URL, server.URL)
opts := DefaultTestPluginOptions()
opts.Payload = payload
_, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
go func() {
time.Sleep(time.Second)
_, err := anilistPlatform.GetAnime(t.Context(), 178022)
if err != nil {
t.Errorf("GetAnime returned error: %v", err)
}
// _, err = anilistPlatform.GetAnime(177709)
// if err != nil {
// t.Errorf("GetAnime returned error: %v", err)
// }
}()
manager.PrintPluginPoolMetrics(opts.ID)
time.Sleep(3 * time.Second)
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaPluginStore(t *testing.T) {
payload := `
function init() {
$app.onGetAnime((e) => {
$store.set("anime", e.anime);
$store.set("value", 42);
e.next();
});
$app.onGetAnime((e) => {
console.log("Hook 2, value", $store.get("value"));
console.log("Hook 2, value 2", $store.get("value2"));
e.next();
});
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
plugin, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
m, err := anilistPlatform.GetAnime(t.Context(), 178022)
if err != nil {
t.Fatalf("GetAnime returned error: %v", err)
}
util.Spew(m.Title)
util.Spew(m.Synonyms)
m, err = anilistPlatform.GetAnime(t.Context(), 177709)
if err != nil {
t.Fatalf("GetAnime returned error: %v", err)
}
util.Spew(m.Title)
value := plugin.store.Get("value")
require.NotNil(t, value)
manager.PrintPluginPoolMetrics(opts.ID)
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaPluginJsonFieldNames(t *testing.T) {
payload := `
function init() {
$app.onPreUpdateEntryProgress((e) => {
console.log("pre update entry progress", e)
$store.set("mediaId", e.mediaId);
e.next();
});
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
plugin, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
err = anilistPlatform.UpdateEntryProgress(t.Context(), 178022, 1, lo.ToPtr(1))
if err != nil {
t.Fatalf("GetAnime returned error: %v", err)
}
mediaId := plugin.store.Get("mediaId")
require.NotNil(t, mediaId)
manager.PrintPluginPoolMetrics(opts.ID)
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaPluginAnilistCustomQuery(t *testing.T) {
payload := `
function init() {
$ui.register((ctx) => {
const token = $database.anilist.getToken()
try {
const res = $anilist.customQuery({ query:` + "`" + `
query GetOnePiece {
Media(id: 21) {
title {
romaji
english
native
userPreferred
}
airingSchedule(perPage: 1, page: 1) {
nodes {
episode
airingAt
}
}
id
}
}
` + "`" + `, variables: {}}, token);
console.log("res", res)
} catch (e) {
console.error("Error fetching anime list", e);
}
});
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
opts.Permissions = extension.PluginPermissions{
Scopes: []extension.PluginPermissionScope{
extension.PluginPermissionAnilist,
extension.PluginPermissionAnilistToken,
extension.PluginPermissionDatabase,
},
}
plugin, _, manager, _, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
_ = plugin
manager.PrintPluginPoolMetrics(opts.ID)
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaPluginAnilistListAnime(t *testing.T) {
payload := `
function init() {
$ui.register((ctx) => {
try {
const res = $anilist.listRecentAnime(1, 15, undefined, undefined, undefined)
console.log("res", res)
} catch (e) {
console.error("Error fetching anime list", e)
}
})
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
opts.Permissions = extension.PluginPermissions{
Scopes: []extension.PluginPermissionScope{
extension.PluginPermissionAnilist,
},
}
plugin, _, manager, _, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
_ = plugin
manager.PrintPluginPoolMetrics(opts.ID)
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaPluginStorage(t *testing.T) {
payload := `
function init() {
$app.onGetAnime((e) => {
if ($storage.get("foo") !== "qux") {
throw new Error("foo should be qux")
}
$storage.set("foo", "anime")
console.log("foo", $storage.get("foo"))
$store.set("expectedValue4", "anime")
e.next();
});
$ui.register((ctx) => {
$storage.set("foo", "bar")
console.log("foo", $storage.get("foo"))
$store.set("expectedValue1", "bar")
ctx.setTimeout(() => {
console.log("foo", $storage.get("foo"))
$storage.set("foo", "baz")
console.log("foo", $storage.get("foo"))
$store.set("expectedValue2", "baz")
}, 1000)
ctx.setTimeout(() => {
console.log("foo", $storage.get("foo"))
$storage.set("foo", "qux")
console.log("foo", $storage.get("foo"))
$store.set("expectedValue3", "qux")
}, 1500)
})
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
opts.Permissions = extension.PluginPermissions{
Scopes: []extension.PluginPermissionScope{
extension.PluginPermissionDatabase,
extension.PluginPermissionStorage,
},
}
plugin, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
_ = plugin
manager.PrintPluginPoolMetrics(opts.ID)
time.Sleep(2 * time.Second)
_, err = anilistPlatform.GetAnime(t.Context(), 178022)
require.NoError(t, err)
expectedValue1 := plugin.store.Get("expectedValue1")
require.Equal(t, "bar", expectedValue1)
expectedValue2 := plugin.store.Get("expectedValue2")
require.Equal(t, "baz", expectedValue2)
expectedValue3 := plugin.store.Get("expectedValue3")
require.Equal(t, "qux", expectedValue3)
expectedValue4 := plugin.store.Get("expectedValue4")
require.Equal(t, "anime", expectedValue4)
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaPluginTryCatch(t *testing.T) {
payload := `
function init() {
$ui.register((ctx) => {
try {
throw new Error("test error")
} catch (e) {
console.log("catch", e)
$store.set("error", e)
}
try {
undefined.f()
} catch (e) {
console.log("catch 2", e)
$store.set("error2", e)
}
})
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
plugin, _, manager, _, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
manager.PrintPluginPoolMetrics(opts.ID)
err1 := plugin.store.Get("error")
require.NotNil(t, err1)
err2 := plugin.store.Get("error2")
require.NotNil(t, err2)
}
/////////////////////////////////////////////////////////////////////////////////////////////
func TestGojaSharedMemory(t *testing.T) {
payload := `
function init() {
$ui.register((ctx) => {
const state = ctx.state("test")
$store.set("state", state)
})
$app.onGetAnime((e) => {
const state = $store.get("state")
console.log("state", state)
console.log("state value", state.get())
e.next();
})
}
`
opts := DefaultTestPluginOptions()
opts.Payload = payload
plugin, _, manager, anilistPlatform, _, err := InitTestPlugin(t, opts)
require.NoError(t, err)
_ = plugin
manager.PrintPluginPoolMetrics(opts.ID)
_, err = anilistPlatform.GetAnime(t.Context(), 178022)
require.NoError(t, err)
time.Sleep(2 * time.Second)
}
/////////////////////////////////////////////////////////////////////////////////////////////s
func getPlaybackManager(t *testing.T) (*playbackmanager.PlaybackManager, *anilist.AnimeCollection, error) {
logger := util.NewLogger()
wsEventManager := events.NewMockWSEventManager(logger)
database, err := db.NewDatabase(test_utils.ConfigData.Path.DataDir, test_utils.ConfigData.Database.Name, logger)
if err != nil {
t.Fatalf("error while creating database, %v", err)
}
filecacher, err := filecache.NewCacher(t.TempDir())
require.NoError(t, err)
anilistClient := anilist.TestGetMockAnilistClient()
anilistPlatform := anilist_platform.NewAnilistPlatform(anilistClient, logger)
animeCollection, err := anilistPlatform.GetAnimeCollection(t.Context(), true)
metadataProvider := metadata.GetMockProvider(t)
require.NoError(t, err)
continuityManager := continuity.NewManager(&continuity.NewManagerOptions{
FileCacher: filecacher,
Logger: logger,
Database: database,
})
playbackManager := playbackmanager.New(&playbackmanager.NewPlaybackManagerOptions{
WSEventManager: wsEventManager,
Logger: logger,
Platform: anilistPlatform,
MetadataProvider: metadataProvider,
Database: database,
RefreshAnimeCollectionFunc: func() {
// Do nothing
},
DiscordPresence: nil,
IsOffline: lo.ToPtr(false),
ContinuityManager: continuityManager,
})
playbackManager.SetAnimeCollection(animeCollection)
playbackManager.SetSettings(&playbackmanager.Settings{
AutoPlayNextEpisode: false,
})
playbackManager.SetMediaPlayerRepository(mediaplayer.NewRepository(&mediaplayer.NewRepositoryOptions{
Mpv: mpv.New(logger, "", ""),
Logger: logger,
Default: "mpv",
ContinuityManager: continuityManager,
}))
return playbackManager, animeCollection, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,332 @@
/**
* Is offline
*/
declare const __isOffline__: boolean
/**
* Fetch
*/
declare function fetch(url: string, options?: FetchOptions): Promise<FetchResponse>
interface FetchOptions {
/** HTTP method, defaults to GET */
method?: string
/** Request headers */
headers?: Record<string, string>
/** Request body */
body?: any
/** Whether to bypass cloudflare */
noCloudflareBypass?: boolean
/** Timeout in seconds, defaults to 35 */
timeout?: number
}
interface FetchResponse {
/** Response status code */
status: number
/** Response status text */
statusText: string
/** Request method used */
method: string
/** Raw response headers */
rawHeaders: Record<string, string[]>
/** Whether the response was successful (status in range 200-299) */
ok: boolean
/** Request URL */
url: string
/** Response headers */
headers: Record<string, string>
/** Response cookies */
cookies: Record<string, string>
/** Whether the response was redirected */
redirected: boolean
/** Response content type */
contentType: string
/** Response content length */
contentLength: number
/** Get response text */
text(): string
/** Parse response as JSON */
json<T = any>(): T
}
/**
* Replaces the reference of the value with the new value.
* @param value - The value to replace
* @param newValue - The new value
*/
declare function $replace<T = any>(value: T, newValue: T): void
/**
* Creates a deep copy of the value.
* @param value - The value to copy
* @returns A deep copy of the value
*/
declare function $clone<T = any>(value: T): T
/**
* Converts a value to a string
* @param value - The value to convert
* @returns The string representation of the value
*/
declare function $toString(value: any): string
/**
* Converts a value to a bytes array
* @param value - The value to convert
* @returns The bytes array
*/
declare function $toBytes(value: any): Uint8Array
/**
* Sleeps for a specified amount of time
* @param milliseconds - The amount of time to sleep in milliseconds
*/
declare function $sleep(milliseconds: number): void
/**
*
* @param model
*/
declare function $arrayOf<T>(model: T): T[]
/**
* Marshals and unmarshals a value to a JSON string
* @param data - The value to marshal
* @param dst - The destination to unmarshal the value to. Must be a reference.
* @throws If unmarshalling fails
*/
declare function $unmarshalJSON(data: any, dst: any): void
/**
* Get a user preference
* @param key The key of the preference
* @returns The value of the preference set by the user, the default value if it is not set, or undefined.
*/
declare function $getUserPreference(key: string): string | undefined;
/**
* Habari
*/
declare namespace $habari {
interface Metadata {
season_number?: string[]
part_number?: string[]
title?: string
formatted_title?: string
anime_type?: string[]
year?: string
audio_term?: string[]
device_compatibility?: string[]
episode_number?: string[]
other_episode_number?: string[]
episode_number_alt?: string[]
episode_title?: string
file_checksum?: string
file_extension?: string
file_name?: string
language?: string[]
release_group?: string
release_information?: string[]
release_version?: string[]
source?: string[]
subtitles?: string[]
video_resolution?: string
video_term?: string[]
volume_number?: string[]
}
/**
* Parses a filename and returns the metadata
* @param filename - The filename to parse
* @returns The metadata
*/
function parse(filename: string): Metadata
}
/**
* Buffer
*/
declare class Buffer extends ArrayBuffer {
static poolSize: number
constructor(arg?: string | ArrayBuffer | ArrayLike<number>, encoding?: string)
static from(arrayBuffer: ArrayBuffer): Buffer
static from(array: ArrayLike<number>): Buffer
static from(string: string, encoding?: string): Buffer
static alloc(size: number, fill?: string | number, encoding?: string): Buffer
equals(other: Buffer | Uint8Array): boolean
toString(encoding?: string): string
}
/**
* Crypto
*/
declare class WordArray {
toString(encoder?: CryptoJSEncoder): string;
}
// CryptoJS supports AES-128, AES-192, and AES-256. It will pick the variant by the size of the key you pass in. If you use a passphrase,
// then it will generate a 256-bit key.
declare class CryptoJS {
static AES: {
encrypt: (message: string, key: string | Uint8Array, cfg?: AESConfig) => WordArray;
decrypt: (message: string | WordArray, key: string | Uint8Array, cfg?: AESConfig) => WordArray;
}
static enc: {
Utf8: CryptoJSEncoder;
Base64: CryptoJSEncoder;
Hex: CryptoJSEncoder;
Latin1: CryptoJSEncoder;
Utf16: CryptoJSEncoder;
Utf16LE: CryptoJSEncoder;
}
}
declare interface AESConfig {
iv?: Uint8Array;
}
declare class CryptoJSEncoder {
stringify(input: Uint8Array): string;
parse(input: string): Uint8Array;
}
/**
* Doc
*/
declare class DocSelection {
// Retrieves the value of the specified attribute for the first element in the DocSelection.
// To get the value for each element individually, use a looping construct such as each or map.
attr(name: string): string | undefined;
// Returns an object containing the attributes of the first element in the DocSelection.
attrs(): { [key: string]: string };
// Gets the child elements of each element in the DocSelection, optionally filtered by a selector.
children(selector?: string): DocSelection;
// For each element in the DocSelection, gets the first ancestor that matches the selector by testing the element itself
// and traversing up through its ancestors in the DOM tree.
closest(selector?: string): DocSelection;
// Gets the children of each element in the DocSelection, including text and comment nodes.
contents(): DocSelection;
// Gets the children of each element in the DocSelection, filtered by the specified selector.
contentsFiltered(selector: string): DocSelection;
// Gets the value of a data attribute for the first element in the DocSelection.
// If no name is provided, returns an object containing all data attributes.
data<T extends string | undefined>(name?: T): T extends string ? (string | undefined) : { [key: string]: string };
// Iterates over each element in the DocSelection, executing a function for each matched element.
each(callback: (index: number, element: DocSelection) => void): DocSelection;
// Ends the most recent filtering operation in the current chain and returns the set of matched elements to its previous state.
end(): DocSelection;
// Reduces the set of matched elements to the one at the specified index. If a negative index is given, it counts backwards starting at the end
// of the set.
eq(index: number): DocSelection;
// Filters the set of matched elements to those that match the selector.
filter(selector: string | ((index: number, element: DocSelection) => boolean)): DocSelection;
// Gets the descendants of each element in the DocSelection, filtered by a selector.
find(selector: string): DocSelection;
// Reduces the set of matched elements to the first element in the DocSelection.
first(): DocSelection;
// Reduces the set of matched elements to those that have a descendant that matches the selector.
has(selector: string): DocSelection;
// Gets the combined text contents of each element in the DocSelection, including their descendants.
text(): string;
// Gets the HTML contents of the first element in the DocSelection.
html(): string | null;
// Checks the set of matched elements against a selector and returns true if at least one of these elements matches.
is(selector: string | ((index: number, element: DocSelection) => boolean)): boolean;
// Reduces the set of matched elements to the last element in the DocSelection.
last(): DocSelection;
// Gets the number of elements in the DocSelection.
length(): number;
// Passes each element in the current matched set through a function, producing an array of the return values.
map<T>(callback: (index: number, element: DocSelection) => T): T[];
// Gets the next sibling of each element in the DocSelection, optionally filtered by a selector.
next(selector?: string): DocSelection;
// Gets all following siblings of each element in the DocSelection, optionally filtered by a selector.
nextAll(selector?: string): DocSelection;
// Gets the next siblings of each element in the DocSelection, up to but not including the element matched by the selector.
nextUntil(selector: string, until?: string): DocSelection;
// Removes elements from the DocSelection that match the selector.
not(selector: string | ((index: number, element: DocSelection) => boolean)): DocSelection;
// Gets the parent of each element in the DocSelection, optionally filtered by a selector.
parent(selector?: string): DocSelection;
// Gets the ancestors of each element in the DocSelection, optionally filtered by a selector.
parents(selector?: string): DocSelection;
// Gets the ancestors of each element in the DocSelection, up to but not including the element matched by the selector.
parentsUntil(selector: string, until?: string): DocSelection;
// Gets the previous sibling of each element in the DocSelection, optionally filtered by a selector.
prev(selector?: string): DocSelection;
// Gets all preceding siblings of each element in the DocSelection, optionally filtered by a selector.
prevAll(selector?: string): DocSelection;
// Gets the previous siblings of each element in the DocSelection, up to but not including the element matched by the selector.
prevUntil(selector: string, until?: string): DocSelection;
// Gets the siblings of each element in the DocSelection, optionally filtered by a selector.
siblings(selector?: string): DocSelection;
}
declare class Doc extends DocSelection {
constructor(html: string);
}
declare function LoadDoc(html: string): DocSelectionFunction;
declare interface DocSelectionFunction {
(selector: string): DocSelection;
}
/**
* Torrent utils
*/
declare interface $torrentUtils {
/**
* Get a magnet link from a base64 encoded torrent data
* @param b64 - The base64 encoded torrent data
* @returns The magnet link
*/
getMagnetLinkFromTorrentData(b64: string): string
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,822 @@
/**
* OS module provides a platform-independent interface to operating system functionality.
* This is a restricted subset of Go's os package with permission checks.
*/
declare namespace $os {
/** The operating system (e.g., "darwin", "linux", "windows") */
const platform: string
/** The system architecture (e.g., "amd64", "arm64") */
const arch: string
/**
* Creates and executes a new command with the given arguments.
* Command execution is subject to permission checks.
* @param name The name of the command to run
* @param args The arguments to pass to the command
* @returns A command object or an error if the command is not authorized
*/
function cmd(name: string, ...args: string[]): $os.Cmd
/**
* Reads the entire file specified by path.
* @param path The path to the file to read
* @returns The file contents as a byte array
* @throws Error if the path is not authorized for reading
*/
function readFile(path: string): Uint8Array
/**
* Writes data to the named file, creating it if necessary.
* If the file exists, it is truncated.
* @param path The path to the file to write
* @param data The data to write to the file
* @param perm The file mode (permissions)
* @throws Error if the path is not authorized for writing
*/
function writeFile(path: string, data: Uint8Array, perm: number): void
/**
* Reads a directory, returning a list of directory entries.
* @param path The path to the directory to read
* @returns An array of directory entries
* @throws Error if the path is not authorized for reading
*/
function readDir(path: string): $os.DirEntry[]
/**
* Returns the default directory to use for temporary files.
* @returns The temporary directory path or empty string if not authorized
* @throws Error if the path is not authorized for reading
*/
function tempDir(): string
/**
* Returns the user's configuration directory.
* @returns The configuration directory path or empty string if not authorized
* @throws Error if the path is not authorized for reading
*/
function configDir(): string
/**
* Returns the user's home directory.
* @returns The home directory path or empty string if not authorized
* @throws Error if the path is not authorized for reading
*/
function homeDir(): string
/**
* Returns the user's cache directory.
* @returns The cache directory path or empty string if not authorized
* @throws Error if the path is not authorized for reading
*/
function cacheDir(): string
/**
* Changes the size of the named file.
* @param path The path to the file to truncate
* @param size The new size of the file
* @throws Error if the path is not authorized for writing
*/
function truncate(path: string, size: number): void
/**
* Creates a new directory with the specified name and permission bits.
* @param path The path of the directory to create
* @param perm The permission bits
* @throws Error if the path is not authorized for writing
*/
function mkdir(path: string, perm: number): void
/**
* Creates a directory named path, along with any necessary parents.
* @param path The path of the directory to create
* @param perm The permission bits
* @throws Error if the path is not authorized for writing
*/
function mkdirAll(path: string, perm: number): void
/**
* Renames (moves) oldpath to newpath.
* @param oldpath The source path
* @param newpath The destination path
* @throws Error if either path is not authorized for writing
*/
function rename(oldpath: string, newpath: string): void
/**
* Removes the named file or (empty) directory.
* @param path The path to remove
* @throws Error if the path is not authorized for writing
*/
function remove(path: string): void
/**
* Removes path and any children it contains.
* @param path The path to remove recursively
* @throws Error if the path is not authorized for writing
*/
function removeAll(path: string): void
/**
* Returns a FileInfo describing the named file.
* @param path The path to get information about
* @returns Information about the file
* @throws Error if the path is not authorized for reading
*/
function stat(path: string): $os.FileInfo
/**
* Opens a file for reading and writing.
* @param path The path to the file to open
* @param flag The flags to open the file with
* @param perm The file mode (permissions)
* @returns A file object or an error if the file is not authorized for writing
*/
function openFile(path: string, flag: number, perm: number): $os.File
interface File {
chmod(mode: number): void
chown(uid: number, gid: number): void
close(): void
fd(): number
name(): string
read(b: Uint8Array): number
readAt(b: Uint8Array, off: number): number
readDir(n: number): $os.DirEntry[]
readFrom(r: $io.Reader): number
readdir(n: number): $os.FileInfo[]
readdirnames(n: number): string[]
seek(offset: number, whence: number): number
setDeadline(t: Date): void
setReadDeadline(t: Date): void
setWriteDeadline(t: Date): void
stat(): $os.FileInfo
sync(): void
syscallConn(): any /* Not documented */
truncate(size: number): void
write(b: Uint8Array): number
writeAt(b: Uint8Array, off: number): number
writeString(s: string): number
writeTo(w: $io.Writer): number
}
/**
* Cmd represents an external command being prepared or run.
* A Cmd cannot be reused after calling its Run, Output or CombinedOutput methods.
*/
interface Cmd {
/**
* Args holds command line arguments, including the command as Args[0].
* If the Args field is empty or nil, Run uses {Path}.
* In typical use, both Path and Args are set by calling Command.
*/
args: string[]
/**
* If Cancel is non-nil, the command must have been created with CommandContext
* and Cancel will be called when the command's Context is done.
*/
cancel: () => void
/**
* Dir specifies the working directory of the command.
* If Dir is the empty string, Run runs the command in the calling process's current directory.
*/
dir: string
/**
* Env specifies the environment of the process.
* Each entry is of the form "key=value".
* If Env is nil, the new process uses the current process's environment.
*/
env: string[]
/** Error information if the command failed */
err: Error
/**
* ExtraFiles specifies additional open files to be inherited by the new process.
* It does not include standard input, standard output, or standard error.
*/
extraFiles: $os.File[]
/**
* Path is the path of the command to run.
* This is the only field that must be set to a non-zero value.
*/
path: string
/** Process is the underlying process, once started. */
process?: $os.Process
/** ProcessState contains information about an exited process. */
processState?: $os.ProcessState
/** Standard error of the command */
stderr: any
/** Standard input of the command */
stdin: any
/** Standard output of the command */
stdout: any
/** SysProcAttr holds optional, operating system-specific attributes. */
sysProcAttr?: any
/**
* If WaitDelay is non-zero, it bounds the time spent waiting on two sources of
* unexpected delay in Wait: a child process that fails to exit after the associated
* Context is canceled, and a child process that exits but leaves its I/O pipes unclosed.
*/
waitDelay: number
/**
* CombinedOutput runs the command and returns its combined standard output and standard error.
* @returns The combined output as a string or byte array
*/
combinedOutput(): string | number[]
/**
* Environ returns a copy of the environment in which the command would be run as it is currently configured.
* @returns The environment variables as an array of strings
*/
environ(): string[]
/**
* Output runs the command and returns its standard output.
* @returns The standard output as a string or byte array
*/
output(): string | number[]
/**
* Run starts the specified command and waits for it to complete.
* The returned error is nil if the command runs, has no problems copying stdin, stdout,
* and stderr, and exits with a zero exit status.
*/
run(): void
/**
* Start starts the specified command but does not wait for it to complete.
* If Start returns successfully, the c.Process field will be set.
*/
start(): void
/**
* StderrPipe returns a pipe that will be connected to the command's standard error when the command starts.
* @returns A readable stream for the command's standard error
*/
stderrPipe(): any
/**
* StdinPipe returns a pipe that will be connected to the command's standard input when the command starts.
* @returns A writable stream for the command's standard input
*/
stdinPipe(): any
/**
* StdoutPipe returns a pipe that will be connected to the command's standard output when the command starts.
* @returns A readable stream for the command's standard output
*/
stdoutPipe(): any
/**
* String returns a human-readable description of the command.
* It is intended only for debugging.
* @returns A string representation of the command
*/
string(): string
/**
* Wait waits for the command to exit and waits for any copying to stdin or copying from stdout or stderr to complete.
* The command must have been started by Start.
*/
wait(): void
}
interface Process {
kill(): void
wait(): void
signal(sig: Signal): void
}
interface Signal {
string(): string
signal(): void
}
const Kill: Signal
const Interrupt: Signal
interface ProcessState {
pid(): number
string(): string
exitCode(): number
}
/**
* AsyncCmd represents an external command being prepared or run asynchronously.
*/
interface AsyncCmd {
/**
* Get the underlying $os.Cmd.
* To start the command, call start() on the underlying command, not run().
* @returns The underlying $os.Cmd
*/
getCommand(): $os.Cmd
/**
* Run the command
* @param callback The callback to call each time data is available from the command's stdout or stderr
* @param data The data from the command's stdout
* @param err The data from the command's stderr
* @param exitCode The exit code of the command
* @param signal The signal that terminated the command
*/
run(callback: (data: Uint8Array | undefined,
err: Uint8Array | undefined,
exitCode: number | undefined,
signal: string | undefined,
) => void): void
}
/**
* FileInfo describes a file and is returned by stat.
*/
interface FileInfo {
/** Base name of the file */
name(): string
/** Length in bytes for regular files system-dependent for others */
size(): number
/** File mode bits */
mode(): number
/** Modification time */
modTime(): Date
/** Abbreviation for mode().isDir() */
isDir(): boolean
/** Underlying data source (can return null) */
sys(): any
}
/**
* DirEntry is an entry read from a directory.
*/
interface DirEntry {
/** Returns the name of the file (or subdirectory) described by the entry */
name(): string
/** Reports whether the entry describes a directory */
isDir(): boolean
/** Returns the type bits for the entry */
type(): number
/** Returns the FileInfo for the file or subdirectory described by the entry */
info(): $os.FileInfo
}
/**
* Constants for file mode bits
*/
namespace FileMode {
/** Is a directory */
const ModeDir: number
/** Append-only */
const ModeAppend: number
/** Exclusive use */
const ModeExclusive: number
/** Temporary file */
const ModeTemporary: number
/** Symbolic link */
const ModeSymlink: number
/** Device file */
const ModeDevice: number
/** Named pipe (FIFO) */
const ModeNamedPipe: number
/** Unix domain socket */
const ModeSocket: number
/** Setuid */
const ModeSetuid: number
/** Setgid */
const ModeSetgid: number
/** Unix character device, when ModeDevice is set */
const ModeCharDevice: number
/** Sticky */
const ModeSticky: number
/** Non-regular file */
const ModeIrregular: number
/** Mask for the type bits. For regular files, none will be set */
const ModeType: number
/** Unix permission bits, 0o777 */
const ModePerm: number
}
}
/**
* Filepath module provides functions to manipulate file paths in a way compatible with the target operating system.
*/
declare namespace $filepath {
const skipDir: GoError
/**
* Returns the last element of path.
* @param path The path to get the base name from
* @returns The base name of the path
*/
function base(path: string): string
/**
* Cleans the path by applying a series of rules.
* @param path The path to clean
* @returns The cleaned path
*/
function clean(path: string): string
/**
* Returns all but the last element of path.
* @param path The path to get the directory from
* @returns The directory containing the file
*/
function dir(path: string): string
/**
* Returns the file extension of path.
* @param path The path to get the extension from
* @returns The file extension (including the dot)
*/
function ext(path: string): string
/**
* Converts path from slash-separated to OS-specific separator.
* @param path The path to convert
* @returns The path with OS-specific separators
*/
function fromSlash(path: string): string
/**
* Returns a list of files matching the pattern in the base directory.
* @param basePath The base directory to search in
* @param pattern The glob pattern to match
* @returns An array of matching file paths
* @throws Error if the base path is not authorized for reading
*/
function glob(basePath: string, pattern: string): string[]
/**
* Reports whether the path is absolute.
* @param path The path to check
* @returns True if the path is absolute
*/
function isAbs(path: string): boolean
/**
* Joins any number of path elements into a single path.
* @param paths The path elements to join
* @returns The joined path
*/
function join(...paths: string[]): string
/**
* Reports whether name matches the shell pattern.
* @param pattern The pattern to match against
* @param name The string to check
* @returns True if name matches pattern
*/
function match(pattern: string, name: string): boolean
/**
* Returns the relative path from basepath to targpath.
* @param basepath The base path
* @param targpath The target path
* @returns The relative path
*/
function rel(basepath: string, targpath: string): string
/**
* Splits path into directory and file components.
* @param path The path to split
* @returns An array with [directory, file]
*/
function split(path: string): [string, string]
/**
* Splits a list of paths joined by the OS-specific ListSeparator.
* @param path The path list to split
* @returns An array of paths
*/
function splitList(path: string): string[]
/**
* Converts path from OS-specific separator to slash-separated.
* @param path The path to convert
* @returns The path with forward slashes
*/
function toSlash(path: string): string
/**
* Walks the file tree rooted at the specificed path, calling walkFn for each file or directory.
* It reads entire directories into memory before proceeding.
* @param root The root directory to start walking from
* @param walkFn The function to call for each file or directory
* @throws Error if the root path is not authorized for reading
*/
function walk(root: string, walkFn: (path: string, info: $os.FileInfo, err: GoError) => GoError): void
/**
* Walks the file tree rooted at the specificed path, calling walkDirFn for each file or directory.
* @param root The root directory to start walking from
* @param walkDirFn The function to call for each file or directory
* @throws Error if the root path is not authorized for reading
*/
function walkDir(root: string, walkDirFn: (path: string, d: $os.DirEntry, err: GoError) => GoError): void
}
type GoError = string | undefined
/**
* Extra OS utilities not in the standard library.
*/
declare namespace $osExtra {
/**
* Unwraps an archive and moves its contents to the destination.
* @param src The source archive path
* @param dest The destination directory
* @throws Error if either path is not authorized for writing
*/
function unwrapAndMove(src: string, dest: string): void
/**
* Extracts a ZIP archive to the destination directory.
* @param src The source ZIP file path
* @param dest The destination directory
* @throws Error if either path is not authorized for writing
*/
function unzip(src: string, dest: string): void
/**
* Extracts a RAR archive to the destination directory.
* @param src The source RAR file path
* @param dest The destination directory
* @throws Error if either path is not authorized for writing
*/
function unrar(src: string, dest: string): void
/**
* Returns the user's desktop directory.
* @returns The desktop directory path or empty string if not authorized
* @throws Error if the path is not authorized for reading
*/
function desktopDir(): string
/**
* Returns the user's documents directory.
* @returns The documents directory path or empty string if not authorized
* @throws Error if the path is not authorized for reading
*/
function documentsDir(): string
/**
* Returns the user's downloads directory.
* @returns The downloads directory path or empty string if not authorized
* @throws Error if the path is not authorized for reading
*/
function downloadDir(): string
/**
* Creates a new AsyncCmd instance.
* Get the underlying $os.Cmd with getCommand().
* @param name The name of the command to execute
* @param arg The arguments to pass to the command
* @returns A new AsyncCmd instance
*/
function asyncCmd(name: string, ...args: string[]): $os.AsyncCmd
}
/**
* Downloader module for downloading files with progress tracking.
*/
declare namespace $downloader {
/**
* Download status constants
*/
type DownloadStatus = "downloading" | "completed" | "cancelled" | "error"
/**
* Download progress information
*/
interface DownloadProgress {
/** Unique download identifier */
id: string
/** Source URL */
url: string
/** Destination file path */
destination: string
/** Number of bytes downloaded so far */
totalBytes: number
/** Total file size in bytes (if known) */
totalSize: number
/** Download speed in bytes per second */
speed: number
/** Download completion percentage (0-100) */
percentage: number
/** Current download status */
status: DownloadStatus
/** Error message if status is ERROR */
error?: string
/** Time of the last progress update */
lastUpdate: Date
/** Time when the download started */
startTime: Date
}
/**
* Download options
*/
interface DownloadOptions {
/** Timeout in seconds */
timeout?: number
/** HTTP headers to send with the request */
headers?: Record<string, string>
}
/**
* Starts a file download.
* @param url The URL to download from
* @param destination The path to save the file to
* @param options Download options
* @returns A unique download ID
* @throws Error if the destination path is not authorized for writing
*/
function download(url: string, destination: string, options?: DownloadOptions): string
/**
* Watches a download for progress updates.
* @param downloadId The download ID to watch
* @param callback Function to call with progress updates
* @returns A function to cancel the watch
*/
function watch(downloadId: string, callback: (progress: DownloadProgress | undefined) => void): () => void
/**
* Gets the current progress of a download.
* @param downloadId The download ID to check
* @returns The current download progress
*/
function getProgress(downloadId: string): DownloadProgress | undefined
/**
* Lists all active downloads.
* @returns An array of download progress objects
*/
function listDownloads(): DownloadProgress[]
/**
* Cancels a specific download.
* @param downloadId The download ID to cancel
* @returns True if the download was cancelled
*/
function cancel(downloadId: string): boolean
/**
* Cancels all active downloads.
* @returns The number of downloads cancelled
*/
function cancelAll(): number
}
/**
* MIME type utilities.
*/
declare namespace $mime {
/**
* Parses a MIME type string and returns the media type and parameters.
* @param contentType The MIME type string to parse
* @returns An object containing the media type and parameters
* @throws Error if parsing fails
*/
function parse(contentType: string): { mediaType: string; parameters: Record<string, string> }
}
/**
* IO module provides basic interfaces to I/O primitives.
* This is a restricted subset of Go's io package with permission checks.
*/
declare namespace $io {
/**
* Reader is the interface that wraps the basic Read method.
* Read reads up to len(p) bytes into p. It returns the number of bytes
* read (0 <= n <= len(p)) and any error encountered.
*/
interface Reader {
read(p: Uint8Array): number
}
/**
* Writer is the interface that wraps the basic Write method.
* Write writes len(p) bytes from p to the underlying data stream.
* It returns the number of bytes written from p (0 <= n <= len(p))
* and any error encountered that caused the write to stop early.
*/
interface Writer {
write(p: Uint8Array): number
}
/**
* Closer is the interface that wraps the basic Close method.
* The behavior of Close after already being called is undefined.
*/
interface Closer {
close(): void
}
/**
* ReadWriter is the interface that groups the basic Read and Write methods.
*/
interface ReadWriter extends Reader, Writer {
}
/**
* ReadCloser is the interface that groups the basic Read and Close methods.
*/
interface ReadCloser extends Reader, Closer {
}
/**
* WriteCloser is the interface that groups the basic Write and Close methods.
*/
interface WriteCloser extends Writer, Closer {
}
/**
* ReadWriteCloser is the interface that groups the basic Read, Write and Close methods.
*/
interface ReadWriteCloser extends Reader, Writer, Closer {
}
/**
* ReaderFrom is the interface that wraps the ReadFrom method.
* ReadFrom reads data from r until EOF or error.
* The return value n is the number of bytes read.
*/
interface ReaderFrom {
readFrom(r: Reader): number
}
/**
* WriterTo is the interface that wraps the WriteTo method.
* WriteTo writes data to w until there's no more data to write or
* when an error occurs. The return value n is the number of bytes
* written.
*/
interface WriterTo {
writeTo(w: Writer): number
}
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"es2015",
"dom"
],
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": false,
"forceConsistentCasingInFileNames": true
}
}

View File

@@ -0,0 +1,88 @@
declare type AnimeProviderSmartSearchFilter = "batch" | "episodeNumber" | "resolution" | "query" | "bestReleases"
declare type AnimeProviderType = "main" | "special"
declare interface AnimeProviderSettings {
canSmartSearch: boolean
smartSearchFilters: AnimeProviderSmartSearchFilter[]
supportsAdult: boolean
type: AnimeProviderType
}
declare interface Media {
id: number
idMal?: number
status?: string
format?: string
englishTitle?: string
romajiTitle?: string
episodeCount?: number
absoluteSeasonOffset?: number
synonyms: string[]
isAdult: boolean
startDate?: FuzzyDate
}
declare interface FuzzyDate {
year: number
month?: number
day?: number
}
declare interface AnimeSearchOptions {
media: Media
query: string
}
declare interface AnimeSmartSearchOptions {
media: Media
query: string
batch: boolean
episodeNumber: number
resolution: string
anidbAID: number
anidbEID: number
bestReleases: boolean
}
declare interface AnimeTorrent {
name: string
date: string
size: number
formattedSize: string
seeders: number
leechers: number
downloadCount: number
link: string
downloadUrl: string
magnetLink?: string
infoHash?: string
resolution?: string
isBatch?: boolean
episodeNumber?: number
releaseGroup?: string
isBestRelease: boolean
confirmed: boolean
}
declare interface AnimeTorrentProvider {
// Returns the search results depending on the query.
search(opts: AnimeSearchOptions): Promise<AnimeTorrent[]>
// Returns the search results depending on the search options.
smartSearch(opts: AnimeSmartSearchOptions): Promise<AnimeTorrent[]>
// Returns the info hash of the torrent.
// This should just return the info hash without scraping the torrent page if already available.
getTorrentInfoHash(torrent: AnimeTorrent): Promise<string>
// Returns the magnet link of the torrent.
// This should just return the magnet link without scraping the torrent page if already available.
getTorrentMagnetLink(torrent: AnimeTorrent): Promise<string>
// Returns the latest torrents.
getLatest(): Promise<AnimeTorrent[]>
// Returns the provider settings.
getSettings(): AnimeProviderSettings
}

View File

@@ -0,0 +1,176 @@
/// <reference path="./anime-torrent-provider.d.ts" />
class Provider {
api = "https://nyaa.si/?page=rss"
getSettings(): AnimeProviderSettings {
return {
canSmartSearch: false,
smartSearchFilters: [],
supportsAdult: false,
type: "main",
}
}
async fetchTorrents(url: string): Promise<NyaaTorrent[]> {
const furl = `${this.api}&q=+${encodeURIComponent(url)}&c=1_3`
try {
console.log(furl)
const response = await fetch(furl)
if (!response.ok) {
throw new Error(`Failed to fetch torrents, ${response.statusText}`)
}
const xmlText = await response.text()
const torrents = this.parseXML(xmlText)
console.log(torrents)
return torrents
}
catch (error) {
throw new Error(`Error fetching torrents: ${error}`)
}
}
async search(opts: AnimeSearchOptions): Promise<AnimeTorrent[]> {
console.log(opts)
const torrents = await this.fetchTorrents(opts.query)
return torrents.map(t => this.toAnimeTorrent(t))
}
toAnimeTorrent(torrent: NyaaTorrent): AnimeTorrent {
return {
name: torrent.title,
date: new Date(torrent.timestamp * 1000).toISOString(),
size: torrent.total_size,
formattedSize: torrent.size,
seeders: torrent.seeders,
leechers: torrent.leechers,
downloadCount: torrent.torrent_download_count,
link: torrent.link,
downloadUrl: torrent.torrent_url,
magnetLink: torrent.magnet_uri,
infoHash: torrent.info_hash,
resolution: "",
isBatch: false,
isBestRelease: false,
confirmed: false,
}
}
async smartSearch(opts: AnimeSmartSearchOptions): Promise<AnimeTorrent[]> {
const ret: AnimeTorrent[] = []
return ret
}
private parseXML(xmlText: string): NyaaTorrent[] {
const torrents: NyaaTorrent[] = []
// Helper to extract content between XML tags
const getTagContent = (xml: string, tag: string): string => {
const regex = new RegExp(`<${tag}[^>]*>([^<]*)</${tag}>`)
const match = xml.match(regex)
return match ? match[1].trim() : ""
}
// Helper to extract content from nyaa namespace tags
const getNyaaTagContent = (xml: string, tag: string): string => {
const regex = new RegExp(`<nyaa:${tag}[^>]*>([^<]*)</nyaa:${tag}>`)
const match = xml.match(regex)
return match ? match[1].trim() : ""
}
// Split XML into items
const itemRegex = /<item>([\s\S]*?)<\/item>/g
let match
let id = 1
while ((match = itemRegex.exec(xmlText)) !== null) {
const itemXml = match[1]
const title = getTagContent(itemXml, "title")
const link = getTagContent(itemXml, "link")
const pubDate = getTagContent(itemXml, "pubDate")
const seeders = parseInt(getNyaaTagContent(itemXml, "seeders")) || 0
const leechers = parseInt(getNyaaTagContent(itemXml, "leechers")) || 0
const downloads = parseInt(getNyaaTagContent(itemXml, "downloads")) || 0
const infoHash = getNyaaTagContent(itemXml, "infoHash")
const size = getNyaaTagContent(itemXml, "size")
// Convert size string (e.g., "571.3 MiB") to bytes
const sizeInBytes = (() => {
const match = size.match(/^([\d.]+)\s*([KMGT]iB)$/)
if (!match) return 0
const [, num, unit] = match
const multipliers: { [key: string]: number } = {
"KiB": 1024,
"MiB": 1024 * 1024,
"GiB": 1024 * 1024 * 1024,
"TiB": 1024 * 1024 * 1024 * 1024,
}
return Math.round(parseFloat(num) * multipliers[unit])
})()
const torrent: NyaaTorrent = {
id: id++,
title,
link,
timestamp: Math.floor(new Date(pubDate).getTime() / 1000),
status: "success",
torrent_url: link,
info_hash: infoHash,
magnet_uri: `magnet:?xt=urn:btih:${infoHash}`,
seeders,
leechers,
torrent_download_count: downloads,
total_size: sizeInBytes,
size,
num_files: 1,
anidb_aid: 0,
anidb_eid: 0,
anidb_fid: 0,
article_url: link,
article_title: title,
website_url: "https://nyaa.si",
}
torrents.push(torrent)
}
return torrents
}
}
type NyaaTorrent = {
id: number
title: string
link: string
timestamp: number
status: string
size: string
tosho_id?: number
nyaa_id?: number
nyaa_subdom?: any
anidex_id?: number
torrent_url: string
info_hash: string
info_hash_v2?: string
magnet_uri: string
seeders: number
leechers: number
torrent_download_count: number
tracker_updated?: any
nzb_url?: string
total_size: number
num_files: number
anidb_aid: number
anidb_eid: number
anidb_fid: number
article_url: string
article_title: string
website_url: string
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"es2015",
"dom"
],
"module": "commonjs",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
}
}

View File

@@ -0,0 +1,80 @@
package extension_repo_test
import (
"github.com/davecgh/go-spew/spew"
"github.com/stretchr/testify/require"
"seanime/internal/extension"
hibikemanga "seanime/internal/extension/hibike/manga"
"seanime/internal/manga/providers"
"seanime/internal/util"
"testing"
)
// Tests the external manga provider extension loaded from the extension directory.
// This will load the extensions from ./testdir
func TestExternalGoMangaExtension(t *testing.T) {
repo := getRepo(t)
// Load all extensions
// This should load all the extensions in the directory
repo.ReloadExternalExtensions()
ext, found := repo.GetMangaProviderExtensionByID("mangapill-external")
require.True(t, found)
t.Logf("\nExtension:\n\tID: %s \n\tName: %s", ext.GetID(), ext.GetName())
// Test the extension
so := hibikemanga.SearchOptions{
Query: "Dandadan",
}
searchResults, err := ext.GetProvider().Search(so)
require.NoError(t, err)
require.GreaterOrEqual(t, len(searchResults), 1)
chapters, err := ext.GetProvider().FindChapters(searchResults[0].ID)
require.NoError(t, err)
require.GreaterOrEqual(t, len(chapters), 1)
spew.Dump(chapters[0])
}
// Tests the built-in manga provider extension
func TestBuiltinMangaExtension(t *testing.T) {
logger := util.NewLogger()
repo := getRepo(t)
// Load all extensions
// This should load all the extensions in the directory
repo.ReloadBuiltInExtension(extension.Extension{
ID: "seanime-builtin-mangapill",
Type: "manga-provider",
Name: "Mangapill",
Version: "0.0.0",
Language: "go",
ManifestURI: "",
Description: "",
Author: "",
Payload: "",
}, manga_providers.NewMangapill(logger))
ext, found := repo.GetMangaProviderExtensionByID("seanime-builtin-mangapill")
require.True(t, found)
t.Logf("\nExtension:\n\tID: %s \n\tName: %s", ext.GetID(), ext.GetName())
// Test the extension
so := hibikemanga.SearchOptions{
Query: "Dandadan",
}
searchResults, err := ext.GetProvider().Search(so)
require.NoError(t, err)
spew.Dump(searchResults)
}

View File

@@ -0,0 +1,78 @@
package extension_repo
import (
"reflect"
"strings"
"unicode"
"github.com/dop251/goja"
)
var (
_ goja.FieldNameMapper = (*FieldMapper)(nil)
)
// FieldMapper provides custom mapping between Go and JavaScript property names.
//
// It is similar to the builtin "uncapFieldNameMapper" but also converts
// all uppercase identifiers to their lowercase equivalent (eg. "GET" -> "get").
// It also checks for JSON tags and uses them if they exist.
type FieldMapper struct {
}
// FieldName implements the [FieldNameMapper.FieldName] interface method.
func (u FieldMapper) FieldName(t reflect.Type, f reflect.StructField) string {
// First check for a JSON tag
if jsonTag := f.Tag.Get("json"); jsonTag != "" {
// Split by comma to handle cases like `json:"name,omitempty"`
parts := strings.Split(jsonTag, ",")
// If the JSON tag isn't "-" (which means don't include this field)
if parts[0] != "-" && parts[0] != "" {
return parts[0]
}
}
// Fall back to default conversion
return convertGoToJSName(f.Name)
}
// MethodName implements the [FieldNameMapper.MethodName] interface method.
func (u FieldMapper) MethodName(_ reflect.Type, m reflect.Method) string {
return convertGoToJSName(m.Name)
}
var nameExceptions = map[string]string{"OAuth2": "oauth2"}
func convertGoToJSName(name string) string {
if v, ok := nameExceptions[name]; ok {
return v
}
startUppercase := make([]rune, 0, len(name))
for _, c := range name {
if c != '_' && !unicode.IsUpper(c) && !unicode.IsDigit(c) {
break
}
startUppercase = append(startUppercase, c)
}
totalStartUppercase := len(startUppercase)
// all uppercase eg. "JSON" -> "json"
if len(name) == totalStartUppercase {
return strings.ToLower(name)
}
// eg. "JSONField" -> "jsonField"
if totalStartUppercase > 1 {
return strings.ToLower(name[0:totalStartUppercase-1]) + name[totalStartUppercase-1:]
}
// eg. "GetField" -> "getField"
if totalStartUppercase == 1 {
return strings.ToLower(name[0:1]) + name[1:]
}
return name
}

View File

@@ -0,0 +1,50 @@
package extension_repo
import (
"fmt"
"io"
"seanime/internal/constants"
"seanime/internal/extension"
"seanime/internal/util"
"github.com/goccy/go-json"
"github.com/samber/lo"
)
func (r *Repository) GetMarketplaceExtensions(url string) (extensions []*extension.Extension, err error) {
defer util.HandlePanicInModuleWithError("extension_repo/GetMarketplaceExtensions", &err)
marketplaceUrl := constants.DefaultExtensionMarketplaceURL
if url != "" {
marketplaceUrl = url
}
return r.getMarketplaceExtensions(marketplaceUrl)
}
func (r *Repository) getMarketplaceExtensions(url string) (extensions []*extension.Extension, err error) {
resp, err := r.client.Get(url)
if err != nil {
r.logger.Error().Err(err).Msgf("marketplace: Failed to get marketplace extension: %s", url)
return nil, fmt.Errorf("failed to get marketplace extension: %s", url)
}
defer resp.Body.Close()
bodyR, err := io.ReadAll(resp.Body)
if err != nil {
r.logger.Error().Err(err).Msgf("marketplace: Failed to read marketplace extension: %s", url)
return nil, fmt.Errorf("failed to read marketplace extension: %s", url)
}
err = json.Unmarshal(bodyR, &extensions)
if err != nil {
r.logger.Error().Err(err).Msgf("marketplace: Failed to unmarshal marketplace extension: %s", url)
return nil, fmt.Errorf("failed to unmarshal marketplace extension: %s", url)
}
extensions = lo.Filter(extensions, func(item *extension.Extension, _ int) bool {
return item.ID != "" && item.ManifestURI != ""
})
return
}

View File

@@ -0,0 +1,85 @@
package mediaplayer_testdir
//import (
// "fmt"
// "strings"
//
// hibikemediaplayer "seanime/internal/extension/hibike/mediaplayer"
//)
//
//type (
// // MobilePlayer is an extension that sends media links the mobile device's media player.
// MobilePlayer struct {
// config mobilePlayerConfig
// }
//
// mobilePlayerConfig struct {
// iosPlayer string
// androidPlayer string
// }
//)
//
//func NewMediaPlayer() hibikemediaplayer.MediaPlayer {
// return &MobilePlayer{}
//}
//
//func (m *MobilePlayer) InitConfig(config map[string]interface{}) {
// iosPlayer, _ := config["iosPlayer"].(string)
// androidPlayer, _ := config["androidPlayer"].(string)
//
// m.config = mobilePlayerConfig{
// iosPlayer: iosPlayer,
// androidPlayer: androidPlayer,
// }
//}
//
//func (m *MobilePlayer) GetSettings() hibikemediaplayer.Settings {
// return hibikemediaplayer.Settings{
// CanTrackProgress: false,
// }
//}
//
//func (m *MobilePlayer) Play(req hibikemediaplayer.PlayRequest) (*hibikemediaplayer.PlayResponse, error) {
// return m.getPlayResponse(req)
//}
//
//func (m *MobilePlayer) Stream(req hibikemediaplayer.PlayRequest) (*hibikemediaplayer.PlayResponse, error) {
// return m.getPlayResponse(req)
//}
//
//func (m *MobilePlayer) getPlayResponse(req hibikemediaplayer.PlayRequest) (*hibikemediaplayer.PlayResponse, error) {
// var url string
// if req.ClientInfo.Platform == "ios" {
// // Play on iOS
// switch m.config.iosPlayer {
// case "outplayer":
// url = getOutplayerUrl(req.Path)
// }
// }
//
// if url == "" {
// return nil, fmt.Errorf("no player found for platform %s", req.ClientInfo.Platform)
// }
//
// return &hibikemediaplayer.PlayResponse{
// OpenURL: url,
// }, nil
//}
//
//func getOutplayerUrl(url string) (ret string) {
// ret = strings.Replace(url, "http://", "outplayer://", 1)
// ret = strings.Replace(ret, "https://", "outplayer://", 1)
// return
//}
//
//func (m *MobilePlayer) GetPlaybackStatus() (*hibikemediaplayer.PlaybackStatus, error) {
// return nil, fmt.Errorf("not implemented")
//}
//
//func (m *MobilePlayer) Start() error {
// return nil
//}
//
//func (m *MobilePlayer) Stop() error {
// return nil
//}

View File

@@ -0,0 +1,46 @@
{
"id": "mobileplayer",
"name": "MobilePlayer",
"description": "",
"version": "0.0.1",
"type": "mediaplayer",
"manifestURI": "",
"language": "go",
"author": "Seanime",
"config": {
"requiresConfig": true,
"fields": [
{
"type": "select",
"label": "iOS Player",
"name": "iosPlayer",
"options": [
{
"label": "Outplayer",
"value": "outplayer"
},
{
"label": "VLC",
"value": "vlc"
}
]
},
{
"type": "select",
"label": "Android Player",
"name": "androidPlayer",
"options": [
{
"label": "VLC",
"value": "vlc"
},
{
"label": "MX Player",
"value": "mxplayer"
}
]
}
]
},
"payload": ""
}

View File

@@ -0,0 +1,169 @@
package extension_repo
import (
"seanime/internal/events"
"seanime/internal/extension"
"seanime/internal/manga/providers"
"seanime/internal/onlinestream/providers"
"seanime/internal/torrents/animetosho"
"seanime/internal/torrents/nyaa"
"seanime/internal/torrents/seadex"
"seanime/internal/util"
"seanime/internal/util/filecache"
"testing"
)
func GetMockExtensionRepository(t *testing.T) *Repository {
logger := util.NewLogger()
filecacher, _ := filecache.NewCacher(t.TempDir())
extensionRepository := NewRepository(&NewRepositoryOptions{
Logger: logger,
ExtensionDir: t.TempDir(),
WSEventManager: events.NewMockWSEventManager(logger),
FileCacher: filecacher,
})
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "comick",
Name: "ComicK",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeMangaProvider,
Author: "Seanime",
Description: "",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/comick.webp",
}, manga_providers.NewComicK(logger))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "comick-multi",
Name: "ComicK (Multi)",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeMangaProvider,
Author: "Seanime",
Description: "",
Lang: "multi",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/comick.webp",
}, manga_providers.NewComicKMulti(logger))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "mangapill",
Name: "Mangapill",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeMangaProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/mangapill.png",
}, manga_providers.NewMangapill(logger))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "mangadex",
Name: "Mangadex",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeMangaProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/mangadex.png",
}, manga_providers.NewMangadex(logger))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "manganato",
Name: "Manganato",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeMangaProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/manganato.png",
}, manga_providers.NewManganato(logger))
//
// Built-in online stream providers
//
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "gogoanime",
Name: "Gogoanime",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeOnlinestreamProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/gogoanime.png",
}, onlinestream_providers.NewGogoanime(logger))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "zoro",
Name: "Hianime",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeOnlinestreamProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/hianime.png",
}, onlinestream_providers.NewZoro(logger))
//
// Built-in torrent providers
//
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "nyaa",
Name: "Nyaa",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeAnimeTorrentProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/nyaa.png",
}, nyaa.NewProvider(logger, nyaa.CategoryAnimeEng))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "nyaa-sukebei",
Name: "Nyaa Sukebei",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeAnimeTorrentProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/nyaa.png",
}, nyaa.NewSukebeiProvider(logger))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "animetosho",
Name: "AnimeTosho",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeAnimeTorrentProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/animetosho.png",
}, animetosho.NewProvider(logger))
extensionRepository.ReloadBuiltInExtension(extension.Extension{
ID: "seadex",
Name: "SeaDex",
Version: "",
ManifestURI: "builtin",
Language: extension.LanguageGo,
Type: extension.TypeAnimeTorrentProvider,
Author: "Seanime",
Lang: "en",
Icon: "https://raw.githubusercontent.com/5rahim/hibike/main/icons/seadex.png",
}, seadex.NewProvider(logger))
return extensionRepository
}

View File

@@ -0,0 +1,39 @@
package extension_repo_test
import (
"github.com/davecgh/go-spew/spew"
"github.com/stretchr/testify/require"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
"testing"
)
func TestExternalGoOnlinestreamProviderExtension(t *testing.T) {
repo := getRepo(t)
// Load all extensions
// This should load all the extensions in the directory
repo.ReloadExternalExtensions()
ext, found := repo.GetOnlinestreamProviderExtensionByID("gogoanime-external")
require.True(t, found)
t.Logf("\nExtension:\n\tID: %s \n\tName: %s", ext.GetID(), ext.GetName())
searchResults, err := ext.GetProvider().Search(hibikeonlinestream.SearchOptions{
Query: "Blue Lock",
Dub: false,
})
require.NoError(t, err)
require.GreaterOrEqual(t, len(searchResults), 1)
episodes, err := ext.GetProvider().FindEpisodes(searchResults[0].ID)
require.NoError(t, err)
require.GreaterOrEqual(t, len(episodes), 1)
server, err := ext.GetProvider().FindEpisodeServer(episodes[0], ext.GetProvider().GetSettings().EpisodeServers[0])
require.NoError(t, err)
spew.Dump(server)
}

View File

@@ -0,0 +1,343 @@
package extension_repo
import (
"context"
"net/http"
"os"
"seanime/internal/events"
"seanime/internal/extension"
hibikemanga "seanime/internal/extension/hibike/manga"
hibiketorrent "seanime/internal/extension/hibike/torrent"
"seanime/internal/goja/goja_runtime"
"seanime/internal/hook"
"seanime/internal/util"
"seanime/internal/util/filecache"
"seanime/internal/util/result"
"sync"
"time"
"github.com/rs/zerolog"
"github.com/samber/lo"
)
type (
// Repository manages all extensions
Repository struct {
logger *zerolog.Logger
fileCacher *filecache.Cacher
wsEventManager events.WSEventManagerInterface
// Absolute path to the directory containing all extensions
extensionDir string
// Store all active Goja VMs
// - When reloading extensions, all VMs are interrupted
gojaExtensions *result.Map[string, GojaExtension]
gojaRuntimeManager *goja_runtime.Manager
// Extension bank
// - When reloading extensions, external extensions are removed & re-added
extensionBank *extension.UnifiedBank
invalidExtensions *result.Map[string, *extension.InvalidExtension]
hookManager hook.Manager
client *http.Client
// Cache the of all built-in extensions when they're first loaded
// This is used to quickly determine if an extension is built-in or not and to reload them
builtinExtensions *result.Map[string, *builtinExtension]
updateData []UpdateData
updateDataMu sync.Mutex
// Called when the external extensions are loaded for the first time
firstExternalExtensionLoadedFunc context.CancelFunc
}
builtinExtension struct {
extension.Extension
provider interface{}
}
AllExtensions struct {
Extensions []*extension.Extension `json:"extensions"`
InvalidExtensions []*extension.InvalidExtension `json:"invalidExtensions"`
// List of extensions with invalid user config extensions, these extensions are still loaded
InvalidUserConfigExtensions []*extension.InvalidExtension `json:"invalidUserConfigExtensions"`
// List of extension IDs that have an update available
// This is only populated when the user clicks on "Check for updates"
HasUpdate []UpdateData `json:"hasUpdate"`
}
UpdateData struct {
ExtensionID string `json:"extensionID"`
ManifestURI string `json:"manifestURI"`
Version string `json:"version"`
}
MangaProviderExtensionItem struct {
ID string `json:"id"`
Name string `json:"name"`
Lang string `json:"lang"` // ISO 639-1 language code
Settings hibikemanga.Settings `json:"settings"`
}
OnlinestreamProviderExtensionItem struct {
ID string `json:"id"`
Name string `json:"name"`
Lang string `json:"lang"` // ISO 639-1 language code
EpisodeServers []string `json:"episodeServers"`
SupportsDub bool `json:"supportsDub"`
}
AnimeTorrentProviderExtensionItem struct {
ID string `json:"id"`
Name string `json:"name"`
Lang string `json:"lang"` // ISO 639-1 language code
Settings hibiketorrent.AnimeProviderSettings `json:"settings"`
}
)
type NewRepositoryOptions struct {
Logger *zerolog.Logger
ExtensionDir string
WSEventManager events.WSEventManagerInterface
FileCacher *filecache.Cacher
HookManager hook.Manager
}
func NewRepository(opts *NewRepositoryOptions) *Repository {
// Make sure the extension directory exists
_ = os.MkdirAll(opts.ExtensionDir, os.ModePerm)
ret := &Repository{
logger: opts.Logger,
extensionDir: opts.ExtensionDir,
wsEventManager: opts.WSEventManager,
gojaExtensions: result.NewResultMap[string, GojaExtension](),
gojaRuntimeManager: goja_runtime.NewManager(opts.Logger),
extensionBank: extension.NewUnifiedBank(),
invalidExtensions: result.NewResultMap[string, *extension.InvalidExtension](),
fileCacher: opts.FileCacher,
hookManager: opts.HookManager,
client: http.DefaultClient,
builtinExtensions: result.NewResultMap[string, *builtinExtension](),
updateData: make([]UpdateData, 0),
}
firstExtensionLoadedCtx, firstExtensionLoadedCancel := context.WithCancel(context.Background())
ret.firstExternalExtensionLoadedFunc = firstExtensionLoadedCancel
// Fetch extension updates at launch and every 12 hours
go func(firstExtensionLoadedCtx context.Context) {
defer util.HandlePanicInModuleThen("extension_repo/fetchExtensionUpdates", func() {
ret.firstExternalExtensionLoadedFunc = nil
})
for {
if ret.firstExternalExtensionLoadedFunc != nil {
// Block until the first external extensions are loaded
select {
case <-firstExtensionLoadedCtx.Done():
}
}
ret.firstExternalExtensionLoadedFunc = nil
ret.updateData = ret.checkForUpdates()
if len(ret.updateData) > 0 {
// Signal the frontend that there are updates available
ret.wsEventManager.SendEvent(events.ExtensionUpdatesFound, ret.updateData)
}
time.Sleep(12 * time.Hour)
}
}(firstExtensionLoadedCtx)
return ret
}
func (r *Repository) GetAllExtensions(withUpdates bool) (ret *AllExtensions) {
invalidExtensions := r.ListInvalidExtensions()
fatalInvalidExtensions := lo.Filter(invalidExtensions, func(ext *extension.InvalidExtension, _ int) bool {
return ext.Code != extension.InvalidExtensionUserConfigError
})
userConfigInvalidExtensions := lo.Filter(invalidExtensions, func(ext *extension.InvalidExtension, _ int) bool {
return ext.Code == extension.InvalidExtensionUserConfigError
})
ret = &AllExtensions{
Extensions: r.ListExtensionData(),
InvalidExtensions: fatalInvalidExtensions,
InvalidUserConfigExtensions: userConfigInvalidExtensions,
}
// Send the update data to the frontend if there are any updates
if len(r.updateData) > 0 {
ret.HasUpdate = r.updateData
}
if withUpdates {
ret.HasUpdate = r.checkForUpdates()
r.updateData = ret.HasUpdate
}
return
}
func (r *Repository) GetUpdateData() (ret []UpdateData) {
return r.updateData
}
func (r *Repository) ListExtensionData() (ret []*extension.Extension) {
r.extensionBank.Range(func(key string, ext extension.BaseExtension) bool {
retExt := extension.ToExtensionData(ext)
retExt.Payload = ""
ret = append(ret, retExt)
return true
})
return ret
}
func (r *Repository) ListDevelopmentModeExtensions() (ret []*extension.Extension) {
r.extensionBank.Range(func(key string, ext extension.BaseExtension) bool {
if ext.GetIsDevelopment() {
retExt := extension.ToExtensionData(ext)
retExt.Payload = ""
ret = append(ret, retExt)
}
return true
})
return ret
}
func (r *Repository) ListInvalidExtensions() (ret []*extension.InvalidExtension) {
r.invalidExtensions.Range(func(key string, ext *extension.InvalidExtension) bool {
ext.Extension.Payload = ""
ret = append(ret, ext)
return true
})
return ret
}
func (r *Repository) GetExtensionPayload(id string) (ret string) {
ext, found := r.extensionBank.Get(id)
if !found {
return ""
}
return ext.GetPayload()
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// Lists
// - Lists are used to display available options to the user based on the extensions installed
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
func (r *Repository) ListMangaProviderExtensions() []*MangaProviderExtensionItem {
ret := make([]*MangaProviderExtensionItem, 0)
extension.RangeExtensions(r.extensionBank, func(key string, ext extension.MangaProviderExtension) bool {
settings := ext.GetProvider().GetSettings()
ret = append(ret, &MangaProviderExtensionItem{
ID: ext.GetID(),
Name: ext.GetName(),
Lang: extension.GetExtensionLang(ext.GetLang()),
Settings: settings,
})
return true
})
return ret
}
func (r *Repository) ListOnlinestreamProviderExtensions() []*OnlinestreamProviderExtensionItem {
ret := make([]*OnlinestreamProviderExtensionItem, 0)
extension.RangeExtensions(r.extensionBank, func(key string, ext extension.OnlinestreamProviderExtension) bool {
settings := ext.GetProvider().GetSettings()
ret = append(ret, &OnlinestreamProviderExtensionItem{
ID: ext.GetID(),
Name: ext.GetName(),
Lang: extension.GetExtensionLang(ext.GetLang()),
EpisodeServers: settings.EpisodeServers,
SupportsDub: settings.SupportsDub,
})
return true
})
return ret
}
func (r *Repository) ListAnimeTorrentProviderExtensions() []*AnimeTorrentProviderExtensionItem {
ret := make([]*AnimeTorrentProviderExtensionItem, 0)
extension.RangeExtensions(r.extensionBank, func(key string, ext extension.AnimeTorrentProviderExtension) bool {
settings := ext.GetProvider().GetSettings()
ret = append(ret, &AnimeTorrentProviderExtensionItem{
ID: ext.GetID(),
Name: ext.GetName(),
Lang: extension.GetExtensionLang(ext.GetLang()),
Settings: hibiketorrent.AnimeProviderSettings{
Type: settings.Type,
CanSmartSearch: settings.CanSmartSearch,
SupportsAdult: settings.SupportsAdult,
SmartSearchFilters: lo.Map(settings.SmartSearchFilters, func(value hibiketorrent.AnimeProviderSmartSearchFilter, _ int) hibiketorrent.AnimeProviderSmartSearchFilter {
return value
}),
},
})
return true
})
return ret
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// GetLoadedExtension returns the loaded extension by ID.
// It returns an extension.BaseExtension interface, so it can be used to get the extension's details.
func (r *Repository) GetLoadedExtension(id string) (extension.BaseExtension, bool) {
var ext extension.BaseExtension
ext, found := r.extensionBank.Get(id)
if found {
return ext, true
}
return nil, false
}
func (r *Repository) GetExtensionBank() *extension.UnifiedBank {
return r.extensionBank
}
func (r *Repository) GetMangaProviderExtensionByID(id string) (extension.MangaProviderExtension, bool) {
ext, found := extension.GetExtension[extension.MangaProviderExtension](r.extensionBank, id)
return ext, found
}
func (r *Repository) GetOnlinestreamProviderExtensionByID(id string) (extension.OnlinestreamProviderExtension, bool) {
ext, found := extension.GetExtension[extension.OnlinestreamProviderExtension](r.extensionBank, id)
return ext, found
}
func (r *Repository) GetAnimeTorrentProviderExtensionByID(id string) (extension.AnimeTorrentProviderExtension, bool) {
ext, found := extension.GetExtension[extension.AnimeTorrentProviderExtension](r.extensionBank, id)
return ext, found
}
func (r *Repository) loadPlugin(ext *extension.Extension) (err error) {
defer util.HandlePanicInModuleWithError("extension_repo/loadPlugin", &err)
err = r.loadPluginExtension(ext)
if err != nil {
r.logger.Error().Err(err).Str("id", ext.ID).Msg("extensions: Failed to load plugin")
return err
}
r.logger.Debug().Str("id", ext.ID).Msg("extensions: Loaded plugin")
return
}

View File

@@ -0,0 +1,19 @@
package extension_repo_test
import (
"seanime/internal/events"
"seanime/internal/extension_repo"
"seanime/internal/util"
"testing"
)
func getRepo(t *testing.T) *extension_repo.Repository {
logger := util.NewLogger()
wsEventManager := events.NewMockWSEventManager(logger)
return extension_repo.NewRepository(&extension_repo.NewRepositoryOptions{
Logger: logger,
ExtensionDir: "testdir",
WSEventManager: wsEventManager,
})
}

View File

@@ -0,0 +1,614 @@
package main
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"github.com/goccy/go-json"
"github.com/gocolly/colly"
"github.com/rs/zerolog"
"io"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
hibikeonlinestream "seanime/internal/extension/hibike/onlinestream"
)
const (
DefaultServer = "default"
GogoanimeProvider = "gogoanime-external"
GogocdnServer = "gogocdn"
VidstreamingServer = "vidstreaming"
StreamSBServer = "streamsb"
)
type Gogoanime struct {
BaseURL string
AjaxURL string
Client http.Client
UserAgent string
logger *zerolog.Logger
}
func NewProvider(logger *zerolog.Logger) hibikeonlinestream.Provider {
return &Gogoanime{
BaseURL: "https://anitaku.to",
AjaxURL: "https://ajax.gogocdn.net",
Client: http.Client{},
UserAgent: util.GetRandomUserAgent(),
logger: logger,
}
}
func (g *Gogoanime) GetEpisodeServers() []string {
return []string{GogocdnServer, VidstreamingServer}
}
func (g *Gogoanime) Search(query string, dubbed bool) ([]*hibikeonlinestream.SearchResult, error) {
var results []*hibikeonlinestream.SearchResult
g.logger.Debug().Str("query", query).Bool("dubbed", dubbed).Msg("gogoanime: Searching anime")
c := colly.NewCollector(
colly.UserAgent(g.UserAgent),
)
c.OnHTML(".last_episodes > ul > li", func(e *colly.HTMLElement) {
id := ""
idParts := strings.Split(e.ChildAttr("p.name > a", "href"), "/")
if len(idParts) > 2 {
id = idParts[2]
}
title := e.ChildText("p.name > a")
url := g.BaseURL + e.ChildAttr("p.name > a", "href")
subOrDub := hibikeonlinestream.Sub
if strings.Contains(strings.ToLower(e.ChildText("p.name > a")), "dub") {
subOrDub = hibikeonlinestream.Dub
}
results = append(results, &hibikeonlinestream.SearchResult{
ID: id,
Title: title,
URL: url,
SubOrDub: subOrDub,
})
})
searchURL := g.BaseURL + "/search.html?keyword=" + url.QueryEscape(query)
if dubbed {
searchURL += "%20(Dub)"
}
err := c.Visit(searchURL)
if err != nil {
return nil, err
}
g.logger.Debug().Int("count", len(results)).Msg("gogoanime: Fetched anime")
return results, nil
}
func (g *Gogoanime) FindEpisode(id string) ([]*hibikeonlinestream.EpisodeDetails, error) {
var episodes []*hibikeonlinestream.EpisodeDetails
g.logger.Debug().Str("id", id).Msg("gogoanime: Fetching episodes")
if !strings.Contains(id, "gogoanime") {
id = fmt.Sprintf("%s/category/%s", g.BaseURL, id)
}
c := colly.NewCollector(
colly.UserAgent(g.UserAgent),
)
var epStart, epEnd, movieID, alias string
c.OnHTML("#episode_page > li > a", func(e *colly.HTMLElement) {
if epStart == "" {
epStart = e.Attr("ep_start")
}
epEnd = e.Attr("ep_end")
})
c.OnHTML("#movie_id", func(e *colly.HTMLElement) {
movieID = e.Attr("value")
})
c.OnHTML("#alias", func(e *colly.HTMLElement) {
alias = e.Attr("value")
})
err := c.Visit(id)
if err != nil {
g.logger.Error().Err(err).Msg("gogoanime: Failed to fetch episodes")
return nil, err
}
c2 := colly.NewCollector(
colly.UserAgent(g.UserAgent),
)
c2.OnHTML("#episode_related > li", func(e *colly.HTMLElement) {
episodeIDParts := strings.Split(e.ChildAttr("a", "href"), "/")
if len(episodeIDParts) < 2 {
return
}
episodeID := strings.TrimSpace(episodeIDParts[1])
episodeNumberStr := strings.TrimPrefix(e.ChildText("div.name"), "EP ")
episodeNumber, err := strconv.Atoi(episodeNumberStr)
if err != nil {
g.logger.Error().Err(err).Str("episodeID", episodeID).Msg("failed to parse episode number")
return
}
episodes = append(episodes, &hibikeonlinestream.EpisodeDetails{
Provider: GogoanimeProvider,
ID: episodeID,
Number: episodeNumber,
URL: g.BaseURL + "/" + episodeID,
})
})
ajaxURL := fmt.Sprintf("%s/ajax/load-list-episode", g.AjaxURL)
ajaxParams := url.Values{
"ep_start": {epStart},
"ep_end": {epEnd},
"id": {movieID},
"alias": {alias},
"default_ep": {"0"},
}
ajaxURLWithParams := fmt.Sprintf("%s?%s", ajaxURL, ajaxParams.Encode())
err = c2.Visit(ajaxURLWithParams)
if err != nil {
g.logger.Error().Err(err).Msg("gogoanime: Failed to fetch episodes")
return nil, err
}
g.logger.Debug().Int("count", len(episodes)).Msg("gogoanime: Fetched episodes")
return episodes, nil
}
func (g *Gogoanime) FindEpisodeServer(episodeInfo *hibikeonlinestream.EpisodeDetails, server string) (*hibikeonlinestream.EpisodeServer, error) {
var source *hibikeonlinestream.EpisodeServer
if server == DefaultServer {
server = GogocdnServer
}
g.logger.Debug().Str("server", string(server)).Str("episodeID", episodeInfo.ID).Msg("gogoanime: Fetching server sources")
c := colly.NewCollector()
switch server {
case VidstreamingServer:
c.OnHTML(".anime_muti_link > ul > li.vidcdn > a", func(e *colly.HTMLElement) {
src := e.Attr("data-video")
gogocdn := NewGogoCDN()
videoSources, err := gogocdn.Extract(src)
if err == nil {
source = &hibikeonlinestream.EpisodeServer{
Provider: GogoanimeProvider,
Server: server,
Headers: map[string]string{
"Referer": g.BaseURL + "/" + episodeInfo.ID,
},
VideoSources: videoSources,
}
}
})
case GogocdnServer, "":
c.OnHTML("#load_anime > div > div > iframe", func(e *colly.HTMLElement) {
src := e.Attr("src")
gogocdn := NewGogoCDN()
videoSources, err := gogocdn.Extract(src)
if err == nil {
source = &hibikeonlinestream.EpisodeServer{
Provider: GogoanimeProvider,
Server: server,
Headers: map[string]string{
"Referer": g.BaseURL + "/" + episodeInfo.ID,
},
VideoSources: videoSources,
}
}
})
case StreamSBServer:
c.OnHTML(".anime_muti_link > ul > li.streamsb > a", func(e *colly.HTMLElement) {
src := e.Attr("data-video")
streamsb := NewStreamSB()
videoSources, err := streamsb.Extract(src)
if err == nil {
source = &hibikeonlinestream.EpisodeServer{
Provider: GogoanimeProvider,
Server: server,
Headers: map[string]string{
"Referer": g.BaseURL + "/" + episodeInfo.ID,
"watchsb": "streamsb",
"User-Agent": g.UserAgent,
},
VideoSources: videoSources,
}
}
})
}
err := c.Visit(g.BaseURL + "/" + episodeInfo.ID)
if err != nil {
return nil, err
}
if source == nil {
g.logger.Warn().Str("server", string(server)).Msg("gogoanime: No sources found")
return nil, fmt.Errorf("no sources found")
}
g.logger.Debug().Str("server", string(server)).Int("videoSources", len(source.VideoSources)).Msg("gogoanime: Fetched server sources")
return source, nil
}
type cdnKeys struct {
key []byte
secondKey []byte
iv []byte
}
type GogoCDN struct {
client *http.Client
serverName string
keys cdnKeys
referrer string
}
func NewGogoCDN() *GogoCDN {
return &GogoCDN{
client: &http.Client{},
serverName: "goload",
keys: cdnKeys{
key: []byte("37911490979715163134003223491201"),
secondKey: []byte("54674138327930866480207815084989"),
iv: []byte("3134003223491201"),
},
}
}
// Extract fetches and extracts video sources from the provided URI.
func (g *GogoCDN) Extract(uri string) (vs []*hibikeonlinestream.VideoSource, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("failed to extract video sources")
}
}()
// Instantiate a new collector
c := colly.NewCollector(
// Allow visiting the same page multiple times
colly.AllowURLRevisit(),
)
ur, err := url.Parse(uri)
if err != nil {
return nil, err
}
// Variables to hold extracted values
var scriptValue, id string
id = ur.Query().Get("id")
// Find and extract the script value and id
c.OnHTML("script[data-name='episode']", func(e *colly.HTMLElement) {
scriptValue = e.Attr("data-value")
})
// Start scraping
err = c.Visit(uri)
if err != nil {
return nil, err
}
// Check if scriptValue and id are found
if scriptValue == "" || id == "" {
return nil, errors.New("script value or id not found")
}
// Extract video sources
ajaxUrl := fmt.Sprintf("%s://%s/encrypt-ajax.php?%s", ur.Scheme, ur.Host, g.generateEncryptedAjaxParams(id, scriptValue))
req, err := http.NewRequest("GET", ajaxUrl, nil)
if err != nil {
return nil, err
}
req.Header.Set("X-Requested-With", "XMLHttpRequest")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/javascript, */*; q=0.01")
encryptedData, err := g.client.Do(req)
if err != nil {
return nil, err
}
defer encryptedData.Body.Close()
encryptedDataBytesRes, err := io.ReadAll(encryptedData.Body)
if err != nil {
return nil, err
}
var encryptedDataBytes map[string]string
err = json.Unmarshal(encryptedDataBytesRes, &encryptedDataBytes)
if err != nil {
return nil, err
}
data, err := g.decryptAjaxData(encryptedDataBytes["data"])
source, ok := data["source"].([]interface{})
// Check if source is found
if !ok {
return nil, errors.New("source not found")
}
var results []*hibikeonlinestream.VideoSource
urls := make([]string, 0)
for _, src := range source {
s := src.(map[string]interface{})
urls = append(urls, s["file"].(string))
}
sourceBK, ok := data["source_bk"].([]interface{})
if ok {
for _, src := range sourceBK {
s := src.(map[string]interface{})
urls = append(urls, s["file"].(string))
}
}
for _, url := range urls {
vs, ok := g.urlToVideoSource(url, source, sourceBK)
if ok {
results = append(results, vs...)
}
}
return results, nil
}
func (g *GogoCDN) urlToVideoSource(url string, source []interface{}, sourceBK []interface{}) (vs []*hibikeonlinestream.VideoSource, ok bool) {
defer func() {
if r := recover(); r != nil {
ok = false
}
}()
ret := make([]*hibikeonlinestream.VideoSource, 0)
if strings.Contains(url, ".m3u8") {
resResult, err := http.Get(url)
if err != nil {
return nil, false
}
defer resResult.Body.Close()
bodyBytes, err := io.ReadAll(resResult.Body)
if err != nil {
return nil, false
}
bodyString := string(bodyBytes)
resolutions := regexp.MustCompile(`(RESOLUTION=)(.*)(\s*?)(\s.*)`).FindAllStringSubmatch(bodyString, -1)
baseURL := url[:strings.LastIndex(url, "/")]
for _, res := range resolutions {
quality := strings.Split(strings.Split(res[2], "x")[1], ",")[0]
url := fmt.Sprintf("%s/%s", baseURL, strings.TrimSpace(res[4]))
ret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceM3U8, Quality: quality + "p"})
}
ret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceM3U8, Quality: "default"})
} else {
for _, src := range source {
s := src.(map[string]interface{})
if s["file"].(string) == url {
quality := strings.Split(s["label"].(string), " ")[0] + "p"
ret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceMP4, Quality: quality})
}
}
if sourceBK != nil {
for _, src := range sourceBK {
s := src.(map[string]interface{})
if s["file"].(string) == url {
ret = append(ret, &hibikeonlinestream.VideoSource{URL: url, Type: hibikeonlinestream.VideoSourceMP4, Quality: "backup"})
}
}
}
}
return ret, true
}
// generateEncryptedAjaxParams generates encrypted AJAX parameters.
func (g *GogoCDN) generateEncryptedAjaxParams(id, scriptValue string) string {
encryptedKey := g.encrypt(id, g.keys.iv, g.keys.key)
decryptedToken := g.decrypt(scriptValue, g.keys.iv, g.keys.key)
return fmt.Sprintf("id=%s&alias=%s", encryptedKey, decryptedToken)
}
// encrypt encrypts the given text using AES CBC mode.
func (g *GogoCDN) encrypt(text string, iv []byte, key []byte) string {
block, _ := aes.NewCipher(key)
textBytes := []byte(text)
textBytes = pkcs7Padding(textBytes, aes.BlockSize)
cipherText := make([]byte, len(textBytes))
mode := cipher.NewCBCEncrypter(block, iv)
mode.CryptBlocks(cipherText, textBytes)
return base64.StdEncoding.EncodeToString(cipherText)
}
// decrypt decrypts the given text using AES CBC mode.
func (g *GogoCDN) decrypt(text string, iv []byte, key []byte) string {
block, _ := aes.NewCipher(key)
cipherText, _ := base64.StdEncoding.DecodeString(text)
plainText := make([]byte, len(cipherText))
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(plainText, cipherText)
plainText = pkcs7Trimming(plainText)
return string(plainText)
}
func (g *GogoCDN) decryptAjaxData(encryptedData string) (map[string]interface{}, error) {
decodedData, err := base64.StdEncoding.DecodeString(encryptedData)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(g.keys.secondKey)
if err != nil {
return nil, err
}
if len(decodedData) < aes.BlockSize {
return nil, fmt.Errorf("cipher text too short")
}
iv := g.keys.iv
mode := cipher.NewCBCDecrypter(block, iv)
mode.CryptBlocks(decodedData, decodedData)
// Remove padding
decodedData = pkcs7Trimming(decodedData)
var data map[string]interface{}
err = json.Unmarshal(decodedData, &data)
if err != nil {
return nil, err
}
return data, nil
}
// pkcs7Padding pads the text to be a multiple of blockSize using Pkcs7 padding.
func pkcs7Padding(text []byte, blockSize int) []byte {
padding := blockSize - len(text)%blockSize
padText := bytes.Repeat([]byte{byte(padding)}, padding)
return append(text, padText...)
}
// pkcs7Trimming removes Pkcs7 padding from the text.
func pkcs7Trimming(text []byte) []byte {
length := len(text)
unpadding := int(text[length-1])
return text[:(length - unpadding)]
}
type StreamSB struct {
Host string
Host2 string
UserAgent string
}
func NewStreamSB() *StreamSB {
return &StreamSB{
Host: "https://streamsss.net/sources50",
Host2: "https://watchsb.com/sources50",
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36",
}
}
func (s *StreamSB) Payload(hex string) string {
return "566d337678566f743674494a7c7c" + hex + "7c7c346b6767586d6934774855537c7c73747265616d7362/6565417268755339773461447c7c346133383438333436313335376136323337373433383634376337633465366534393338373136643732373736343735373237613763376334363733353737303533366236333463353333363534366137633763373337343732363536313664373336327c7c6b586c3163614468645a47617c7c73747265616d7362"
}
func (s *StreamSB) Extract(uri string) (vs []*hibikeonlinestream.VideoSource, err error) {
defer func() {
if r := recover(); r != nil {
err = errors.New("failed to extract video sources")
}
}()
var ret []*hibikeonlinestream.VideoSource
id := strings.Split(uri, "/e/")[1]
if strings.Contains(id, "html") {
id = strings.Split(id, ".html")[0]
}
if id == "" {
return nil, errors.New("cannot find ID")
}
client := &http.Client{}
req, _ := http.NewRequest("GET", fmt.Sprintf("%s/%s", s.Host, s.Payload(hex.EncodeToString([]byte(id)))), nil)
req.Header.Add("watchsb", "sbstream")
req.Header.Add("User-Agent", s.UserAgent)
req.Header.Add("Referer", uri)
res, err := client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
var jsonResponse map[string]interface{}
err = json.Unmarshal(body, &jsonResponse)
if err != nil {
return nil, err
}
streamData, ok := jsonResponse["stream_data"].(map[string]interface{})
if !ok {
return nil, fmt.Errorf("stream data not found")
}
m3u8Urls, err := client.Get(streamData["file"].(string))
if err != nil {
return nil, err
}
defer m3u8Urls.Body.Close()
m3u8Body, err := io.ReadAll(m3u8Urls.Body)
if err != nil {
return nil, err
}
videoList := strings.Split(string(m3u8Body), "#EXT-X-STREAM-INF:")
for _, video := range videoList {
if !strings.Contains(video, "m3u8") {
continue
}
url := strings.Split(video, "\n")[1]
quality := strings.Split(strings.Split(video, "RESOLUTION=")[1], ",")[0]
quality = strings.Split(quality, "x")[1]
ret = append(ret, &hibikeonlinestream.VideoSource{
URL: url,
Quality: quality + "p",
Type: hibikeonlinestream.VideoSourceM3U8,
})
}
ret = append(ret, &hibikeonlinestream.VideoSource{
URL: streamData["file"].(string),
Quality: "auto",
Type: map[bool]hibikeonlinestream.VideoSourceType{true: hibikeonlinestream.VideoSourceM3U8, false: hibikeonlinestream.VideoSourceMP4}[strings.Contains(streamData["file"].(string), ".m3u8")],
})
return ret, nil
}

View File

@@ -0,0 +1,226 @@
package main
import (
"fmt"
"github.com/5rahim/hibike/pkg/util/bypass"
"github.com/5rahim/hibike/pkg/util/common"
"github.com/5rahim/hibike/pkg/util/similarity"
"github.com/gocolly/colly"
"github.com/rs/zerolog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
const MangapillProvider = "mangapill-external"
type (
Mangapill struct {
Url string
Client *http.Client
UserAgent string
logger *zerolog.Logger
}
)
func NewProvider(logger *zerolog.Logger) manga.Provider {
c := &http.Client{
Timeout: 60 * time.Second,
}
c.Transport = bypass.AddCloudFlareByPass(c.Transport)
return &Mangapill{
Url: "https://mangapill.com",
Client: c,
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.3",
logger: logger,
}
}
// DEVNOTE: Unique ID
// Each chapter ID has this format: {number}${slug} -- e.g. 6502-10004000$gokurakugai-chapter-4
// The chapter ID is split by the $ character to reconstruct the chapter URL for subsequent requests
func (mp *Mangapill) Search(opts manga.SearchOptions) (ret []*manga.SearchResult, err error) {
ret = make([]*manga.SearchResult, 0)
mp.logger.Debug().Str("query", opts.Query).Msg("mangapill: Searching manga")
uri := fmt.Sprintf("%s/search?q=%s", mp.Url, url.QueryEscape(opts.Query))
c := colly.NewCollector(
colly.UserAgent(mp.UserAgent),
)
c.WithTransport(mp.Client.Transport)
c.OnHTML("div.container div.my-3.justify-end > div", func(e *colly.HTMLElement) {
defer func() {
if r := recover(); r != nil {
}
}()
result := &manga.SearchResult{
Provider: "mangapill",
}
result.ID = strings.Split(e.ChildAttr("a", "href"), "/manga/")[1]
result.ID = strings.Replace(result.ID, "/", "$", -1)
title := e.DOM.Find("div > a > div.mt-3").Text()
result.Title = strings.TrimSpace(title)
altTitles := e.DOM.Find("div > a > div.text-xs.text-secondary").Text()
if altTitles != "" {
result.Synonyms = []string{strings.TrimSpace(altTitles)}
}
compTitles := []string{result.Title}
if len(result.Synonyms) > 0 {
compTitles = append(compTitles, result.Synonyms[0])
}
compRes, _ := similarity.FindBestMatchWithSorensenDice(opts.Query, compTitles)
result.SearchRating = compRes.Rating
result.Image = e.ChildAttr("a img", "data-src")
yearStr := e.DOM.Find("div > div.flex > div").Eq(1).Text()
year, err := strconv.Atoi(strings.TrimSpace(yearStr))
if err != nil {
result.Year = 0
} else {
result.Year = year
}
ret = append(ret, result)
})
err = c.Visit(uri)
if err != nil {
mp.logger.Error().Err(err).Msg("mangapill: Failed to visit")
return nil, err
}
// code
if len(ret) == 0 {
mp.logger.Error().Str("query", opts.Query).Msg("mangapill: No results found")
return nil, fmt.Errorf("no results found")
}
mp.logger.Info().Int("count", len(ret)).Msg("mangapill: Found results")
return ret, nil
}
func (mp *Mangapill) FindChapters(id string) (ret []*manga.ChapterDetails, err error) {
ret = make([]*manga.ChapterDetails, 0)
mp.logger.Debug().Str("mangaId", id).Msg("mangapill: Finding chapters")
uriId := strings.Replace(id, "$", "/", -1)
uri := fmt.Sprintf("%s/manga/%s", mp.Url, uriId)
c := colly.NewCollector(
colly.UserAgent(mp.UserAgent),
)
c.WithTransport(mp.Client.Transport)
c.OnHTML("div.container div.border-border div#chapters div.grid-cols-1 a", func(e *colly.HTMLElement) {
defer func() {
if r := recover(); r != nil {
}
}()
chapter := &manga.ChapterDetails{
Provider: MangapillProvider,
}
chapter.ID = strings.Split(e.Attr("href"), "/chapters/")[1]
chapter.ID = strings.Replace(chapter.ID, "/", "$", -1)
chapter.Title = strings.TrimSpace(e.Text)
splitTitle := strings.Split(chapter.Title, "Chapter ")
if len(splitTitle) < 2 {
return
}
chapter.Chapter = splitTitle[1]
ret = append(ret, chapter)
})
err = c.Visit(uri)
if err != nil {
mp.logger.Error().Err(err).Msg("mangapill: Failed to visit")
return nil, err
}
if len(ret) == 0 {
mp.logger.Error().Str("mangaId", id).Msg("mangapill: No chapters found")
return nil, fmt.Errorf("no chapters found")
}
common.Reverse(ret)
for i, chapter := range ret {
chapter.Index = uint(i)
}
mp.logger.Info().Int("count", len(ret)).Msg("mangapill: Found chapters")
return ret, nil
}
func (mp *Mangapill) FindChapterPages(id string) (ret []*manga.ChapterPage, err error) {
ret = make([]*manga.ChapterPage, 0)
mp.logger.Debug().Str("chapterId", id).Msg("mangapill: Finding chapter pages")
uriId := strings.Replace(id, "$", "/", -1)
uri := fmt.Sprintf("%s/chapters/%s", mp.Url, uriId)
c := colly.NewCollector(
colly.UserAgent(mp.UserAgent),
)
c.WithTransport(mp.Client.Transport)
c.OnHTML("chapter-page", func(e *colly.HTMLElement) {
defer func() {
if r := recover(); r != nil {
}
}()
page := &manga.ChapterPage{}
page.URL = e.DOM.Find("div picture img").AttrOr("data-src", "")
if page.URL == "" {
return
}
indexStr := e.DOM.Find("div[data-summary] > div").Text()
index, _ := strconv.Atoi(strings.Split(strings.Split(indexStr, "page ")[1], "/")[0])
page.Index = index - 1
page.Headers = map[string]string{
"Referer": mp.Url,
}
ret = append(ret, page)
})
err = c.Visit(uri)
if err != nil {
mp.logger.Error().Err(err).Msg("mangapill: Failed to visit")
return nil, err
}
if len(ret) == 0 {
mp.logger.Error().Str("chapterId", id).Msg("mangapill: No pages found")
return nil, fmt.Errorf("no pages found")
}
mp.logger.Info().Int("count", len(ret)).Msg("mangapill: Found pages")
return ret, nil
}

View File

@@ -0,0 +1,69 @@
package main
import (
"net/http"
"time"
bypass "github.com/5rahim/hibike/pkg/util/bypass"
"github.com/rs/zerolog"
torrent "seanime/internal/extension/hibike/torrent"
)
type (
MyAnimeTorrentProvider struct {
url string
client *http.Client
logger *zerolog.Logger
}
)
func NewProvider(logger *zerolog.Logger) torrent.AnimeProvider {
c := &http.Client{
Timeout: 60 * time.Second,
}
c.Transport = bypass.AddCloudFlareByPass(c.Transport)
return &MyAnimeTorrentProvider{
url: "https://example.com",
client: c,
logger: logger,
}
}
func (m *MyAnimeTorrentProvider) Search(opts torrent.AnimeSearchOptions) ([]*torrent.AnimeTorrent, error) {
//TODO implement me
panic("implement me")
}
func (m *MyAnimeTorrentProvider) SmartSearch(opts torrent.AnimeSmartSearchOptions) ([]*torrent.AnimeTorrent, error) {
//TODO implement me
panic("implement me")
}
func (m *MyAnimeTorrentProvider) GetTorrentInfoHash(torrent *torrent.AnimeTorrent) (string, error) {
//TODO implement me
panic("implement me")
}
func (m *MyAnimeTorrentProvider) GetTorrentMagnetLink(torrent *torrent.AnimeTorrent) (string, error) {
//TODO implement me
panic("implement me")
}
func (m *MyAnimeTorrentProvider) GetLatest() ([]*torrent.AnimeTorrent, error) {
//TODO implement me
panic("implement me")
}
func (m *MyAnimeTorrentProvider) GetSettings() torrent.AnimeProviderSettings {
return torrent.AnimeProviderSettings{
CanSmartSearch: true,
SmartSearchFilters: []torrent.AnimeProviderSmartSearchFilter{
torrent.AnimeProviderSmartSearchFilterEpisodeNumber,
torrent.AnimeProviderSmartSearchFilterResolution,
torrent.AnimeProviderSmartSearchFilterQuery,
torrent.AnimeProviderSmartSearchFilterBatch,
},
SupportsAdult: false,
Type: "main",
}
}

View File

@@ -0,0 +1,49 @@
package main
import (
"net/http"
"time"
bypass "github.com/5rahim/hibike/pkg/util/bypass"
"github.com/rs/zerolog"
onlinestream "seanime/internal/extension/hibike/onlinestream"
)
type (
Provider struct {
url string
client *http.Client
logger *zerolog.Logger
}
)
func NewProvider(logger *zerolog.Logger) onlinestream.Provider {
c := &http.Client{
Timeout: 60 * time.Second,
}
c.Transport = bypass.AddCloudFlareByPass(c.Transport)
return &Provider{
url: "https://example.com",
client: c,
logger: logger,
}
}
func (p *Provider) Search(query string, dub bool) ([]*onlinestream.SearchResult, error) {
//TODO implement me
panic("implement me")
}
func (p *Provider) FindEpisode(id string) ([]*onlinestream.EpisodeDetails, error) {
//TODO implement me
panic("implement me")
}
func (p *Provider) FindEpisodeServer(episode *onlinestream.EpisodeDetails, server string) (*onlinestream.EpisodeServer, error) {
//TODO implement me
panic("implement me")
}
func (p *Provider) GetEpisodeServers() []string {
return []string{"server1", "server2"}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
package extension_repo_test

View File

@@ -0,0 +1,159 @@
package extension_repo
import (
"fmt"
"seanime/internal/extension"
"seanime/internal/plugin"
"seanime/internal/util"
"seanime/internal/util/filecache"
"strings"
)
func getExtensionUserConfigBucketKey(extId string) string {
return fmt.Sprintf("ext_user_config_%s", extId)
}
var (
ErrMissingUserConfig = fmt.Errorf("extension: user config is missing")
ErrIncompatibleUserConfig = fmt.Errorf("extension: user config is incompatible")
)
// loadUserConfig loads the user config for the given extension by getting it from the cache and modifying the payload.
// This should be called before loading the extension.
// If the user config is absent OR the current user config is outdated, it will return an error.
// When an error is returned, the extension will not be loaded and the user will be prompted to update the extension on the frontend.
func (r *Repository) loadUserConfig(ext *extension.Extension) (err error) {
defer util.HandlePanicInModuleThen("extension_repo/loadUserConfig", func() {
err = nil
})
// If the extension doesn't define a user config, skip this step
if ext.UserConfig == nil {
return nil
}
bucket := filecache.NewPermanentBucket(getExtensionUserConfigBucketKey(ext.ID))
// Get the user config from the cache
var savedConfig extension.SavedUserConfig
found, _ := r.fileCacher.GetPerm(bucket, ext.ID, &savedConfig)
// No user config found but the extension requires it
if !found && ext.UserConfig.RequiresConfig {
return ErrMissingUserConfig
}
// If the user config is outdated, return an error
if found && savedConfig.Version != ext.UserConfig.Version {
return ErrIncompatibleUserConfig
}
// Store the user config so it's accessible inside the VMs
ext.SavedUserConfig = &savedConfig
if ext.SavedUserConfig.Values == nil {
ext.SavedUserConfig.Values = make(map[string]string)
}
ext.SavedUserConfig.Version = ext.UserConfig.Version
if found {
// Replace the placeholders in the payload with the saved values
for _, field := range ext.UserConfig.Fields {
savedValue, found := savedConfig.Values[field.Name]
if !found {
ext.Payload = strings.ReplaceAll(ext.Payload, fmt.Sprintf("{{%s}}", field.Name), field.Default)
ext.SavedUserConfig.Values[field.Name] = field.Default // Update saved config
} else {
ext.Payload = strings.ReplaceAll(ext.Payload, fmt.Sprintf("{{%s}}", field.Name), savedValue)
ext.SavedUserConfig.Values[field.Name] = savedValue // Update saved config
}
}
return nil
} else {
// If the user config is missing but isn't required, replace the placeholders with the default values
for _, field := range ext.UserConfig.Fields {
ext.Payload = strings.ReplaceAll(ext.Payload, fmt.Sprintf("{{%s}}", field.Name), field.Default)
ext.SavedUserConfig.Values[field.Name] = field.Default // Update saved config
}
}
return nil
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
type ExtensionUserConfig struct {
UserConfig *extension.UserConfig `json:"userConfig"`
SavedUserConfig *extension.SavedUserConfig `json:"savedUserConfig"`
}
func (r *Repository) GetExtensionUserConfig(id string) (ret *ExtensionUserConfig) {
ret = &ExtensionUserConfig{
UserConfig: nil,
SavedUserConfig: nil,
}
defer util.HandlePanicInModuleThen("extension_repo/GetExtensionUserConfig", func() {})
ext, found := r.extensionBank.Get(id)
if !found {
return
}
ret.UserConfig = ext.GetUserConfig()
bucket := filecache.NewPermanentBucket(getExtensionUserConfigBucketKey(id))
var savedConfig extension.SavedUserConfig
found, _ = r.fileCacher.GetPerm(bucket, id, &savedConfig)
if found {
ret.SavedUserConfig = &savedConfig
}
return
}
func (r *Repository) SaveExtensionUserConfig(id string, savedConfig *extension.SavedUserConfig) (err error) {
defer util.HandlePanicInModuleWithError("extension_repo/SaveExtensionUserConfig", &err)
// Save the config
bucket := filecache.NewPermanentBucket(getExtensionUserConfigBucketKey(id))
err = r.fileCacher.SetPerm(bucket, id, savedConfig)
if err != nil {
return err
}
// If the extension is built-in, reload it
builtinExt, isBuiltIn := r.builtinExtensions.Get(id)
if isBuiltIn {
r.reloadBuiltInExtension(builtinExt.Extension, builtinExt.provider)
return nil
}
// Reload the extension
r.reloadExtension(id)
return nil
}
// This should be called when the extension is uninstalled
func (r *Repository) deleteExtensionUserConfig(id string) (err error) {
defer util.HandlePanicInModuleWithError("extension_repo/deleteExtensionUserConfig", &err)
// Delete the config
bucket := filecache.NewPermanentBucket(getExtensionUserConfigBucketKey(id))
err = r.fileCacher.RemovePerm(bucket.Name())
if err != nil {
return err
}
return nil
}
// This should be called when the extension is uninstalled
func (r *Repository) deletePluginData(id string) {
defer util.HandlePanicInModuleThen("extension_repo/deletePluginData", func() {
})
plugin.GlobalAppContext.DropPluginData(id)
}

View File

@@ -0,0 +1,159 @@
package extension_repo
import (
"errors"
"fmt"
"regexp"
"seanime/internal/extension"
"seanime/internal/util"
"strings"
)
func pluginManifestSanityCheck(ext *extension.Extension) error {
// Not a plugin, so no need to check
if ext.Type != extension.TypePlugin {
return nil
}
// Check plugin manifest
if ext.Plugin == nil {
return fmt.Errorf("plugin manifest is missing")
}
// Check plugin manifest version
if ext.Plugin.Version == "" {
return fmt.Errorf("plugin manifest version is missing")
}
// Check plugin permissions version
if ext.Plugin.Version != extension.PluginManifestVersion {
return fmt.Errorf("unsupported plugin manifest version: %v", ext.Plugin.Version)
}
return nil
}
func manifestSanityCheck(ext *extension.Extension) error {
if ext.ID == "" || ext.Name == "" || ext.Version == "" || ext.Language == "" || ext.Type == "" || ext.Author == "" {
return fmt.Errorf("extension is missing required fields, ID: %v, Name: %v, Version: %v, Language: %v, Type: %v, Author: %v, Payload: %v",
ext.ID, ext.Name, ext.Version, ext.Language, ext.Type, ext.Author, len(ext.Payload))
}
if ext.Payload == "" && ext.PayloadURI == "" {
return fmt.Errorf("extension is missing payload and payload URI")
}
// Check the ID
if err := isValidExtensionID(ext.ID); err != nil {
return err
}
// Check name length
if len(ext.Name) > 50 {
return fmt.Errorf("extension name is too long")
}
// Check author length
if len(ext.Author) > 25 {
return fmt.Errorf("extension author is too long")
}
if !util.IsValidVersion(ext.Version) {
return fmt.Errorf("invalid version: %v", ext.Version)
}
// Check language
if ext.Language != extension.LanguageGo &&
ext.Language != extension.LanguageJavascript &&
ext.Language != extension.LanguageTypescript {
return fmt.Errorf("unsupported language: %v", ext.Language)
}
// Check type
if ext.Type != extension.TypeMangaProvider &&
ext.Type != extension.TypeOnlinestreamProvider &&
ext.Type != extension.TypeAnimeTorrentProvider &&
ext.Type != extension.TypePlugin {
return fmt.Errorf("unsupported extension type: %v", ext.Type)
}
if ext.Type == extension.TypePlugin {
if err := pluginManifestSanityCheck(ext); err != nil {
return err
}
}
ext.Lang = strings.ToLower(ext.Lang)
return nil
}
// extensionSanityCheck checks if the extension has all the required fields in the manifest.
func (r *Repository) extensionSanityCheck(ext *extension.Extension) error {
if err := manifestSanityCheck(ext); err != nil {
return err
}
// Check that the ID is unique
if err := r.isUniqueExtensionID(ext.ID); err != nil {
return err
}
return nil
}
// checks if the extension ID is valid
// Note: The ID must start with a letter and contain only alphanumeric characters
// because it can either be used as a package name or appear in a filename
func isValidExtensionID(id string) error {
if id == "" {
return errors.New("extension ID is empty")
}
if len(id) > 40 {
return errors.New("extension ID is too long")
}
if len(id) < 3 {
return errors.New("extension ID is too short")
}
if !isValidExtensionIDString(id) {
return errors.New("extension ID contains invalid characters")
}
return nil
}
func isValidExtensionIDString(id string) bool {
// Check if the ID starts with a letter and contains only alphanumeric characters
re := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9\-]*[a-zA-Z0-9]$`)
ok := re.MatchString(id)
if !ok {
return false
}
return true
}
func (r *Repository) isUniqueExtensionID(id string) error {
// Check if the ID is not a reserved built-in extension ID
_, found := r.extensionBank.Get(id)
if found {
return errors.New("extension ID is already in use")
}
return nil
}
func ReplacePackageName(src string, newPkgName string) string {
rgxp, err := regexp.Compile(`package \w+`)
if err != nil {
return ""
}
ogPkg := rgxp.FindString(src)
if ogPkg == "" {
return src
}
return strings.Replace(src, ogPkg, "package "+newPkgName, 1)
}

View File

@@ -0,0 +1,52 @@
package extension_repo
import (
"seanime/internal/util"
"strings"
"testing"
)
func TestExtensionID(t *testing.T) {
tests := []struct {
id string
expected bool
}{
{"my-extension", true},
{"my-extension-", false},
{"-my-extension", false},
{"my-extension-1", true},
{"my.extension", false},
{"my_extension", false},
}
for _, test := range tests {
if isValidExtensionIDString(test.id) != test.expected {
t.Errorf("isValidExtensionID(%v) != %v", test.id, test.expected)
}
}
}
func TestReplacePackageName(t *testing.T) {
extensionPackageName := "ext_" + util.GenerateCryptoID()
payload := `package main
import (
"bytes"
"crypto/aes"
"crypto/cipher"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"`
newPayload := ReplacePackageName(payload, extensionPackageName)
if strings.Contains(newPayload, "package main") {
t.Errorf("ReplacePackageName failed")
}
t.Log(newPayload)
}