mirror of https://github.com/stashapp/stash.git
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:
parent
d95ef4059a
commit
987fa80786
|
@ -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
|
||||
####
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
fragment PackageData on Package {
|
||||
package_id
|
||||
name
|
||||
version
|
||||
date
|
||||
metadata
|
||||
sourceURL
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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!
|
||||
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -127,6 +127,8 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
|
|||
LiveTranscodeInputArgs: config.GetLiveTranscodeInputArgs(),
|
||||
LiveTranscodeOutputArgs: config.GetLiveTranscodeOutputArgs(),
|
||||
DrawFunscriptHeatmapRange: config.GetDrawFunscriptHeatmapRange(),
|
||||
ScraperPackageSources: config.GetScraperPackageSources(),
|
||||
PluginPackageSources: config.GetPluginPackageSources(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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")
|
||||
}
|
|
@ -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"`
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -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{})
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
|
@ -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",
|
||||
|
|
|
@ -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) => (
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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()}>
|
||||
|
|
|
@ -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()}>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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"
|
||||
|
|
|
@ -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
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 };
|
||||
};
|
Loading…
Reference in New Issue