node build fixed
This commit is contained in:
12
seanime-2.9.10/internal/extension_repo/README.md
Normal file
12
seanime-2.9.10/internal/extension_repo/README.md
Normal 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"
|
||||
```
|
||||
149
seanime-2.9.10/internal/extension_repo/builtin.go
Normal file
149
seanime-2.9.10/internal/extension_repo/builtin.go
Normal 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")
|
||||
}
|
||||
672
seanime-2.9.10/internal/extension_repo/external.go
Normal file
672
seanime-2.9.10/internal/extension_repo/external.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
24
seanime-2.9.10/internal/extension_repo/external_fs.go
Normal file
24
seanime-2.9.10/internal/extension_repo/external_fs.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
148
seanime-2.9.10/internal/extension_repo/external_plugin.go
Normal file
148
seanime-2.9.10/internal/extension_repo/external_plugin.go
Normal 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
|
||||
}
|
||||
276
seanime-2.9.10/internal/extension_repo/goja.go
Normal file
276
seanime-2.9.10/internal/extension_repo/goja.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom"
|
||||
],
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"downlevelIteration": true
|
||||
}
|
||||
}
|
||||
201
seanime-2.9.10/internal/extension_repo/goja_base.go
Normal file
201
seanime-2.9.10/internal/extension_repo/goja_base.go
Normal 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
|
||||
}
|
||||
400
seanime-2.9.10/internal/extension_repo/goja_benchmark_test.go
Normal file
400
seanime-2.9.10/internal/extension_repo/goja_benchmark_test.go
Normal 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)
|
||||
}
|
||||
162
seanime-2.9.10/internal/extension_repo/goja_extension_test.go
Normal file
162
seanime-2.9.10/internal/extension_repo/goja_extension_test.go
Normal 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)
|
||||
}
|
||||
130
seanime-2.9.10/internal/extension_repo/goja_manga_provider.go
Normal file
130
seanime-2.9.10/internal/extension_repo/goja_manga_provider.go
Normal 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
|
||||
}
|
||||
43
seanime-2.9.10/internal/extension_repo/goja_manga_test/manga-provider.d.ts
vendored
Normal file
43
seanime-2.9.10/internal/extension_repo/goja_manga_test/manga-provider.d.ts
vendored
Normal 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
|
||||
}
|
||||
@@ -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 } }[];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"es2015",
|
||||
"dom"
|
||||
],
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
79
seanime-2.9.10/internal/extension_repo/goja_onlinestream_test/onlinestream-provider.d.ts
vendored
Normal file
79
seanime-2.9.10/internal/extension_repo/goja_onlinestream_test/onlinestream-provider.d.ts
vendored
Normal 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
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"esnext",
|
||||
"dom"
|
||||
],
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"downlevelIteration": true
|
||||
}
|
||||
}
|
||||
419
seanime-2.9.10/internal/extension_repo/goja_plugin.go
Normal file
419
seanime-2.9.10/internal/extension_repo/goja_plugin.go
Normal 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
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
2036
seanime-2.9.10/internal/extension_repo/goja_plugin_system_test.go
Normal file
2036
seanime-2.9.10/internal/extension_repo/goja_plugin_system_test.go
Normal file
File diff suppressed because it is too large
Load Diff
798
seanime-2.9.10/internal/extension_repo/goja_plugin_test.go
Normal file
798
seanime-2.9.10/internal/extension_repo/goja_plugin_test.go
Normal 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
|
||||
}
|
||||
3678
seanime-2.9.10/internal/extension_repo/goja_plugin_types/app.d.ts
vendored
Normal file
3678
seanime-2.9.10/internal/extension_repo/goja_plugin_types/app.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
332
seanime-2.9.10/internal/extension_repo/goja_plugin_types/core.d.ts
vendored
Normal file
332
seanime-2.9.10/internal/extension_repo/goja_plugin_types/core.d.ts
vendored
Normal 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
|
||||
}
|
||||
1852
seanime-2.9.10/internal/extension_repo/goja_plugin_types/plugin.d.ts
vendored
Normal file
1852
seanime-2.9.10/internal/extension_repo/goja_plugin_types/plugin.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
822
seanime-2.9.10/internal/extension_repo/goja_plugin_types/system.d.ts
vendored
Normal file
822
seanime-2.9.10/internal/extension_repo/goja_plugin_types/system.d.ts
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"es2015",
|
||||
"dom"
|
||||
],
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": false,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}
|
||||
88
seanime-2.9.10/internal/extension_repo/goja_torrent_test/anime-torrent-provider.d.ts
vendored
Normal file
88
seanime-2.9.10/internal/extension_repo/goja_torrent_test/anime-torrent-provider.d.ts
vendored
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es5",
|
||||
"lib": [
|
||||
"es2015",
|
||||
"dom"
|
||||
],
|
||||
"module": "commonjs",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
78
seanime-2.9.10/internal/extension_repo/mapper.go
Normal file
78
seanime-2.9.10/internal/extension_repo/mapper.go
Normal 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
|
||||
}
|
||||
50
seanime-2.9.10/internal/extension_repo/marketplace.go
Normal file
50
seanime-2.9.10/internal/extension_repo/marketplace.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
//}
|
||||
@@ -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": ""
|
||||
}
|
||||
169
seanime-2.9.10/internal/extension_repo/mock.go
Normal file
169
seanime-2.9.10/internal/extension_repo/mock.go
Normal 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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
}
|
||||
343
seanime-2.9.10/internal/extension_repo/repository.go
Normal file
343
seanime-2.9.10/internal/extension_repo/repository.go
Normal 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
|
||||
}
|
||||
19
seanime-2.9.10/internal/extension_repo/repository_test.go
Normal file
19
seanime-2.9.10/internal/extension_repo/repository_test.go
Normal 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,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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
1
seanime-2.9.10/internal/extension_repo/testdir/noop.go
Normal file
1
seanime-2.9.10/internal/extension_repo/testdir/noop.go
Normal file
@@ -0,0 +1 @@
|
||||
package extension_repo_test
|
||||
159
seanime-2.9.10/internal/extension_repo/userconfig.go
Normal file
159
seanime-2.9.10/internal/extension_repo/userconfig.go
Normal 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)
|
||||
}
|
||||
159
seanime-2.9.10/internal/extension_repo/utils.go
Normal file
159
seanime-2.9.10/internal/extension_repo/utils.go
Normal 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)
|
||||
}
|
||||
52
seanime-2.9.10/internal/extension_repo/utils_test.go
Normal file
52
seanime-2.9.10/internal/extension_repo/utils_test.go
Normal 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)
|
||||
}
|
||||
Reference in New Issue
Block a user