mirror of
https://github.com/stashapp/stash.git
synced 2026-06-19 06:51:19 -05:00
Compare commits
9 Commits
update-con
...
latest_dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
405c67de7e | ||
|
|
3e40b900b3 | ||
|
|
8a98b72c1d | ||
|
|
c797d2147f | ||
|
|
631abda07a | ||
|
|
690782e1f5 | ||
|
|
26072e2b37 | ||
|
|
0ce36f678a | ||
|
|
8bd34dd002 |
5
.github/CODEOWNERS
vendored
Normal file
5
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
/ui/v2.5/src/docs/en/Manual/ @DogmaDragon
|
||||
/docs/ @DogmaDragon
|
||||
README.md @DogmaDragon
|
||||
/docker/ @feederbox826
|
||||
/.github/workflows/ @feederbox826
|
||||
@@ -20,11 +20,16 @@ All pull requests must use descriptive and concise titles and follow the provide
|
||||
- Pull requests must include code tests that sufficiently cover the changes made.
|
||||
- You must detail the manual testing done and describe the steps taken to sufficiently verify the changes.
|
||||
- You must be able to explain any line of code and design decision during the review process.
|
||||
- You may not have more than 3 open pull requests at a time, unless you have received explicit permission from a maintainer. If you have more than 3 open pull requests, maintainers may ask you to close some of them before they will review any of them.
|
||||
|
||||
By submitting a pull request, you agree that you have read and understood and that you are in compliance with the guidelines outlined here, including the [AI Usage Policy](docs/AI_POLICY.md).
|
||||
|
||||
You also agree to license your contribution under the [AGPL](/LICENSE.md) license, and that all of your previous contributions to the project are also licensed under the AGPL.
|
||||
|
||||
## Bounties
|
||||
|
||||
Pull requests for bounties must be discussed with maintainers before submitting a pull request to ensure it fits with the overall design vision of the project. Failure to do so may result in the pull request being rejected.
|
||||
|
||||
## Goals and Design Vision
|
||||
|
||||
The goal of Stash is to be:
|
||||
|
||||
2
go.mod
2
go.mod
@@ -115,7 +115,7 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.1 // indirect
|
||||
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
|
||||
github.com/rs/zerolog v1.30.0 // indirect
|
||||
github.com/russross/blackfriday/v2 v2.1.0 // indirect
|
||||
|
||||
4
go.sum
4
go.sum
@@ -534,8 +534,8 @@ github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsT
|
||||
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
|
||||
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
|
||||
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E=
|
||||
|
||||
@@ -3,8 +3,6 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
// jsonNumberToNumber converts a JSON number to either a float64 or int64.
|
||||
@@ -17,15 +15,6 @@ func jsonNumberToNumber(n json.Number) interface{} {
|
||||
return ret
|
||||
}
|
||||
|
||||
// anyJSONNumberToNumber converts a JSON number using jsonNumberToNumber, otherwise it returns the existing value
|
||||
func anyJSONNumberToNumber(v any) any {
|
||||
if n, ok := v.(json.Number); ok {
|
||||
return jsonNumberToNumber(n)
|
||||
}
|
||||
|
||||
return v
|
||||
}
|
||||
|
||||
// ConvertMapJSONNumbers converts all JSON numbers in a map to either float64 or int64.
|
||||
func convertMapJSONNumbers(m map[string]interface{}) (ret map[string]interface{}) {
|
||||
if m == nil {
|
||||
@@ -45,21 +34,3 @@ func convertMapJSONNumbers(m map[string]interface{}) (ret map[string]interface{}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func convertCustomFieldCriterionValues(c models.CustomFieldCriterionInput) models.CustomFieldCriterionInput {
|
||||
nv := make([]any, len(c.Value))
|
||||
for i, v := range c.Value {
|
||||
nv[i] = anyJSONNumberToNumber(v)
|
||||
}
|
||||
c.Value = nv
|
||||
return c
|
||||
}
|
||||
|
||||
func convertCustomFieldCriterionInputJSONNumbers(c []models.CustomFieldCriterionInput) []models.CustomFieldCriterionInput {
|
||||
ret := make([]models.CustomFieldCriterionInput, len(c))
|
||||
for i, v := range c {
|
||||
ret[i] = convertCustomFieldCriterionValues(v)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
@@ -31,11 +31,6 @@ func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *mod
|
||||
}
|
||||
}
|
||||
|
||||
// #5682 - convert JSON numbers to float64 or int64
|
||||
if performerFilter != nil {
|
||||
performerFilter.CustomFields = convertCustomFieldCriterionInputJSONNumbers(performerFilter.CustomFields)
|
||||
}
|
||||
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
var performers []*models.Performer
|
||||
var err error
|
||||
|
||||
@@ -147,7 +147,7 @@ func (t *SceneIdentifier) getOptions(source ScraperSource) MetadataOptions {
|
||||
if source.Options.IncludeMalePerformers != nil {
|
||||
options.IncludeMalePerformers = source.Options.IncludeMalePerformers
|
||||
}
|
||||
if source.Options.PerformerGenders != nil {
|
||||
if len(source.Options.PerformerGenders) > 0 {
|
||||
options.PerformerGenders = source.Options.PerformerGenders
|
||||
}
|
||||
if source.Options.SkipMultipleMatches != nil {
|
||||
@@ -209,7 +209,7 @@ func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene,
|
||||
|
||||
// Determine allowed genders for performer filtering
|
||||
var allowedGenders []models.GenderEnum
|
||||
if options.PerformerGenders != nil {
|
||||
if len(options.PerformerGenders) > 0 {
|
||||
// New field takes precedence
|
||||
allowedGenders = options.PerformerGenders
|
||||
} else if options.IncludeMalePerformers != nil && !*options.IncludeMalePerformers {
|
||||
|
||||
@@ -530,6 +530,204 @@ func Test_getScenePartial(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getOptions(t *testing.T) {
|
||||
boolTrue := true
|
||||
boolFalse := false
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
defaultOptions *MetadataOptions
|
||||
sourceOptions *MetadataOptions
|
||||
want MetadataOptions
|
||||
}{
|
||||
{
|
||||
name: "nil source options returns defaults unchanged",
|
||||
defaultOptions: &MetadataOptions{
|
||||
SetOrganized: &boolTrue,
|
||||
PerformerGenders: []models.GenderEnum{models.GenderEnumFemale},
|
||||
},
|
||||
sourceOptions: nil,
|
||||
want: MetadataOptions{
|
||||
SetOrganized: &boolTrue,
|
||||
PerformerGenders: []models.GenderEnum{models.GenderEnumFemale},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil PerformerGenders in source does not override default",
|
||||
defaultOptions: &MetadataOptions{
|
||||
PerformerGenders: []models.GenderEnum{models.GenderEnumFemale},
|
||||
},
|
||||
sourceOptions: &MetadataOptions{
|
||||
PerformerGenders: nil,
|
||||
},
|
||||
want: MetadataOptions{
|
||||
PerformerGenders: []models.GenderEnum{models.GenderEnumFemale},
|
||||
},
|
||||
},
|
||||
{
|
||||
// When the UI sends an empty performerGenders array (all genders allowed),
|
||||
// it must not be treated as a filter that blocks all performers.
|
||||
name: "empty PerformerGenders in source does not override default",
|
||||
defaultOptions: &MetadataOptions{
|
||||
PerformerGenders: []models.GenderEnum{models.GenderEnumFemale},
|
||||
},
|
||||
sourceOptions: &MetadataOptions{
|
||||
PerformerGenders: []models.GenderEnum{},
|
||||
},
|
||||
want: MetadataOptions{
|
||||
PerformerGenders: []models.GenderEnum{models.GenderEnumFemale},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "non-empty PerformerGenders in source overrides default",
|
||||
defaultOptions: &MetadataOptions{
|
||||
PerformerGenders: []models.GenderEnum{models.GenderEnumFemale},
|
||||
},
|
||||
sourceOptions: &MetadataOptions{
|
||||
PerformerGenders: []models.GenderEnum{models.GenderEnumMale},
|
||||
},
|
||||
want: MetadataOptions{
|
||||
PerformerGenders: []models.GenderEnum{models.GenderEnumMale},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Empty source PerformerGenders with nil default means no filter (include all).
|
||||
name: "empty PerformerGenders with nil default yields nil (no filter)",
|
||||
defaultOptions: nil,
|
||||
sourceOptions: &MetadataOptions{
|
||||
PerformerGenders: []models.GenderEnum{},
|
||||
},
|
||||
want: MetadataOptions{},
|
||||
},
|
||||
{
|
||||
name: "source overrides scalar fields",
|
||||
defaultOptions: &MetadataOptions{
|
||||
SetCoverImage: &boolTrue,
|
||||
SetOrganized: &boolTrue,
|
||||
},
|
||||
sourceOptions: &MetadataOptions{
|
||||
SetCoverImage: &boolFalse,
|
||||
},
|
||||
want: MetadataOptions{
|
||||
SetCoverImage: &boolFalse,
|
||||
SetOrganized: &boolTrue,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
identifier := SceneIdentifier{
|
||||
DefaultOptions: tt.defaultOptions,
|
||||
}
|
||||
source := ScraperSource{Options: tt.sourceOptions}
|
||||
got := identifier.getOptions(source)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getSceneUpdater_performerGenders(t *testing.T) {
|
||||
const (
|
||||
femalePerformerID = 1
|
||||
malePerformerID = 2
|
||||
)
|
||||
femaleIDStr := strconv.Itoa(femalePerformerID)
|
||||
maleIDStr := strconv.Itoa(malePerformerID)
|
||||
female := models.GenderEnumFemale.String()
|
||||
male := models.GenderEnumMale.String()
|
||||
boolFalse := false
|
||||
|
||||
db := mocks.NewDatabase()
|
||||
|
||||
// Scene with no existing performers; all relationship fields pre-loaded so
|
||||
// getSceneUpdater does not need to hit the database.
|
||||
scene := &models.Scene{
|
||||
ID: 1,
|
||||
URLs: models.NewRelatedStrings([]string{}),
|
||||
PerformerIDs: models.NewRelatedIDs([]int{}),
|
||||
TagIDs: models.NewRelatedIDs([]int{}),
|
||||
StashIDs: models.NewRelatedStashIDs([]models.StashID{}),
|
||||
}
|
||||
|
||||
// Scraped scene with one female and one male performer, both already in the
|
||||
// database (StoredID set). No studio, tags, cover or stash IDs, so no DB
|
||||
// calls are needed beyond resolving the performer IDs.
|
||||
scrapedScene := &models.ScrapedScene{
|
||||
Performers: []*models.ScrapedPerformer{
|
||||
{StoredID: &femaleIDStr, Gender: &female},
|
||||
{StoredID: &maleIDStr, Gender: &male},
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
performerGenders []models.GenderEnum
|
||||
includeMalePerformers *bool
|
||||
wantPerformerIDs []int
|
||||
}{
|
||||
{
|
||||
// nil means "no filter configured" — all performers pass through.
|
||||
name: "nil PerformerGenders includes all genders",
|
||||
performerGenders: nil,
|
||||
wantPerformerIDs: []int{femalePerformerID, malePerformerID},
|
||||
},
|
||||
{
|
||||
// An empty slice sent by the UI when no gender restriction is set must
|
||||
// also mean "no filter". This was the root cause of the identify bug.
|
||||
name: "empty PerformerGenders includes all genders",
|
||||
performerGenders: []models.GenderEnum{},
|
||||
wantPerformerIDs: []int{femalePerformerID, malePerformerID},
|
||||
},
|
||||
{
|
||||
name: "female-only filter excludes male performer",
|
||||
performerGenders: []models.GenderEnum{models.GenderEnumFemale},
|
||||
wantPerformerIDs: []int{femalePerformerID},
|
||||
},
|
||||
{
|
||||
name: "male-only filter excludes female performer",
|
||||
performerGenders: []models.GenderEnum{models.GenderEnumMale},
|
||||
wantPerformerIDs: []int{malePerformerID},
|
||||
},
|
||||
{
|
||||
// Legacy field: empty PerformerGenders falls back to IncludeMalePerformers.
|
||||
name: "empty PerformerGenders falls back to IncludeMalePerformers=false",
|
||||
performerGenders: []models.GenderEnum{},
|
||||
includeMalePerformers: &boolFalse,
|
||||
wantPerformerIDs: []int{femalePerformerID},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
opts := MetadataOptions{
|
||||
SetCoverImage: &boolFalse,
|
||||
PerformerGenders: tt.performerGenders,
|
||||
IncludeMalePerformers: tt.includeMalePerformers,
|
||||
}
|
||||
|
||||
identifier := SceneIdentifier{
|
||||
TxnManager: db,
|
||||
SceneReaderUpdater: db.Scene,
|
||||
StudioReaderWriter: db.Studio,
|
||||
PerformerCreator: db.Performer,
|
||||
TagFinderCreator: db.Tag,
|
||||
DefaultOptions: &opts,
|
||||
}
|
||||
|
||||
result := &scrapeResult{
|
||||
source: ScraperSource{},
|
||||
result: scrapedScene,
|
||||
}
|
||||
|
||||
updater, err := identifier.getSceneUpdater(testCtx, scene, result)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, updater.Partial.PerformerIDs, "expected PerformerIDs to be set")
|
||||
assert.ElementsMatch(t, tt.wantPerformerIDs, updater.Partial.PerformerIDs.IDs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_shouldSetSingleValueField(t *testing.T) {
|
||||
const invalid = "invalid"
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -101,6 +102,17 @@ func (s *customFieldsStore) validateCustomFieldName(fieldName string) error {
|
||||
|
||||
func getSQLValueFromCustomFieldInput(input interface{}) (interface{}, error) {
|
||||
switch v := input.(type) {
|
||||
case json.Number:
|
||||
if i, err := v.Int64(); err == nil {
|
||||
return i, nil
|
||||
}
|
||||
|
||||
f, err := v.Float64()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid custom field number %q: %w", v, err)
|
||||
}
|
||||
|
||||
return f, nil
|
||||
case []interface{}, map[string]interface{}:
|
||||
// TODO - in future it would be nice to convert to a JSON string
|
||||
// however, we would need some way to differentiate between a JSON string and a regular string
|
||||
|
||||
@@ -5,6 +5,7 @@ package sqlite_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math"
|
||||
"path/filepath"
|
||||
@@ -5330,6 +5331,85 @@ func TestSceneQueryCustomFields(t *testing.T) {
|
||||
[]int{sceneIdxWithPerformer},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"json number greater than",
|
||||
&models.SceneFilterType{
|
||||
CustomFields: []models.CustomFieldCriterionInput{
|
||||
{
|
||||
Field: "real",
|
||||
Modifier: models.CriterionModifierGreaterThan,
|
||||
Value: []any{json.Number("0.15")},
|
||||
},
|
||||
},
|
||||
},
|
||||
[]int{sceneIdxWithPerformer},
|
||||
[]int{sceneIdxWithGallery},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"json number equals",
|
||||
&models.SceneFilterType{
|
||||
CustomFields: []models.CustomFieldCriterionInput{
|
||||
{
|
||||
Field: "real",
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
Value: []any{json.Number("0.2")},
|
||||
},
|
||||
},
|
||||
},
|
||||
[]int{sceneIdxWithPerformer},
|
||||
[]int{sceneIdxWithGallery},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"json number between",
|
||||
&models.SceneFilterType{
|
||||
CustomFields: []models.CustomFieldCriterionInput{
|
||||
{
|
||||
Field: "real",
|
||||
Modifier: models.CriterionModifierBetween,
|
||||
Value: []any{json.Number("0.15"), json.Number("0.25")},
|
||||
},
|
||||
},
|
||||
},
|
||||
[]int{sceneIdxWithPerformer},
|
||||
[]int{sceneIdxWithGallery},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"nested performer json number greater than",
|
||||
&models.SceneFilterType{
|
||||
PerformersFilter: &models.PerformerFilterType{
|
||||
CustomFields: []models.CustomFieldCriterionInput{
|
||||
{
|
||||
Field: "real",
|
||||
Modifier: models.CriterionModifierGreaterThan,
|
||||
Value: []any{json.Number("0.15")},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
[]int{sceneIdxWithTwoPerformers},
|
||||
[]int{sceneIdxWithPerformer},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"nested performer json number less than",
|
||||
&models.SceneFilterType{
|
||||
PerformersFilter: &models.PerformerFilterType{
|
||||
CustomFields: []models.CustomFieldCriterionInput{
|
||||
{
|
||||
Field: "real",
|
||||
Modifier: models.CriterionModifierLessThan,
|
||||
Value: []any{json.Number("0.15")},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
[]int{sceneIdxWithTwoPerformers},
|
||||
[]int{sceneIdxWithPerformer},
|
||||
false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"regexp"
|
||||
|
||||
"github.com/Yamashou/gqlgenc/clientv2"
|
||||
"github.com/stashapp/stash/internal/build"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/scraper"
|
||||
"github.com/stashapp/stash/pkg/stashbox/graphql"
|
||||
@@ -52,6 +53,23 @@ func setApiKeyHeader(apiKey string) clientv2.RequestInterceptor {
|
||||
}
|
||||
}
|
||||
|
||||
func setUserAgentHeader() clientv2.RequestInterceptor {
|
||||
version, githash, _ := build.Version()
|
||||
v := version
|
||||
if v == "" {
|
||||
v = githash
|
||||
}
|
||||
if v == "" {
|
||||
v = "unknown"
|
||||
}
|
||||
ua := "stash/" + v
|
||||
|
||||
return func(ctx context.Context, req *http.Request, gqlInfo *clientv2.GQLRequestInfo, res interface{}, next clientv2.RequestInterceptorFunc) error {
|
||||
req.Header.Set("User-Agent", ua)
|
||||
return next(ctx, req, gqlInfo, res)
|
||||
}
|
||||
}
|
||||
|
||||
func rateLimit(n int) clientv2.RequestInterceptor {
|
||||
perSec := float64(n) / 60
|
||||
limiter := rate.NewLimiter(rate.Limit(perSec), 1)
|
||||
@@ -83,10 +101,11 @@ func NewClient(box models.StashBox, options ...ClientOption) *Client {
|
||||
}
|
||||
|
||||
authHeader := setApiKeyHeader(box.APIKey)
|
||||
userAgentHeader := setUserAgentHeader()
|
||||
limitRequests := rateLimit(ret.maxRequestsPerMinute)
|
||||
|
||||
client := &graphql.Client{
|
||||
Client: clientv2.NewClient(ret.httpClient, box.Endpoint, nil, authHeader, limitRequests),
|
||||
Client: clientv2.NewClient(ret.httpClient, box.Endpoint, nil, authHeader, userAgentHeader, limitRequests),
|
||||
}
|
||||
|
||||
ret.client = client
|
||||
|
||||
@@ -1068,8 +1068,8 @@ ul.selectable-list {
|
||||
background-color: transparent;
|
||||
bottom: $navbar-height;
|
||||
margin: auto;
|
||||
pointer-events: none;
|
||||
position: sticky;
|
||||
width: fit-content;
|
||||
z-index: 10;
|
||||
|
||||
@include media-breakpoint-up(sm) {
|
||||
@@ -1080,6 +1080,7 @@ ul.selectable-list {
|
||||
.pagination-footer {
|
||||
margin: auto;
|
||||
padding: 0.5rem 1rem 0.75rem;
|
||||
pointer-events: auto;
|
||||
|
||||
width: fit-content;
|
||||
|
||||
|
||||
@@ -838,13 +838,16 @@ export const PerformerTagger: React.FC<ITaggerProps> = ({ performers }) => {
|
||||
const selectedEndpoint =
|
||||
stashConfig?.general.stashBoxes[selectedEndpointIndex];
|
||||
|
||||
const selectedEndpointInput = useMemo(
|
||||
() => ({
|
||||
const selectedEndpointInput = useMemo(() => {
|
||||
if (!selectedEndpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: selectedEndpoint.endpoint,
|
||||
index: selectedEndpointIndex,
|
||||
}),
|
||||
[selectedEndpoint, selectedEndpointIndex]
|
||||
);
|
||||
};
|
||||
}, [selectedEndpoint, selectedEndpointIndex]);
|
||||
|
||||
if (!config) return <LoadingIndicator />;
|
||||
|
||||
@@ -931,7 +934,7 @@ export const PerformerTagger: React.FC<ITaggerProps> = ({ performers }) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedEndpointIndex === -1 || !selectedEndpoint) {
|
||||
if (selectedEndpointIndex === -1 || !selectedEndpointInput) {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<h3 className="text-center mt-4">
|
||||
|
||||
@@ -520,13 +520,16 @@ export const StudioTagger: React.FC<ITaggerProps> = ({ studios }) => {
|
||||
const selectedEndpoint =
|
||||
stashConfig?.general.stashBoxes[selectedEndpointIndex];
|
||||
|
||||
const selectedEndpointInput = useMemo(
|
||||
() => ({
|
||||
const selectedEndpointInput = useMemo(() => {
|
||||
if (!selectedEndpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: selectedEndpoint.endpoint,
|
||||
index: selectedEndpointIndex,
|
||||
}),
|
||||
[selectedEndpoint, selectedEndpointIndex]
|
||||
);
|
||||
};
|
||||
}, [selectedEndpoint, selectedEndpointIndex]);
|
||||
|
||||
if (!config) return <LoadingIndicator />;
|
||||
|
||||
@@ -612,7 +615,7 @@ export const StudioTagger: React.FC<ITaggerProps> = ({ studios }) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedEndpointIndex === -1 || !selectedEndpoint) {
|
||||
if (selectedEndpointIndex === -1 || !selectedEndpointInput) {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<h3 className="text-center mt-4">
|
||||
|
||||
@@ -534,13 +534,16 @@ export const TagTagger: React.FC<ITaggerProps> = ({ tags }) => {
|
||||
const selectedEndpoint =
|
||||
stashConfig?.general.stashBoxes[selectedEndpointIndex];
|
||||
|
||||
const selectedEndpointInput = useMemo(
|
||||
() => ({
|
||||
const selectedEndpointInput = useMemo(() => {
|
||||
if (!selectedEndpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
return {
|
||||
endpoint: selectedEndpoint.endpoint,
|
||||
index: selectedEndpointIndex,
|
||||
}),
|
||||
[selectedEndpoint, selectedEndpointIndex]
|
||||
);
|
||||
};
|
||||
}, [selectedEndpoint, selectedEndpointIndex]);
|
||||
|
||||
if (!config) return <LoadingIndicator />;
|
||||
|
||||
@@ -619,7 +622,7 @@ export const TagTagger: React.FC<ITaggerProps> = ({ tags }) => {
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedEndpointIndex === -1 || !selectedEndpoint) {
|
||||
if (selectedEndpointIndex === -1 || !selectedEndpointInput) {
|
||||
return (
|
||||
<div className="my-4">
|
||||
<h3 className="text-center mt-4">
|
||||
|
||||
@@ -91,7 +91,9 @@ Enter the URL in the `edit` tab of an Item. If a scraper is installed that suppo
|
||||
|
||||
## Tagger view
|
||||
|
||||
The Tagger view is accessed from the scenes page. It allows the user to run scrapers on all items on the current page. The Tagger presents the user with potential matches for an item from a selected stash-box instance or metadata source if supported. The user needs to select the correct metadata information to save.
|
||||
The Tagger view is available from supported list pages. Scenes can use stash-boxes and compatible metadata scrapers. Performers, studios, and tags use a configured stash-box instance.
|
||||
|
||||
The Tagger presents the user with potential matches for an item from a selected stash-box instance or metadata source if supported. The user needs to select the correct metadata information to save.
|
||||
|
||||
When used in combination with stash-box, the user can optionally submit scene fingerprints to contribute to a stash-box instance. A scene fingerprint consists of any generated hashes (`phash`, `oshash`, `md5`) and the scene duration. Fingerprint submissions are associated with your stash-box account. Submitting fingerprints assists others in matching their files, because stash-box returns a count of matching user submitted fingerprints with every potential match.
|
||||
|
||||
@@ -99,8 +101,11 @@ When used in combination with stash-box, the user can optionally submit scene fi
|
||||
|---|:---:|:---:|
|
||||
| gallery | | |
|
||||
| group | | |
|
||||
| image | | |
|
||||
| performer | ✔️ | |
|
||||
| scene | ✔️ | ✔️ |
|
||||
| studio | ✔️ | |
|
||||
| tag | ✔️ | |
|
||||
|
||||
## Identify task
|
||||
|
||||
|
||||
Reference in New Issue
Block a user