Scraper and plugin manager (#4242)

* Add package manager
* Add SettingModal validate
* Reverse modal button order
* Add plugin package management
* Refactor ClearableInput
This commit is contained in:
WithoutPants 2023-11-22 10:01:11 +11:00 committed by GitHub
parent d95ef4059a
commit 987fa80786
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
42 changed files with 3484 additions and 35 deletions

8
.gitignore vendored
View File

@ -21,11 +21,6 @@ vendor
# GraphQL generated output
internal/api/generated_*.go
####
# Jetbrains
####
####
# Visual Studio
####
@ -52,9 +47,6 @@ internal/api/generated_*.go
.idea/**/uiDesigner.xml
.idea/**/dbnavigator.xml
# Goland Junk
pkg/pkg
####
# Random
####

View File

@ -53,6 +53,17 @@ fragment ConfigGeneralData on ConfigGeneralResult {
liveTranscodeInputArgs
liveTranscodeOutputArgs
drawFunscriptHeatmapRange
scraperPackageSources {
name
url
local_path
}
pluginPackageSources {
name
url
local_path
}
}
fragment ConfigInterfaceData on ConfigInterfaceResult {

View File

@ -0,0 +1,8 @@
fragment PackageData on Package {
package_id
name
version
date
metadata
sourceURL
}

View File

@ -17,3 +17,15 @@ mutation ConfigurePlugin($plugin_id: ID!, $input: Map!) {
mutation SetPluginsEnabled($enabledMap: BoolMap!) {
setPluginsEnabled(enabledMap: $enabledMap)
}
mutation InstallPluginPackages($packages: [PackageSpecInput!]!) {
installPackages(type: Plugin, packages: $packages)
}
mutation UpdatePluginPackages($packages: [PackageSpecInput!]!) {
updatePackages(type: Plugin, packages: $packages)
}
mutation UninstallPluginPackages($packages: [PackageSpecInput!]!) {
uninstallPackages(type: Plugin, packages: $packages)
}

View File

@ -1,3 +1,15 @@
mutation ReloadScrapers {
reloadScrapers
}
mutation InstallScraperPackages($packages: [PackageSpecInput!]!) {
installPackages(type: Scraper, packages: $packages)
}
mutation UpdateScraperPackages($packages: [PackageSpecInput!]!) {
updatePackages(type: Scraper, packages: $packages)
}
mutation UninstallScraperPackages($packages: [PackageSpecInput!]!) {
uninstallPackages(type: Scraper, packages: $packages)
}

View File

@ -43,3 +43,27 @@ query PluginTasks {
}
}
}
query InstalledPluginPackages {
installedPackages(type: Plugin) {
...PackageData
}
}
query InstalledPluginPackagesStatus {
installedPackages(type: Plugin) {
...PackageData
upgrade {
...PackageData
}
}
}
query AvailablePluginPackages($source: String!) {
availablePackages(source: $source, type: Plugin) {
...PackageData
requires {
package_id
}
}
}

View File

@ -119,3 +119,27 @@ query ScrapeMovieURL($url: String!) {
...ScrapedMovieData
}
}
query InstalledScraperPackages {
installedPackages(type: Scraper) {
...PackageData
}
}
query InstalledScraperPackagesStatus {
installedPackages(type: Scraper) {
...PackageData
upgrade {
...PackageData
}
}
}
query AvailableScraperPackages($source: String!) {
availablePackages(source: $source, type: Scraper) {
...PackageData
requires {
package_id
}
}
}

View File

@ -168,6 +168,12 @@ type Query {
"List available plugin operations"
pluginTasks: [PluginTask!]
# Packages
"List installed packages"
installedPackages(type: PackageType!): [Package!]!
"List available packages"
availablePackages(type: PackageType!, source: String!): [Package!]!
# Config
"Returns the current, complete configuration"
configuration: ConfigResult!
@ -381,6 +387,29 @@ type Mutation {
): ID!
reloadPlugins: Boolean!
"""
Installs the given packages.
If a package is already installed, it will be updated if needed..
If an error occurs when installing a package, the job will continue to install the remaining packages.
Returns the job ID
"""
installPackages(type: PackageType!, packages: [PackageSpecInput!]!): ID!
"""
Updates the given packages.
If a package is not installed, it will not be installed.
If a package does not need to be updated, it will not be updated.
If no packages are provided, all packages of the given type will be updated.
If an error occurs when updating a package, the job will continue to update the remaining packages.
Returns the job ID.
"""
updatePackages(type: PackageType!, packages: [PackageSpecInput!]): ID!
"""
Uninstalls the given packages.
If an error occurs when uninstalling a package, the job will continue to uninstall the remaining packages.
Returns the job ID
"""
uninstallPackages(type: PackageType!, packages: [PackageSpecInput!]!): ID!
stopJob(job_id: ID!): Boolean!
stopAllJobs: Boolean!

View File

@ -167,6 +167,11 @@ input ConfigGeneralInput {
stashBoxes: [StashBoxInput!]
"Python path - resolved using path if unset"
pythonPath: String
"Source of scraper packages"
scraperPackageSources: [PackageSourceInput!]
"Source of plugin packages"
pluginPackageSources: [PackageSourceInput!]
}
type ConfigGeneralResult {
@ -280,6 +285,11 @@ type ConfigGeneralResult {
stashBoxes: [StashBox!]!
"Python path - resolved using path if unset"
pythonPath: String!
"Source of scraper packages"
scraperPackageSources: [PackageSource!]!
"Source of plugin packages"
pluginPackageSources: [PackageSource!]!
}
input ConfigDisableDropdownCreateInput {

View File

@ -0,0 +1,36 @@
enum PackageType {
Scraper
Plugin
}
type Package {
package_id: String!
name: String!
version: String
date: Timestamp
requires: [Package!]!
sourceURL: String!
"The available upgraded version of this package"
upgrade: Package
metadata: Map!
}
input PackageSpecInput {
id: String!
sourceURL: String!
}
type PackageSource {
name: String
url: String!
local_path: String
}
input PackageSourceInput {
name: String
url: String!
local_path: String
}

View File

@ -347,6 +347,18 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
c.Set(config.DrawFunscriptHeatmapRange, input.DrawFunscriptHeatmapRange)
}
refreshScraperSource := false
if input.ScraperPackageSources != nil {
c.Set(config.ScraperPackageSources, input.ScraperPackageSources)
refreshScraperSource = true
}
refreshPluginSource := false
if input.PluginPackageSources != nil {
c.Set(config.PluginPackageSources, input.PluginPackageSources)
refreshPluginSource = true
}
if err := c.Write(); err != nil {
return makeConfigGeneralResult(), err
}
@ -361,6 +373,12 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
if refreshBlobStorage {
manager.GetInstance().SetBlobStoreOptions()
}
if refreshScraperSource {
manager.GetInstance().RefreshScraperSourceManager()
}
if refreshPluginSource {
manager.GetInstance().RefreshPluginSourceManager()
}
return makeConfigGeneralResult(), nil
}

View File

@ -0,0 +1,82 @@
package api
import (
"context"
"strconv"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/task"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
func refreshPackageType(typeArg PackageType) {
mgr := manager.GetInstance()
if typeArg == PackageTypePlugin {
if err := mgr.PluginCache.LoadPlugins(); err != nil {
logger.Errorf("Error reading plugin configs: %v", err)
}
} else if typeArg == PackageTypeScraper {
if err := mgr.ScraperCache.ReloadScrapers(); err != nil {
logger.Errorf("Error reading scraper configs: %v", err)
}
}
}
func (r *mutationResolver) InstallPackages(ctx context.Context, typeArg PackageType, packages []*models.PackageSpecInput) (string, error) {
pm, err := getPackageManager(typeArg)
if err != nil {
return "", err
}
mgr := manager.GetInstance()
t := &task.InstallPackagesJob{
PackagesJob: task.PackagesJob{
PackageManager: pm,
OnComplete: func() { refreshPackageType(typeArg) },
},
Packages: packages,
}
jobID := mgr.JobManager.Add(ctx, "Installing packages...", t)
return strconv.Itoa(jobID), nil
}
func (r *mutationResolver) UpdatePackages(ctx context.Context, typeArg PackageType, packages []*models.PackageSpecInput) (string, error) {
pm, err := getPackageManager(typeArg)
if err != nil {
return "", err
}
mgr := manager.GetInstance()
t := &task.UpdatePackagesJob{
PackagesJob: task.PackagesJob{
PackageManager: pm,
OnComplete: func() { refreshPackageType(typeArg) },
},
Packages: packages,
}
jobID := mgr.JobManager.Add(ctx, "Updating packages...", t)
return strconv.Itoa(jobID), nil
}
func (r *mutationResolver) UninstallPackages(ctx context.Context, typeArg PackageType, packages []*models.PackageSpecInput) (string, error) {
pm, err := getPackageManager(typeArg)
if err != nil {
return "", err
}
mgr := manager.GetInstance()
t := &task.UninstallPackagesJob{
PackagesJob: task.PackagesJob{
PackageManager: pm,
OnComplete: func() { refreshPackageType(typeArg) },
},
Packages: packages,
}
jobID := mgr.JobManager.Add(ctx, "Updating packages...", t)
return strconv.Itoa(jobID), nil
}

View File

@ -127,6 +127,8 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
LiveTranscodeInputArgs: config.GetLiveTranscodeInputArgs(),
LiveTranscodeOutputArgs: config.GetLiveTranscodeOutputArgs(),
DrawFunscriptHeatmapRange: config.GetDrawFunscriptHeatmapRange(),
ScraperPackageSources: config.GetScraperPackageSources(),
PluginPackageSources: config.GetPluginPackageSources(),
}
}

View File

@ -0,0 +1,194 @@
package api
import (
"context"
"errors"
"sort"
"strings"
"github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/pkg"
"github.com/stashapp/stash/pkg/sliceutil"
)
var ErrInvalidPackageType = errors.New("invalid package type")
func getPackageManager(typeArg PackageType) (*pkg.Manager, error) {
var pm *pkg.Manager
switch typeArg {
case PackageTypeScraper:
pm = manager.GetInstance().ScraperPackageManager
case PackageTypePlugin:
pm = manager.GetInstance().PluginPackageManager
default:
return nil, ErrInvalidPackageType
}
return pm, nil
}
func manifestToPackage(p pkg.Manifest) *Package {
ret := &Package{
PackageID: p.ID,
Name: p.Name,
SourceURL: p.RepositoryURL,
}
if len(p.Version) > 0 {
ret.Version = &p.Version
}
if !p.Date.IsZero() {
ret.Date = &p.Date.Time
}
ret.Metadata = p.Metadata
if ret.Metadata == nil {
ret.Metadata = make(map[string]interface{})
}
return ret
}
func remotePackageToPackage(p pkg.RemotePackage, index pkg.RemotePackageIndex) *Package {
ret := &Package{
PackageID: p.ID,
Name: p.Name,
}
if len(p.Version) > 0 {
ret.Version = &p.Version
}
if !p.Date.IsZero() {
ret.Date = &p.Date.Time
}
ret.Metadata = p.Metadata
if ret.Metadata == nil {
ret.Metadata = make(map[string]interface{})
}
ret.SourceURL = p.Repository.Path()
for _, r := range p.Requires {
// required packages must come from the same source
spec := models.PackageSpecInput{
ID: r,
SourceURL: p.Repository.Path(),
}
req, found := index[spec]
if !found {
// shouldn't happen, but we'll ignore it
continue
}
ret.Requires = append(ret.Requires, remotePackageToPackage(req, index))
}
return ret
}
func sortedPackageSpecKeys[V any](m map[models.PackageSpecInput]V) []models.PackageSpecInput {
// sort keys
var keys []models.PackageSpecInput
for k := range m {
keys = append(keys, k)
}
sort.Slice(keys, func(i, j int) bool {
if strings.EqualFold(keys[i].ID, keys[j].ID) {
return keys[i].ID < keys[j].ID
}
return strings.ToLower(keys[i].ID) < strings.ToLower(keys[j].ID)
})
return keys
}
func (r *queryResolver) getInstalledPackagesWithUpgrades(ctx context.Context, pm *pkg.Manager) ([]*Package, error) {
// get all installed packages
installed, err := pm.ListInstalled(ctx)
if err != nil {
return nil, err
}
// get remotes for all installed packages
allRemoteList, err := pm.ListInstalledRemotes(ctx, installed)
if err != nil {
return nil, err
}
packageStatusIndex := pkg.MakePackageStatusIndex(installed, allRemoteList)
ret := make([]*Package, len(packageStatusIndex))
i := 0
for _, k := range sortedPackageSpecKeys(packageStatusIndex) {
v := packageStatusIndex[k]
p := manifestToPackage(*v.Local)
if v.Upgradable() {
pp := remotePackageToPackage(*v.Remote, allRemoteList)
p.Upgrade = pp
}
ret[i] = p
i++
}
return ret, nil
}
func (r *queryResolver) InstalledPackages(ctx context.Context, typeArg PackageType) ([]*Package, error) {
pm, err := getPackageManager(typeArg)
if err != nil {
return nil, err
}
installed, err := pm.ListInstalled(ctx)
if err != nil {
return nil, err
}
var ret []*Package
if sliceutil.Contains(graphql.CollectAllFields(ctx), "upgrade") {
ret, err = r.getInstalledPackagesWithUpgrades(ctx, pm)
if err != nil {
return nil, err
}
} else {
ret = make([]*Package, len(installed))
i := 0
for _, k := range sortedPackageSpecKeys(installed) {
ret[i] = manifestToPackage(installed[k])
i++
}
}
return ret, nil
}
func (r *queryResolver) AvailablePackages(ctx context.Context, typeArg PackageType, source string) ([]*Package, error) {
pm, err := getPackageManager(typeArg)
if err != nil {
return nil, err
}
available, err := pm.ListRemote(ctx, source)
if err != nil {
return nil, err
}
ret := make([]*Package, len(available))
i := 0
for _, k := range sortedPackageSpecKeys(available) {
p := available[k]
ret[i] = remotePackageToPackage(p, available)
i++
}
return ret, nil
}

View File

@ -21,6 +21,7 @@ import (
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/paths"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/utils"
)
@ -137,6 +138,9 @@ const (
PluginsSettingPrefix = PluginsSetting + "."
DisabledPlugins = "plugins.disabled"
PluginPackageSources = "plugins.package_sources"
ScraperPackageSources = "scrapers.package_sources"
// i18n
Language = "language"
@ -1520,6 +1524,61 @@ func (i *Instance) ActivatePublicAccessTripwire(requestIP string) error {
return i.Write()
}
func (i *Instance) getPackageSources(key string) []*models.PackageSource {
var sources []*models.PackageSource
if err := i.unmarshalKey(key, &sources); err != nil {
logger.Warnf("error in unmarshalkey: %v", err)
}
return sources
}
func (i *Instance) GetPluginPackageSources() []*models.PackageSource {
return i.getPackageSources(PluginPackageSources)
}
func (i *Instance) GetScraperPackageSources() []*models.PackageSource {
return i.getPackageSources(ScraperPackageSources)
}
type packagePathGetter struct {
getterFn func() []*models.PackageSource
}
func (g packagePathGetter) GetAllSourcePaths() []string {
p := g.getterFn()
var ret []string
for _, v := range p {
ret = sliceutil.AppendUnique(ret, v.LocalPath)
}
return ret
}
func (g packagePathGetter) GetSourcePath(srcURL string) string {
p := g.getterFn()
for _, v := range p {
if v.URL == srcURL {
return v.LocalPath
}
}
return ""
}
func (i *Instance) GetPluginPackagePathGetter() packagePathGetter {
return packagePathGetter{
getterFn: i.GetPluginPackageSources,
}
}
func (i *Instance) GetScraperPackagePathGetter() packagePathGetter {
return packagePathGetter{
getterFn: i.GetScraperPackageSources,
}
}
func (i *Instance) Validate() error {
i.RLock()
defer i.RUnlock()

View File

@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"runtime"
@ -29,6 +30,7 @@ import (
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/paths"
"github.com/stashapp/stash/pkg/pkg"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/scraper"
@ -130,6 +132,9 @@ type Manager struct {
PluginCache *plugin.Cache
ScraperCache *scraper.Cache
PluginPackageManager *pkg.Manager
ScraperPackageManager *pkg.Manager
DownloadStore *DownloadStore
DLNAService *dlna.Service
@ -229,6 +234,9 @@ func initialize() error {
dlnaRepository := dlna.NewRepository(repo)
instance.DLNAService = dlna.NewService(dlnaRepository, cfg, &sceneServer)
instance.RefreshPluginSourceManager()
instance.RefreshScraperSourceManager()
if !cfg.IsNewSystem() {
logger.Infof("using config file: %s", cfg.GetConfigFile())
@ -280,6 +288,26 @@ func initialize() error {
return nil
}
func initialisePackageManager(localPath string, srcPathGetter pkg.SourcePathGetter, cachePathGetter pkg.CachePathGetter) *pkg.Manager {
const timeout = 10 * time.Second
httpClient := &http.Client{
Transport: &http.Transport{
Proxy: http.ProxyFromEnvironment,
},
Timeout: timeout,
}
return &pkg.Manager{
Local: &pkg.Store{
BaseDir: localPath,
ManifestFile: pkg.ManifestFile,
},
PackagePathGetter: srcPathGetter,
Client: httpClient,
CachePathGetter: cachePathGetter,
}
}
func videoFileFilter(ctx context.Context, f models.File) bool {
return useAsVideo(f.Base().Path)
}
@ -566,6 +594,14 @@ func (s *Manager) RefreshStreamManager() {
s.StreamManager = ffmpeg.NewStreamManager(cacheDir, s.FFMPEG, s.FFProbe, s.Config, s.ReadLockManager)
}
func (s *Manager) RefreshScraperSourceManager() {
s.ScraperPackageManager = initialisePackageManager(s.Config.GetScrapersPath(), s.Config.GetScraperPackagePathGetter(), s.Config)
}
func (s *Manager) RefreshPluginSourceManager() {
s.PluginPackageManager = initialisePackageManager(s.Config.GetPluginsPath(), s.Config.GetPluginPackagePathGetter(), s.Config)
}
func setSetupDefaults(input *SetupInput) {
if input.ConfigLocation == "" {
input.ConfigLocation = filepath.Join(fsutil.GetHomeDirectory(), ".stash", "config.yml")

View File

@ -0,0 +1,134 @@
package task
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/pkg"
)
type PackagesJob struct {
PackageManager *pkg.Manager
OnComplete func()
}
func (j *PackagesJob) installPackage(ctx context.Context, p models.PackageSpecInput, progress *job.Progress) error {
defer progress.Increment()
if err := j.PackageManager.Install(ctx, p); err != nil {
return fmt.Errorf("installing package: %w", err)
}
return nil
}
type InstallPackagesJob struct {
PackagesJob
Packages []*models.PackageSpecInput
}
func (j *InstallPackagesJob) Execute(ctx context.Context, progress *job.Progress) {
progress.SetTotal(len(j.Packages))
for _, p := range j.Packages {
if job.IsCancelled(ctx) {
logger.Info("Cancelled installing packages")
return
}
logger.Infof("Installing package %s", p.ID)
taskDesc := fmt.Sprintf("Installing %s", p.ID)
progress.ExecuteTask(taskDesc, func() {
if err := j.installPackage(ctx, *p, progress); err != nil {
logger.Errorf("Error installing package %s from %s: %v", p.ID, p.SourceURL, err)
}
})
}
if j.OnComplete != nil {
j.OnComplete()
}
logger.Infof("Finished installing packages")
}
type UpdatePackagesJob struct {
PackagesJob
Packages []*models.PackageSpecInput
}
func (j *UpdatePackagesJob) Execute(ctx context.Context, progress *job.Progress) {
// if no packages are specified, update all
if len(j.Packages) == 0 {
installed, err := j.PackageManager.InstalledStatus(ctx)
if err != nil {
logger.Errorf("Error getting installed packages: %v", err)
return
}
for _, p := range installed {
if p.Upgradable() {
j.Packages = append(j.Packages, &models.PackageSpecInput{
ID: p.Local.ID,
SourceURL: p.Remote.Repository.Path(),
})
}
}
}
progress.SetTotal(len(j.Packages))
for _, p := range j.Packages {
if job.IsCancelled(ctx) {
logger.Info("Cancelled updating packages")
return
}
logger.Infof("Updating package %s", p.ID)
taskDesc := fmt.Sprintf("Updating %s", p.ID)
progress.ExecuteTask(taskDesc, func() {
if err := j.installPackage(ctx, *p, progress); err != nil {
logger.Errorf("Error updating package %s from %s: %v", p.ID, p.SourceURL, err)
}
})
}
if j.OnComplete != nil {
j.OnComplete()
}
logger.Infof("Finished updating packages")
}
type UninstallPackagesJob struct {
PackagesJob
Packages []*models.PackageSpecInput
}
func (j *UninstallPackagesJob) Execute(ctx context.Context, progress *job.Progress) {
progress.SetTotal(len(j.Packages))
for _, p := range j.Packages {
if job.IsCancelled(ctx) {
logger.Info("Cancelled installing packages")
return
}
logger.Infof("Uninstalling package %s", p.ID)
taskDesc := fmt.Sprintf("Uninstalling %s", p.ID)
progress.ExecuteTask(taskDesc, func() {
if err := j.PackageManager.Uninstall(ctx, *p); err != nil {
logger.Errorf("Error uninstalling package %s: %v", p.ID, err)
}
})
}
if j.OnComplete != nil {
j.OnComplete()
}
logger.Infof("Finished uninstalling packages")
}

12
pkg/models/package.go Normal file
View File

@ -0,0 +1,12 @@
package models
type PackageSpecInput struct {
ID string `json:"id"`
SourceURL string `json:"sourceURL"`
}
type PackageSource struct {
Name *string `json:"name"`
LocalPath string `json:"localPath"`
URL string `json:"url"`
}

82
pkg/pkg/cache.go Normal file
View File

@ -0,0 +1,82 @@
package pkg
import (
"fmt"
"io"
"os"
"path/filepath"
"time"
"github.com/stashapp/stash/pkg/hash/md5"
"github.com/stashapp/stash/pkg/logger"
)
const cacheSubDir = "package_lists"
type repositoryCache struct {
cachePath string
}
func (c *repositoryCache) path(url string) string {
// convert the url to md5
hash := md5.FromString(url)
return filepath.Join(c.cachePath, cacheSubDir, hash)
}
func (c *repositoryCache) lastModified(url string) *time.Time {
if c == nil {
return nil
}
path := c.path(url)
s, err := os.Stat(path)
if err != nil {
// ignore
logger.Debugf("error getting cached file %s: %v", path, err)
return nil
}
ret := s.ModTime()
return &ret
}
func (c *repositoryCache) getPackageList(url string) (io.ReadCloser, error) {
path := c.path(url)
ret, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to get file %q: %w", path, err)
}
return ret, nil
}
func (c *repositoryCache) cacheFile(url string, data io.ReadCloser) (io.ReadCloser, error) {
if c == nil {
return data, nil
}
path := c.path(url)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
// ignore, just return the original file
logger.Debugf("error creating cache path %s: %v", filepath.Dir(path), err)
return data, nil
}
f, err := os.Create(path)
if err != nil {
// ignore, just return the original file
logger.Debugf("error creating cached file %s: %v", path, err)
return data, nil
}
defer data.Close()
if _, err := io.Copy(f, data); err != nil {
_ = f.Close()
return nil, fmt.Errorf("writing to cache file %s - %w", path, err)
}
_ = f.Close()
return c.getPackageList(url)
}

268
pkg/pkg/manager.go Normal file
View File

@ -0,0 +1,268 @@
package pkg
import (
"archive/zip"
"bytes"
"context"
"crypto/sha256"
"fmt"
"io"
"net/http"
"net/url"
"path/filepath"
"github.com/stashapp/stash/pkg/models"
)
type CachePathGetter interface {
GetCachePath() string
}
// SourcePathGetter gets the source path for a given package URL.
type SourcePathGetter interface {
// GetAllSourcePaths gets all source paths.
GetAllSourcePaths() []string
// GetSourcePath gets the source path for the given package URL.
GetSourcePath(srcURL string) string
}
// Manager manages the installation of paks.
type Manager struct {
Local *Store
PackagePathGetter SourcePathGetter
CachePathGetter CachePathGetter
Client *http.Client
}
func (m *Manager) remoteFromURL(path string) (*httpRepository, error) {
u, err := url.Parse(path)
if err != nil {
return nil, fmt.Errorf("parsing path: %w", err)
}
cachePath := m.CachePathGetter.GetCachePath()
var cache *repositoryCache
if cachePath != "" {
cache = &repositoryCache{cachePath: cachePath}
}
return newHttpRepository(*u, m.Client, cache), nil
}
func (m *Manager) ListInstalled(ctx context.Context) (LocalPackageIndex, error) {
paths := m.PackagePathGetter.GetAllSourcePaths()
var installedList []Manifest
for _, p := range paths {
store := m.Local.sub(p)
srcList, err := store.List(ctx)
if err != nil {
return nil, fmt.Errorf("listing local packages: %w", err)
}
installedList = append(installedList, srcList...)
}
return localPackageIndexFromList(installedList), nil
}
func (m *Manager) ListRemote(ctx context.Context, remoteURL string) (RemotePackageIndex, error) {
r, err := m.remoteFromURL(remoteURL)
if err != nil {
return nil, fmt.Errorf("creating remote repository: %w", err)
}
list, err := r.List(ctx)
if err != nil {
return nil, fmt.Errorf("listing remote packages: %w", err)
}
// add link to RemotePackage
for i := range list {
list[i].Repository = r
}
ret := remotePackageIndexFromList(list)
return ret, nil
}
func (m *Manager) ListInstalledRemotes(ctx context.Context, installed LocalPackageIndex) (RemotePackageIndex, error) {
// get remotes for all installed packages
allRemoteList := make(RemotePackageIndex)
remoteURLs := installed.remoteURLs()
for _, remoteURL := range remoteURLs {
remoteList, err := m.ListRemote(ctx, remoteURL)
if err != nil {
return nil, err
}
allRemoteList.merge(remoteList)
}
return allRemoteList, nil
}
func (m *Manager) InstalledStatus(ctx context.Context) (PackageStatusIndex, error) {
// get all installed packages
installed, err := m.ListInstalled(ctx)
if err != nil {
return nil, err
}
// get remotes for all installed packages
allRemoteList, err := m.ListInstalledRemotes(ctx, installed)
if err != nil {
return nil, err
}
ret := MakePackageStatusIndex(installed, allRemoteList)
return ret, nil
}
func (m *Manager) packageByID(ctx context.Context, spec models.PackageSpecInput) (*RemotePackage, error) {
l, err := m.ListRemote(ctx, spec.SourceURL)
if err != nil {
return nil, err
}
pkg, found := l[spec]
if !found {
return nil, nil
}
return &pkg, nil
}
func (m *Manager) getStore(remoteURL string) *Store {
srcPath := m.PackagePathGetter.GetSourcePath(remoteURL)
store := m.Local.sub(srcPath)
return store
}
func (m *Manager) Install(ctx context.Context, spec models.PackageSpecInput) error {
remote, err := m.remoteFromURL(spec.SourceURL)
if err != nil {
return fmt.Errorf("creating remote repository: %w", err)
}
pkg, err := m.packageByID(ctx, spec)
if err != nil {
return fmt.Errorf("getting remote package: %w", err)
}
fromRemote, err := remote.GetPackageZip(ctx, *pkg)
if err != nil {
return fmt.Errorf("getting remote package: %w", err)
}
defer fromRemote.Close()
d, err := io.ReadAll(fromRemote)
if err != nil {
return fmt.Errorf("reading package data: %w", err)
}
sha := fmt.Sprintf("%x", sha256.Sum256(d))
if sha != pkg.Sha256 {
return fmt.Errorf("package data (%s) does not match expected SHA256 (%s)", sha, pkg.Sha256)
}
zr, err := zip.NewReader(bytes.NewReader(d), int64(len(d)))
if err != nil {
return fmt.Errorf("reading zip data: %w", err)
}
store := m.getStore(spec.SourceURL)
// uninstall existing package if present
if _, err := store.getManifest(ctx, pkg.ID); err == nil {
if err := m.deletePackageFiles(ctx, store, pkg.ID); err != nil {
return fmt.Errorf("uninstalling existing package: %w", err)
}
}
if err := m.installPackage(*pkg, store, zr); err != nil {
return fmt.Errorf("installing package: %w", err)
}
return nil
}
func (m *Manager) installPackage(pkg RemotePackage, store *Store, zr *zip.Reader) error {
manifest := Manifest{
ID: pkg.ID,
Name: pkg.Name,
Metadata: pkg.Metadata,
PackageVersion: pkg.PackageVersion,
RepositoryURL: pkg.Repository.Path(),
}
for _, f := range zr.File {
if f.FileInfo().IsDir() {
continue
}
i, err := f.Open()
if err != nil {
return err
}
fn := filepath.Clean(f.Name)
if err := store.writeFile(pkg.ID, fn, f.Mode(), i); err != nil {
i.Close()
return fmt.Errorf("writing file %q: %w", fn, err)
}
i.Close()
manifest.Files = append(manifest.Files, fn)
}
if err := store.writeManifest(pkg.ID, manifest); err != nil {
return fmt.Errorf("writing manifest: %w", err)
}
return nil
}
// Uninstall uninstalls the given package.
func (m *Manager) Uninstall(ctx context.Context, spec models.PackageSpecInput) error {
store := m.getStore(spec.SourceURL)
if err := m.deletePackageFiles(ctx, store, spec.ID); err != nil {
return fmt.Errorf("deleting local package: %w", err)
}
// also delete the directory
// ignore errors
_ = store.deletePackageDir(spec.ID)
return nil
}
func (m *Manager) deletePackageFiles(ctx context.Context, store *Store, id string) error {
manifest, err := store.getManifest(ctx, id)
if err != nil {
return fmt.Errorf("getting manifest: %w", err)
}
for _, f := range manifest.Files {
if err := store.deleteFile(id, f); err != nil {
// ignore
continue
}
}
if err := store.deleteManifest(id); err != nil {
return fmt.Errorf("deleting manifest: %w", err)
}
return nil
}

198
pkg/pkg/pkg.go Normal file
View File

@ -0,0 +1,198 @@
package pkg
import (
"fmt"
"time"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil"
)
const timeFormat = "2006-01-02 15:04:05 -0700"
type Time struct {
time.Time
}
func (t *Time) UnmarshalYAML(unmarshal func(interface{}) error) error {
var s string
if err := unmarshal(&s); err != nil {
return err
}
parsed, err := time.Parse(timeFormat, s)
if err != nil {
return err
}
t.Time = parsed
return nil
}
func (t Time) MarshalYAML() (interface{}, error) {
return t.Format(timeFormat), nil
}
type PackageMetadata map[string]interface{}
type PackageVersion struct {
Version string `yaml:"version"`
Date Time `yaml:"date"`
}
func (v PackageVersion) Upgradable(o PackageVersion) bool {
return o.Date.After(v.Date.Time)
}
func (v PackageVersion) String() string {
ret := v.Version
if !v.Date.IsZero() {
date := v.Date.Format("2006-01-02")
if ret != "" {
ret += fmt.Sprintf(" (%s)", date)
} else {
ret = date
}
}
return ret
}
type PackageLocation struct {
// Path is the path to the package zip file.
// This may be relative or absolute.
Path string `yaml:"path"`
Sha256 string `yaml:"sha256"`
}
type RemotePackage struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Repository remoteRepository `yaml:"-"`
Requires []string `yaml:"requires"`
Metadata PackageMetadata `yaml:"metadata"`
PackageVersion `yaml:",inline"`
PackageLocation `yaml:",inline"`
}
func (p RemotePackage) PackageSpecInput() models.PackageSpecInput {
return models.PackageSpecInput{
ID: p.ID,
SourceURL: p.Repository.Path(),
}
}
type Manifest struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Metadata PackageMetadata `yaml:"metadata"`
PackageVersion `yaml:",inline"`
Requires []string `yaml:"requires"`
RepositoryURL string `yaml:"source_repository"`
Files []string `yaml:"files"`
}
func (m Manifest) PackageSpecInput() models.PackageSpecInput {
return models.PackageSpecInput{
ID: m.ID,
SourceURL: m.RepositoryURL,
}
}
// RemotePackageIndex is a map of package name to RemotePackage
type RemotePackageIndex map[models.PackageSpecInput]RemotePackage
func (i RemotePackageIndex) merge(o RemotePackageIndex) {
for id, pkg := range o {
if existing, found := i[id]; found {
if existing.Date.After(pkg.Date.Time) {
continue
}
}
i[id] = pkg
}
}
func remotePackageIndexFromList(packages []RemotePackage) RemotePackageIndex {
index := make(RemotePackageIndex)
for _, pkg := range packages {
specInput := pkg.PackageSpecInput()
// if package already exists in map, choose the newest
if existing, found := index[specInput]; found {
if existing.Date.After(pkg.Date.Time) {
continue
}
}
index[specInput] = pkg
}
return index
}
// LocalPackageIndex is a map of package name to RemotePackage
type LocalPackageIndex map[models.PackageSpecInput]Manifest
func (i LocalPackageIndex) remoteURLs() []string {
var ret []string
for _, pkg := range i {
ret = sliceutil.AppendUnique(ret, pkg.RepositoryURL)
}
return ret
}
func localPackageIndexFromList(packages []Manifest) LocalPackageIndex {
index := make(LocalPackageIndex)
for _, pkg := range packages {
index[pkg.PackageSpecInput()] = pkg
}
return index
}
type PackageStatus struct {
Local *Manifest
Remote *RemotePackage
}
func (s PackageStatus) Upgradable() bool {
if s.Local == nil || s.Remote == nil {
return false
}
return s.Local.Upgradable(s.Remote.PackageVersion)
}
type PackageStatusIndex map[models.PackageSpecInput]PackageStatus
func MakePackageStatusIndex(installed LocalPackageIndex, remote RemotePackageIndex) PackageStatusIndex {
i := make(PackageStatusIndex)
for spec, pkg := range installed {
pkgCopy := pkg
s := PackageStatus{
Local: &pkgCopy,
}
if remotePkg, found := remote[spec]; found {
s.Remote = &remotePkg
}
i[spec] = s
}
return i
}
func (i PackageStatusIndex) Upgradable() []PackageStatus {
var ret []PackageStatus
for _, s := range i {
if s.Upgradable() {
ret = append(ret, s)
}
}
return ret
}

22
pkg/pkg/repository.go Normal file
View File

@ -0,0 +1,22 @@
package pkg
import (
"context"
"io"
)
// remoteRepository is a repository that can be used to get paks from.
type remoteRepository interface {
RemotePackageLister
RemotePackageGetter
Path() string
}
type RemotePackageLister interface {
// List returns all specs in the repository.
List(ctx context.Context) ([]RemotePackage, error)
}
type RemotePackageGetter interface {
GetPackageZip(ctx context.Context, pkg RemotePackage) (io.ReadCloser, error)
}

205
pkg/pkg/repository_http.go Normal file
View File

@ -0,0 +1,205 @@
// Package http provides a repository implementation for HTTP.
package pkg
import (
"context"
"fmt"
"io"
"io/fs"
"net/http"
"net/url"
"os"
"path"
"time"
"github.com/stashapp/stash/pkg/logger"
"gopkg.in/yaml.v2"
)
// DefaultCacheTTL is the default time to live for the index cache.
const DefaultCacheTTL = 5 * time.Minute
// httpRepository is a HTTP based repository.
// It is configured with a package list URL. Packages are located from the Path field of the package.
//
// The index is cached for the duration of CacheTTL. The first request after the cache expires will cause the index to be reloaded.
type httpRepository struct {
packageListURL url.URL
client *http.Client
cache *repositoryCache
}
// newHttpRepository creates a new Repository. If client is nil then http.DefaultClient is used.
func newHttpRepository(packageListURL url.URL, client *http.Client, cache *repositoryCache) *httpRepository {
if client == nil {
client = http.DefaultClient
}
return &httpRepository{
packageListURL: packageListURL,
client: client,
cache: cache,
}
}
func (r *httpRepository) Path() string {
return r.packageListURL.String()
}
func (r *httpRepository) List(ctx context.Context) ([]RemotePackage, error) {
u := r.packageListURL
// the package list URL may be file://, in which case we need to use the local file system
var (
f io.ReadCloser
err error
)
if u.Scheme == "file" {
f, err = r.getLocalFile(ctx, u.Path)
} else {
f, err = r.getFileCached(ctx, u)
}
if err != nil {
return nil, fmt.Errorf("failed to get package list: %w", err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, fmt.Errorf("failed to read package list: %w", err)
}
var index []RemotePackage
if err := yaml.Unmarshal(data, &index); err != nil {
return nil, fmt.Errorf("reading package list: %w", err)
}
return index, nil
}
func isURL(s string) bool {
u, err := url.Parse(s)
return err == nil && u.Scheme != "" && (u.Scheme == "file" || u.Host != "")
}
func (r *httpRepository) resolvePath(p string) url.URL {
// if the path can be resolved to a URL, then use that
if isURL(p) {
// isURL ensures URL is valid
u, _ := url.Parse(p)
return *u
}
// otherwise, determine if the path is relative or absolute
// if it's relative, then join it with the package list URL
u := r.packageListURL
if path.IsAbs(p) {
u.Path = p
} else {
u.Path = path.Join(path.Dir(u.Path), p)
}
return u
}
func (r *httpRepository) GetPackageZip(ctx context.Context, pkg RemotePackage) (io.ReadCloser, error) {
p := pkg.Path
u := r.resolvePath(p)
var (
f io.ReadCloser
err error
)
// the package list URL may be file://, in which case we need to use the local file system
// the package zip path may be a URL. A remotely hosted list may _not_ use local files.
if u.Scheme == "file" {
if r.packageListURL.Scheme != "file" {
return nil, fmt.Errorf("%s is invalid for a remotely hosted package list", u.String())
}
f, err = r.getLocalFile(ctx, u.Path)
} else {
f, err = r.getFile(ctx, u)
}
if err != nil {
return nil, fmt.Errorf("failed to get package file: %w", err)
}
return f, nil
}
// getFileCached tries to get the list from the local cache.
// If it is not found or is stale, then it gets it normally.
func (r *httpRepository) getFileCached(ctx context.Context, u url.URL) (io.ReadCloser, error) {
// check if the file is in the cache first
localModTime := r.cache.lastModified(u.String())
if localModTime != nil {
// get the update time of the file
req, err := http.NewRequestWithContext(ctx, http.MethodHead, u.String(), nil)
if err != nil {
// shouldn't happen
return nil, err
}
resp, err := r.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get remote file: %w", err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("failed to get remote file: %s", resp.Status)
}
lastModified := resp.Header.Get("Last-Modified")
if lastModified != "" {
remoteModTime, _ := time.Parse(http.TimeFormat, lastModified)
if !remoteModTime.After(*localModTime) {
logger.Debugf("cached version of %s is equal or newer than remote", u.String())
return r.cache.getPackageList(u.String())
}
}
logger.Debugf("cached version of %s is older than remote", u.String())
}
return r.getFile(ctx, u)
}
func (r *httpRepository) getFile(ctx context.Context, u url.URL) (io.ReadCloser, error) {
logger.Debugf("fetching %s", u.String())
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
if err != nil {
// shouldn't happen
return nil, err
}
resp, err := r.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to get remote file: %w", err)
}
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("failed to get remote file: %s", resp.Status)
}
return r.cache.cacheFile(u.String(), resp.Body)
}
func (r *httpRepository) getLocalFile(ctx context.Context, path string) (fs.File, error) {
ret, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("failed to get file %q: %w", path, err)
}
return ret, nil
}
var _ = remoteRepository(&httpRepository{})

View File

@ -0,0 +1,55 @@
// Package http provides a repository implementation for HTTP.
package pkg
import (
"net/url"
"reflect"
"testing"
)
func TestHttpRepository_resolvePath(t *testing.T) {
mustParse := func(s string) url.URL {
u, err := url.Parse(s)
if err != nil {
panic(err)
}
return *u
}
tests := []struct {
name string
packageListURL url.URL
p string
want url.URL
}{
{
name: "relative",
packageListURL: mustParse("https://example.com/foo/packages.yaml"),
p: "bar",
want: mustParse("https://example.com/foo/bar"),
},
{
name: "absolute",
packageListURL: mustParse("https://example.com/foo/packages.yaml"),
p: "/bar",
want: mustParse("https://example.com/bar"),
},
{
name: "different server",
packageListURL: mustParse("https://example.com/foo/packages.yaml"),
p: "http://example.org/bar",
want: mustParse("http://example.org/bar"),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &httpRepository{
packageListURL: tt.packageListURL,
}
got := r.resolvePath(tt.p)
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("HttpRepository.resolvePath() = %v, want %v", got, tt.want)
}
})
}
}

158
pkg/pkg/store.go Normal file
View File

@ -0,0 +1,158 @@
package pkg
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"gopkg.in/yaml.v2"
)
// ManifestFile is the default filename for the package manifest.
const ManifestFile = "manifest"
// Store is a folder-based local repository.
// Packages are installed in their own directory under BaseDir.
// The package details are stored in a file named based on PackageFile.
type Store struct {
BaseDir string
// ManifestFile is the filename of the package file.
ManifestFile string
}
// sub returns a new Store with the given path appended to the BaseDir.
func (r *Store) sub(path string) *Store {
if path == "" || path == "." {
return r
}
return &Store{
BaseDir: filepath.Join(r.BaseDir, path),
ManifestFile: r.ManifestFile,
}
}
func (r *Store) List(ctx context.Context) ([]Manifest, error) {
e, err := os.ReadDir(r.BaseDir)
// ignore if directory cannot be read
if err != nil {
return nil, nil
}
var ret []Manifest
for _, ee := range e {
if !ee.IsDir() {
// ignore non-directories
continue
}
pkg, err := r.getManifest(ctx, ee.Name())
if err != nil {
// ignore if manifest does not exist
if errors.Is(err, os.ErrNotExist) {
continue
}
return nil, err
}
ret = append(ret, *pkg)
}
return ret, nil
}
func (r *Store) packageDir(id string) string {
return filepath.Join(r.BaseDir, id)
}
func (r *Store) manifestPath(id string) string {
return filepath.Join(r.packageDir(id), r.ManifestFile)
}
func (r *Store) getManifest(ctx context.Context, packageID string) (*Manifest, error) {
pfp := r.manifestPath(packageID)
data, err := os.ReadFile(pfp)
if err != nil {
return nil, fmt.Errorf("reading manifest file %q: %w", pfp, err)
}
var manifest Manifest
if err := yaml.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("reading manifest file %q: %w", pfp, err)
}
return &manifest, nil
}
func (r *Store) ensurePackageExists(packageID string) error {
// ensure the manifest file exists
if _, err := os.Stat(r.manifestPath(packageID)); err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("package %q does not exist", packageID)
}
}
return nil
}
func (r *Store) writeFile(packageID string, name string, mode fs.FileMode, i io.Reader) error {
fn := filepath.Join(r.packageDir(packageID), name)
if err := os.MkdirAll(filepath.Dir(fn), os.ModePerm); err != nil {
return fmt.Errorf("creating directory %v: %w", fn, err)
}
o, err := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
if err != nil {
return err
}
defer o.Close()
if _, err := io.Copy(o, i); err != nil {
return err
}
return nil
}
func (r *Store) writeManifest(packageID string, m Manifest) error {
pfp := r.manifestPath(packageID)
data, err := yaml.Marshal(m)
if err != nil {
return fmt.Errorf("marshaling manifest: %w", err)
}
if err := os.WriteFile(pfp, data, os.ModePerm); err != nil {
return fmt.Errorf("writing manifest file %q: %w", pfp, err)
}
return nil
}
func (r *Store) deleteFile(packageID string, name string) error {
// ensure the package exists
if err := r.ensurePackageExists(packageID); err != nil {
return err
}
pkgDir := r.packageDir(packageID)
fp := filepath.Join(pkgDir, name)
return os.Remove(fp)
}
func (r *Store) deleteManifest(packageID string) error {
return r.deleteFile(packageID, r.ManifestFile)
}
func (r *Store) deletePackageDir(packageID string) error {
return os.Remove(r.packageDir(packageID))
}

View File

@ -8,7 +8,7 @@
"build": "vite build",
"build-ci": "yarn run validate && yarn run build",
"validate": "yarn run lint && yarn run check && yarn run format-check",
"lint": "yarn run lint:css && yarn run lint:js",
"lint": "yarn run lint:js && yarn run lint:css",
"lint:css": "stylelint --cache \"src/**/*.scss\"",
"lint:js": "eslint --cache src/",
"check": "tsc --noEmit",

View File

@ -102,6 +102,7 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
onSelect,
onUnselect,
}) => {
const intl = useIntl();
const objects = useMemo(() => {
return queryResults.filter(
(p) =>
@ -124,6 +125,7 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
focus={inputFocus}
value={query}
setValue={(v) => onQueryChange(v)}
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
/>
<ul>
{selected.map((p) => (

View File

@ -253,6 +253,7 @@ export interface ISettingModal<T> {
close: (v?: T) => void;
renderField: (value: T | undefined, setValue: (v?: T) => void) => JSX.Element;
modalProps?: ModalProps;
validate?: (v: T) => boolean | undefined;
}
export const SettingModal = <T extends {}>(props: ISettingModal<T>) => {
@ -265,6 +266,7 @@ export const SettingModal = <T extends {}>(props: ISettingModal<T>) => {
close,
renderField,
modalProps,
validate,
} = props;
const intl = useIntl();
@ -299,6 +301,7 @@ export const SettingModal = <T extends {}>(props: ISettingModal<T>) => {
type="submit"
variant="primary"
onClick={() => close(currentValue)}
disabled={!currentValue || (validate && !validate(currentValue))}
>
<FormattedMessage id="actions.confirm" />
</Button>

View File

@ -0,0 +1,227 @@
import React, { useEffect, useState, useMemo } from "react";
import * as GQL from "src/core/generated-graphql";
import {
evictQueries,
getClient,
queryAvailablePluginPackages,
useInstallPluginPackages,
useInstalledPluginPackages,
useInstalledPluginPackagesStatus,
useUninstallPluginPackages,
useUpdatePluginPackages,
} from "src/core/StashService";
import { useMonitorJob } from "src/utils/job";
import {
AvailablePackages,
InstalledPackages,
RemotePackage,
} from "../Shared/PackageManager/PackageManager";
import { useSettings } from "./context";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { SettingSection } from "./SettingSection";
const impactedPackageChangeQueries = [
GQL.PluginsDocument,
GQL.PluginTasksDocument,
GQL.InstalledPluginPackagesDocument,
GQL.InstalledPluginPackagesStatusDocument,
];
export const InstalledPluginPackages: React.FC = () => {
const [loadUpgrades, setLoadUpgrades] = useState(false);
const [jobID, setJobID] = useState<string>();
const { job } = useMonitorJob(jobID, () => onPackageChanges());
const { data: installedPlugins, refetch: refetchPackages1 } =
useInstalledPluginPackages({
skip: loadUpgrades,
});
const {
data: withStatus,
refetch: refetchPackages2,
loading: statusLoading,
} = useInstalledPluginPackagesStatus({
skip: !loadUpgrades,
});
const [updatePackages] = useUpdatePluginPackages();
const [uninstallPackages] = useUninstallPluginPackages();
async function onUpdatePackages(packages: GQL.PackageSpecInput[]) {
const r = await updatePackages({
variables: {
packages,
},
});
setJobID(r.data?.updatePackages);
}
async function onUninstallPackages(packages: GQL.PackageSpecInput[]) {
const r = await uninstallPackages({
variables: {
packages,
},
});
setJobID(r.data?.uninstallPackages);
}
function refetchPackages() {
refetchPackages1();
refetchPackages2();
}
function onPackageChanges() {
// job is complete, refresh all local data
const ac = getClient();
evictQueries(ac.cache, impactedPackageChangeQueries);
}
function onCheckForUpdates() {
if (!loadUpgrades) {
setLoadUpgrades(true);
} else {
refetchPackages();
}
}
const installedPackages = useMemo(() => {
if (withStatus?.installedPackages) {
return withStatus.installedPackages;
}
return installedPlugins?.installedPackages ?? [];
}, [installedPlugins, withStatus]);
const loading = !!job || statusLoading;
return (
<SettingSection headingID="config.plugins.installed_plugins">
<div className="package-manager">
<InstalledPackages
loading={loading}
packages={installedPackages}
onCheckForUpdates={onCheckForUpdates}
onUpdatePackages={(packages) =>
onUpdatePackages(
packages.map((p) => ({
id: p.package_id,
sourceURL: p.upgrade!.sourceURL,
}))
)
}
onUninstallPackages={(packages) =>
onUninstallPackages(
packages.map((p) => ({
id: p.package_id,
sourceURL: p.sourceURL,
}))
)
}
updatesLoaded={loadUpgrades}
/>
</div>
</SettingSection>
);
};
export const AvailablePluginPackages: React.FC = () => {
const { general, loading: configLoading, error, saveGeneral } = useSettings();
const [sources, setSources] = useState<GQL.PackageSource[]>();
const [jobID, setJobID] = useState<string>();
const { job } = useMonitorJob(jobID, () => onPackageChanges());
const [installPackages] = useInstallPluginPackages();
async function onInstallPackages(packages: GQL.PackageSpecInput[]) {
const r = await installPackages({
variables: {
packages,
},
});
setJobID(r.data?.installPackages);
}
function onPackageChanges() {
// job is complete, refresh all local data
const ac = getClient();
evictQueries(ac.cache, impactedPackageChangeQueries);
}
useEffect(() => {
if (!sources && !configLoading && general.pluginPackageSources) {
setSources(general.pluginPackageSources);
}
}, [sources, configLoading, general.pluginPackageSources]);
async function loadSource(source: string): Promise<RemotePackage[]> {
const { data } = await queryAvailablePluginPackages(source);
return data.availablePackages;
}
function addSource(source: GQL.PackageSource) {
saveGeneral({
pluginPackageSources: [...(general.pluginPackageSources ?? []), source],
});
setSources((prev) => {
return [...(prev ?? []), source];
});
}
function editSource(existing: GQL.PackageSource, changed: GQL.PackageSource) {
saveGeneral({
pluginPackageSources: general.pluginPackageSources?.map((s) =>
s.url === existing.url ? changed : s
),
});
setSources((prev) => {
return prev?.map((s) => (s.url === existing.url ? changed : s));
});
}
function deleteSource(source: GQL.PackageSource) {
saveGeneral({
pluginPackageSources: general.pluginPackageSources?.filter(
(s) => s.url !== source.url
),
});
setSources((prev) => {
return prev?.filter((s) => s.url !== source.url);
});
}
function renderDescription(pkg: RemotePackage) {
if (pkg.metadata.description) {
return pkg.metadata.description;
}
}
if (error) return <h1>{error.message}</h1>;
if (configLoading) return <LoadingIndicator />;
const loading = !!job;
return (
<SettingSection headingID="config.plugins.available_plugins">
<div className="package-manager">
<AvailablePackages
loading={loading}
onInstallPackages={onInstallPackages}
renderDescription={renderDescription}
loadSource={(source) => loadSource(source)}
sources={sources ?? []}
addSource={addSource}
editSource={editSource}
deleteSource={deleteSource}
/>
</div>
</SettingSection>
);
};

View File

@ -0,0 +1,221 @@
import React, { useEffect, useState, useMemo } from "react";
import * as GQL from "src/core/generated-graphql";
import {
evictQueries,
getClient,
queryAvailableScraperPackages,
useInstallScraperPackages,
useInstalledScraperPackages,
useInstalledScraperPackagesStatus,
useUninstallScraperPackages,
useUpdateScraperPackages,
} from "src/core/StashService";
import { useMonitorJob } from "src/utils/job";
import {
AvailablePackages,
InstalledPackages,
RemotePackage,
} from "../Shared/PackageManager/PackageManager";
import { useSettings } from "./context";
import { LoadingIndicator } from "../Shared/LoadingIndicator";
import { SettingSection } from "./SettingSection";
const impactedPackageChangeQueries = [
GQL.ListPerformerScrapersDocument,
GQL.ListSceneScrapersDocument,
GQL.ListMovieScrapersDocument,
GQL.InstalledScraperPackagesDocument,
GQL.InstalledScraperPackagesStatusDocument,
];
export const InstalledScraperPackages: React.FC = () => {
const [loadUpgrades, setLoadUpgrades] = useState(false);
const [jobID, setJobID] = useState<string>();
const { job } = useMonitorJob(jobID, () => onPackageChanges());
const { data: installedScrapers, refetch: refetchPackages1 } =
useInstalledScraperPackages({
skip: loadUpgrades,
});
const {
data: withStatus,
refetch: refetchPackages2,
loading: statusLoading,
} = useInstalledScraperPackagesStatus({
skip: !loadUpgrades,
});
const [updatePackages] = useUpdateScraperPackages();
const [uninstallPackages] = useUninstallScraperPackages();
async function onUpdatePackages(packages: GQL.PackageSpecInput[]) {
const r = await updatePackages({
variables: {
packages,
},
});
setJobID(r.data?.updatePackages);
}
async function onUninstallPackages(packages: GQL.PackageSpecInput[]) {
const r = await uninstallPackages({
variables: {
packages,
},
});
setJobID(r.data?.uninstallPackages);
}
function refetchPackages() {
refetchPackages1();
refetchPackages2();
}
function onPackageChanges() {
// job is complete, refresh all local data
const ac = getClient();
evictQueries(ac.cache, impactedPackageChangeQueries);
}
function onCheckForUpdates() {
if (!loadUpgrades) {
setLoadUpgrades(true);
} else {
refetchPackages();
}
}
const installedPackages = useMemo(() => {
if (withStatus?.installedPackages) {
return withStatus.installedPackages;
}
return installedScrapers?.installedPackages ?? [];
}, [installedScrapers, withStatus]);
const loading = !!job || statusLoading;
return (
<SettingSection headingID="config.scraping.installed_scrapers">
<div className="package-manager">
<InstalledPackages
loading={loading}
packages={installedPackages}
onCheckForUpdates={onCheckForUpdates}
onUpdatePackages={(packages) =>
onUpdatePackages(
packages.map((p) => ({
id: p.package_id,
sourceURL: p.upgrade!.sourceURL,
}))
)
}
onUninstallPackages={(packages) =>
onUninstallPackages(
packages.map((p) => ({
id: p.package_id,
sourceURL: p.sourceURL,
}))
)
}
updatesLoaded={loadUpgrades}
/>
</div>
</SettingSection>
);
};
export const AvailableScraperPackages: React.FC = () => {
const { general, loading: configLoading, error, saveGeneral } = useSettings();
const [sources, setSources] = useState<GQL.PackageSource[]>();
const [jobID, setJobID] = useState<string>();
const { job } = useMonitorJob(jobID, () => onPackageChanges());
const [installPackages] = useInstallScraperPackages();
async function onInstallPackages(packages: GQL.PackageSpecInput[]) {
const r = await installPackages({
variables: {
packages,
},
});
setJobID(r.data?.installPackages);
}
function onPackageChanges() {
// job is complete, refresh all local data
const ac = getClient();
evictQueries(ac.cache, impactedPackageChangeQueries);
}
useEffect(() => {
if (!sources && !configLoading && general.scraperPackageSources) {
setSources(general.scraperPackageSources);
}
}, [sources, configLoading, general.scraperPackageSources]);
async function loadSource(source: string): Promise<RemotePackage[]> {
const { data } = await queryAvailableScraperPackages(source);
return data.availablePackages;
}
function addSource(source: GQL.PackageSource) {
saveGeneral({
scraperPackageSources: [...(general.scraperPackageSources ?? []), source],
});
setSources((prev) => {
return [...(prev ?? []), source];
});
}
function editSource(existing: GQL.PackageSource, changed: GQL.PackageSource) {
saveGeneral({
scraperPackageSources: general.scraperPackageSources?.map((s) =>
s.url === existing.url ? changed : s
),
});
setSources((prev) => {
return prev?.map((s) => (s.url === existing.url ? changed : s));
});
}
function deleteSource(source: GQL.PackageSource) {
saveGeneral({
scraperPackageSources: general.scraperPackageSources?.filter(
(s) => s.url !== source.url
),
});
setSources((prev) => {
return prev?.filter((s) => s.url !== source.url);
});
}
if (error) return <h1>{error.message}</h1>;
if (configLoading) return <LoadingIndicator />;
const loading = !!job;
return (
<SettingSection headingID="config.scraping.available_scrapers">
<div className="package-manager">
<AvailablePackages
loading={loading}
onInstallPackages={onInstallPackages}
loadSource={(source) => loadSource(source)}
sources={sources ?? []}
addSource={addSource}
editSource={editSource}
deleteSource={deleteSource}
/>
</div>
</SettingSection>
);
};

View File

@ -22,6 +22,10 @@ import {
} from "./Inputs";
import { faLink, faSyncAlt } from "@fortawesome/free-solid-svg-icons";
import { useSettings } from "./context";
import {
AvailablePluginPackages,
InstalledPluginPackages,
} from "./PluginPackageManager";
interface IPluginSettingProps {
pluginID: string;
@ -242,6 +246,9 @@ export const SettingsPluginsPanel: React.FC = () => {
return (
<>
<InstalledPluginPackages />
<AvailablePluginPackages />
<SettingSection headingID="config.categories.plugins">
<Setting headingID="actions.reload_plugins">
<Button onClick={() => onReloadPlugins()}>

View File

@ -19,6 +19,10 @@ import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs";
import { useSettings } from "./context";
import { StashBoxSetting } from "./StashBoxConfiguration";
import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
import {
AvailableScraperPackages,
InstalledScraperPackages,
} from "./ScraperPackageManager";
interface IURLList {
urls: string[];
@ -346,6 +350,9 @@ export const SettingsScrapingPanel: React.FC = () => {
/>
</SettingSection>
<InstalledScraperPackages />
<AvailableScraperPackages />
<SettingSection headingID="config.scraping.scrapers">
<div className="content">
<Button onClick={() => onReloadScrapers()}>

View File

@ -0,0 +1,33 @@
import React from "react";
import { Button, Modal } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
export interface IAlertModalProps {
text: JSX.Element | string;
show?: boolean;
confirmButtonText?: string;
onConfirm: () => void;
onCancel: () => void;
}
export const AlertModal: React.FC<IAlertModalProps> = ({
text,
show,
confirmButtonText,
onConfirm,
onCancel,
}) => {
return (
<Modal show={show}>
<Modal.Body>{text}</Modal.Body>
<Modal.Footer>
<Button variant="danger" onClick={() => onConfirm()}>
{confirmButtonText ?? <FormattedMessage id="actions.confirm" />}
</Button>
<Button variant="secondary" onClick={() => onCancel()}>
<FormattedMessage id="actions.cancel" />
</Button>
</Modal.Footer>
</Modal>
);
};

View File

@ -8,17 +8,23 @@ import useFocus from "src/utils/focus";
interface IClearableInput {
value: string;
setValue: (value: string) => void;
focus: ReturnType<typeof useFocus>;
focus?: ReturnType<typeof useFocus>;
placeholder?: string;
}
export const ClearableInput: React.FC<IClearableInput> = ({
value,
setValue,
focus,
placeholder,
}) => {
const intl = useIntl();
const [queryRef, setQueryFocus] = focus;
const [defaultQueryRef, setQueryFocusDefault] = useFocus();
const [queryRef, setQueryFocus] = focus || [
defaultQueryRef,
setQueryFocusDefault,
];
const queryClearShowing = !!value;
function onChangeQuery(event: React.FormEvent<HTMLInputElement>) {
@ -34,7 +40,7 @@ export const ClearableInput: React.FC<IClearableInput> = ({
<div className="clearable-input-group">
<FormControl
ref={queryRef}
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
placeholder={placeholder}
value={value}
onInput={onChangeQuery}
className="clearable-text-field"

View File

@ -13,7 +13,7 @@ interface IButton {
interface IModal {
show: boolean;
onHide?: () => void;
header?: string;
header?: JSX.Element | string;
icon?: IconDefinition;
cancel?: IButton;
accept?: IButton;
@ -59,24 +59,6 @@ export const ModalComponent: React.FC<IModal> = ({
<div>{leftFooterButtons}</div>
<div>
{footerButtons}
{cancel ? (
<Button
disabled={isRunning}
variant={cancel.variant ?? "primary"}
onClick={cancel.onClick}
className="ml-2"
>
{cancel.text ?? (
<FormattedMessage
id="actions.cancel"
defaultMessage="Cancel"
description="Cancels the current action and dismisses the modal."
/>
)}
</Button>
) : (
""
)}
<Button
disabled={isRunning || disabled}
variant={accept?.variant ?? "primary"}
@ -95,6 +77,24 @@ export const ModalComponent: React.FC<IModal> = ({
)
)}
</Button>
{cancel ? (
<Button
disabled={isRunning}
variant={cancel.variant ?? "primary"}
onClick={cancel.onClick}
className="ml-2"
>
{cancel.text ?? (
<FormattedMessage
id="actions.cancel"
defaultMessage="Cancel"
description="Cancels the current action and dismisses the modal."
/>
)}
</Button>
) : (
""
)}
</div>
</Modal.Footer>
</Modal>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,100 @@
.package-manager {
padding: 1em;
.package-source {
font-weight: bold;
.source-controls {
align-items: center;
display: flex;
justify-content: end;
}
}
.package-cell,
.package-source {
cursor: pointer;
}
.package-collapse-button {
color: $text-color;
}
.package-manager-table-container {
max-height: 300px;
overflow-y: auto;
}
table thead {
background-color: $card-bg;
position: sticky;
top: 0;
z-index: 1;
.button-cell {
width: 40px;
}
}
table td {
vertical-align: middle;
}
.package-version,
.package-date,
.package-name,
.package-id {
display: block;
}
.package-date,
.package-id {
color: $muted-gray;
font-size: 0.8rem;
}
.package-manager-toolbar {
display: flex;
justify-content: space-between;
div {
display: flex;
}
.btn {
margin-left: 0.5em;
}
}
.package-required-by {
color: $warning;
font-size: 0.8rem;
}
.LoadingIndicator-message {
display: inline-block;
font-size: 1rem;
margin-left: 0.5em;
margin-top: 0;
}
.source-error {
& > .fa-icon {
color: $warning;
}
.btn {
margin-left: 0.5em;
}
}
}
.package-manager-no-results {
color: $text-muted;
padding: 1em;
text-align: center;
.btn {
margin-top: 0.5em;
}
}

View File

@ -457,21 +457,31 @@ div.react-datepicker {
.clearable-text-field,
.clearable-text-field:active,
.clearable-text-field:focus {
background-color: #394b59;
background-color: $secondary;
border: 0;
border-color: #394b59;
border-color: $secondary;
color: #fff;
}
.clearable-text-field-clear {
background-color: #394b59;
color: #bfccd6;
background-color: $secondary;
color: $muted-gray;
font-size: 0.875rem;
margin: 0.375rem 0.75rem;
padding: 0;
position: absolute;
right: 0;
z-index: 4;
&:hover,
&:focus,
&:active,
&:not(:disabled):not(.disabled):active,
&:not(:disabled):not(.disabled):active:focus {
background-color: $secondary;
border-color: transparent;
box-shadow: none;
}
}
.string-list-row .input-group {

View File

@ -1945,6 +1945,43 @@ export const queryScrapeGalleryURL = (url: string) =>
fetchPolicy: "network-only",
});
/// Packages
export const useInstalledScraperPackages = GQL.useInstalledScraperPackagesQuery;
export const useInstalledScraperPackagesStatus =
GQL.useInstalledScraperPackagesStatusQuery;
export const queryAvailableScraperPackages = (source: string) =>
client.query<GQL.AvailableScraperPackagesQuery>({
query: GQL.AvailableScraperPackagesDocument,
variables: {
source,
},
fetchPolicy: "network-only",
});
export const useInstallScraperPackages = GQL.useInstallScraperPackagesMutation;
export const useUpdateScraperPackages = GQL.useUpdateScraperPackagesMutation;
export const useUninstallScraperPackages =
GQL.useUninstallScraperPackagesMutation;
export const useInstalledPluginPackages = GQL.useInstalledPluginPackagesQuery;
export const useInstalledPluginPackagesStatus =
GQL.useInstalledPluginPackagesStatusQuery;
export const queryAvailablePluginPackages = (source: string) =>
client.query<GQL.AvailablePluginPackagesQuery>({
query: GQL.AvailablePluginPackagesDocument,
variables: {
source,
},
fetchPolicy: "network-only",
});
export const useInstallPluginPackages = GQL.useInstallPluginPackagesMutation;
export const useUpdatePluginPackages = GQL.useUpdatePluginPackagesMutation;
export const useUninstallPluginPackages =
GQL.useUninstallPluginPackagesMutation;
/// Configuration
export const useConfiguration = () => GQL.useConfigurationQuery();

View File

@ -18,6 +18,7 @@
@import "src/components/Studios/styles.scss";
@import "src/components/Shared/styles.scss";
@import "src/components/Shared/Rating/styles.scss";
@import "src/components/Shared/PackageManager/styles.scss";
@import "src/components/Tags/styles.scss";
@import "src/components/Wall/styles.scss";
@import "src/components/Tagger/styles.scss";

View File

@ -79,6 +79,7 @@
"previous_action": "Back",
"reassign": "Reassign",
"refresh": "Refresh",
"reload": "Reload",
"reload_plugins": "Reload plugins",
"reload_scrapers": "Reload scrapers",
"remove": "Remove",
@ -385,14 +386,18 @@
"log_level": "Log Level"
},
"plugins": {
"available_plugins": "Available Plugins",
"hooks": "Hooks",
"installed_plugins": "Installed Plugins",
"triggers_on": "Triggers on"
},
"scraping": {
"available_scrapers": "Available Scrapers",
"entity_metadata": "{entityType} Metadata",
"entity_scrapers": "{entityType} scrapers",
"excluded_tag_patterns_desc": "Regexps of tag names to exclude from scraping results",
"excluded_tag_patterns_head": "Excluded Tag Patterns",
"installed_scrapers": "Installed Scrapers",
"scraper": "Scraper",
"scrapers": "Scrapers",
"search_by_name": "Search by name",
@ -1067,6 +1072,35 @@
"o_counter": "O-Counter",
"operations": "Operations",
"organized": "Organised",
"package_manager": {
"add_source": "Add Source",
"edit_source": "Edit Source",
"check_for_updates": "Check for Updates",
"confirm_delete_source": "Are you sure you want to delete source {name} ({url})?",
"confirm_uninstall": "Are you sure you want to uninstall {number} packages?",
"description": "Description",
"hide_unselected": "Hide unselected",
"install": "Install",
"installed_version": "Installed Version",
"latest_version": "Latest Version",
"no_sources": "No sources configured",
"no_packages": "No packages found",
"required_by": "Required by {packages}",
"source": {
"name": "Name",
"url": "Index URL",
"local_path": {
"heading": "Local Path",
"description": "Relative path to store packages for this source. Note that changing this requires the packages to be moved manually."
}
},
"package": "Package",
"selected_only": "Selected only",
"show_all": "Show all",
"uninstall": "Uninstall",
"update": "Update",
"version": "Version"
},
"pagination": {
"first": "First",
"last": "Last",
@ -1358,6 +1392,7 @@
"aliases_must_be_unique": "aliases must be unique",
"date_invalid_form": "${path} must be in YYYY-MM-DD form",
"required": "${path} is a required field",
"unique": "${path} must be unique",
"urls_must_be_unique": "URLs must be unique"
},
"videos": "Videos",

75
ui/v2.5/src/utils/job.ts Normal file
View File

@ -0,0 +1,75 @@
import { useEffect, useState } from "react";
import {
Job,
JobStatusUpdateType,
useJobQueueQuery,
useJobsSubscribeSubscription,
} from "src/core/generated-graphql";
export type JobFragment = Pick<
Job,
"id" | "status" | "subTasks" | "description" | "progress"
>;
export const useMonitorJob = (
jobID: string | undefined | null,
onComplete?: () => void
) => {
const jobsSubscribe = useJobsSubscribeSubscription({
skip: !jobID,
});
const { data: jobData, loading } = useJobQueueQuery({
fetchPolicy: "network-only",
skip: !jobID,
});
const [job, setJob] = useState<JobFragment | undefined>();
useEffect(() => {
if (!jobID) {
return;
}
if (loading) {
return;
}
const j = jobData?.jobQueue?.find((jj) => jj.id === jobID);
if (j) {
setJob(j);
} else {
// must've already finished
setJob(undefined);
if (onComplete) {
onComplete();
}
}
}, [jobID, jobData, loading, onComplete]);
// monitor batch operation
useEffect(() => {
if (!jobID) {
return;
}
if (!jobsSubscribe.data) {
return;
}
const event = jobsSubscribe.data.jobsSubscribe;
if (event.job.id !== jobID) {
return;
}
if (event.type !== JobStatusUpdateType.Remove) {
setJob(event.job);
} else {
setJob(undefined);
if (onComplete) {
onComplete();
}
}
}, [jobsSubscribe, jobID, onComplete]);
return { job };
};