mirror of
https://github.com/stashapp/stash.git
synced 2025-12-16 20:07:05 +03:00
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:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -21,11 +21,6 @@ vendor
|
|||||||
# GraphQL generated output
|
# GraphQL generated output
|
||||||
internal/api/generated_*.go
|
internal/api/generated_*.go
|
||||||
|
|
||||||
####
|
|
||||||
# Jetbrains
|
|
||||||
####
|
|
||||||
|
|
||||||
|
|
||||||
####
|
####
|
||||||
# Visual Studio
|
# Visual Studio
|
||||||
####
|
####
|
||||||
@@ -52,9 +47,6 @@ internal/api/generated_*.go
|
|||||||
.idea/**/uiDesigner.xml
|
.idea/**/uiDesigner.xml
|
||||||
.idea/**/dbnavigator.xml
|
.idea/**/dbnavigator.xml
|
||||||
|
|
||||||
# Goland Junk
|
|
||||||
pkg/pkg
|
|
||||||
|
|
||||||
####
|
####
|
||||||
# Random
|
# Random
|
||||||
####
|
####
|
||||||
|
|||||||
@@ -53,6 +53,17 @@ fragment ConfigGeneralData on ConfigGeneralResult {
|
|||||||
liveTranscodeInputArgs
|
liveTranscodeInputArgs
|
||||||
liveTranscodeOutputArgs
|
liveTranscodeOutputArgs
|
||||||
drawFunscriptHeatmapRange
|
drawFunscriptHeatmapRange
|
||||||
|
|
||||||
|
scraperPackageSources {
|
||||||
|
name
|
||||||
|
url
|
||||||
|
local_path
|
||||||
|
}
|
||||||
|
pluginPackageSources {
|
||||||
|
name
|
||||||
|
url
|
||||||
|
local_path
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment ConfigInterfaceData on ConfigInterfaceResult {
|
fragment ConfigInterfaceData on ConfigInterfaceResult {
|
||||||
|
|||||||
8
graphql/documents/data/package.graphql
Normal file
8
graphql/documents/data/package.graphql
Normal file
@@ -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!) {
|
mutation SetPluginsEnabled($enabledMap: BoolMap!) {
|
||||||
setPluginsEnabled(enabledMap: $enabledMap)
|
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 {
|
mutation ReloadScrapers {
|
||||||
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
|
...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"
|
"List available plugin operations"
|
||||||
pluginTasks: [PluginTask!]
|
pluginTasks: [PluginTask!]
|
||||||
|
|
||||||
|
# Packages
|
||||||
|
"List installed packages"
|
||||||
|
installedPackages(type: PackageType!): [Package!]!
|
||||||
|
"List available packages"
|
||||||
|
availablePackages(type: PackageType!, source: String!): [Package!]!
|
||||||
|
|
||||||
# Config
|
# Config
|
||||||
"Returns the current, complete configuration"
|
"Returns the current, complete configuration"
|
||||||
configuration: ConfigResult!
|
configuration: ConfigResult!
|
||||||
@@ -381,6 +387,29 @@ type Mutation {
|
|||||||
): ID!
|
): ID!
|
||||||
reloadPlugins: Boolean!
|
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!
|
stopJob(job_id: ID!): Boolean!
|
||||||
stopAllJobs: Boolean!
|
stopAllJobs: Boolean!
|
||||||
|
|
||||||
|
|||||||
@@ -167,6 +167,11 @@ input ConfigGeneralInput {
|
|||||||
stashBoxes: [StashBoxInput!]
|
stashBoxes: [StashBoxInput!]
|
||||||
"Python path - resolved using path if unset"
|
"Python path - resolved using path if unset"
|
||||||
pythonPath: String
|
pythonPath: String
|
||||||
|
|
||||||
|
"Source of scraper packages"
|
||||||
|
scraperPackageSources: [PackageSourceInput!]
|
||||||
|
"Source of plugin packages"
|
||||||
|
pluginPackageSources: [PackageSourceInput!]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ConfigGeneralResult {
|
type ConfigGeneralResult {
|
||||||
@@ -280,6 +285,11 @@ type ConfigGeneralResult {
|
|||||||
stashBoxes: [StashBox!]!
|
stashBoxes: [StashBox!]!
|
||||||
"Python path - resolved using path if unset"
|
"Python path - resolved using path if unset"
|
||||||
pythonPath: String!
|
pythonPath: String!
|
||||||
|
|
||||||
|
"Source of scraper packages"
|
||||||
|
scraperPackageSources: [PackageSource!]!
|
||||||
|
"Source of plugin packages"
|
||||||
|
pluginPackageSources: [PackageSource!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
input ConfigDisableDropdownCreateInput {
|
input ConfigDisableDropdownCreateInput {
|
||||||
|
|||||||
36
graphql/schema/types/package.graphql
Normal file
36
graphql/schema/types/package.graphql
Normal 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
|
||||||
|
}
|
||||||
@@ -347,6 +347,18 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
|
|||||||
c.Set(config.DrawFunscriptHeatmapRange, input.DrawFunscriptHeatmapRange)
|
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 {
|
if err := c.Write(); err != nil {
|
||||||
return makeConfigGeneralResult(), err
|
return makeConfigGeneralResult(), err
|
||||||
}
|
}
|
||||||
@@ -361,6 +373,12 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
|
|||||||
if refreshBlobStorage {
|
if refreshBlobStorage {
|
||||||
manager.GetInstance().SetBlobStoreOptions()
|
manager.GetInstance().SetBlobStoreOptions()
|
||||||
}
|
}
|
||||||
|
if refreshScraperSource {
|
||||||
|
manager.GetInstance().RefreshScraperSourceManager()
|
||||||
|
}
|
||||||
|
if refreshPluginSource {
|
||||||
|
manager.GetInstance().RefreshPluginSourceManager()
|
||||||
|
}
|
||||||
|
|
||||||
return makeConfigGeneralResult(), nil
|
return makeConfigGeneralResult(), nil
|
||||||
}
|
}
|
||||||
|
|||||||
82
internal/api/resolver_mutation_package.go
Normal file
82
internal/api/resolver_mutation_package.go
Normal 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
|
||||||
|
}
|
||||||
@@ -127,6 +127,8 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
|
|||||||
LiveTranscodeInputArgs: config.GetLiveTranscodeInputArgs(),
|
LiveTranscodeInputArgs: config.GetLiveTranscodeInputArgs(),
|
||||||
LiveTranscodeOutputArgs: config.GetLiveTranscodeOutputArgs(),
|
LiveTranscodeOutputArgs: config.GetLiveTranscodeOutputArgs(),
|
||||||
DrawFunscriptHeatmapRange: config.GetDrawFunscriptHeatmapRange(),
|
DrawFunscriptHeatmapRange: config.GetDrawFunscriptHeatmapRange(),
|
||||||
|
ScraperPackageSources: config.GetScraperPackageSources(),
|
||||||
|
PluginPackageSources: config.GetPluginPackageSources(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
194
internal/api/resolver_query_package.go
Normal file
194
internal/api/resolver_query_package.go
Normal 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
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
"github.com/stashapp/stash/pkg/logger"
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
"github.com/stashapp/stash/pkg/models/paths"
|
"github.com/stashapp/stash/pkg/models/paths"
|
||||||
|
"github.com/stashapp/stash/pkg/sliceutil"
|
||||||
"github.com/stashapp/stash/pkg/utils"
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,6 +138,9 @@ const (
|
|||||||
PluginsSettingPrefix = PluginsSetting + "."
|
PluginsSettingPrefix = PluginsSetting + "."
|
||||||
DisabledPlugins = "plugins.disabled"
|
DisabledPlugins = "plugins.disabled"
|
||||||
|
|
||||||
|
PluginPackageSources = "plugins.package_sources"
|
||||||
|
ScraperPackageSources = "scrapers.package_sources"
|
||||||
|
|
||||||
// i18n
|
// i18n
|
||||||
Language = "language"
|
Language = "language"
|
||||||
|
|
||||||
@@ -1520,6 +1524,61 @@ func (i *Instance) ActivatePublicAccessTripwire(requestIP string) error {
|
|||||||
return i.Write()
|
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 {
|
func (i *Instance) Validate() error {
|
||||||
i.RLock()
|
i.RLock()
|
||||||
defer i.RUnlock()
|
defer i.RUnlock()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"runtime"
|
||||||
@@ -29,6 +30,7 @@ import (
|
|||||||
"github.com/stashapp/stash/pkg/logger"
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
"github.com/stashapp/stash/pkg/models/paths"
|
"github.com/stashapp/stash/pkg/models/paths"
|
||||||
|
"github.com/stashapp/stash/pkg/pkg"
|
||||||
"github.com/stashapp/stash/pkg/plugin"
|
"github.com/stashapp/stash/pkg/plugin"
|
||||||
"github.com/stashapp/stash/pkg/scene"
|
"github.com/stashapp/stash/pkg/scene"
|
||||||
"github.com/stashapp/stash/pkg/scraper"
|
"github.com/stashapp/stash/pkg/scraper"
|
||||||
@@ -130,6 +132,9 @@ type Manager struct {
|
|||||||
PluginCache *plugin.Cache
|
PluginCache *plugin.Cache
|
||||||
ScraperCache *scraper.Cache
|
ScraperCache *scraper.Cache
|
||||||
|
|
||||||
|
PluginPackageManager *pkg.Manager
|
||||||
|
ScraperPackageManager *pkg.Manager
|
||||||
|
|
||||||
DownloadStore *DownloadStore
|
DownloadStore *DownloadStore
|
||||||
|
|
||||||
DLNAService *dlna.Service
|
DLNAService *dlna.Service
|
||||||
@@ -229,6 +234,9 @@ func initialize() error {
|
|||||||
dlnaRepository := dlna.NewRepository(repo)
|
dlnaRepository := dlna.NewRepository(repo)
|
||||||
instance.DLNAService = dlna.NewService(dlnaRepository, cfg, &sceneServer)
|
instance.DLNAService = dlna.NewService(dlnaRepository, cfg, &sceneServer)
|
||||||
|
|
||||||
|
instance.RefreshPluginSourceManager()
|
||||||
|
instance.RefreshScraperSourceManager()
|
||||||
|
|
||||||
if !cfg.IsNewSystem() {
|
if !cfg.IsNewSystem() {
|
||||||
logger.Infof("using config file: %s", cfg.GetConfigFile())
|
logger.Infof("using config file: %s", cfg.GetConfigFile())
|
||||||
|
|
||||||
@@ -280,6 +288,26 @@ func initialize() error {
|
|||||||
return nil
|
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 {
|
func videoFileFilter(ctx context.Context, f models.File) bool {
|
||||||
return useAsVideo(f.Base().Path)
|
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)
|
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) {
|
func setSetupDefaults(input *SetupInput) {
|
||||||
if input.ConfigLocation == "" {
|
if input.ConfigLocation == "" {
|
||||||
input.ConfigLocation = filepath.Join(fsutil.GetHomeDirectory(), ".stash", "config.yml")
|
input.ConfigLocation = filepath.Join(fsutil.GetHomeDirectory(), ".stash", "config.yml")
|
||||||
|
|||||||
134
internal/manager/task/packages.go
Normal file
134
internal/manager/task/packages.go
Normal 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
12
pkg/models/package.go
Normal 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
82
pkg/pkg/cache.go
Normal 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
268
pkg/pkg/manager.go
Normal 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
198
pkg/pkg/pkg.go
Normal 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
22
pkg/pkg/repository.go
Normal 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
205
pkg/pkg/repository_http.go
Normal 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{})
|
||||||
55
pkg/pkg/repository_http_test.go
Normal file
55
pkg/pkg/repository_http_test.go
Normal 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
158
pkg/pkg/store.go
Normal 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))
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"build-ci": "yarn run validate && yarn run build",
|
"build-ci": "yarn run validate && yarn run build",
|
||||||
"validate": "yarn run lint && yarn run check && yarn run format-check",
|
"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:css": "stylelint --cache \"src/**/*.scss\"",
|
||||||
"lint:js": "eslint --cache src/",
|
"lint:js": "eslint --cache src/",
|
||||||
"check": "tsc --noEmit",
|
"check": "tsc --noEmit",
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onUnselect,
|
onUnselect,
|
||||||
}) => {
|
}) => {
|
||||||
|
const intl = useIntl();
|
||||||
const objects = useMemo(() => {
|
const objects = useMemo(() => {
|
||||||
return queryResults.filter(
|
return queryResults.filter(
|
||||||
(p) =>
|
(p) =>
|
||||||
@@ -124,6 +125,7 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
|
|||||||
focus={inputFocus}
|
focus={inputFocus}
|
||||||
value={query}
|
value={query}
|
||||||
setValue={(v) => onQueryChange(v)}
|
setValue={(v) => onQueryChange(v)}
|
||||||
|
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
|
||||||
/>
|
/>
|
||||||
<ul>
|
<ul>
|
||||||
{selected.map((p) => (
|
{selected.map((p) => (
|
||||||
|
|||||||
@@ -253,6 +253,7 @@ export interface ISettingModal<T> {
|
|||||||
close: (v?: T) => void;
|
close: (v?: T) => void;
|
||||||
renderField: (value: T | undefined, setValue: (v?: T) => void) => JSX.Element;
|
renderField: (value: T | undefined, setValue: (v?: T) => void) => JSX.Element;
|
||||||
modalProps?: ModalProps;
|
modalProps?: ModalProps;
|
||||||
|
validate?: (v: T) => boolean | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SettingModal = <T extends {}>(props: ISettingModal<T>) => {
|
export const SettingModal = <T extends {}>(props: ISettingModal<T>) => {
|
||||||
@@ -265,6 +266,7 @@ export const SettingModal = <T extends {}>(props: ISettingModal<T>) => {
|
|||||||
close,
|
close,
|
||||||
renderField,
|
renderField,
|
||||||
modalProps,
|
modalProps,
|
||||||
|
validate,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
@@ -299,6 +301,7 @@ export const SettingModal = <T extends {}>(props: ISettingModal<T>) => {
|
|||||||
type="submit"
|
type="submit"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
onClick={() => close(currentValue)}
|
onClick={() => close(currentValue)}
|
||||||
|
disabled={!currentValue || (validate && !validate(currentValue))}
|
||||||
>
|
>
|
||||||
<FormattedMessage id="actions.confirm" />
|
<FormattedMessage id="actions.confirm" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
227
ui/v2.5/src/components/Settings/PluginPackageManager.tsx
Normal file
227
ui/v2.5/src/components/Settings/PluginPackageManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
221
ui/v2.5/src/components/Settings/ScraperPackageManager.tsx
Normal file
221
ui/v2.5/src/components/Settings/ScraperPackageManager.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -22,6 +22,10 @@ import {
|
|||||||
} from "./Inputs";
|
} from "./Inputs";
|
||||||
import { faLink, faSyncAlt } from "@fortawesome/free-solid-svg-icons";
|
import { faLink, faSyncAlt } from "@fortawesome/free-solid-svg-icons";
|
||||||
import { useSettings } from "./context";
|
import { useSettings } from "./context";
|
||||||
|
import {
|
||||||
|
AvailablePluginPackages,
|
||||||
|
InstalledPluginPackages,
|
||||||
|
} from "./PluginPackageManager";
|
||||||
|
|
||||||
interface IPluginSettingProps {
|
interface IPluginSettingProps {
|
||||||
pluginID: string;
|
pluginID: string;
|
||||||
@@ -242,6 +246,9 @@ export const SettingsPluginsPanel: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
<InstalledPluginPackages />
|
||||||
|
<AvailablePluginPackages />
|
||||||
|
|
||||||
<SettingSection headingID="config.categories.plugins">
|
<SettingSection headingID="config.categories.plugins">
|
||||||
<Setting headingID="actions.reload_plugins">
|
<Setting headingID="actions.reload_plugins">
|
||||||
<Button onClick={() => onReloadPlugins()}>
|
<Button onClick={() => onReloadPlugins()}>
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import { BooleanSetting, StringListSetting, StringSetting } from "./Inputs";
|
|||||||
import { useSettings } from "./context";
|
import { useSettings } from "./context";
|
||||||
import { StashBoxSetting } from "./StashBoxConfiguration";
|
import { StashBoxSetting } from "./StashBoxConfiguration";
|
||||||
import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
|
import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
|
||||||
|
import {
|
||||||
|
AvailableScraperPackages,
|
||||||
|
InstalledScraperPackages,
|
||||||
|
} from "./ScraperPackageManager";
|
||||||
|
|
||||||
interface IURLList {
|
interface IURLList {
|
||||||
urls: string[];
|
urls: string[];
|
||||||
@@ -346,6 +350,9 @@ export const SettingsScrapingPanel: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</SettingSection>
|
</SettingSection>
|
||||||
|
|
||||||
|
<InstalledScraperPackages />
|
||||||
|
<AvailableScraperPackages />
|
||||||
|
|
||||||
<SettingSection headingID="config.scraping.scrapers">
|
<SettingSection headingID="config.scraping.scrapers">
|
||||||
<div className="content">
|
<div className="content">
|
||||||
<Button onClick={() => onReloadScrapers()}>
|
<Button onClick={() => onReloadScrapers()}>
|
||||||
|
|||||||
33
ui/v2.5/src/components/Shared/Alert.tsx
Normal file
33
ui/v2.5/src/components/Shared/Alert.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -8,17 +8,23 @@ import useFocus from "src/utils/focus";
|
|||||||
interface IClearableInput {
|
interface IClearableInput {
|
||||||
value: string;
|
value: string;
|
||||||
setValue: (value: string) => void;
|
setValue: (value: string) => void;
|
||||||
focus: ReturnType<typeof useFocus>;
|
focus?: ReturnType<typeof useFocus>;
|
||||||
|
placeholder?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ClearableInput: React.FC<IClearableInput> = ({
|
export const ClearableInput: React.FC<IClearableInput> = ({
|
||||||
value,
|
value,
|
||||||
setValue,
|
setValue,
|
||||||
focus,
|
focus,
|
||||||
|
placeholder,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
const [queryRef, setQueryFocus] = focus;
|
const [defaultQueryRef, setQueryFocusDefault] = useFocus();
|
||||||
|
const [queryRef, setQueryFocus] = focus || [
|
||||||
|
defaultQueryRef,
|
||||||
|
setQueryFocusDefault,
|
||||||
|
];
|
||||||
const queryClearShowing = !!value;
|
const queryClearShowing = !!value;
|
||||||
|
|
||||||
function onChangeQuery(event: React.FormEvent<HTMLInputElement>) {
|
function onChangeQuery(event: React.FormEvent<HTMLInputElement>) {
|
||||||
@@ -34,7 +40,7 @@ export const ClearableInput: React.FC<IClearableInput> = ({
|
|||||||
<div className="clearable-input-group">
|
<div className="clearable-input-group">
|
||||||
<FormControl
|
<FormControl
|
||||||
ref={queryRef}
|
ref={queryRef}
|
||||||
placeholder={`${intl.formatMessage({ id: "actions.search" })}…`}
|
placeholder={placeholder}
|
||||||
value={value}
|
value={value}
|
||||||
onInput={onChangeQuery}
|
onInput={onChangeQuery}
|
||||||
className="clearable-text-field"
|
className="clearable-text-field"
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ interface IButton {
|
|||||||
interface IModal {
|
interface IModal {
|
||||||
show: boolean;
|
show: boolean;
|
||||||
onHide?: () => void;
|
onHide?: () => void;
|
||||||
header?: string;
|
header?: JSX.Element | string;
|
||||||
icon?: IconDefinition;
|
icon?: IconDefinition;
|
||||||
cancel?: IButton;
|
cancel?: IButton;
|
||||||
accept?: IButton;
|
accept?: IButton;
|
||||||
@@ -59,24 +59,6 @@ export const ModalComponent: React.FC<IModal> = ({
|
|||||||
<div>{leftFooterButtons}</div>
|
<div>{leftFooterButtons}</div>
|
||||||
<div>
|
<div>
|
||||||
{footerButtons}
|
{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
|
<Button
|
||||||
disabled={isRunning || disabled}
|
disabled={isRunning || disabled}
|
||||||
variant={accept?.variant ?? "primary"}
|
variant={accept?.variant ?? "primary"}
|
||||||
@@ -95,6 +77,24 @@ export const ModalComponent: React.FC<IModal> = ({
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
</Button>
|
</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>
|
</div>
|
||||||
</Modal.Footer>
|
</Modal.Footer>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
1002
ui/v2.5/src/components/Shared/PackageManager/PackageManager.tsx
Normal file
1002
ui/v2.5/src/components/Shared/PackageManager/PackageManager.tsx
Normal file
File diff suppressed because it is too large
Load Diff
100
ui/v2.5/src/components/Shared/PackageManager/styles.scss
Normal file
100
ui/v2.5/src/components/Shared/PackageManager/styles.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -457,21 +457,31 @@ div.react-datepicker {
|
|||||||
.clearable-text-field,
|
.clearable-text-field,
|
||||||
.clearable-text-field:active,
|
.clearable-text-field:active,
|
||||||
.clearable-text-field:focus {
|
.clearable-text-field:focus {
|
||||||
background-color: #394b59;
|
background-color: $secondary;
|
||||||
border: 0;
|
border: 0;
|
||||||
border-color: #394b59;
|
border-color: $secondary;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.clearable-text-field-clear {
|
.clearable-text-field-clear {
|
||||||
background-color: #394b59;
|
background-color: $secondary;
|
||||||
color: #bfccd6;
|
color: $muted-gray;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
margin: 0.375rem 0.75rem;
|
margin: 0.375rem 0.75rem;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 0;
|
right: 0;
|
||||||
z-index: 4;
|
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 {
|
.string-list-row .input-group {
|
||||||
|
|||||||
@@ -1945,6 +1945,43 @@ export const queryScrapeGalleryURL = (url: string) =>
|
|||||||
fetchPolicy: "network-only",
|
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
|
/// Configuration
|
||||||
|
|
||||||
export const useConfiguration = () => GQL.useConfigurationQuery();
|
export const useConfiguration = () => GQL.useConfigurationQuery();
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
@import "src/components/Studios/styles.scss";
|
@import "src/components/Studios/styles.scss";
|
||||||
@import "src/components/Shared/styles.scss";
|
@import "src/components/Shared/styles.scss";
|
||||||
@import "src/components/Shared/Rating/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/Tags/styles.scss";
|
||||||
@import "src/components/Wall/styles.scss";
|
@import "src/components/Wall/styles.scss";
|
||||||
@import "src/components/Tagger/styles.scss";
|
@import "src/components/Tagger/styles.scss";
|
||||||
|
|||||||
@@ -79,6 +79,7 @@
|
|||||||
"previous_action": "Back",
|
"previous_action": "Back",
|
||||||
"reassign": "Reassign",
|
"reassign": "Reassign",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
|
"reload": "Reload",
|
||||||
"reload_plugins": "Reload plugins",
|
"reload_plugins": "Reload plugins",
|
||||||
"reload_scrapers": "Reload scrapers",
|
"reload_scrapers": "Reload scrapers",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
@@ -385,14 +386,18 @@
|
|||||||
"log_level": "Log Level"
|
"log_level": "Log Level"
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
"available_plugins": "Available Plugins",
|
||||||
"hooks": "Hooks",
|
"hooks": "Hooks",
|
||||||
|
"installed_plugins": "Installed Plugins",
|
||||||
"triggers_on": "Triggers on"
|
"triggers_on": "Triggers on"
|
||||||
},
|
},
|
||||||
"scraping": {
|
"scraping": {
|
||||||
|
"available_scrapers": "Available Scrapers",
|
||||||
"entity_metadata": "{entityType} Metadata",
|
"entity_metadata": "{entityType} Metadata",
|
||||||
"entity_scrapers": "{entityType} scrapers",
|
"entity_scrapers": "{entityType} scrapers",
|
||||||
"excluded_tag_patterns_desc": "Regexps of tag names to exclude from scraping results",
|
"excluded_tag_patterns_desc": "Regexps of tag names to exclude from scraping results",
|
||||||
"excluded_tag_patterns_head": "Excluded Tag Patterns",
|
"excluded_tag_patterns_head": "Excluded Tag Patterns",
|
||||||
|
"installed_scrapers": "Installed Scrapers",
|
||||||
"scraper": "Scraper",
|
"scraper": "Scraper",
|
||||||
"scrapers": "Scrapers",
|
"scrapers": "Scrapers",
|
||||||
"search_by_name": "Search by name",
|
"search_by_name": "Search by name",
|
||||||
@@ -1067,6 +1072,35 @@
|
|||||||
"o_counter": "O-Counter",
|
"o_counter": "O-Counter",
|
||||||
"operations": "Operations",
|
"operations": "Operations",
|
||||||
"organized": "Organised",
|
"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": {
|
"pagination": {
|
||||||
"first": "First",
|
"first": "First",
|
||||||
"last": "Last",
|
"last": "Last",
|
||||||
@@ -1358,6 +1392,7 @@
|
|||||||
"aliases_must_be_unique": "aliases must be unique",
|
"aliases_must_be_unique": "aliases must be unique",
|
||||||
"date_invalid_form": "${path} must be in YYYY-MM-DD form",
|
"date_invalid_form": "${path} must be in YYYY-MM-DD form",
|
||||||
"required": "${path} is a required field",
|
"required": "${path} is a required field",
|
||||||
|
"unique": "${path} must be unique",
|
||||||
"urls_must_be_unique": "URLs must be unique"
|
"urls_must_be_unique": "URLs must be unique"
|
||||||
},
|
},
|
||||||
"videos": "Videos",
|
"videos": "Videos",
|
||||||
|
|||||||
75
ui/v2.5/src/utils/job.ts
Normal file
75
ui/v2.5/src/utils/job.ts
Normal 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 };
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user