Compare commits

...

9 Commits

Author SHA1 Message Date
DogmaDragon
405c67de7e Update CODEOWNERS 2026-06-16 16:31:46 +03:00
gregpetersonanon
3e40b900b3 Fix: Scene Identify not Respecting Identify Settings (#7047)
* Fixed issue when identifying scenes from stashdb

* Added additional tests for Identify task

* Reverted change to comment in identify.go

* Reverted changes to performer.go and test

* Reformatted identify_test.go
2026-06-15 15:37:59 -05:00
CynicalAtropos
8a98b72c1d Fix: json.Number Custom Field Filters(#7040) 2026-06-15 15:32:38 -05:00
DogmaDragon
c797d2147f Create CODEOWNERS 2026-06-15 12:18:31 +03:00
InfiniteStash
631abda07a Add User-Agent header for stash-box requests (#7034) 2026-06-15 12:10:05 +10:00
dependabot[bot]
690782e1f5 Bump github.com/quic-go/quic-go from 0.59.0 to 0.59.1 (#6994)
Bumps [github.com/quic-go/quic-go](https://github.com/quic-go/quic-go) from 0.59.0 to 0.59.1.
- [Release notes](https://github.com/quic-go/quic-go/releases)
- [Commits](https://github.com/quic-go/quic-go/compare/v0.59.0...v0.59.1)

---
updated-dependencies:
- dependency-name: github.com/quic-go/quic-go
  dependency-version: 0.59.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-15 10:31:40 +10:00
CynicalAtropos
26072e2b37 Fix: Tagger view UI crash without Stash-Box Config(#7042) 2026-06-14 17:19:48 -07:00
DogmaDragon
0ce36f678a Document pull request limit and bounty discussion requirement (#7027)
* Add guidelines for bounty pull requests in CONTRIBUTING.md

* Update CONTRIBUTING.md to clarify pull request limit

* Add exception mention
2026-06-14 16:52:13 -07:00
CynicalAtropos
8bd34dd002 Fix: Pagination Footer Centering With Sidebar (#7041) 2026-06-14 16:51:24 -07:00
16 changed files with 360 additions and 60 deletions

5
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1,5 @@
/ui/v2.5/src/docs/en/Manual/ @DogmaDragon
/docs/ @DogmaDragon
README.md @DogmaDragon
/docker/ @feederbox826
/.github/workflows/ @feederbox826

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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;

View File

@@ -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">

View File

@@ -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">

View File

@@ -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">

View File

@@ -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