Compare commits

...

34 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
Gykes
db4b33f537 Fix: Size Summary Should Represent All Files (#7006)
* modify image size check + tests
* modify scene size check + tests
2026-06-11 20:22:50 +10:00
Gykes
8abbac98d5 New: Support JXL Images in Zips (#7002)
* add extension and update scan logic
* update docs
2026-06-11 20:19:06 +10:00
dependabot[bot]
ed18ea12d4 Bump immutable in /pkg/plugin/examples/react-component (#6946)
Bumps [immutable](https://github.com/immutable-js/immutable-js) from 5.1.4 to 5.1.5.
- [Release notes](https://github.com/immutable-js/immutable-js/releases)
- [Changelog](https://github.com/immutable-js/immutable-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/immutable-js/immutable-js/compare/v5.1.4...v5.1.5)

---
updated-dependencies:
- dependency-name: immutable
  dependency-version: 5.1.5
  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-11 19:52:44 +10:00
Slick Daddy
9cb2ffd56c feat: Add depth support to tag count filters for hierarchical sidebar display (#6929)
* Replace tag query count tests with table-based tests
* Add test for scene count depth = 1
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-06-11 15:07:55 +10:00
Slick Daddy
a827006b50 Fix phash precision loss in JSON import/export (#6905)
* Fix phash precision loss in JSON import/export (#6894)
* Use json.RawMessage for Fingerprint. Manually unmarshal numbers into int64.

Reject floating point numbers.

* Add unit test
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-06-11 14:07:45 +10:00
Stash-KennyG
a31ccef98d Add sorting and filtering options to sub-groups UI (#6900)
* Add sorting to sub-group UI

- Implemented sorting functionality for sub-groups based on their descriptions in the database queries.
- Updated the GroupList component to allow sorting regardless of the view.
- Added localization for sub-group description in en-GB.json.
- Included sub-group description in the list of sortable options in the groups filter model.

* Implement view filtering and saving.

- Refactor GroupList and GroupSubGroupsPanel components for consistency with general models.
- Removed unused defaultFilter from GroupList and GroupSubGroupsPanel.
- Simplified FilteredGroupList by eliminating unnecessary state variables.
- Fixed dropdown alignment issue in FilteredListToolbar by setting menuPortalTarget to document.body.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Add defaultSort and manualSortBy FilteredGroupList fields.

manualSortBy indicates which sort by value allows manual reordering.

Sets both fields to "sub_group_order" in GroupSubGroupsPanel.

* Revert getSort changes

This function needs to be refactored separately

* Handle sub_group_description without getSort

Eliminates the need to poke group-specific logic into getSort.

---------
Co-authored-by: KennyG <kennyg@kennyg.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-06-11 11:08:56 +10:00
Slick Daddy
f76f66b725 feat(studio): add child_ids support to Studio API and UI (#6898)
* Add child_ids to studio update GraphQL schema input
2026-06-10 14:58:24 +10:00
Stash-KennyG
45ca5a4d27 Add hoverPlacementLabel prop to ScrapedPerformersRow component (#6892)
Co-authored-by: KennyG <kennyg@kennyg.com>
2026-06-10 14:45:22 +10:00
Wasylq
763f6903bc [feat] Configure fields when batch tagging performers (#6885)
* Configure fields when batch tagging performers

Added a "Configure fields" button that allows you to select which fields
get refreshed when you do "batch update performers"

This field allows you to sync all data with exceptions, or merging your
local edits with stashbox data
2026-06-10 14:37:51 +10:00
Slick Daddy
759ec317cd Add gallery sorting by performer age (#6879)
* Add gallery sorting by performer age
* Handle null performer ages in gallery sorting
* Document performer age sort semantics and add multi-performer tests
* Fix performer age gallery sorting integration tests.

Import testify/require and replace invalid date helper usage with parsed model dates so integration-tagged sqlite tests compile and run.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Fix ineffassign in performer_age gallery sort fallback.

Declare fallback before branching so golangci-lint ineffassign passes.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-10 14:21:09 +10:00
Slick Daddy
f3bfd8db75 feat(api): add scene_filter parameter to findDuplicateScenes (#6884)
* feat(schema): add scene_filter to findDuplicateScenes

Expands the findDuplicateScenes query to accept a full SceneFilterType. This enables filtering out specific directories or tags from the duplication matching process.

* feat(backend): update SceneReaderWriter interface to accept filter

Modifies the FindDuplicates signature to take a SceneFilterType pointer, allowing the underlying repository to filter the pool of scenes before duplicate checking.

* test(backend): update SceneReaderWriter mock

Updates the mock definition to match the new FindDuplicates method signature.

* feat(api): pass scene filter to duplicate checking repository

Modifies the FindDuplicateScenes GraphQL resolver to pass the newly added scene_filter schema argument into the database repository layer.

* feat(sqlite): dynamically build FindDuplicates query

Refactors the FindDuplicates implementation to use the internal qb.makeQuery tool instead of static raw SQL. This enables the duplicate checker to utilize the provided SceneFilterType, natively supporting advanced filtering like path exclusions.

* test(sqlite): update duplicate checking tests

Updates the unit tests to pass a nil scene filter, matching the new FindDuplicates method signature.

* test: add comprehensive scene_filter tests for FindDuplicates

- Add TestSceneStore_FindDuplicatesWithFilter with 5 test cases:
  * nil filter baseline
  * tag filter includes only tagged duplicate pair
  * non-existent tag filter returns empty
  * fuzzy matching + tag filter
- Move intPtr helper to setup_test.go
2026-06-10 14:00:25 +10:00
NativeRavenclaw
e470ee34d8 New: Add File Paths to Image Scraper Input (#6856)
* Add Path to image scraper args
* Add a proper imageInput struct for scraper args
* Add test for imageInputFromImage
2026-06-10 13:30:57 +10:00
InfiniteStash
e28ab14c6e Remove phash when oshash changes (#7021) 2026-06-09 09:51:02 +10:00
DogmaDragon
c39e1657ae Replace relative with absolute links 2026-06-07 04:55:52 +03:00
DogmaDragon
58f8b17196 docs: Add gallery and images to built-in Auto Tag scraper desc (#7012) 2026-06-05 08:49:11 -07:00
WithoutPants
6958bdd66a Add missing Mousetrap.unbind in GroupPage component 2026-06-05 15:03:59 +10:00
spaceyuck
608bb97e6c add image scraping to built-in Auto-Tag scraper (#6650)
* add Auto-Tag scraper to image details edit scrape menu

it was already available for galleries and scenes, images just seem to
have slipped through
2026-06-05 14:46:40 +10:00
ordureconnoisseur
20df97e361 RatingBanner: expose rating value + system as DOM attributes (#6976)
Add data-rating100, data-rating-system, and a --rating-100 CSS custom
property to the .rating-banner element so CSS themes can style ratings
without parsing class names or separately querying the rating-system config.

Purely additive: the existing rating-banner / rating-100-N / rating-N
classes are unchanged, so no existing styles break. No API/schema change;
both values are already available in the component.
2026-06-05 14:26:51 +10:00
void-function865
50fc593177 Use file checksum for image URL cache-busting (#6998)
Image URLs appended a cache-buster derived from updated_at, which bumps
on any metadata edit (rating, o-counter, tags, etc.). Since none of those
change the image bytes, the URL changed needlessly, forcing the browser
to re-download the image and remount it - most visibly as a reload/flicker
in the gallery lightbox when setting a rating.

Key the cache-buster on the primary file's checksum instead, so the URL
is stable across metadata edits and only changes when the file content
changes. Fall back to updated_at when no primary file is present. Scenes
and galleries use separate builders and are intentionally left on
updated_at, since their thumbnails can change without the underlying
content changing.

Co-authored-by: void-function865 <void-function865@users.noreply.github.com>
2026-06-05 14:20:48 +10:00
Gykes
2ad3724dee Fix: Recreate Zip-based Galleries on Scan When Deleted but File Unchanged (#7001) 2026-06-05 14:14:00 +10:00
WithoutPants
4bd16c29b1 Biome format (#7005)
* Enable formatting

* Format files.

trailingCommas set to es5 to limit the number of file changes
2026-06-05 14:10:39 +10:00
DogmaDragon
9353d862ea Add direct link to contributing document in issue templates (#7003)
* Add direct link to contributing document
* Fix link, add clarifications
2026-06-05 13:45:05 +10:00
WithoutPants
5ed738558e Biome fixes (#7004)
* Enable and fix lint/performance/noAccumulatingSpread
* Enable and fix lint/suspicious/noTsIgnore
* Enable and fix lint/suspicious/noConfusingVoidType
* Enable and fix lint/suspicious/noShadowRestrictedNames
* Enable and fix lint/suspicious/noNonNullAssertedOptionalChain
* Enable and fix lint/suspicious/noGlobalIsNan
* Enable and fix lint/suspicious/useIterableCallbackReturn
* Enable and fix lint/suspicious/noTemplateCurlyInString
* Enable and fix lint/suspicious/noDoubleEquals
* Enable and fix lint/suspicious/noImplicitAnyLet
* Enable and fix lint/suspicious/noExplicitAny
* Document noArrayIndexKey omission
* Enable and fix lint/correctness/noEmptyPattern
* Enable and fix lint/correctness/noUnusedVariables
* Enable and fix lint/correctness/useParseIntRadix
* Enable and fix lint/correctness/noUnusedFunctionParameters
* Enable and fix lint/correctness/noUnusedPrivateClassMembers
* Enable and fix lint/correctness/noSwitchDeclarations
* Enable and fix lint/correctness/noInnerDeclarations
* Enable and fix lint/correctness/useExhaustiveDependencies
* Enable and fix lint/correctness/useJsxKeyInIterable
* Enable and fix lint/style/useShorthandFunctionType
* Enable and fix lint/complexity/noUselessSwitchCase
* Enable and fix lint/complexity/useDateNow
* Enable and fix lint/complexity/noUselessEscapeInRegex
* Enable and fix lint/complexity/noArguments
* Enable and fix lint/complexity/noUselessRename
* Enable and fix lint/complexity/noExtraBooleanCast
* Enable and fix lint/complexity/noUselessUndefinedInitialization
* Enable and fix lint/complexity/useFlatMap
* Enable and fix lint/complexity/useIndexOf
* Enable and fix lint/complexity/noBannedTypes
* Enable and fix lint/complexity/useOptionalChain
* Enable and fix lint/complexity/useArrowFunction
* Enable and fix lint/complexity/noUselessFragments
* Enable and fix lint/style/useConst
* Remove remaining unnecessary rule omissions and document leftovers
2026-06-05 13:43:36 +10:00
DogmaDragon
e0fd439c02 Fix internal link in CONTRIBUTING.md [skip-ci] 2026-06-05 05:27:23 +03:00
CynicalAtropos
b9381872f1 Handle UTF-8 BOM in funscript parsing (#6988) 2026-06-05 09:35:36 +10:00
258 changed files with 3648 additions and 1868 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

@@ -4,14 +4,14 @@ labels: ["bug report"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
value: Thanks for taking the time to fill out this bug report! Make sure to read [Contributing](https://github.com/stashapp/stash/blob/develop/docs/CONTRIBUTING.md) document before submitting.
- type: checkboxes
id: confirm-troubleshooting
attributes:
label: Have you enabled troubleshooting mode?
description: |
To ensure the bug is not caused by custom modifications or plugins make sure to enable troubleshooting mode before filing the report. In Stash go to Settings and click **Troubleshooting mode** and then retest to see if the bug still occurs.
To ensure the bug is not caused by custom modifications or plugins make sure to enable troubleshooting mode before filing the report. In Stash go to Settings and click **Troubleshooting mode** and then retest to see if the bug still occurs.
It's important to note that troubleshooting mode only affects UI modifications and plugins.
options:
- label: I confirm that the troubleshooting mode is enabled.
required: true
@@ -56,13 +56,13 @@ body:
placeholder: (e.g. v0.28.1)
validations:
required: true
- type: input
- type: textarea
id: devicedetails
attributes:
label: Device details
description: |
If this is an issue that occurs when using the Stash interface, please provide details of the device/browser used which presents the reported issue.
placeholder: (e.g. Firefox 97 (64-bit) on Windows 11)
Please provide details about the device you are using, including the operating system and browser (if applicable).
placeholder: Firefox 97 (64-bit) on Windows 11
validations:
required: false
- type: textarea

View File

@@ -2,6 +2,9 @@ name: Feature Request
description: Request a new feature or idea to be added to Stash
labels: ["feature request"]
body:
- type: markdown
attributes:
value: Thank you for taking the time to submit a feature request! Make sure to read [Contributing](https://github.com/stashapp/stash/blob/develop/docs/CONTRIBUTING.md) document before submitting.
- type: textarea
id: description
attributes:

View File

@@ -23,8 +23,8 @@
## Checklist
<!-- Mark [x] to indicate completion. -->
- [ ] I have read and understood the [Contributing](docs/CONTRIBUTING.md) document.
- [ ] I have read and understood the [AI Usage Policy](docs/AI_POLICY.md) document.
- [ ] I have read and understood the [Contributing](https://github.com/stashapp/stash/blob/develop/docs/CONTRIBUTING.md) document.
- [ ] I have read and understood the [AI Usage Policy](https://github.com/stashapp/stash/blob/develop/docs/AI_POLICY.md) document.
- [ ] I have made corresponding changes to the documentation (if applicable).
## AI Usage Disclosure

View File

@@ -2,7 +2,7 @@
## AI Usage Policy
Please see our [AI Usage Policy](docs/AI_POLICY.md) for guidelines on the use of AI in contributions to this project.
Please see our [AI Usage Policy](/docs/AI_POLICY.md) for guidelines on the use of AI in contributions to this project.
## Issues
@@ -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:
@@ -45,4 +50,4 @@ Other requirements:
- Interfaces with external systems (for example stash-box) should be made as generic as possible.
Design considerations:
- Features are easy to add and difficult to remove. Large superfluous features should be scrutinised and avoided where possible (e.g. DLNA, filename parser). Such features should be considered for third-party plugins instead.
- Features are easy to add and difficult to remove. Large superfluous features should be scrutinised and avoided where possible (e.g. DLNA, filename parser). Such features should be considered for third-party plugins instead.

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

@@ -51,6 +51,7 @@ type Query {
Fractional seconds are ok: 0.5 will mean only files that have durations within 0.5 seconds between them will be matched based on PHash distance.
"""
duration_diff: Float
scene_filter: SceneFilterType
): [[Scene!]!]!
"Return valid stream paths"

View File

@@ -633,28 +633,28 @@ input TagFilterType {
is_missing: String
"Filter by number of scenes with this tag"
scene_count: IntCriterionInput
scene_count: HierarchicalCountInput
"Filter by number of images with this tag"
image_count: IntCriterionInput
image_count: HierarchicalCountInput
"Filter by number of galleries with this tag"
gallery_count: IntCriterionInput
gallery_count: HierarchicalCountInput
"Filter by number of performers with this tag"
performer_count: IntCriterionInput
performer_count: HierarchicalCountInput
"Filter by number of studios with this tag"
studio_count: IntCriterionInput
studio_count: HierarchicalCountInput
"Filter by number of movies with this tag"
movie_count: IntCriterionInput
movie_count: HierarchicalCountInput
"Filter by number of group with this tag"
group_count: IntCriterionInput
group_count: HierarchicalCountInput
"Filter by number of markers with this tag"
marker_count: IntCriterionInput
marker_count: HierarchicalCountInput
"Filter by parent tags"
parents: HierarchicalMultiCriterionInput
@@ -914,6 +914,14 @@ input IntCriterionInput {
modifier: CriterionModifier!
}
input HierarchicalCountInput {
value: Int!
value2: Int
modifier: CriterionModifier!
"Number of descendant levels to include. Negative values include all descendants."
depth: Int
}
input FloatCriterionInput {
value: Float!
value2: Float

View File

@@ -302,6 +302,8 @@ input StashBoxBatchTagInput {
stash_box_endpoint: String
"Fields to exclude when executing the tagging"
exclude_fields: [String!]
"Collection fields to merge (add to existing) instead of overwriting when executing the tagging"
merge_fields: [String!]
"Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false"
refresh: Boolean!
"If batch adding studios, should their parent studios also be created?"

View File

@@ -36,6 +36,7 @@ input StudioCreateInput {
url: String @deprecated(reason: "Use urls")
urls: [String!]
parent_id: ID
child_ids: [ID!]
"This should be a URL or a base64 encoded data URL"
image: String
stash_ids: [StashIDInput!]
@@ -58,6 +59,7 @@ input StudioUpdateInput {
url: String @deprecated(reason: "Use urls")
urls: [String!]
parent_id: ID
child_ids: [ID!]
"This should be a URL or a base64 encoded data URL"
image: String
stash_ids: [StashIDInput!]

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

@@ -14,6 +14,63 @@ import (
)
// used to refetch studio after hooks run
func clearRemovedChildStudios(ctx context.Context, qb models.StudioReaderWriter, parentStudioID int, childStudioIDs []int) error {
currentChildren, err := qb.FindChildren(ctx, parentStudioID)
if err != nil {
return err
}
newChildStudioIDs := make(map[int]struct{}, len(childStudioIDs))
for _, childStudioID := range childStudioIDs {
newChildStudioIDs[childStudioID] = struct{}{}
}
for _, currentChild := range currentChildren {
if _, keep := newChildStudioIDs[currentChild.ID]; keep {
continue
}
clearParentPartial := models.NewStudioPartial()
clearParentPartial.ID = currentChild.ID
clearParentPartial.ParentID = models.NewOptionalIntPtr(nil)
if _, err := qb.UpdatePartial(ctx, clearParentPartial); err != nil {
return err
}
}
return nil
}
func setChildStudios(ctx context.Context, qb models.StudioReaderWriter, parentStudioID int, childStudioIDs []int) error {
if err := clearRemovedChildStudios(ctx, qb, parentStudioID, childStudioIDs); err != nil {
return err
}
newChildStudioIDs := make(map[int]struct{}, len(childStudioIDs))
for _, childStudioID := range childStudioIDs {
if _, found := newChildStudioIDs[childStudioID]; found {
continue
}
newChildStudioIDs[childStudioID] = struct{}{}
childPartial := models.NewStudioPartial()
childPartial.ID = childStudioID
childPartial.ParentID = models.NewOptionalInt(parentStudioID)
if err := studio.ValidateModify(ctx, childPartial, qb); err != nil {
return err
}
if _, err := qb.UpdatePartial(ctx, childPartial); err != nil {
return err
}
}
return nil
}
func (r *mutationResolver) getStudio(ctx context.Context, id int) (ret *models.Studio, err error) {
if err := r.withTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Studio.Find(ctx, id)
@@ -62,6 +119,11 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
if err != nil {
return nil, fmt.Errorf("converting tag ids: %w", err)
}
childStudioIDs, err := stringslice.StringSliceToIntSlice(input.ChildIds)
if err != nil {
return nil, fmt.Errorf("converting child ids: %w", err)
}
newStudio.CustomFields = convertMapJSONNumbers(input.CustomFields)
// Process the base 64 encoded image string
@@ -93,6 +155,12 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
}
}
if input.ChildIds != nil {
if err := setChildStudios(ctx, qb, newStudio.ID, childStudioIDs); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
@@ -135,6 +203,11 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
return nil, fmt.Errorf("converting tag ids: %w", err)
}
childStudioIDs, err := stringslice.StringSliceToIntSlice(input.ChildIds)
if err != nil {
return nil, fmt.Errorf("converting child ids: %w", err)
}
if translator.hasField("urls") {
// ensure url not included in the input
if err := validateNoLegacyURLs(translator); err != nil {
@@ -197,6 +270,12 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
}
}
if translator.hasField("child_ids") {
if err := clearRemovedChildStudios(ctx, qb, studioID, childStudioIDs); err != nil {
return err
}
}
if err := studio.ValidateModify(ctx, updatedStudio, qb); err != nil {
return err
}
@@ -212,6 +291,12 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
}
}
if translator.hasField("child_ids") {
if err := setChildStudios(ctx, qb, studioID, childStudioIDs); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err

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

@@ -227,7 +227,7 @@ func (r *queryResolver) ParseSceneFilenames(ctx context.Context, filter *models.
return ret, nil
}
func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int, durationDiff *float64) (ret [][]*models.Scene, err error) {
func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int, durationDiff *float64, sceneFilter *models.SceneFilterType) (ret [][]*models.Scene, err error) {
dist := 0
durDiff := -1.
if distance != nil {
@@ -237,7 +237,7 @@ func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int,
durDiff = *durationDiff
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Scene.FindDuplicates(ctx, dist, durDiff)
ret, err = r.repository.Scene.FindDuplicates(ctx, dist, durDiff, sceneFilter)
return err
}); err != nil {
return nil, err

View File

@@ -24,17 +24,30 @@ func NewImageURLBuilder(baseURL string, image *models.Image) ImageURLBuilder {
}
}
// cacheBuster returns the value used to bust client-side caches. The image
// content is immutable for a given file, so the checksum of the primary file
// is preferred: this keeps the URL stable across metadata edits (rating,
// o-counter, tags, etc.) so the browser can reuse its cached copy. When no
// primary file is present the checksum is empty, in which case we fall back to
// the updated timestamp.
func (b ImageURLBuilder) cacheBuster() string {
if b.Checksum != "" {
return b.Checksum
}
return b.UpdatedAt
}
func (b ImageURLBuilder) GetImageURL() string {
return b.BaseURL + "/image/" + b.ImageID + "/image?t=" + b.UpdatedAt
return b.BaseURL + "/image/" + b.ImageID + "/image?t=" + b.cacheBuster()
}
func (b ImageURLBuilder) GetThumbnailURL() string {
return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?t=" + b.UpdatedAt
return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?t=" + b.cacheBuster()
}
func (b ImageURLBuilder) GetPreviewURL() string {
if exists, err := fsutil.FileExists(manager.GetInstance().Paths.Generated.GetClipPreviewPath(b.Checksum, models.DefaultGthumbWidth)); exists && err == nil {
return b.BaseURL + "/image/" + b.ImageID + "/preview?" + b.UpdatedAt
return b.BaseURL + "/image/" + b.ImageID + "/preview?t=" + b.cacheBuster()
} else {
return ""
}

View File

@@ -0,0 +1,106 @@
package urlbuilders
import "testing"
func TestImageURLBuilder_cacheBuster(t *testing.T) {
tests := []struct {
name string
checksum string
updatedAt string
want string
}{
{
name: "prefers checksum when present",
checksum: "abc123",
updatedAt: "1700000000",
want: "abc123",
},
{
name: "falls back to updatedAt when checksum is empty",
checksum: "",
updatedAt: "1700000000",
want: "1700000000",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := ImageURLBuilder{Checksum: tt.checksum, UpdatedAt: tt.updatedAt}
if got := b.cacheBuster(); got != tt.want {
t.Errorf("cacheBuster() = %q, want %q", got, tt.want)
}
})
}
}
func TestImageURLBuilder_GetImageURL(t *testing.T) {
tests := []struct {
name string
checksum string
updatedAt string
want string
}{
{
name: "uses checksum so the URL is stable across metadata edits",
checksum: "abc123",
updatedAt: "1700000000",
want: "http://localhost:9999/image/42/image?t=abc123",
},
{
name: "falls back to updatedAt when there is no primary file",
checksum: "",
updatedAt: "1700000000",
want: "http://localhost:9999/image/42/image?t=1700000000",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := ImageURLBuilder{
BaseURL: "http://localhost:9999",
ImageID: "42",
Checksum: tt.checksum,
UpdatedAt: tt.updatedAt,
}
if got := b.GetImageURL(); got != tt.want {
t.Errorf("GetImageURL() = %q, want %q", got, tt.want)
}
})
}
}
func TestImageURLBuilder_GetThumbnailURL(t *testing.T) {
tests := []struct {
name string
checksum string
updatedAt string
want string
}{
{
name: "uses checksum so the URL is stable across metadata edits",
checksum: "abc123",
updatedAt: "1700000000",
want: "http://localhost:9999/image/42/thumbnail?t=abc123",
},
{
name: "falls back to updatedAt when there is no primary file",
checksum: "",
updatedAt: "1700000000",
want: "http://localhost:9999/image/42/thumbnail?t=1700000000",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
b := ImageURLBuilder{
BaseURL: "http://localhost:9999",
ImageID: "42",
Checksum: tt.checksum,
UpdatedAt: tt.updatedAt,
}
if got := b.GetThumbnailURL(); got != tt.want {
t.Errorf("GetThumbnailURL() = %q, want %q", got, tt.want)
}
})
}
}

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

@@ -315,7 +315,7 @@ const (
// slice default values
var (
defaultVideoExtensions = []string{"m4v", "mp4", "mov", "wmv", "avi", "mpg", "mpeg", "rmvb", "rm", "flv", "asf", "mkv", "webm", "f4v"}
defaultImageExtensions = []string{"png", "jpg", "jpeg", "gif", "webp", "avif"}
defaultImageExtensions = []string{"png", "jpg", "jpeg", "gif", "webp", "avif", "jxl"}
defaultGalleryExtensions = []string{"zip", "cbz"}
defaultMenuItems = []string{"scenes", "images", "groups", "markers", "galleries", "performers", "studios", "tags"}
)

View File

@@ -48,6 +48,12 @@ type Action struct {
Speed float64
}
var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
func unmarshalFunscriptData(data []byte, funscript *Script) error {
return json.Unmarshal(bytes.TrimPrefix(data, utf8BOM), funscript)
}
type GradientTable []struct {
Col colorful.Color
Pos float64
@@ -96,7 +102,7 @@ func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string, sceneD
}
var funscript Script
err = json.Unmarshal(data, &funscript)
err = unmarshalFunscriptData(data, &funscript)
if err != nil {
return Script{}, err
}
@@ -370,7 +376,7 @@ func LoadFunscriptData(path string) (Script, error) {
}
var funscript Script
err = json.Unmarshal(data, &funscript)
err = unmarshalFunscriptData(data, &funscript)
if err != nil {
return Script{}, err
}

View File

@@ -0,0 +1,53 @@
package manager
import (
"os"
"path/filepath"
"testing"
)
func TestLoadFunscriptDataHandlesUTF8BOM(t *testing.T) {
path := filepath.Join(t.TempDir(), "test.funscript")
data := append([]byte{0xEF, 0xBB, 0xBF}, []byte(`{"actions":[{"at":100,"pos":40},{"at":50,"pos":20}]}`)...)
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
load func() (Script, error)
}{
{
name: "load funscript data",
load: func() (Script, error) {
return LoadFunscriptData(path)
},
},
{
name: "heatmap generator load funscript data",
load: func() (Script, error) {
return NewInteractiveHeatmapSpeedGenerator(false).LoadFunscriptData(path, 1)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
funscript, err := tt.load()
if err != nil {
t.Fatal(err)
}
if len(funscript.Actions) != 2 {
t.Fatalf("expected 2 actions, got %d", len(funscript.Actions))
}
if funscript.Actions[0].At != 50 || funscript.Actions[0].Pos != 20 {
t.Errorf("expected first action to be sorted to at=50 pos=20, got at=%v pos=%d", funscript.Actions[0].At, funscript.Actions[0].Pos)
}
if funscript.Actions[1].At != 100 || funscript.Actions[1].Pos != 40 {
t.Errorf("expected second action to be sorted to at=100 pos=40, got at=%v pos=%d", funscript.Actions[1].At, funscript.Actions[1].Pos)
}
})
}
}

View File

@@ -431,6 +431,8 @@ type StashBoxBatchTagInput struct {
StashBoxEndpoint *string `json:"stash_box_endpoint"`
// Fields to exclude when executing the tagging
ExcludeFields []string `json:"exclude_fields"`
// Collection fields to merge (add to existing) instead of overwriting when executing the tagging
MergeFields []string `json:"merge_fields"`
// Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false
Refresh bool `json:"refresh"`
// If batch adding studios or tags, should their parent entities also be created?
@@ -480,6 +482,7 @@ func (s *Manager) batchTagPerformersByIds(ctx context.Context, input StashBoxBat
performer: performer,
box: box,
excludedFields: input.ExcludeFields,
mergeFields: input.MergeFields,
})
}
}
@@ -500,6 +503,7 @@ func (s *Manager) batchTagPerformersByNamesOrStashIds(input StashBoxBatchTagInpu
stashID: &stashID,
box: box,
excludedFields: input.ExcludeFields,
mergeFields: input.MergeFields,
})
}
}
@@ -516,6 +520,7 @@ func (s *Manager) batchTagPerformersByNamesOrStashIds(input StashBoxBatchTagInpu
name: &name,
box: box,
excludedFields: input.ExcludeFields,
mergeFields: input.MergeFields,
})
}
}
@@ -546,6 +551,7 @@ func (s *Manager) batchTagAllPerformers(ctx context.Context, input StashBoxBatch
performer: performer,
box: box,
excludedFields: input.ExcludeFields,
mergeFields: input.MergeFields,
})
}
return nil

View File

@@ -3,6 +3,7 @@ package manager
import (
"archive/zip"
"context"
gojson "encoding/json"
"fmt"
"io"
"os"
@@ -29,6 +30,7 @@ import (
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/studio"
"github.com/stashapp/stash/pkg/tag"
"github.com/stashapp/stash/pkg/utils"
)
type ExportTask struct {
@@ -423,9 +425,25 @@ func fileToJSON(f models.File) jsonschema.DirEntry {
}
for _, fp := range bf.Fingerprints {
fingerprintValue := fp.Fingerprint
// Convert phash to hex string
if fp.Type == models.FingerprintTypePhash {
if v, ok := fp.Fingerprint.(int64); ok {
fingerprintValue = utils.PhashToString(v)
}
}
// encode manually into json.RawMessage
fvEncoded, err := gojson.Marshal(fingerprintValue)
if err != nil {
// ignore - should not happen
logger.Warnf("[files] <%s> error encoding fingerprint %q value: %v", base.Filename(), fp.Type, err)
continue
}
base.Fingerprints = append(base.Fingerprints, jsonschema.Fingerprint{
Type: fp.Type,
Fingerprint: fp.Fingerprint,
Fingerprint: gojson.RawMessage(fvEncoded),
})
}

View File

@@ -381,9 +381,11 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress *
// handle rename should have already handled the contents of the zip file
// so shouldn't need to scan it again.
// Only scan zip contents if the file is new, the fingerprint changed,
// or if a force rescan was requested.
// if a force rescan was requested, or if the handler was required because
// a related object (e.g. a deleted gallery) is missing and needs to be
// recreated from the contents.
if j.scanner.IsZipFile(f.Info.Name()) && (r.New || r.FingerprintChanged || j.scanner.Rescan) {
if j.scanner.IsZipFile(f.Info.Name()) && (r.New || r.FingerprintChanged || j.scanner.Rescan || r.HandlerRequired) {
ff := r.File
f.BaseFile = ff.Base()

View File

@@ -27,6 +27,7 @@ type stashBoxBatchPerformerTagTask struct {
stashID *string
performer *models.Performer
excludedFields []string
mergeFields []string
}
func (t *stashBoxBatchPerformerTagTask) getName() string {
@@ -54,8 +55,13 @@ func (t *stashBoxBatchPerformerTagTask) Start(ctx context.Context) {
excluded[field] = true
}
merge := map[string]bool{}
for _, field := range t.mergeFields {
merge[field] = true
}
if performer != nil {
t.processMatchedPerformer(ctx, performer, excluded)
t.processMatchedPerformer(ctx, performer, excluded, merge)
} else {
logger.Infof("No match found for %s", t.getName())
}
@@ -157,7 +163,7 @@ func (t *stashBoxBatchPerformerTagTask) handleMergedPerformer(ctx context.Contex
return mergedPerformer, nil
}
func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool) {
func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool, merge map[string]bool) {
if t.performer != nil {
storedID, _ := strconv.Atoi(*p.StoredID)
@@ -176,7 +182,7 @@ func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Cont
return err
}
partial := p.ToPartial(t.box.Endpoint, excluded, existingStashIDs)
partial := p.ToPartial(t.box.Endpoint, excluded, merge, existingStashIDs)
// if we're setting the performer's aliases, and not the name, then filter out the name
// from the aliases to avoid duplicates

View File

@@ -5,6 +5,8 @@ import (
"errors"
"fmt"
"image"
"io"
"os"
"path/filepath"
"strings"
@@ -33,10 +35,18 @@ func (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) (
// ignore clips in non-OsFS filesystems as ffprobe cannot read them
// TODO - copy to temp file if not an OsFS
if _, isOs := fs.(*file.OsFS); !isOs {
ext := strings.ToLower(filepath.Ext(base.Path))
// AVIF images inside zip files are not supported
if strings.ToLower(filepath.Ext(base.Path)) == ".avif" {
if ext == ".avif" {
return nil, fmt.Errorf("%w: %s", ErrUnsupportedAVIFInZip, base.Path)
}
// Go cannot decode JXL from a stream, so extract to a temp file and probe by path
if ext == ".jxl" {
return d.decorateViaTempFile(fs, f)
}
logger.Debugf("assuming ImageFile for non-OsFS file %q", base.Path)
return decorateFallback(fs, f)
}
@@ -137,6 +147,46 @@ func decorateFallback(fs models.FS, f models.File) (models.File, error) {
return ret, nil
}
// decorateViaTempFile extracts a non-OsFS file (e.g. inside a zip) to a temp file so ffprobe can read it by path, for formats like JXL that ffprobe reads but Go cannot decode from a stream.
func (d *Decorator) decorateViaTempFile(fs models.FS, f models.File) (models.File, error) {
base := f.Base()
r, err := fs.Open(base.Path)
if err != nil {
return nil, err
}
defer r.Close()
// preserve the extension so ffprobe can detect the format
tmp, err := os.CreateTemp("", "stash-image-*"+filepath.Ext(base.Path))
if err != nil {
return nil, err
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, r); err != nil {
tmp.Close()
return nil, err
}
tmp.Close()
probe, err := d.FFProbe.NewVideoFile(tmp.Name())
if err != nil {
return nil, err
}
ret := &models.ImageFile{
BaseFile: base,
Format: probe.VideoCodec,
Width: probe.Width,
Height: probe.Height,
}
adjustForOrientation(fs, base.Path, ret)
return ret, nil
}
func (d *Decorator) IsMissingMetadata(ctx context.Context, fs models.FS, f models.File) bool {
const (
unsetString = "unset"

View File

@@ -2,13 +2,16 @@ package file
import (
"context"
gojson "encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"time"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/utils"
)
var ErrZipFileNotExist = errors.New("zip file does not exist")
@@ -92,6 +95,31 @@ func (i *Importer) fileJSONToFile(ctx context.Context, fileJSON jsonschema.DirEn
return nil, errors.New("unknown file type")
}
func unmarshalFingerprintValue(fp gojson.RawMessage) (interface{}, error) {
// try to unmarshal as string first
var str string
if err := gojson.Unmarshal(fp, &str); err == nil {
return str, nil
}
// if that fails, try to unmarshal as number
var num gojson.Number
if err := gojson.Unmarshal(fp, &num); err == nil {
// reject floating point values
if strings.Contains(num.String(), ".") {
return nil, fmt.Errorf("floating point fingerprint values are not supported: %s", num.String())
}
ret, err := num.Int64()
if err != nil {
return nil, fmt.Errorf("error parsing fingerprint number: %v", err)
}
return ret, nil
}
return nil, fmt.Errorf("unable to unmarshal fingerprint value: %s", string(fp))
}
func (i *Importer) baseFileJSONToBaseFile(ctx context.Context, baseJSON *jsonschema.BaseFile) (*models.BaseFile, error) {
baseFile := models.BaseFile{
DirEntry: models.DirEntry{
@@ -104,9 +132,32 @@ func (i *Importer) baseFileJSONToBaseFile(ctx context.Context, baseJSON *jsonsch
}
for _, fp := range baseJSON.Fingerprints {
fingerprintValue, err := unmarshalFingerprintValue(fp.Fingerprint)
if err != nil {
return nil, fmt.Errorf("error unmarshaling fingerprint value for type %q: %v", fp.Type, err)
}
// Handle phash: convert hex string to int64, or use legacy int64 values
if fp.Type == models.FingerprintTypePhash {
switch v := fingerprintValue.(type) {
case string:
// New format: hex string
phash, err := utils.StringToPhash(v)
if err != nil {
return nil, fmt.Errorf("error parsing phash hex string %q: %v", v, err)
} else {
fingerprintValue = phash
}
case int64:
// Old format: int64 number
// nothing to do
default:
return nil, fmt.Errorf("unexpected type for phash fingerprint: %T", v)
}
}
baseFile.Fingerprints = append(baseFile.Fingerprints, models.Fingerprint{
Type: fp.Type,
Fingerprint: fp.Fingerprint,
Fingerprint: fingerprintValue,
})
}

137
pkg/file/import_test.go Normal file
View File

@@ -0,0 +1,137 @@
package file
import (
"context"
gojson "encoding/json"
"testing"
"time"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stretchr/testify/assert"
)
func TestImporterPreImport(t *testing.T) {
type testCase struct {
name string
input jsonschema.DirEntry
expected *models.BaseFile
err bool
}
testCases := []testCase{
{
name: "phash as hex string",
input: &jsonschema.BaseFile{
Fingerprints: []jsonschema.Fingerprint{
{
Type: models.FingerprintTypePhash,
Fingerprint: gojson.RawMessage(`"0123456789abcdef"`),
},
},
},
expected: &models.BaseFile{
Fingerprints: []models.Fingerprint{
{
Type: models.FingerprintTypePhash,
Fingerprint: int64(0x0123456789abcdef),
},
},
},
},
{
name: "phash as legacy int64",
input: &jsonschema.BaseFile{
Fingerprints: []jsonschema.Fingerprint{
{
Type: models.FingerprintTypePhash,
Fingerprint: gojson.RawMessage(`1234567890123456789`),
},
},
},
expected: &models.BaseFile{
Fingerprints: []models.Fingerprint{
{
Type: models.FingerprintTypePhash,
Fingerprint: int64(1234567890123456789),
},
},
},
},
{
name: "phash as legacy int64 #6894",
input: &jsonschema.BaseFile{
Fingerprints: []jsonschema.Fingerprint{
{
Type: models.FingerprintTypePhash,
Fingerprint: gojson.RawMessage(`6391326009969271747`),
},
},
},
expected: &models.BaseFile{
Fingerprints: []models.Fingerprint{
{
Type: models.FingerprintTypePhash,
Fingerprint: int64(6391326009969271747),
},
},
},
},
{
name: "floating point fingerprint value",
input: &jsonschema.BaseFile{
Fingerprints: []jsonschema.Fingerprint{
{
Type: models.FingerprintTypeMD5,
Fingerprint: gojson.RawMessage(`12345.6789`),
},
},
},
err: true,
},
{
name: "invalid hex string phash",
input: &jsonschema.BaseFile{
Fingerprints: []jsonschema.Fingerprint{
{
Type: models.FingerprintTypePhash,
Fingerprint: gojson.RawMessage(`"not a hex string"`),
},
},
},
err: true,
},
}
ctx := context.Background()
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
assert := assert.New(t)
importer := &Importer{
Input: tc.input,
}
err := importer.PreImport(ctx)
if !tc.err && err != nil {
t.Fatalf("unexpected error: %v", err)
}
if tc.err {
assert.Error(err)
return
}
// unset timestamps and basename to avoid test failures due to time differences
if importer.file != nil {
importer.file.Base().CreatedAt = time.Time{}
importer.file.Base().UpdatedAt = time.Time{}
importer.file.Base().ModTime = time.Time{}
importer.file.Base().Basename = ""
}
assert.Equal(tc.expected, importer.file)
})
}
}

View File

@@ -348,6 +348,7 @@ type ScanFileResult struct {
Renamed bool
Updated bool
FingerprintChanged bool
HandlerRequired bool
}
func (r ScanFileResult) IsUnchanged() bool {
@@ -842,6 +843,14 @@ func (s *Scanner) removeOutdatedFingerprints(existing models.File, fp models.Fin
return
}
b := existing.Base()
// oshash has changed - drop phash in case file contents are different
if b.Fingerprints.For(models.FingerprintTypePhash) != nil {
logger.Infof("Removing outdated phash from %s", b.Path)
b.Fingerprints = b.Fingerprints.Remove(models.FingerprintTypePhash)
}
md5 := fp.For(models.FingerprintTypeMD5)
if md5 != nil {
@@ -850,8 +859,7 @@ func (s *Scanner) removeOutdatedFingerprints(existing models.File, fp models.Fin
}
// oshash has changed, MD5 is missing - remove MD5 from the existing fingerprints
logger.Infof("Removing outdated checksum from %s", existing.Base().Path)
b := existing.Base()
logger.Infof("Removing outdated checksum from %s", b.Path)
b.Fingerprints = b.Fingerprints.Remove(models.FingerprintTypeMD5)
}
@@ -911,7 +919,8 @@ func (s *Scanner) onUnchangedFile(ctx context.Context, f ScannedFile, existing m
// if this file is a zip file, then we need to rescan the contents
// as well. We do this by indicating that the file is updated.
return &ScanFileResult{
File: existing,
Updated: true,
File: existing,
Updated: true,
HandlerRequired: true,
}, nil
}

View File

@@ -130,6 +130,21 @@ func (i IntCriterionInput) ValidModifier() bool {
return false
}
type HierarchicalCountInput struct {
Value int `json:"value"`
Value2 *int `json:"value2"`
Modifier CriterionModifier `json:"modifier"`
Depth *int `json:"depth"`
}
func (i HierarchicalCountInput) ValidModifier() bool {
switch i.Modifier {
case CriterionModifierEquals, CriterionModifierNotEquals, CriterionModifierGreaterThan, CriterionModifierLessThan, CriterionModifierIsNull, CriterionModifierNotNull, CriterionModifierBetween, CriterionModifierNotBetween:
return true
}
return false
}
type FloatCriterionInput struct {
Value float64 `json:"value"`
Value2 *float64 `json:"value2"`

View File

@@ -2,6 +2,7 @@ package jsonschema
import (
"bytes"
gojson "encoding/json"
"errors"
"fmt"
"io"
@@ -71,8 +72,10 @@ func (f *BaseFile) IsFile() bool {
}
type Fingerprint struct {
Type string `json:"type,omitempty"`
Fingerprint interface{} `json:"fingerprint,omitempty"`
Type string `json:"type,omitempty"`
// Fingerprint can be string or number, so use RawMessage and unmarshal manually
// #6894: if this is interface{}, it is unmarshalled into a float64 which causes precision loss for phash values.
Fingerprint gojson.RawMessage `json:"fingerprint,omitempty"`
}
type VideoFile struct {

View File

@@ -664,13 +664,13 @@ func (_m *SceneReaderWriter) FindByPrimaryFileID(ctx context.Context, fileID mod
return r0, r1
}
// FindDuplicates provides a mock function with given fields: ctx, distance, durationDiff
func (_m *SceneReaderWriter) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Scene, error) {
ret := _m.Called(ctx, distance, durationDiff)
// FindDuplicates provides a mock function with given fields: ctx, distance, durationDiff, filter
func (_m *SceneReaderWriter) FindDuplicates(ctx context.Context, distance int, durationDiff float64, filter *models.SceneFilterType) ([][]*models.Scene, error) {
ret := _m.Called(ctx, distance, durationDiff, filter)
var r0 [][]*models.Scene
if rf, ok := ret.Get(0).(func(context.Context, int, float64) [][]*models.Scene); ok {
r0 = rf(ctx, distance, durationDiff)
if rf, ok := ret.Get(0).(func(context.Context, int, float64, *models.SceneFilterType) [][]*models.Scene); ok {
r0 = rf(ctx, distance, durationDiff, filter)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([][]*models.Scene)
@@ -678,8 +678,8 @@ func (_m *SceneReaderWriter) FindDuplicates(ctx context.Context, distance int, d
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int, float64) error); ok {
r1 = rf(ctx, distance, durationDiff)
if rf, ok := ret.Get(1).(func(context.Context, int, float64, *models.SceneFilterType) error); ok {
r1 = rf(ctx, distance, durationDiff, filter)
} else {
r1 = ret.Error(1)
}

View File

@@ -348,13 +348,17 @@ func (p *ScrapedPerformer) GetImage(ctx context.Context, excluded map[string]boo
return nil, nil
}
func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, existingStashIDs []StashID) PerformerPartial {
func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, merge map[string]bool, existingStashIDs []StashID) PerformerPartial {
ret := NewPerformerPartial()
if p.Aliases != nil && !excluded["aliases"] {
mode := RelationshipUpdateModeSet
if merge["aliases"] {
mode = RelationshipUpdateModeAdd
}
ret.Aliases = &UpdateStrings{
Values: stringslice.FromString(*p.Aliases, ","),
Mode: RelationshipUpdateModeSet,
Mode: mode,
}
}
if p.Birthdate != nil && !excluded["birthdate"] {
@@ -430,12 +434,17 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
ret.Tattoos = NewOptionalString(*p.Tattoos)
}
urlMode := RelationshipUpdateModeSet
if merge["urls"] {
urlMode = RelationshipUpdateModeAdd
}
// if URLs are provided, only use those
if len(p.URLs) > 0 {
if !excluded["urls"] {
ret.URLs = &UpdateStrings{
Values: p.URLs,
Mode: RelationshipUpdateModeSet,
Mode: urlMode,
}
}
} else {
@@ -453,7 +462,7 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
if len(urls) > 0 {
ret.URLs = &UpdateStrings{
Values: urls,
Mode: RelationshipUpdateModeSet,
Mode: urlMode,
}
}
}

View File

@@ -28,7 +28,7 @@ type SceneFinder interface {
FindByPerformerID(ctx context.Context, performerID int) ([]*Scene, error)
FindByGalleryID(ctx context.Context, performerID int) ([]*Scene, error)
FindByGroupID(ctx context.Context, groupID int) ([]*Scene, error)
FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*Scene, error)
FindDuplicates(ctx context.Context, distance int, durationDiff float64, filter *SceneFilterType) ([][]*Scene, error)
}
// SceneQueryer provides methods to query scenes.

View File

@@ -62,6 +62,7 @@ type StudioCreateInput struct {
URL *string `json:"url"` // deprecated
Urls []string `json:"urls"`
ParentID *string `json:"parent_id"`
ChildIds []string `json:"child_ids"`
// This should be a URL or a base64 encoded data URL
Image *string `json:"image"`
StashIds []StashIDInput `json:"stash_ids"`
@@ -82,6 +83,7 @@ type StudioUpdateInput struct {
URL *string `json:"url"` // deprecated
Urls []string `json:"urls"`
ParentID *string `json:"parent_id"`
ChildIds []string `json:"child_ids"`
// This should be a URL or a base64 encoded data URL
Image *string `json:"image"`
StashIds []StashIDInput `json:"stash_ids"`

View File

@@ -15,21 +15,21 @@ type TagFilterType struct {
// Filter to only include tags missing this property
IsMissing *string `json:"is_missing"`
// Filter by number of scenes with this tag
SceneCount *IntCriterionInput `json:"scene_count"`
SceneCount *HierarchicalCountInput `json:"scene_count"`
// Filter by number of images with this tag
ImageCount *IntCriterionInput `json:"image_count"`
ImageCount *HierarchicalCountInput `json:"image_count"`
// Filter by number of galleries with this tag
GalleryCount *IntCriterionInput `json:"gallery_count"`
GalleryCount *HierarchicalCountInput `json:"gallery_count"`
// Filter by number of performers with this tag
PerformerCount *IntCriterionInput `json:"performer_count"`
PerformerCount *HierarchicalCountInput `json:"performer_count"`
// Filter by number of studios with this tag
StudioCount *IntCriterionInput `json:"studio_count"`
StudioCount *HierarchicalCountInput `json:"studio_count"`
// Filter by number of groups with this tag
GroupCount *IntCriterionInput `json:"group_count"`
GroupCount *HierarchicalCountInput `json:"group_count"`
// Filter by number of movies with this tag
MovieCount *IntCriterionInput `json:"movie_count"`
MovieCount *HierarchicalCountInput `json:"movie_count"`
// Filter by number of markers with this tag
MarkerCount *IntCriterionInput `json:"marker_count"`
MarkerCount *HierarchicalCountInput `json:"marker_count"`
// Filter by parent tags
Parents *HierarchicalMultiCriterionInput `json:"parents"`
// Filter by child tags

View File

@@ -9,19 +9,19 @@ importers:
.:
devDependencies:
'@types/react':
specifier: ^18.2.31
specifier: ^18.3.26
version: 18.3.26
'@types/react-dom':
specifier: ^18.2.14
specifier: ^18.3.7
version: 18.3.7(@types/react@18.3.26)
cpx:
specifier: ^1.5.0
version: 1.5.0
sass:
specifier: ^1.69.4
specifier: ^1.93.2
version: 1.93.2
typescript:
specifier: ^5.2.2
specifier: ^5.9.3
version: 5.9.3
packages:
@@ -356,7 +356,7 @@ packages:
glob@7.2.3:
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
deprecated: Glob versions prior to v9 are no longer supported
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
@@ -381,8 +381,8 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
immutable@5.1.4:
resolution: {integrity: sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==}
immutable@5.1.5:
resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==}
inflight@1.0.6:
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
@@ -1160,7 +1160,7 @@ snapshots:
dependencies:
function-bind: 1.1.2
immutable@5.1.4: {}
immutable@5.1.5: {}
inflight@1.0.6:
dependencies:
@@ -1464,7 +1464,7 @@ snapshots:
sass@1.93.2:
dependencies:
chokidar: 4.0.3
immutable: 5.1.4
immutable: 5.1.5
source-map-js: 1.2.1
optionalDependencies:
'@parcel/watcher': 2.5.1

View File

@@ -89,6 +89,49 @@ func autotagMatchTags(ctx context.Context, path string, tagReader models.TagAuto
return ret, nil
}
func (s autotagScraper) viaImage(ctx context.Context, _client *http.Client, image *models.Image) (*models.ScrapedImage, error) {
path := image.Path
if path == "" {
return nil, nil
}
var ret *models.ScrapedImage
// only trim extension if image is file-based
trimExt := image.PrimaryFileID != nil
// populate performers, studio and tags based on image path
if err := txn.WithReadTxn(ctx, s.txnManager, func(ctx context.Context) error {
performers, err := autotagMatchPerformers(ctx, path, s.performerReader, trimExt)
if err != nil {
return fmt.Errorf("autotag scraper viaImage: %w", err)
}
studio, err := autotagMatchStudio(ctx, path, s.studioReader, trimExt)
if err != nil {
return fmt.Errorf("autotag scraper viaImage: %w", err)
}
tags, err := autotagMatchTags(ctx, path, s.tagReader, trimExt)
if err != nil {
return fmt.Errorf("autotag scraper viaImage: %w", err)
}
if len(performers) > 0 || studio != nil || len(tags) > 0 {
ret = &models.ScrapedImage{
Performers: performers,
Studio: studio,
Tags: tags,
}
}
return nil
}); err != nil {
return nil, err
}
return ret, nil
}
func (s autotagScraper) viaScene(ctx context.Context, _client *http.Client, scene *models.Scene) (*models.ScrapedScene, error) {
var ret *models.ScrapedScene
const trimExt = false
@@ -181,6 +224,8 @@ func (s autotagScraper) supports(ty ScrapeContentType) bool {
return true
case ScrapeContentTypeGallery:
return true
case ScrapeContentTypeImage:
return true
}
return false
@@ -204,6 +249,9 @@ func (s autotagScraper) spec() Scraper {
Gallery: &ScraperSpec{
SupportedScrapes: supportedScrapes,
},
Image: &ScraperSpec{
SupportedScrapes: supportedScrapes,
},
}
}

View File

@@ -205,6 +205,52 @@ func galleryInputFromGallery(gallery *models.Gallery) galleryInput {
return ret
}
type imageInput struct {
ID string `json:"id"`
Title string `json:"title"`
Urls []string `json:"urls"`
Date *string `json:"date"`
Details string `json:"details"`
Code string `json:"code,omitempty"`
Photographer string `json:"photographer,omitempty"`
Files []fileInput `json:"files,omitempty"`
}
func imageInputFromImage(image *models.Image) imageInput {
dateToStringPtr := func(s *models.Date) *string {
if s != nil {
v := s.String()
return &v
}
return nil
}
// fallback to file basename if title is empty
title := image.GetTitle()
urls := image.URLs.List()
ret := imageInput{
ID: strconv.Itoa(image.ID),
Title: title,
Urls: urls,
Details: image.Details,
Date: dateToStringPtr(image.Date),
Code: image.Code,
Photographer: image.Photographer,
}
for _, f := range image.Files.List() {
fi := fileInputFromFile(*f.Base())
ret.Files = append(ret.Files, fi)
}
return ret
}
var ErrScraperScript = errors.New("scraper script error")
type scriptScraper struct {
@@ -392,6 +438,9 @@ func (s *scriptFragmentScraper) scrapeByFragment(ctx context.Context, input Inpu
case input.Scene != nil:
inString, err = json.Marshal(*input.Scene)
ty = ScrapeContentTypeScene
case input.Image != nil:
inString, err = json.Marshal(*input.Image)
ty = ScrapeContentTypeImage
}
if err != nil {
@@ -430,7 +479,7 @@ func (s *scriptFragmentScraper) scrapeGalleryByGallery(ctx context.Context, gall
}
func (s *scriptFragmentScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) {
inString, err := json.Marshal(imageToUpdateInput(image))
inString, err := json.Marshal(imageInputFromImage(image))
if err != nil {
return nil, err

View File

@@ -0,0 +1,52 @@
package scraper
import (
"fmt"
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stretchr/testify/assert"
)
func Test_imageInputFromImage_worksWithMultipleFiles(t *testing.T) {
date, _ := models.ParseDate("2020-01-01")
model := models.Image{
ID: 1,
Title: "Test Image",
URLs: models.NewRelatedStrings([]string{"https://example.com/image.png"}),
Date: &date,
Code: "Code",
Photographer: "Photographer",
Files: models.NewRelatedFiles([]models.File{
makeImageFile(1),
makeImageFile(2),
}),
}
input := imageInputFromImage(&model)
assert.Equal(t, "1", input.ID)
assert.Equal(t, "Test Image", input.Title)
assert.Equal(t, "https://example.com/image.png", input.Urls[0])
assert.Equal(t, "2020-01-01", *input.Date)
assert.Equal(t, "Code", input.Code)
assert.Equal(t, "Photographer", input.Photographer)
assert.Equal(t, "/data/images/image_0001_.png", input.Files[0].Path)
assert.Equal(t, "/data/images/image_0002_.png", input.Files[1].Path)
}
func getImageStringValue(index int, field string) string {
return fmt.Sprintf("image_%04d_%s", index, field)
}
func makeImageFile(i int) *models.ImageFile {
return &models.ImageFile{
BaseFile: &models.BaseFile{
Path: "/data/images/" + getImageStringValue(i, ".png"),
Basename: getImageStringValue(i, ".png"),
},
Height: 200,
Width: 300,
}
}

View File

@@ -438,26 +438,3 @@ func (s *stashScraper) scrapeImageByImage(ctx context.Context, image *models.Ima
func (s *stashScraper) scrapeByURL(_ context.Context, _ string, _ ScrapeContentType) (ScrapedContent, error) {
return nil, ErrNotSupported
}
func imageToUpdateInput(gallery *models.Image) models.ImageUpdateInput {
dateToStringPtr := func(s *models.Date) *string {
if s != nil {
v := s.String()
return &v
}
return nil
}
// fallback to file basename if title is empty
title := gallery.GetTitle()
urls := gallery.URLs.List()
return models.ImageUpdateInput{
ID: strconv.Itoa(gallery.ID),
Title: &title,
Details: &gallery.Details,
Urls: urls,
Date: dateToStringPtr(gallery.Date),
}
}

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

@@ -829,6 +829,7 @@ var gallerySortOptions = sortOptions{
"id",
"images_count",
"path",
"performer_age",
"performer_count",
"random",
"rating",
@@ -890,6 +891,34 @@ func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.F
query.sortAndPagination += getCountSort(galleryTable, galleriesTagsTable, galleryIDColumn, direction)
case "performer_count":
query.sortAndPagination += getCountSort(galleryTable, performersGalleriesTable, galleryIDColumn, direction)
case "performer_age":
// Multi-performer semantics:
// - ASC sorts by the youngest performer in each gallery (MIN age)
// - DESC sorts by the oldest performer in each gallery (MAX age)
aggregation := "MIN"
if direction == "DESC" {
// DESC uses oldest performer age for each gallery.
aggregation = "MAX"
}
var fallback string
if direction == "ASC" {
// ASC puts NULL first by default, so coalesce to sqlite max int.
fallback = "9223372036854775807"
} else {
// DESC puts larger values first; coalesce NULL to sqlite min int to keep NULLs last.
fallback = "-9223372036854775808"
}
query.sortAndPagination += fmt.Sprintf(
" ORDER BY (SELECT COALESCE(%s(JulianDay(galleries.date) - JulianDay(performers.birthdate)), %s) FROM %s as performers INNER JOIN %s AS aggregation WHERE performers.id = aggregation.%s AND aggregation.%s = %s.id) %s",
aggregation,
fallback,
performerTable,
performersGalleriesTable,
performerIDColumn,
galleryIDColumn,
galleryTable,
getSortDirection(direction),
)
case "path":
// special handling for path
addFileTable()

View File

@@ -12,6 +12,7 @@ import (
"github.com/stashapp/stash/pkg/models"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var invalidID = -1
@@ -1504,6 +1505,7 @@ func galleryQueryQ(ctx context.Context, t *testing.T, q string, expectedGalleryI
// no Q should return all results
filter.Q = nil
filter.PerPage = ptr(-1)
galleries, _, err = qb.Query(ctx, nil, &filter)
if err != nil {
t.Errorf("Error querying gallery: %s", err.Error())
@@ -2825,6 +2827,20 @@ func TestGalleryQuerySorting(t *testing.T) {
-1,
-1,
},
{
"performer age asc",
"performer_age",
models.SortDirectionEnumAsc,
-1,
-1,
},
{
"performer age desc",
"performer_age",
models.SortDirectionEnumDesc,
-1,
-1,
},
}
qb := db.Gallery
@@ -2862,6 +2878,163 @@ func TestGalleryQuerySorting(t *testing.T) {
}
}
func TestGalleryQuerySortingPerformerAgeNullHandling(t *testing.T) {
runWithRollbackTxn(t, "performer age null handling", func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
knownBirthdate, err := models.ParseDate("1990-01-01")
require.NoError(t, err)
galleryDate, err := models.ParseDate("2020-01-01")
require.NoError(t, err)
knownPerformer := models.Performer{
Name: "performer-known-birthdate",
Birthdate: &knownBirthdate,
}
require.NoError(t, db.Performer.Create(ctx, &models.CreatePerformerInput{Performer: &knownPerformer}))
unknownPerformer := models.Performer{
Name: "performer-unknown-birthdate",
}
require.NoError(t, db.Performer.Create(ctx, &models.CreatePerformerInput{Performer: &unknownPerformer}))
knownOnlyGallery := models.Gallery{
Title: "gallery-known-only",
Date: &galleryDate,
PerformerIDs: models.NewRelatedIDs([]int{
knownPerformer.ID,
}),
}
require.NoError(t, db.Gallery.Create(ctx, &models.CreateGalleryInput{Gallery: &knownOnlyGallery}))
mixedGallery := models.Gallery{
Title: "gallery-known-and-unknown",
Date: &galleryDate,
PerformerIDs: models.NewRelatedIDs([]int{
knownPerformer.ID,
unknownPerformer.ID,
}),
}
require.NoError(t, db.Gallery.Create(ctx, &models.CreateGalleryInput{Gallery: &mixedGallery}))
unknownOnlyGallery := models.Gallery{
Title: "gallery-unknown-only",
Date: &galleryDate,
PerformerIDs: models.NewRelatedIDs([]int{
unknownPerformer.ID,
}),
}
require.NoError(t, db.Gallery.Create(ctx, &models.CreateGalleryInput{Gallery: &unknownOnlyGallery}))
findIndex := func(galleries []*models.Gallery, id int) int {
for i, g := range galleries {
if g.ID == id {
return i
}
}
return -1
}
asc := models.SortDirectionEnumAsc
sortBy := "performer_age"
ascGot, _, err := db.Gallery.Query(ctx, nil, &models.FindFilterType{Sort: &sortBy, Direction: &asc})
require.NoError(t, err)
ascKnownOnly := findIndex(ascGot, knownOnlyGallery.ID)
ascMixed := findIndex(ascGot, mixedGallery.ID)
ascUnknownOnly := findIndex(ascGot, unknownOnlyGallery.ID)
assert.NotEqual(-1, ascKnownOnly)
assert.NotEqual(-1, ascMixed)
assert.NotEqual(-1, ascUnknownOnly)
assert.Less(ascKnownOnly, ascUnknownOnly)
assert.Less(ascMixed, ascUnknownOnly)
desc := models.SortDirectionEnumDesc
descGot, _, err := db.Gallery.Query(ctx, nil, &models.FindFilterType{Sort: &sortBy, Direction: &desc})
require.NoError(t, err)
descKnownOnly := findIndex(descGot, knownOnlyGallery.ID)
descMixed := findIndex(descGot, mixedGallery.ID)
descUnknownOnly := findIndex(descGot, unknownOnlyGallery.ID)
assert.NotEqual(-1, descKnownOnly)
assert.NotEqual(-1, descMixed)
assert.NotEqual(-1, descUnknownOnly)
assert.Less(descKnownOnly, descUnknownOnly)
assert.Less(descMixed, descUnknownOnly)
})
}
func TestGalleryQuerySortingPerformerAgeMultiPerformerAggregation(t *testing.T) {
runWithRollbackTxn(t, "performer age multi performer aggregation", func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
youngBirthdate, err := models.ParseDate("2000-01-01")
require.NoError(t, err)
midBirthdate, err := models.ParseDate("1990-01-01")
require.NoError(t, err)
oldBirthdate, err := models.ParseDate("1980-01-01")
require.NoError(t, err)
galleryDate, err := models.ParseDate("2020-01-01")
require.NoError(t, err)
young := models.Performer{Name: "performer-young", Birthdate: &youngBirthdate}
mid := models.Performer{Name: "performer-mid", Birthdate: &midBirthdate}
old := models.Performer{Name: "performer-old", Birthdate: &oldBirthdate}
require.NoError(t, db.Performer.Create(ctx, &models.CreatePerformerInput{Performer: &young}))
require.NoError(t, db.Performer.Create(ctx, &models.CreatePerformerInput{Performer: &mid}))
require.NoError(t, db.Performer.Create(ctx, &models.CreatePerformerInput{Performer: &old}))
galleryYoungAndOld := models.Gallery{
Title: "gallery-young-and-old",
Date: &galleryDate,
PerformerIDs: models.NewRelatedIDs([]int{
young.ID,
old.ID,
}),
}
require.NoError(t, db.Gallery.Create(ctx, &models.CreateGalleryInput{Gallery: &galleryYoungAndOld}))
galleryMidOnly := models.Gallery{
Title: "gallery-mid-only",
Date: &galleryDate,
PerformerIDs: models.NewRelatedIDs([]int{
mid.ID,
}),
}
require.NoError(t, db.Gallery.Create(ctx, &models.CreateGalleryInput{Gallery: &galleryMidOnly}))
findIndex := func(galleries []*models.Gallery, id int) int {
for i, g := range galleries {
if g.ID == id {
return i
}
}
return -1
}
sortBy := "performer_age"
asc := models.SortDirectionEnumAsc
ascGot, _, err := db.Gallery.Query(ctx, nil, &models.FindFilterType{Sort: &sortBy, Direction: &asc})
require.NoError(t, err)
ascYoungAndOld := findIndex(ascGot, galleryYoungAndOld.ID)
ascMidOnly := findIndex(ascGot, galleryMidOnly.ID)
assert.NotEqual(-1, ascYoungAndOld)
assert.NotEqual(-1, ascMidOnly)
// ASC uses MIN(age), so gallery with youngest performer should come first.
assert.Less(ascYoungAndOld, ascMidOnly)
desc := models.SortDirectionEnumDesc
descGot, _, err := db.Gallery.Query(ctx, nil, &models.FindFilterType{Sort: &sortBy, Direction: &desc})
require.NoError(t, err)
descYoungAndOld := findIndex(descGot, galleryYoungAndOld.ID)
descMidOnly := findIndex(descGot, galleryMidOnly.ID)
assert.NotEqual(-1, descYoungAndOld)
assert.NotEqual(-1, descMidOnly)
// DESC uses MAX(age), so gallery with oldest performer should come first.
assert.Less(descYoungAndOld, descMidOnly)
})
}
func TestGalleryStore_AddImages(t *testing.T) {
tests := []struct {
name string

View File

@@ -500,6 +500,7 @@ var groupSortOptions = sortOptions{
"rating",
"scenes_count",
"o_counter",
"sub_group_description",
"sub_group_order",
"tag_count",
"updated_at",
@@ -532,6 +533,15 @@ func (qb *GroupStore) setGroupSort(query *queryBuilder, findFilter *models.FindF
query.joinSort(groupRelationsTable, "", "groups.id = groups_relations.sub_id")
query.sortAndPagination += getSort("order_index", direction, groupRelationsTable)
}
case "sub_group_description":
// as above, we need to handle parent groups differently here
const clause = " ORDER BY COALESCE(%s.description, '') COLLATE NATURAL_CI %s"
if query.hasJoin("groups_parents") {
query.sortAndPagination += fmt.Sprintf(clause, "groups_parents", direction)
} else {
query.joinSort(groupRelationsTable, "", "groups.id = groups_relations.sub_id")
query.sortAndPagination += fmt.Sprintf(clause, groupRelationsTable, direction)
}
case "tag_count":
query.sortAndPagination += getCountSort(groupTable, groupsTagsTable, groupIDColumn, direction)
case "scenes_count": // generic getSort won't work for this

View File

@@ -1124,6 +1124,90 @@ func TestGroupQuerySortOrderIndex(t *testing.T) {
})
}
func TestGroupQuerySortSubGroupDescription(t *testing.T) {
runWithRollbackTxn(t, "sort subgroup description", func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
cEmpty := models.Group{Name: "sort-desc-child-empty"}
c01 := models.Group{Name: "sort-desc-child-01"}
c2 := models.Group{Name: "sort-desc-child-2"}
c10 := models.Group{Name: "sort-desc-child-10"}
assert.NoError(db.Group.Create(ctx, &cEmpty))
assert.NoError(db.Group.Create(ctx, &c01))
assert.NoError(db.Group.Create(ctx, &c2))
assert.NoError(db.Group.Create(ctx, &c10))
parent := models.Group{
Name: "sort-desc-parent",
SubGroups: models.NewRelatedGroupDescriptions([]models.GroupIDDescription{
{GroupID: cEmpty.ID, Description: ""},
{GroupID: c10.ID, Description: "10"},
{GroupID: c2.ID, Description: "2"},
{GroupID: c01.ID, Description: "01"},
}),
}
assert.NoError(db.Group.Create(ctx, &parent))
sortKey := "sub_group_description"
dirAsc := models.SortDirectionEnumAsc
findFilter := models.FindFilterType{
Sort: &sortKey,
Direction: &dirAsc,
}
groupFilter := models.GroupFilterType{
ContainingGroups: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(parent.ID)},
Modifier: models.CriterionModifierIncludes,
},
}
groups, _, err := db.Group.Query(ctx, &groupFilter, &findFilter)
assert.NoError(err)
assert.Len(groups, 4)
assert.Equal(cEmpty.ID, groups[0].ID)
assert.Equal(c01.ID, groups[1].ID)
assert.Equal(c2.ID, groups[2].ID)
assert.Equal(c10.ID, groups[3].ID)
dirDesc := models.SortDirectionEnumDesc
findFilter.Direction = &dirDesc
groups, _, err = db.Group.Query(ctx, &groupFilter, &findFilter)
assert.NoError(err)
assert.Len(groups, 4)
assert.Equal(c10.ID, groups[0].ID)
assert.Equal(c2.ID, groups[1].ID)
assert.Equal(c01.ID, groups[2].ID)
assert.Equal(cEmpty.ID, groups[3].ID)
// Exercise the non-groups_parents code path by filtering on name only.
nameCriterion := models.StringCriterionInput{
Value: "sort-desc-child-",
Modifier: models.CriterionModifierIncludes,
}
nameFilter := models.GroupFilterType{
Name: &nameCriterion,
}
findFilter.Direction = &dirAsc
groups, _, err = db.Group.Query(ctx, &nameFilter, &findFilter)
assert.NoError(err)
assert.Len(groups, 4)
assert.Equal(cEmpty.ID, groups[0].ID)
assert.Equal(c01.ID, groups[1].ID)
assert.Equal(c2.ID, groups[2].ID)
assert.Equal(c10.ID, groups[3].ID)
findFilter.Direction = &dirDesc
groups, _, err = db.Group.Query(ctx, &nameFilter, &findFilter)
assert.NoError(err)
assert.Len(groups, 4)
assert.Equal(c10.ID, groups[0].ID)
assert.Equal(c2.ID, groups[1].ID)
assert.Equal(c01.ID, groups[2].ID)
assert.Equal(cEmpty.ID, groups[3].ID)
})
}
func TestGroupUpdateFrontImage(t *testing.T) {
if err := withRollbackTxn(func(ctx context.Context) error {
qb := db.Group

View File

@@ -950,6 +950,11 @@ func (qb *ImageStore) queryGroupedFields(ctx context.Context, options models.Ima
aggregateQuery.addColumn("SUM(temp.size) as size")
}
// #5503 - select the file id so equal-sized/megapixel files aren't collapsed by DISTINCT
if options.Megapixels || options.TotalSize {
query.addColumn(imagesFilesTable + ".file_id")
}
const includeSortPagination = false
aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination))

View File

@@ -1630,6 +1630,67 @@ func queryImagesWithCount(ctx context.Context, sqb models.ImageReader, imageFilt
return images, result.Count, nil
}
// #5503 - total size/megapixels must include secondary files of equal size/dimensions
func TestImageQueryTotalSizeMultipleFiles(t *testing.T) {
withRollbackTxn(func(ctx context.Context) error {
iqb := db.Image
fqb := db.File
const fileSize = int64(1234)
const fileWidth = 1000
const fileHeight = 1000
makeFile := func(basename string) models.FileID {
f := &models.ImageFile{
BaseFile: &models.BaseFile{
Path: getFilePath(folderIdxWithImageFiles, basename),
Basename: basename,
ParentFolderID: folderIDs[folderIdxWithImageFiles],
Size: fileSize,
},
Width: fileWidth,
Height: fileHeight,
}
if err := fqb.Create(ctx, f); err != nil {
t.Fatalf("creating file: %v", err)
}
return f.ID
}
f1 := makeFile("multifile-image-1.jpg")
f2 := makeFile("multifile-image-2.jpg")
image := &models.Image{Title: "multifile image"}
if err := iqb.Create(ctx, &models.CreateImageInput{
Image: image,
FileIDs: []models.FileID{f1, f2},
}); err != nil {
t.Fatalf("creating image: %v", err)
}
result, err := iqb.Query(ctx, models.ImageQueryOptions{
QueryOptions: models.QueryOptions{Count: true},
ImageFilter: &models.ImageFilterType{
ID: &models.IntCriterionInput{
Modifier: models.CriterionModifierEquals,
Value: image.ID,
},
},
Megapixels: true,
TotalSize: true,
})
if err != nil {
t.Fatalf("querying image: %v", err)
}
assert.Equal(t, 1, result.Count)
assert.Equal(t, float64(fileSize*2), result.TotalSize)
assert.Equal(t, float64(fileWidth*fileHeight*2)/1000000, result.Megapixels)
return nil
})
}
func imageQueryQ(ctx context.Context, t *testing.T, sqb models.ImageReader, q string, expectedImageIdx int) {
filter := models.FindFilterType{
Q: &q,

View File

@@ -41,41 +41,6 @@ const (
sceneCoverBlobColumn = "cover_blob"
)
var findExactDuplicateQuery = `
SELECT GROUP_CONCAT(DISTINCT scene_id) as ids
FROM (
SELECT scenes.id as scene_id
, video_files.duration as file_duration
, files.size as file_size
, files_fingerprints.fingerprint as phash
, abs(max(video_files.duration) OVER (PARTITION by files_fingerprints.fingerprint) - video_files.duration) as durationDiff
FROM scenes
INNER JOIN scenes_files ON (scenes.id = scenes_files.scene_id)
INNER JOIN files ON (scenes_files.file_id = files.id)
INNER JOIN files_fingerprints ON (scenes_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash')
INNER JOIN video_files ON (files.id == video_files.file_id)
)
WHERE durationDiff <= ?1
OR ?1 < 0 -- Always TRUE if the parameter is negative.
-- That will disable the durationDiff checking.
GROUP BY phash
HAVING COUNT(phash) > 1
AND COUNT(DISTINCT scene_id) > 1
ORDER BY SUM(file_size) DESC;
`
var findAllPhashesQuery = `
SELECT scenes.id as id
, files_fingerprints.fingerprint as phash
, video_files.duration as duration
FROM scenes
INNER JOIN scenes_files ON (scenes.id = scenes_files.scene_id)
INNER JOIN files ON (scenes_files.file_id = files.id)
INNER JOIN files_fingerprints ON (scenes_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash')
INNER JOIN video_files ON (files.id == video_files.file_id)
ORDER BY files.size DESC;
`
type sceneRow struct {
ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"`
@@ -1121,6 +1086,11 @@ func (qb *SceneStore) queryGroupedFields(ctx context.Context, options models.Sce
aggregateQuery.addColumn("SUM(temp.size) as size")
}
// #5503 - select the file id so equal-sized/duration files aren't collapsed by DISTINCT
if options.TotalDuration || options.TotalSize {
query.addColumn(scenesFilesTable + ".file_id")
}
const includeSortPagination = false
aggregateQuery.from = fmt.Sprintf("(%s) as temp", query.toSQL(includeSortPagination))
@@ -1462,11 +1432,61 @@ func (qb *SceneStore) GetStashIDs(ctx context.Context, sceneID int) ([]models.St
return sceneRepository.stashIDs.get(ctx, sceneID)
}
func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*models.Scene, error) {
func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int, durationDiff float64, filter *models.SceneFilterType) ([][]*models.Scene, error) {
var dupeIds [][]int
query, err := qb.makeQuery(ctx, filter, nil)
if err != nil {
return nil, err
}
// Add necessary joins for duplicate checking
query.addJoins(
join{
table: scenesFilesTable,
onClause: "scenes.id = scenes_files.scene_id",
},
join{
table: fileTable,
onClause: "scenes_files.file_id = files.id",
},
join{
table: fingerprintTable,
onClause: "scenes_files.file_id = files_fingerprints.file_id AND files_fingerprints.type = 'phash'",
},
join{
table: videoFileTable,
onClause: "files.id = video_files.file_id",
},
)
if distance == 0 {
query.columns = []string{
"scenes.id as scene_id",
"video_files.duration as file_duration",
"files.size as file_size",
"files_fingerprints.fingerprint as phash",
"abs(max(video_files.duration) OVER (PARTITION by files_fingerprints.fingerprint) - video_files.duration) as durationDiff",
}
sqlStr := query.toSQL(false)
finalQuery := `
SELECT GROUP_CONCAT(DISTINCT scene_id) as ids
FROM (` + sqlStr + `)
WHERE phash IS NOT NULL
AND (durationDiff <= ?
OR ? < 0) -- Always TRUE if the parameter is negative.
-- That will disable the durationDiff checking.
GROUP BY phash
HAVING COUNT(phash) > 1
AND COUNT(DISTINCT scene_id) > 1
ORDER BY SUM(file_size) DESC;
`
var ids []string
if err := dbWrapper.Select(ctx, &ids, findExactDuplicateQuery, durationDiff); err != nil {
args := append(query.allArgs(), durationDiff, durationDiff)
if err := dbWrapper.Select(ctx, &ids, finalQuery, args...); err != nil {
return nil, err
}
@@ -1484,9 +1504,19 @@ func (qb *SceneStore) FindDuplicates(ctx context.Context, distance int, duration
}
}
} else {
query.columns = []string{
"scenes.id as id",
"files_fingerprints.fingerprint as phash",
"video_files.duration as duration",
}
query.addWhere("files_fingerprints.fingerprint IS NOT NULL")
query.sortAndPagination = " ORDER BY files.size DESC"
sqlStr := query.toSQL(true)
var hashes []*utils.Phash
if err := sceneRepository.queryFunc(ctx, findAllPhashesQuery, nil, false, func(rows *sqlx.Rows) error {
if err := sceneRepository.queryFunc(ctx, sqlStr, query.allArgs(), false, func(rows *sqlx.Rows) error {
phash := utils.Phash{
Bucket: -1,
Duration: -1,

View File

@@ -5,6 +5,7 @@ package sqlite_test
import (
"context"
"encoding/json"
"fmt"
"math"
"path/filepath"
@@ -2094,6 +2095,62 @@ func sceneQueryQ(ctx context.Context, t *testing.T, sqb models.SceneReader, q st
assert.Len(t, scenes, totalScenes)
}
// #5503 - total size/duration must include secondary files of equal size/duration
func TestSceneQueryTotalSizeMultipleFiles(t *testing.T) {
withRollbackTxn(func(ctx context.Context) error {
sqb := db.Scene
fqb := db.File
const fileSize = int64(1234)
const fileDuration = float64(100)
makeFile := func(basename string) models.FileID {
f := &models.VideoFile{
BaseFile: &models.BaseFile{
Path: getFilePath(folderIdxWithSceneFiles, basename),
Basename: basename,
ParentFolderID: folderIDs[folderIdxWithSceneFiles],
Size: fileSize,
},
Duration: fileDuration,
}
if err := fqb.Create(ctx, f); err != nil {
t.Fatalf("creating file: %v", err)
}
return f.ID
}
f1 := makeFile("multifile-scene-1.mp4")
f2 := makeFile("multifile-scene-2.mp4")
scene := &models.Scene{Title: "multifile scene"}
if err := sqb.Create(ctx, scene, []models.FileID{f1, f2}); err != nil {
t.Fatalf("creating scene: %v", err)
}
result, err := sqb.Query(ctx, models.SceneQueryOptions{
QueryOptions: models.QueryOptions{Count: true},
SceneFilter: &models.SceneFilterType{
ID: &models.IntCriterionInput{
Modifier: models.CriterionModifierEquals,
Value: scene.ID,
},
},
TotalDuration: true,
TotalSize: true,
})
if err != nil {
t.Fatalf("querying scene: %v", err)
}
assert.Equal(t, 1, result.Count)
assert.Equal(t, float64(fileSize*2), result.TotalSize)
assert.Equal(t, fileDuration*2, result.TotalDuration)
return nil
})
}
func TestSceneQuery(t *testing.T) {
var (
endpoint = sceneStashID(sceneIdxWithGallery).Endpoint
@@ -4162,7 +4219,10 @@ func TestSceneQueryPhashDuplicated(t *testing.T) {
duplicated = false
scenes = queryScene(ctx, t, sqb, &sceneFilter, nil)
findFilter := models.FindFilterType{
PerPage: ptr(-1),
}
scenes = queryScene(ctx, t, sqb, &sceneFilter, &findFilter)
// -1 for missing phash
assert.Len(t, scenes, totalScenes-(dupeScenePhashes*2)-1)
@@ -4631,7 +4691,7 @@ func TestSceneStore_FindDuplicates(t *testing.T) {
withRollbackTxn(func(ctx context.Context) error {
distance := 0
durationDiff := -1.
got, err := qb.FindDuplicates(ctx, distance, durationDiff)
got, err := qb.FindDuplicates(ctx, distance, durationDiff, nil)
if err != nil {
t.Errorf("SceneStore.FindDuplicates() error = %v", err)
return nil
@@ -4641,7 +4701,7 @@ func TestSceneStore_FindDuplicates(t *testing.T) {
distance = 1
durationDiff = -1.
got, err = qb.FindDuplicates(ctx, distance, durationDiff)
got, err = qb.FindDuplicates(ctx, distance, durationDiff, nil)
if err != nil {
t.Errorf("SceneStore.FindDuplicates() error = %v", err)
return nil
@@ -4653,6 +4713,214 @@ func TestSceneStore_FindDuplicates(t *testing.T) {
})
}
func TestSceneStore_FindDuplicatesWithFilter(t *testing.T) {
qb := db.Scene
// Helper to create a scene with a specific phash and optional title prefix
createDupeScene := func(ctx context.Context, name string, phash int64) (*models.Scene, error) {
sceneFile := &models.VideoFile{
BaseFile: &models.BaseFile{
Basename: name,
ParentFolderID: folderIDs[folderIdxWithSceneFiles],
Fingerprints: models.Fingerprints{
{Type: models.FingerprintTypeMD5, Fingerprint: name + "_md5"},
{Type: models.FingerprintTypeOshash, Fingerprint: name + "_oshash"},
{Type: models.FingerprintTypePhash, Fingerprint: phash},
},
},
Duration: 100.0,
Width: 1920,
Height: 1080,
}
if err := db.File.Create(ctx, sceneFile); err != nil {
return nil, err
}
scene := &models.Scene{
Title: name,
}
if err := qb.Create(ctx, scene, []models.FileID{sceneFile.ID}); err != nil {
return nil, err
}
return scene, nil
}
// Helper to add tags to a scene
addSceneTags := func(ctx context.Context, sceneID int, tagIDsToAdd []int) error {
_, err := qb.UpdatePartial(ctx, sceneID, models.ScenePartial{
TagIDs: &models.UpdateIDs{
Mode: models.RelationshipUpdateModeSet,
IDs: tagIDsToAdd,
},
})
return err
}
withRollbackTxn(func(ctx context.Context) error {
// Create a test tag to use for filtering
err := db.Tag.Create(ctx, &models.CreateTagInput{
Tag: &models.Tag{
Name: "FindDuplicatesFilterTestTag",
},
})
if err != nil {
t.Errorf("failed to create test tag: %v", err)
return nil
}
// fetch the tag we just created
tagName := "FindDuplicatesFilterTestTag"
tags, _, err := db.Tag.Query(ctx, &models.TagFilterType{
Name: &models.StringCriterionInput{
Value: tagName,
Modifier: models.CriterionModifierEquals,
},
}, &models.FindFilterType{
PerPage: intPtr(1),
})
if err != nil || len(tags) == 0 {
t.Errorf("failed to find test tag: %v", err)
return nil
}
testTagID := tags[0].ID
// Create two pairs of duplicate scenes:
// Pair A: sceneA1 and sceneA2 have the same phash and share a tag
// Pair B: sceneB1 and sceneB2 have the same phash but no tag
const sharedPhash int64 = 999999
sceneA1, err := createDupeScene(ctx, "FilterTest_A1", sharedPhash)
if err != nil {
t.Errorf("failed to create sceneA1: %v", err)
return nil
}
sceneA2, err := createDupeScene(ctx, "FilterTest_A2", sharedPhash)
if err != nil {
t.Errorf("failed to create sceneA2: %v", err)
return nil
}
const otherPhash int64 = 888888
sceneB1, err := createDupeScene(ctx, "FilterTest_B1", otherPhash)
if err != nil {
t.Errorf("failed to create sceneB1: %v", err)
return nil
}
sceneB2, err := createDupeScene(ctx, "FilterTest_B2", otherPhash)
if err != nil {
t.Errorf("failed to create sceneB2: %v", err)
return nil
}
// Add tag only to pair A
if err := addSceneTags(ctx, sceneA1.ID, []int{testTagID}); err != nil {
t.Errorf("failed to add tag to sceneA1: %v", err)
return nil
}
if err := addSceneTags(ctx, sceneA2.ID, []int{testTagID}); err != nil {
t.Errorf("failed to add tag to sceneA2: %v", err)
return nil
}
// Test 1: No filter - should find all duplicates (2 pairs: original + our new ones)
distance := 0
durationDiff := -1.0
got, err := qb.FindDuplicates(ctx, distance, durationDiff, nil)
if err != nil {
t.Errorf("FindDuplicates(nil filter) error = %v", err)
return nil
}
// Should find at least our 2 new pairs (may find more from pre-populated data)
assert.GreaterOrEqual(t, len(got), 2, "nil filter should find at least our 2 new duplicate pairs")
// Test 2: Filter by tag - should only find pair A (the tagged pair)
got, err = qb.FindDuplicates(ctx, distance, durationDiff, &models.SceneFilterType{
Tags: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(testTagID)},
Modifier: models.CriterionModifierIncludes,
},
})
if err != nil {
t.Errorf("FindDuplicates(tag filter) error = %v", err)
return nil
}
// Should find exactly 1 duplicate pair (pair A)
assert.Len(t, got, 1, "tag filter should find exactly 1 duplicate pair")
// Verify the found pair contains our tagged scenes
if len(got) == 1 {
foundIDs := map[int]bool{}
for _, s := range got[0] {
foundIDs[s.ID] = true
}
assert.True(t, foundIDs[sceneA1.ID], "pair A scene 1 should be in results")
assert.True(t, foundIDs[sceneA2.ID], "pair A scene 2 should be in results")
// Pair B (untagged) should NOT be in the results
assert.False(t, foundIDs[sceneB1.ID], "pair B scene 1 should NOT be in tag-filtered results")
assert.False(t, foundIDs[sceneB2.ID], "pair B scene 2 should NOT be in tag-filtered results")
}
// Test 3: Filter by tag that no duplicate scene has - should find nothing
err = db.Tag.Create(ctx, &models.CreateTagInput{
Tag: &models.Tag{
Name: "FindDuplicatesFilterTestTag_NonExistent",
},
})
if err != nil {
t.Errorf("failed to create non-existent tag: %v", err)
return nil
}
nonExistentTagName := "FindDuplicatesFilterTestTag_NonExistent"
tags2, _, err := db.Tag.Query(ctx, &models.TagFilterType{
Name: &models.StringCriterionInput{
Value: nonExistentTagName,
Modifier: models.CriterionModifierEquals,
},
}, &models.FindFilterType{
PerPage: intPtr(1),
})
if err != nil || len(tags2) == 0 {
t.Errorf("failed to find non-existent tag: %v", err)
return nil
}
nonExistentTagID := tags2[0].ID
got, err = qb.FindDuplicates(ctx, distance, durationDiff, &models.SceneFilterType{
Tags: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(nonExistentTagID)},
Modifier: models.CriterionModifierIncludes,
},
})
if err != nil {
t.Errorf("FindDuplicates(non-existent tag filter) error = %v", err)
return nil
}
assert.Len(t, got, 0, "non-existent tag filter should find no duplicates")
// Test 4: Fuzzy match (distance=1) with filter
distance = 1
got, err = qb.FindDuplicates(ctx, distance, durationDiff, &models.SceneFilterType{
Tags: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(testTagID)},
Modifier: models.CriterionModifierIncludes,
},
})
if err != nil {
t.Errorf("FindDuplicates(fuzzy + tag filter) error = %v", err)
return nil
}
// Should still find pair A with fuzzy matching
assert.Len(t, got, 1, "fuzzy + tag filter should find exactly 1 duplicate pair")
return nil
})
}
func TestSceneStore_AssignFiles(t *testing.T) {
tests := []struct {
name string
@@ -5063,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

@@ -84,6 +84,8 @@ const (
sceneIdxMissingPhash
sceneIdxWithPerformerParentTag
sceneIdxWithGroupWithParent
sceneIdxWithChildTag
sceneIdxWithGrandChildTag
// new indexes above
lastSceneIdx
@@ -115,6 +117,7 @@ const (
imageIdxWithPerformerTwoTags
imageIdxWithGrandChildStudio
imageIdxWithPerformerParentTag
imageIdxWithGrandChildTag
// new indexes above
totalImages
)
@@ -167,6 +170,7 @@ const (
groupIdxWithGrandParent
groupIdxWithParentAndScene
groupIdxWithChildWithScene
groupIdxWithGrandChildTag
// groups with dup names start from the end
groupIdxWithDupName
@@ -199,6 +203,7 @@ const (
galleryIdxWithGrandChildStudio
galleryIdxWithoutFile
galleryIdxWithPerformerParentTag
galleryIdxWithGrandChildTag
// new indexes above
lastGalleryIdx
@@ -378,6 +383,8 @@ var (
sceneIdxWithThreeTags: {tagIdx1WithScene, tagIdx2WithScene, tagIdx3WithScene},
sceneIdxWithMarkerAndTag: {tagIdx3WithScene},
sceneIdxWithMarkerTwoTags: {tagIdx2WithScene, tagIdx3WithScene},
sceneIdxWithChildTag: {tagIdxWithParentTag},
sceneIdxWithGrandChildTag: {tagIdxWithGrandParent},
}
scenePerformers = linkMap{
@@ -430,6 +437,7 @@ var (
{sceneIdxWithMarkers, tagIdxWithPrimaryMarkers, []int{tagIdxWithMarkers, tagIdx2WithMarkers}},
{sceneIdxWithMarkerAndTag, tagIdxWithPrimaryMarkers, nil},
{sceneIdxWithMarkerTwoTags, tagIdxWithPrimaryMarkers, nil},
{sceneIdxWithGrandChildTag, tagIdxWithGrandParent, nil},
}
)
@@ -461,9 +469,10 @@ var (
imageIdxWithGrandChildStudio: studioIdxWithGrandParent,
}
imageTags = linkMap{
imageIdxWithTag: {tagIdxWithImage},
imageIdxWithTwoTags: {tagIdx1WithImage, tagIdx2WithImage},
imageIdxWithThreeTags: {tagIdx1WithImage, tagIdx2WithImage, tagIdx3WithImage},
imageIdxWithTag: {tagIdxWithImage},
imageIdxWithTwoTags: {tagIdx1WithImage, tagIdx2WithImage},
imageIdxWithThreeTags: {tagIdx1WithImage, tagIdx2WithImage, tagIdx3WithImage},
imageIdxWithGrandChildTag: {tagIdxWithGrandParent},
}
imagePerformers = linkMap{
imageIdxWithPerformer: {performerIdxWithImage},
@@ -502,9 +511,10 @@ var (
}
galleryTags = linkMap{
galleryIdxWithTag: {tagIdxWithGallery},
galleryIdxWithTwoTags: {tagIdx1WithGallery, tagIdx2WithGallery},
galleryIdxWithThreeTags: {tagIdx1WithGallery, tagIdx2WithGallery, tagIdx3WithGallery},
galleryIdxWithTag: {tagIdxWithGallery},
galleryIdxWithTwoTags: {tagIdx1WithGallery, tagIdx2WithGallery},
galleryIdxWithThreeTags: {tagIdx1WithGallery, tagIdx2WithGallery, tagIdx3WithGallery},
galleryIdxWithGrandChildTag: {tagIdxWithGrandParent},
}
)
@@ -514,9 +524,10 @@ var (
}
groupTags = linkMap{
groupIdxWithTag: {tagIdxWithGroup},
groupIdxWithTwoTags: {tagIdx1WithGroup, tagIdx2WithGroup},
groupIdxWithThreeTags: {tagIdx1WithGroup, tagIdx2WithGroup, tagIdx3WithGroup},
groupIdxWithTag: {tagIdxWithGroup},
groupIdxWithTwoTags: {tagIdx1WithGroup, tagIdx2WithGroup},
groupIdxWithThreeTags: {tagIdx1WithGroup, tagIdx2WithGroup, tagIdx3WithGroup},
groupIdxWithGrandChildTag: {tagIdxWithGrandParent},
}
)
@@ -588,6 +599,10 @@ func indexesToIDPtrs[T any](ids []T, indexes []int) []*T {
return ret
}
func intPtr(i int) *int {
return &i
}
func indexToIDPtr[T any](ids []T, idx int) *T {
if idx < 0 {
return nil
@@ -605,6 +620,10 @@ func indexFromID(ids []int, id int) int {
return -1
}
func ptr[T any](v T) *T {
return &v
}
var db *sqlite.Database
func TestMain(m *testing.M) {

View File

@@ -88,6 +88,7 @@ func getSortDirection(direction string) string {
return direction
}
}
func getSort(sort string, direction string, tableName string) string {
direction = getSortDirection(direction)

View File

@@ -2,6 +2,7 @@ package sqlite
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
)
@@ -217,80 +218,112 @@ func (qb *tagFilterHandler) isMissingCriterionHandler(isMissing *string) criteri
}
}
func (qb *tagFilterHandler) sceneCountCriterionHandler(sceneCount *models.IntCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if sceneCount != nil {
f.addLeftJoin("scenes_tags", "", "scenes_tags.tag_id = tags.id")
clause, args := getIntCriterionWhereClause("count(distinct scenes_tags.scene_id)", *sceneCount)
f.addHaving(clause, args...)
}
// addHierarchicalCountCTE adds a recursive CTE to walk the tag hierarchy.
// depth < 0 includes all descendant levels (unlimited recursion).
// depth >= 0 limits the recursion to that many levels from the root tag.
func (qb *tagFilterHandler) addHierarchicalCountCTE(f *filterBuilder, cteAlias string, depth int) {
if depth < 0 {
// unlimited recursion — no depth tracking needed
f.addRecursiveWith(fmt.Sprintf(
`%[1]s(root_id, descendant_id) AS (
SELECT id, id FROM tags
UNION ALL
SELECT td.root_id, tr.child_id
FROM tags_relations tr
INNER JOIN %[1]s td ON td.descendant_id = tr.parent_id
)`, cteAlias))
} else {
// depth-limited: track recursion level as a CTE column
f.addRecursiveWith(fmt.Sprintf(
`%[1]s(root_id, descendant_id, depth) AS (
SELECT id, id, 0 FROM tags
UNION ALL
SELECT td.root_id, tr.child_id, td.depth + 1
FROM tags_relations tr
INNER JOIN %[1]s td ON td.descendant_id = tr.parent_id
WHERE td.depth < %[2]d
)`, cteAlias, depth))
}
}
func (qb *tagFilterHandler) imageCountCriterionHandler(imageCount *models.IntCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if imageCount != nil {
f.addLeftJoin("images_tags", "", "images_tags.tag_id = tags.id")
clause, args := getIntCriterionWhereClause("count(distinct images_tags.image_id)", *imageCount)
func (qb *tagFilterHandler) hierarchicalCountHandler(input *models.HierarchicalCountInput, joinTable, entityIDCol string) criterionHandlerFunc {
return qb.hierarchicalCountHandlerOnCol(input, joinTable, "tag_id", entityIDCol)
}
f.addHaving(clause, args...)
func (qb *tagFilterHandler) hierarchicalCountHandlerOnCol(input *models.HierarchicalCountInput, joinTable, tagCol, entityIDCol string) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if input == nil {
return
}
intInput := models.IntCriterionInput{
Value: input.Value,
Value2: input.Value2,
Modifier: input.Modifier,
}
if input.Depth != nil {
cteAlias := joinTable + "_desc"
qb.addHierarchicalCountCTE(f, cteAlias, *input.Depth)
f.addLeftJoin(cteAlias, "", fmt.Sprintf("%s.root_id = tags.id", cteAlias))
f.addLeftJoin(joinTable, "", fmt.Sprintf("%[1]s.%[2]s = %[3]s.descendant_id", joinTable, tagCol, cteAlias))
} else {
f.addLeftJoin(joinTable, "", fmt.Sprintf("%s.%s = tags.id", joinTable, tagCol))
}
clause, args := getIntCriterionWhereClause(fmt.Sprintf("count(distinct %s.%s)", joinTable, entityIDCol), intInput)
f.addHaving(clause, args...)
}
}
func (qb *tagFilterHandler) galleryCountCriterionHandler(galleryCount *models.IntCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if galleryCount != nil {
f.addLeftJoin("galleries_tags", "", "galleries_tags.tag_id = tags.id")
clause, args := getIntCriterionWhereClause("count(distinct galleries_tags.gallery_id)", *galleryCount)
f.addHaving(clause, args...)
}
}
func (qb *tagFilterHandler) sceneCountCriterionHandler(sceneCount *models.HierarchicalCountInput) criterionHandlerFunc {
return qb.hierarchicalCountHandler(sceneCount, "scenes_tags", "scene_id")
}
func (qb *tagFilterHandler) performerCountCriterionHandler(performerCount *models.IntCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if performerCount != nil {
f.addLeftJoin("performers_tags", "", "performers_tags.tag_id = tags.id")
clause, args := getIntCriterionWhereClause("count(distinct performers_tags.performer_id)", *performerCount)
f.addHaving(clause, args...)
}
}
func (qb *tagFilterHandler) imageCountCriterionHandler(imageCount *models.HierarchicalCountInput) criterionHandlerFunc {
return qb.hierarchicalCountHandler(imageCount, "images_tags", "image_id")
}
func (qb *tagFilterHandler) studioCountCriterionHandler(studioCount *models.IntCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if studioCount != nil {
f.addLeftJoin("studios_tags", "", "studios_tags.tag_id = tags.id")
clause, args := getIntCriterionWhereClause("count(distinct studios_tags.studio_id)", *studioCount)
f.addHaving(clause, args...)
}
}
func (qb *tagFilterHandler) galleryCountCriterionHandler(galleryCount *models.HierarchicalCountInput) criterionHandlerFunc {
return qb.hierarchicalCountHandler(galleryCount, "galleries_tags", "gallery_id")
}
func (qb *tagFilterHandler) groupCountCriterionHandler(groupCount *models.IntCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if groupCount != nil {
f.addLeftJoin("groups_tags", "", "groups_tags.tag_id = tags.id")
clause, args := getIntCriterionWhereClause("count(distinct groups_tags.group_id)", *groupCount)
f.addHaving(clause, args...)
}
}
func (qb *tagFilterHandler) performerCountCriterionHandler(performerCount *models.HierarchicalCountInput) criterionHandlerFunc {
return qb.hierarchicalCountHandler(performerCount, "performers_tags", "performer_id")
}
func (qb *tagFilterHandler) markerCountCriterionHandler(markerCount *models.IntCriterionInput) criterionHandlerFunc {
func (qb *tagFilterHandler) studioCountCriterionHandler(studioCount *models.HierarchicalCountInput) criterionHandlerFunc {
return qb.hierarchicalCountHandler(studioCount, "studios_tags", "studio_id")
}
func (qb *tagFilterHandler) groupCountCriterionHandler(groupCount *models.HierarchicalCountInput) criterionHandlerFunc {
return qb.hierarchicalCountHandler(groupCount, "groups_tags", "group_id")
}
func (qb *tagFilterHandler) markerCountCriterionHandler(markerCount *models.HierarchicalCountInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if markerCount != nil {
if markerCount == nil {
return
}
intInput := models.IntCriterionInput{
Value: markerCount.Value,
Value2: markerCount.Value2,
Modifier: markerCount.Modifier,
}
if markerCount.Depth != nil {
cteAlias := "scene_markers_desc"
qb.addHierarchicalCountCTE(f, cteAlias, *markerCount.Depth)
f.addLeftJoin(cteAlias, "", fmt.Sprintf("%s.root_id = tags.id", cteAlias))
f.addLeftJoin("scene_markers_tags", "", fmt.Sprintf("scene_markers_tags.tag_id = %s.descendant_id", cteAlias))
f.addLeftJoin("scene_markers", "", "scene_markers_tags.scene_marker_id = scene_markers.id OR scene_markers.primary_tag_id = "+cteAlias+".descendant_id")
} else {
f.addLeftJoin("scene_markers_tags", "", "scene_markers_tags.tag_id = tags.id")
f.addLeftJoin("scene_markers", "", "scene_markers_tags.scene_marker_id = scene_markers.id OR scene_markers.primary_tag_id = tags.id")
clause, args := getIntCriterionWhereClause("count(distinct scene_markers.id)", *markerCount)
f.addHaving(clause, args...)
}
clause, args := getIntCriterionWhereClause("count(distinct scene_markers.id)", intInput)
f.addHaving(clause, args...)
}
}

View File

@@ -569,318 +569,206 @@ func TestTagQueryIsMissingImage(t *testing.T) {
})
}
func TestTagQuerySceneCount(t *testing.T) {
countCriterion := models.IntCriterionInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
func TestTagQueryCounts(t *testing.T) {
tests := []struct {
name string
buildFilter models.TagFilterType
includeIdxs []int
excludeIdxs []int
}{
{
name: "scene_count_equals_1",
buildFilter: models.TagFilterType{SceneCount: &models.HierarchicalCountInput{Value: 1, Modifier: models.CriterionModifierEquals}},
includeIdxs: []int{tagIdxWithScene},
excludeIdxs: []int{tagIdx1WithNothing, tagIdx1WithScene, tagIdx2WithScene, tagIdx3WithScene},
},
{
name: "scene_count_equals_1_depth_1",
buildFilter: models.TagFilterType{SceneCount: &models.HierarchicalCountInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
Depth: ptr(1),
}},
includeIdxs: []int{tagIdxWithScene, tagIdxWithParentTag, tagIdxWithChildTag, tagIdxWithParentAndChild, tagIdxWithGrandParent},
excludeIdxs: []int{tagIdx1WithNothing, tagIdx1WithScene, tagIdx2WithScene, tagIdx3WithScene},
},
{
name: "marker_count_equals_2",
buildFilter: models.TagFilterType{MarkerCount: &models.HierarchicalCountInput{Value: 2, Modifier: models.CriterionModifierEquals}},
includeIdxs: []int{tagIdxWithMarkers, tagIdx2WithMarkers},
excludeIdxs: []int{tagIdxWithPrimaryMarkers, tagIdx1WithNothing, tagIdx2WithNothing},
},
{
name: "image_count_equals_1",
buildFilter: models.TagFilterType{ImageCount: &models.HierarchicalCountInput{Value: 1, Modifier: models.CriterionModifierEquals}},
includeIdxs: []int{tagIdxWithImage, tagIdx3WithImage},
excludeIdxs: []int{tagIdx1WithImage, tagIdx2WithImage, tagIdxWithCoverImage, tagIdx1WithNothing, tagIdx2WithNothing},
},
{
name: "gallery_count_equals_1",
buildFilter: models.TagFilterType{GalleryCount: &models.HierarchicalCountInput{Value: 1, Modifier: models.CriterionModifierEquals}},
includeIdxs: []int{tagIdxWithGallery, tagIdx3WithGallery},
excludeIdxs: []int{tagIdx1WithGallery, tagIdx2WithGallery, tagIdx1WithNothing, tagIdx2WithNothing},
},
{
name: "performer_count_equals_1",
buildFilter: models.TagFilterType{PerformerCount: &models.HierarchicalCountInput{Value: 1, Modifier: models.CriterionModifierEquals}},
includeIdxs: []int{tagIdxWithPerformer, tagIdx1WithPerformer, tagIdxWithParentAndChild},
excludeIdxs: []int{tagIdx2WithPerformer, tagIdx1WithNothing, tagIdx2WithNothing},
},
{
name: "studio_count_equals_1",
buildFilter: models.TagFilterType{StudioCount: &models.HierarchicalCountInput{Value: 1, Modifier: models.CriterionModifierEquals}},
includeIdxs: []int{tagIdxWithStudio, tagIdx1WithStudio},
excludeIdxs: []int{tagIdx2WithStudio, tagIdx1WithNothing, tagIdx2WithNothing},
},
{
name: "parent_count_equals_1",
buildFilter: models.TagFilterType{ParentCount: &models.IntCriterionInput{Value: 1, Modifier: models.CriterionModifierEquals}},
includeIdxs: []int{tagIdxWithParentTag, tagIdxWithGrandParent, tagIdxWithParentAndChild},
excludeIdxs: []int{tagIdx1WithNothing, tagIdx2WithNothing},
},
{
name: "child_count_equals_1",
buildFilter: models.TagFilterType{ChildCount: &models.IntCriterionInput{Value: 1, Modifier: models.CriterionModifierEquals}},
includeIdxs: []int{tagIdxWithChildTag, tagIdxWithGrandChild, tagIdxWithParentAndChild},
excludeIdxs: []int{tagIdx1WithNothing, tagIdx2WithNothing},
},
{
name: "scene_count_equals_1_depth_-1",
buildFilter: models.TagFilterType{SceneCount: &models.HierarchicalCountInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
Depth: ptr(-1),
}},
includeIdxs: []int{tagIdxWithScene, tagIdxWithChildTag, tagIdxWithParentTag, tagIdxWithGrandChild, tagIdxWithParentAndChild, tagIdxWithGrandParent},
excludeIdxs: []int{tagIdx1WithScene, tagIdx2WithScene, tagIdx3WithScene},
},
{
name: "image_count_equals_1_depth_1",
buildFilter: models.TagFilterType{ImageCount: &models.HierarchicalCountInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
Depth: ptr(1),
}},
includeIdxs: []int{tagIdxWithImage, tagIdx3WithImage, tagIdxWithParentAndChild, tagIdxWithGrandParent},
excludeIdxs: []int{tagIdx1WithImage, tagIdx2WithImage},
},
{
name: "image_count_equals_1_depth_-1",
buildFilter: models.TagFilterType{ImageCount: &models.HierarchicalCountInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
Depth: ptr(-1),
}},
includeIdxs: []int{tagIdxWithImage, tagIdx3WithImage, tagIdxWithGrandChild, tagIdxWithParentAndChild, tagIdxWithGrandParent},
excludeIdxs: []int{tagIdx1WithImage, tagIdx2WithImage},
},
{
name: "gallery_count_equals_1_depth_1",
buildFilter: models.TagFilterType{GalleryCount: &models.HierarchicalCountInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
Depth: ptr(1),
}},
includeIdxs: []int{tagIdxWithGallery, tagIdx3WithGallery, tagIdxWithParentAndChild, tagIdxWithGrandParent},
excludeIdxs: []int{tagIdx1WithGallery, tagIdx2WithGallery},
},
{
name: "gallery_count_equals_1_depth_-1",
buildFilter: models.TagFilterType{GalleryCount: &models.HierarchicalCountInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
Depth: ptr(-1),
}},
includeIdxs: []int{tagIdxWithGallery, tagIdx3WithGallery, tagIdxWithGrandChild, tagIdxWithParentAndChild, tagIdxWithGrandParent},
excludeIdxs: []int{tagIdx1WithGallery, tagIdx2WithGallery},
},
{
name: "performer_count_equals_1_depth_1",
buildFilter: models.TagFilterType{PerformerCount: &models.HierarchicalCountInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
Depth: ptr(1),
}},
includeIdxs: []int{tagIdxWithPerformer, tagIdx1WithPerformer, tagIdxWithGrandChild, tagIdxWithParentAndChild},
excludeIdxs: []int{tagIdx2WithPerformer},
},
{
name: "performer_count_equals_1_depth_-1",
buildFilter: models.TagFilterType{PerformerCount: &models.HierarchicalCountInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
Depth: ptr(-1),
}},
includeIdxs: []int{tagIdxWithPerformer, tagIdx1WithPerformer, tagIdxWithGrandChild, tagIdxWithParentAndChild},
excludeIdxs: []int{tagIdx2WithPerformer},
},
{
name: "marker_count_equals_1_depth_1",
buildFilter: models.TagFilterType{MarkerCount: &models.HierarchicalCountInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
Depth: ptr(1),
}},
includeIdxs: []int{tagIdxWithParentAndChild, tagIdxWithGrandParent},
excludeIdxs: []int{tagIdxWithPrimaryMarkers, tagIdxWithMarkers, tagIdx2WithMarkers},
},
{
name: "marker_count_equals_1_depth_-1",
buildFilter: models.TagFilterType{MarkerCount: &models.HierarchicalCountInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
Depth: ptr(-1),
}},
includeIdxs: []int{tagIdxWithGrandChild, tagIdxWithParentAndChild, tagIdxWithGrandParent},
excludeIdxs: []int{tagIdxWithPrimaryMarkers, tagIdxWithMarkers, tagIdx2WithMarkers},
},
{
name: "group_count_equals_1_depth_1",
buildFilter: models.TagFilterType{GroupCount: &models.HierarchicalCountInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
Depth: ptr(1),
}},
includeIdxs: []int{tagIdxWithGroup, tagIdx3WithGroup, tagIdxWithParentAndChild, tagIdxWithGrandParent},
excludeIdxs: []int{tagIdx1WithGroup, tagIdx2WithGroup},
},
{
name: "group_count_equals_1_depth_-1",
buildFilter: models.TagFilterType{GroupCount: &models.HierarchicalCountInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
Depth: ptr(-1),
}},
includeIdxs: []int{tagIdxWithGroup, tagIdx3WithGroup, tagIdxWithGrandChild, tagIdxWithParentAndChild, tagIdxWithGrandParent},
excludeIdxs: []int{tagIdx1WithGroup, tagIdx2WithGroup},
},
}
verifyTagSceneCount(t, countCriterion)
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
qb := db.Tag
tagFilter := &tt.buildFilter
countCriterion.Modifier = models.CriterionModifierNotEquals
verifyTagSceneCount(t, countCriterion)
tags, _, err := qb.Query(ctx, tagFilter, nil)
if err != nil {
t.Fatalf("%s: Error querying tag: %v", tt.name, err)
}
countCriterion.Modifier = models.CriterionModifierLessThan
verifyTagSceneCount(t, countCriterion)
ids := tagsToIDs(tags)
include := indexesToIDs(tagIDs, tt.includeIdxs)
exclude := indexesToIDs(tagIDs, tt.excludeIdxs)
countCriterion.Value = 0
countCriterion.Modifier = models.CriterionModifierGreaterThan
verifyTagSceneCount(t, countCriterion)
}
for _, id := range include {
assert.Contains(t, ids, id, "%s: expected id %d to be included", tt.name, id)
}
func verifyTagSceneCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
qb := db.Tag
tagFilter := models.TagFilterType{
SceneCount: &sceneCountCriterion,
}
tags, _, err := qb.Query(ctx, &tagFilter, nil)
if err != nil {
t.Errorf("Error querying tag: %s", err.Error())
}
for _, tag := range tags {
verifyInt(t, getTagSceneCount(tag.ID), sceneCountCriterion)
}
return nil
})
}
func TestTagQueryMarkerCount(t *testing.T) {
countCriterion := models.IntCriterionInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
for _, id := range exclude {
assert.NotContains(t, ids, id, "%s: expected id %d to be excluded", tt.name, id)
}
})
}
verifyTagMarkerCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierNotEquals
verifyTagMarkerCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierLessThan
verifyTagMarkerCount(t, countCriterion)
countCriterion.Value = 0
countCriterion.Modifier = models.CriterionModifierGreaterThan
verifyTagMarkerCount(t, countCriterion)
}
func verifyTagMarkerCount(t *testing.T, markerCountCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
qb := db.Tag
tagFilter := models.TagFilterType{
MarkerCount: &markerCountCriterion,
}
tags, _, err := qb.Query(ctx, &tagFilter, nil)
if err != nil {
t.Errorf("Error querying tag: %s", err.Error())
}
for _, tag := range tags {
verifyInt(t, getTagMarkerCount(tag.ID), markerCountCriterion)
}
return nil
})
}
func TestTagQueryImageCount(t *testing.T) {
countCriterion := models.IntCriterionInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
}
verifyTagImageCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierNotEquals
verifyTagImageCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierLessThan
verifyTagImageCount(t, countCriterion)
countCriterion.Value = 0
countCriterion.Modifier = models.CriterionModifierGreaterThan
verifyTagImageCount(t, countCriterion)
}
func verifyTagImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
qb := db.Tag
tagFilter := models.TagFilterType{
ImageCount: &imageCountCriterion,
}
tags, _, err := qb.Query(ctx, &tagFilter, nil)
if err != nil {
t.Errorf("Error querying tag: %s", err.Error())
}
for _, tag := range tags {
verifyInt(t, getTagImageCount(tag.ID), imageCountCriterion)
}
return nil
})
}
func TestTagQueryGalleryCount(t *testing.T) {
countCriterion := models.IntCriterionInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
}
verifyTagGalleryCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierNotEquals
verifyTagGalleryCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierLessThan
verifyTagGalleryCount(t, countCriterion)
countCriterion.Value = 0
countCriterion.Modifier = models.CriterionModifierGreaterThan
verifyTagGalleryCount(t, countCriterion)
}
func verifyTagGalleryCount(t *testing.T, imageCountCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
qb := db.Tag
tagFilter := models.TagFilterType{
GalleryCount: &imageCountCriterion,
}
tags, _, err := qb.Query(ctx, &tagFilter, nil)
if err != nil {
t.Errorf("Error querying tag: %s", err.Error())
}
for _, tag := range tags {
verifyInt(t, getTagGalleryCount(tag.ID), imageCountCriterion)
}
return nil
})
}
func TestTagQueryPerformerCount(t *testing.T) {
countCriterion := models.IntCriterionInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
}
verifyTagPerformerCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierNotEquals
verifyTagPerformerCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierLessThan
verifyTagPerformerCount(t, countCriterion)
countCriterion.Value = 0
countCriterion.Modifier = models.CriterionModifierGreaterThan
verifyTagPerformerCount(t, countCriterion)
}
func verifyTagPerformerCount(t *testing.T, imageCountCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
qb := db.Tag
tagFilter := models.TagFilterType{
PerformerCount: &imageCountCriterion,
}
tags, _, err := qb.Query(ctx, &tagFilter, nil)
if err != nil {
t.Errorf("Error querying tag: %s", err.Error())
}
for _, tag := range tags {
verifyInt(t, getTagPerformerCount(tag.ID), imageCountCriterion)
}
return nil
})
}
func TestTagQueryStudioCount(t *testing.T) {
countCriterion := models.IntCriterionInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
}
verifyTagStudioCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierNotEquals
verifyTagStudioCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierLessThan
verifyTagStudioCount(t, countCriterion)
countCriterion.Value = 0
countCriterion.Modifier = models.CriterionModifierGreaterThan
verifyTagStudioCount(t, countCriterion)
}
func verifyTagStudioCount(t *testing.T, imageCountCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
qb := db.Tag
tagFilter := models.TagFilterType{
StudioCount: &imageCountCriterion,
}
tags, _, err := qb.Query(ctx, &tagFilter, nil)
if err != nil {
t.Errorf("Error querying tag: %s", err.Error())
}
for _, tag := range tags {
verifyInt(t, getTagStudioCount(tag.ID), imageCountCriterion)
}
return nil
})
}
func TestTagQueryParentCount(t *testing.T) {
countCriterion := models.IntCriterionInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
}
verifyTagParentCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierNotEquals
verifyTagParentCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierLessThan
verifyTagParentCount(t, countCriterion)
countCriterion.Value = 0
countCriterion.Modifier = models.CriterionModifierGreaterThan
verifyTagParentCount(t, countCriterion)
}
func verifyTagParentCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
qb := db.Tag
tagFilter := models.TagFilterType{
ParentCount: &sceneCountCriterion,
}
tags := queryTags(ctx, t, qb, &tagFilter, nil)
if len(tags) == 0 {
t.Error("Expected at least one tag")
}
for _, tag := range tags {
verifyInt(t, getTagParentCount(tag.ID), sceneCountCriterion)
}
return nil
})
}
func TestTagQueryChildCount(t *testing.T) {
countCriterion := models.IntCriterionInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
}
verifyTagChildCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierNotEquals
verifyTagChildCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierLessThan
verifyTagChildCount(t, countCriterion)
countCriterion.Value = 0
countCriterion.Modifier = models.CriterionModifierGreaterThan
verifyTagChildCount(t, countCriterion)
}
func verifyTagChildCount(t *testing.T, sceneCountCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
qb := db.Tag
tagFilter := models.TagFilterType{
ChildCount: &sceneCountCriterion,
}
tags := queryTags(ctx, t, qb, &tagFilter, nil)
if len(tags) == 0 {
t.Error("Expected at least one tag")
}
for _, tag := range tags {
verifyInt(t, getTagChildCount(tag.ID), sceneCountCriterion)
}
return nil
})
}
func TestTagQueryParent(t *testing.T) {

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

@@ -19,9 +19,6 @@
},
"formatter": {
"enabled": true,
// included so that it doesn't complain about no matching files
// remove this to enable formatting
"includes": ["index.tsx"],
"indentStyle": "space"
},
"linter": {
@@ -32,67 +29,25 @@
},
"rules": {
"a11y": "off",
"complexity": {
"noUselessFragments": "off",
// remove the following after fixing
"useArrowFunction": "off",
"useOptionalChain": "off",
"noBannedTypes": "off",
"useIndexOf": "off",
"useFlatMap": "off",
"noUselessUndefinedInitialization": "off",
"noExtraBooleanCast": "off",
"noUselessRename": "off",
"noArguments": "off",
"noUselessEscapeInRegex": "off",
"useDateNow": "off",
"noUselessSwitchCase": "off"
},
"style": {
"noNestedTernary": "off",
// used where the type is known to be non-null, but the compiler can't figure it out
"noNonNullAssertion": "off",
"useBlockStatements": "off",
// preferable, but not something we want to enforce right now
"useTemplate": "off",
// remove the following after fixing
"useImportType": "off",
"useConst": "off",
"useShorthandFunctionType": "off"
},
"correctness": {
"useJsxKeyInIterable": "off",
// remove the following after fixing
"useExhaustiveDependencies": "off",
"noInnerDeclarations": "off",
"noSwitchDeclarations": "off",
"noUnusedPrivateClassMembers": "off",
"noUnusedFunctionParameters": "off",
"useParseIntRadix": "off",
"noUnusedVariables": "off",
"noEmptyPattern": "off"
"useImportType": "off"
},
"suspicious": {
"noArrayIndexKey": "off",
// remove the following after fixing
"noExplicitAny": "off",
"noImplicitAnyLet": "off",
"noDoubleEquals": "off",
"noTemplateCurlyInString": "off",
"useIterableCallbackReturn": "off",
"noGlobalIsNan": "off",
"noNonNullAssertedOptionalChain": "off",
"noShadowRestrictedNames": "off",
"noConfusingVoidType": "off",
"noTsIgnore": "off"
},
"performance": {
// remove the following after fixing
"noAccumulatingSpread": "off"
// this is used extensively, and there's no obvious fix
"noArrayIndexKey": "off"
}
}
},
"javascript": {
"formatter": {
"quoteStyle": "double"
"quoteStyle": "double",
// es5 requires the least amount of changes and (imo) looks better than "all"
"trailingCommas": "es5"
}
},
"assist": {

View File

@@ -14,7 +14,7 @@ import { ToastProvider } from "src/hooks/Toast";
import { LightboxProvider } from "src/hooks/Lightbox/context";
import { initPolyfills } from "src/polyfills";
import locales, { registerCountry } from "src/locales";
import locales, { NestedMessage, registerCountry } from "src/locales";
import {
useConfiguration,
useConfigureUI,
@@ -101,9 +101,9 @@ function languageMessageString(language: string) {
return language.replace(/-/, "");
}
const AppContainer: React.FC<React.PropsWithChildren<{}>> = PatchFunction(
const AppContainer: React.FC<React.PropsWithChildren<unknown>> = PatchFunction(
"App",
(props: React.PropsWithChildren<{}>) => {
(props: React.PropsWithChildren<unknown>) => {
return <>{props.children}</>;
}
) as React.FC;
@@ -147,8 +147,8 @@ export const App: React.FC = () => {
const intlLanguage = translateLanguageLocale(language);
// use en-GB as default messages if any messages aren't found in the chosen language
const [messages, setMessages] = useState<{}>();
const [customMessages, setCustomMessages] = useState<{}>();
const [messages, setMessages] = useState<Record<string, string>>();
const [customMessages, setCustomMessages] = useState<NestedMessage>();
useEffect(() => {
(async () => {
@@ -233,7 +233,7 @@ export const App: React.FC = () => {
// redirect to migrate page
history.replace("/migrate");
}
}, [systemStatusData, setupMatch, history, location]);
}, [systemStatusData, history, location.pathname]);
function maybeRenderNavbar() {
// don't render navbar for setup views

View File

@@ -48,12 +48,12 @@ const Changelog: React.FC = () => {
const stashVersion = import.meta.env.VITE_APP_STASH_VERSION;
const buildTime = import.meta.env.VITE_APP_DATE;
let buildDate;
let buildDate: string | undefined;
if (buildTime) {
buildDate = buildTime.substring(0, buildTime.indexOf(" "));
}
if (loading) return <></>;
if (loading) return null;
const openState = data?.versions ?? {};

View File

@@ -238,11 +238,9 @@ const FieldOptionsEditor: React.FC<IFieldOptionsEditor> = ({
</Button>
</>
) : (
<>
<Button className="minimal" onClick={() => editField()}>
<Icon icon={faPencilAlt} />
</Button>
</>
<Button className="minimal" onClick={() => editField()}>
<Icon icon={faPencilAlt} />
</Button>
)}
</td>
</tr>
@@ -312,7 +310,7 @@ export const FieldOptionsList: React.FC<IFieldOptionsList> = ({
}
if (!localFieldOptions) {
return <></>;
return null;
}
return (

View File

@@ -215,7 +215,7 @@ export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
ss.stash_box_endpoint === s.source.stash_box_endpoint
);
if (!found) return;
if (!found) return undefined;
const ret: IScraperSource = {
...found,
@@ -317,9 +317,9 @@ export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
// only include scrapers not already present
return !editingSource?.id === undefined
? []
: allSources?.filter((s) => {
: (allSources?.filter((s) => {
return !sources.some((ss) => ss.id === s.id);
}) ?? [];
}) ?? []);
}
function onEditSource(s?: IScraperSource) {

View File

@@ -42,6 +42,7 @@ export const SubmitStashBoxDraft: React.FC<IProps> = ({
boxes[selectedBoxIndex];
// #4354: reset state when shown, or if any props change
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally resetting when any prop changes
useEffect(() => {
if (show) {
setSelectedBoxIndex(0);

View File

@@ -94,7 +94,7 @@ const RecommendationRow: React.FC<IFilter> = ({ mode, filter, header }) => {
/>
);
default:
return <></>;
return null;
}
};
@@ -121,7 +121,7 @@ const SavedFilterResults: React.FC<ISavedFilterResults> = ({
}, [data?.findSavedFilter, config]);
if (loading || !data?.findSavedFilter || !filter) {
return <></>;
return null;
}
const { name, mode } = data.findSavedFilter;
@@ -155,7 +155,7 @@ const CustomFilterResults: React.FC<ICustomFilterProps> = ({
{ id: customFilter.message.id },
customFilter.message.values
)
: customFilter.title ?? "";
: (customFilter.title ?? "");
return (
<RecommendationRow
@@ -183,6 +183,6 @@ export const Control: React.FC<IProps> = ({ content }) => {
case "CustomFilter":
return <CustomFilterResults customFilter={content} />;
default:
return <></>;
return null;
}
};

View File

@@ -147,7 +147,7 @@ const AddContentModal: React.FC<IAddSavedFilterModalProps> = ({
<Form.Control
as="select"
value={premadeFilterIndex}
onChange={(e) => setPremadeFilterIndex(parseInt(e.target.value))}
onChange={(e) => setPremadeFilterIndex(parseInt(e.target.value, 10))}
className="btn-secondary"
>
{premadeFilterOptions.map((c, i) => (
@@ -191,7 +191,7 @@ const AddContentModal: React.FC<IAddSavedFilterModalProps> = ({
case "front_page.types.saved_filter":
onClose({
__typename: "SavedFilter",
savedFilterId: parseInt(savedFilter!),
savedFilterId: parseInt(savedFilter!, 10),
});
return;
}
@@ -234,14 +234,15 @@ const ContentRow: React.FC<IFilterRowProps> = (props: IFilterRowProps) => {
function title() {
switch (props.content.__typename) {
case "SavedFilter":
case "SavedFilter": {
const savedFilterId = String(props.content.savedFilterId);
const savedFilter = props.allSavedFilters.find(
(f) => f.id === savedFilterId
);
if (!savedFilter) return "";
return filterTitle(intl, savedFilter);
case "CustomFilter":
}
case "CustomFilter": {
const asCustomFilter = props.content as ICustomFilter;
if (asCustomFilter.message)
return intl.formatMessage(
@@ -249,6 +250,7 @@ const ContentRow: React.FC<IFilterRowProps> = (props: IFilterRowProps) => {
asCustomFilter.message.values
);
return asCustomFilter.title ?? "";
}
}
}
@@ -360,7 +362,7 @@ export const FrontPageConfig: React.FC<IFrontPageConfigProps> = ({
}
function deleteSavedFilter(index: number) {
setCurrentContent(currentContent.filter((f, i) => i !== index));
setCurrentContent(currentContent.filter((_f, i) => i !== index));
}
return (

View File

@@ -20,14 +20,14 @@ const GalleryImage: React.FC<RouteComponentProps<IGalleryImageParams>> = ({
}) => {
const { id, index: indexStr } = match.params;
let index = parseInt(indexStr);
if (isNaN(index)) {
let index = parseInt(indexStr, 10);
if (Number.isNaN(index)) {
index = 0;
}
const { data, loading, error } = useFindGalleryImageID(id, index);
if (isNaN(index)) {
if (Number.isNaN(index)) {
return <Redirect to={`/galleries/${id}`} />;
}

View File

@@ -63,8 +63,8 @@ export const GalleryAddPanel: React.FC<IGalleryAddProps> = PatchComponent(
);
async function addImages(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel,
_result: GQL.FindImagesQueryResult,
_filter: ListFilterModel,
selectedIds: Set<string>
) {
try {

View File

@@ -214,12 +214,12 @@ export const GalleryEditPanel: React.FC<IProps> = ({
}
async function onScrapeClicked(s: GQL.ScraperSourceInput) {
if (!gallery || !gallery.id) return;
if (!gallery?.id) return;
setIsLoading(true);
try {
const result = await queryScrapeGallery(s.scraper_id!, gallery.id);
if (!result.data || !result.data.scrapeSingleGallery?.length) {
if (!result.data?.scrapeSingleGallery?.length) {
Toast.success("No galleries found");
return;
}
@@ -342,7 +342,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
setIsLoading(true);
try {
const result = await queryScrapeGalleryURL(url);
if (!result || !result.data || !result.data.scrapeGalleryURL) {
if (!result.data?.scrapeGalleryURL) {
return;
}
setScrapedGallery(result.data.scrapeGalleryURL);
@@ -420,7 +420,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
const date = (() => {
try {
return schema.validateSyncAt("date", formik.values);
} catch (e) {
} catch (_e) {
return undefined;
}
})();

View File

@@ -24,7 +24,7 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
props: IFileInfoPanelProps
) => {
const checksum = props.file?.fingerprints.find((f) => f.type === "md5");
const path = props.folder ? props.folder.path : props.file?.path ?? "";
const path = props.folder ? props.folder.path : (props.file?.path ?? "");
const id = props.folder ? "folder" : "path";
return (
@@ -99,7 +99,7 @@ export const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = (
}
if (props.gallery.files.length === 0) {
return <></>;
return null;
}
if (props.gallery.files.length === 1) {

View File

@@ -71,8 +71,8 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> =
);
async function setCover(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel,
_result: GQL.FindImagesQueryResult,
_filter: ListFilterModel,
selectedIds: Set<string>
) {
const coverImageID = selectedIds.values().next();
@@ -102,8 +102,8 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> =
}
async function removeImages(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel,
_result: GQL.FindImagesQueryResult,
_filter: ListFilterModel,
selectedIds: Set<string>
) {
try {

View File

@@ -138,7 +138,7 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = ({
newTags.length === 0
) {
onClose();
return <></>;
return null;
}
function makeNewScrapedItem(): GQL.ScrapedGalleryDataFragment {

View File

@@ -95,7 +95,7 @@ const _GallerySelect: React.FC<
}
const query = await queryFindGalleriesForSelect(filter);
let ret = query.data.findGalleries.galleries.filter((gallery) => {
const ret = query.data.findGalleries.galleries.filter((gallery) => {
// HACK - we should probably exclude these in the backend query, but
// this will do in the short-term
return !exclude.includes(gallery.id.toString());
@@ -296,14 +296,14 @@ const _GalleryIDSelect: React.FC<
onSelectValues?.(items);
}
async function loadObjectsByID(idsToLoad: string[]): Promise<Gallery[]> {
const query = await queryFindGalleriesByIDForSelect(idsToLoad);
const { galleries: loadedGalleries } = query.data.findGalleries;
return loadedGalleries;
}
useEffect(() => {
async function loadObjectsByID(idsToLoad: string[]): Promise<Gallery[]> {
const query = await queryFindGalleriesByIDForSelect(idsToLoad);
const { galleries: loadedGalleries } = query.data.findGalleries;
return loadedGalleries;
}
if (!idsChanged) {
return;
}

View File

@@ -21,7 +21,7 @@ export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
per_page: pageSize,
sort: "path",
};
}, [pageSize]);
}, []);
const { data, loading } = useFindImagesQuery({
variables: {
@@ -46,7 +46,7 @@ export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
const showLightbox = useLightbox(lightboxState);
const showLightboxOnClick: PhotoClickHandler = useCallback(
(event, { index }) => {
(_event, { index }) => {
showLightbox({ initialIndex: index });
},
[showLightbox]
@@ -54,7 +54,7 @@ export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
if (loading) return <LoadingIndicator />;
let photos: {
const photos: {
src: string;
srcSet?: string | string[] | undefined;
sizes?: string | string[] | undefined;
@@ -65,7 +65,7 @@ export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
}[] = [];
images.forEach((image, index) => {
let imageData = {
const imageData = {
src: image.paths.thumbnail!,
width: image.visual_files[0]?.width ?? 0,
height: image.visual_files[0]?.height ?? 0,

View File

@@ -96,75 +96,71 @@ const GalleryWallCard: React.FC<IProps> = ({
let shiftKey = false;
return (
<>
<section
className={`${CLASSNAME} ${CLASSNAME}-${coverOrientation} wall-item`}
onClick={handleCardClick}
onKeyPress={() => showLightboxStart()}
role="button"
tabIndex={0}
{...dragProps}
>
{onSelectedChanged && (
<Form.Control
type="checkbox"
className="wall-item-check mousetrap"
checked={selected}
onChange={() => onSelectedChanged(!selected, shiftKey)}
onClick={(
event: React.MouseEvent<HTMLInputElement, MouseEvent>
) => {
shiftKey = event.shiftKey;
event.stopPropagation();
}}
/>
)}
<RatingSystem value={gallery.rating100} disabled withoutContext />
<img
loading="lazy"
src={imgSrc}
alt=""
className={cx(CLASSNAME_IMG, imgClassname)}
// set orientation based on cover only
onLoad={imgSrc === cover ? onCoverLoad : onNonCoverLoad}
<section
className={`${CLASSNAME} ${CLASSNAME}-${coverOrientation} wall-item`}
onClick={handleCardClick}
onKeyPress={() => showLightboxStart()}
role="button"
tabIndex={0}
{...dragProps}
>
{onSelectedChanged && (
<Form.Control
type="checkbox"
className="wall-item-check mousetrap"
checked={selected}
onChange={() => onSelectedChanged(!selected, shiftKey)}
onClick={(event: React.MouseEvent<HTMLInputElement, MouseEvent>) => {
shiftKey = event.shiftKey;
event.stopPropagation();
}}
/>
<div className="lineargradient">
<footer className={CLASSNAME_FOOTER}>
<Link
to={`/galleries/${gallery.id}`}
onClick={(e) => {
if (selecting) {
e.preventDefault();
handleCardClick(e);
}
e.stopPropagation();
}}
>
{title && (
<TruncatedText
text={title}
lineCount={1}
className={CLASSNAME_TITLE}
/>
)}
<TruncatedText text={performers.join(", ")} />
<div>
{gallery.date && TextUtils.formatFuzzyDate(intl, gallery.date)}
</div>
</Link>
</footer>
<GalleryPreviewScrubber
previewPath={gallery.paths.preview}
defaultPath={cover ?? ""}
imageCount={gallery.image_count}
onClick={(i) => {
showLightbox(i);
)}
<RatingSystem value={gallery.rating100} disabled withoutContext />
<img
loading="lazy"
src={imgSrc}
alt=""
className={cx(CLASSNAME_IMG, imgClassname)}
// set orientation based on cover only
onLoad={imgSrc === cover ? onCoverLoad : onNonCoverLoad}
/>
<div className="lineargradient">
<footer className={CLASSNAME_FOOTER}>
<Link
to={`/galleries/${gallery.id}`}
onClick={(e) => {
if (selecting) {
e.preventDefault();
handleCardClick(e);
}
e.stopPropagation();
}}
onPathChanged={setImgSrc}
/>
</div>
</section>
</>
>
{title && (
<TruncatedText
text={title}
lineCount={1}
className={CLASSNAME_TITLE}
/>
)}
<TruncatedText text={performers.join(", ")} />
<div>
{gallery.date && TextUtils.formatFuzzyDate(intl, gallery.date)}
</div>
</Link>
</footer>
<GalleryPreviewScrubber
previewPath={gallery.paths.preview}
defaultPath={cover ?? ""}
imageCount={gallery.image_count}
onClick={(i) => {
showLightbox(i);
}}
onPathChanged={setImgSrc}
/>
</div>
</section>
);
};

View File

@@ -65,8 +65,8 @@ const GroupTabs: React.FC<{
} = group;
const populatedDefaultTab = useMemo(() => {
if (sceneCount == 0) {
if (performerCount != 0) {
if (sceneCount === 0) {
if (performerCount !== 0) {
return "performers";
} else if (groupCount !== 0) {
return "subgroups";
@@ -175,8 +175,7 @@ const GroupPage: React.FC<IProps> = PatchComponent(
[group.aliases]
);
const isDefaultImage =
group.front_image_path && group.front_image_path.includes("default=true");
const isDefaultImage = group.front_image_path?.includes("default=true");
const lightboxImages = useMemo(() => {
const covers = [];
@@ -202,7 +201,7 @@ const GroupPage: React.FC<IProps> = PatchComponent(
}, [group.front_image_path, group.back_image_path, isDefaultImage]);
const activeFrontImage = useMemo(() => {
let existingImage = group.front_image_path;
const existingImage = group.front_image_path;
if (isEditing) {
if (frontImage === null && existingImage) {
const imageURL = new URL(existingImage);
@@ -217,7 +216,7 @@ const GroupPage: React.FC<IProps> = PatchComponent(
}, [isEditing, group.front_image_path, frontImage]);
const activeBackImage = useMemo(() => {
let existingImage = group.back_image_path;
const existingImage = group.back_image_path;
if (isEditing) {
if (backImage === null) {
return undefined;
@@ -245,6 +244,7 @@ const GroupPage: React.FC<IProps> = PatchComponent(
return () => {
Mousetrap.unbind("e");
Mousetrap.unbind("d d");
Mousetrap.unbind(",");
};
});

View File

@@ -196,7 +196,7 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
formik.setFieldValue("date", state.date);
}
if (state.studio && state.studio.stored_id) {
if (state.studio?.stored_id) {
onSetStudio({
id: state.studio.stored_id,
name: state.studio.name ?? "",
@@ -250,7 +250,7 @@ export const GroupEditPanel: React.FC<IGroupEditPanel> = ({
try {
const result = await queryScrapeGroupURL(url);
if (!result.data || !result.data.scrapeGroupURL) {
if (!result.data?.scrapeGroupURL) {
return;
}

View File

@@ -62,7 +62,7 @@ export const GroupScenesPanel: React.FC<IGroupScenesPanel> = ({
}) => {
const filterHook = useFilterHook(group, showSubGroupContent);
if (group && group.id) {
if (group?.id) {
return (
<FilteredSceneList
filterHook={filterHook}
@@ -73,5 +73,5 @@ export const GroupScenesPanel: React.FC<IGroupScenesPanel> = ({
/>
);
}
return <></>;
return null;
};

View File

@@ -31,8 +31,8 @@ interface IGroupScrapeDialogProps {
export const GroupScrapeDialog: React.FC<IGroupScrapeDialogProps> = ({
group,
groupStudio: groupStudio,
groupTags: groupTags,
groupStudio,
groupTags,
scraped,
onClose,
}) => {
@@ -48,7 +48,7 @@ export const GroupScrapeDialog: React.FC<IGroupScrapeDialogProps> = ({
new ScrapeResult<string>(
TextUtils.secondsToTimestamp(group.duration || 0),
// convert seconds to string if it's a number
scraped.duration && !isNaN(+scraped.duration)
scraped.duration && !Number.isNaN(Number(scraped.duration))
? TextUtils.secondsToTimestamp(parseInt(scraped.duration, 10))
: scraped.duration
)
@@ -123,7 +123,7 @@ export const GroupScrapeDialog: React.FC<IGroupScrapeDialogProps> = ({
newTags.length === 0
) {
onClose();
return <></>;
return null;
}
function makeNewScrapedItem(): GQL.ScrapedGroup {

View File

@@ -54,9 +54,6 @@ const useContainingGroupFilterHook = (
filter.criteria.push(groupCriterion);
}
filter.sortBy = "sub_group_order";
filter.sortDirection = GQL.SortDirectionEnum.Asc;
return filter;
};
};
@@ -67,18 +64,6 @@ interface IGroupSubGroupsPanel {
extraOperations?: IItemListOperation<GQL.FindGroupsQueryResult>[];
}
const defaultFilter = (() => {
const sortBy = "sub_group_order";
const ret = new ListFilterModel(GQL.FilterMode.Groups, undefined, {
defaultSortBy: sortBy,
});
// unset the sort by so that its not included in the URL
ret.sortBy = undefined;
return ret;
})();
export const GroupSubGroupsPanel: React.FC<IGroupSubGroupsPanel> =
PatchComponent(
"GroupSubGroupsPanel",
@@ -93,8 +78,8 @@ export const GroupSubGroupsPanel: React.FC<IGroupSubGroupsPanel> =
const filterHook = useContainingGroupFilterHook(group);
async function removeSubGroups(
result: GQL.FindGroupsQueryResult,
filter: ListFilterModel,
_result: GQL.FindGroupsQueryResult,
_filter: ListFilterModel,
selectedIds: Set<string>
) {
try {
@@ -163,7 +148,8 @@ export const GroupSubGroupsPanel: React.FC<IGroupSubGroupsPanel> =
<>
{modal}
<FilteredGroupList
defaultFilter={defaultFilter}
defaultSort="sub_group_order"
manualSortBy="sub_group_order"
filterHook={filterHook}
alterQuery={active}
fromGroupId={group.id}

View File

@@ -147,13 +147,15 @@ const SidebarContent: React.FC<{
interface IGroupListContext {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
defaultFilter?: ListFilterModel;
view?: View;
alterQuery?: boolean;
}
interface IGroupList extends IGroupListContext {
defaultSort?: string;
fromGroupId?: string;
// specifies the sort by value that allows reordering
manualSortBy?: string;
onMove?: (srcIds: string[], targetId: string, after: boolean) => void;
otherOperations?: IItemListOperation<GQL.FindGroupsQueryResult>[];
}
@@ -204,19 +206,16 @@ export const FilteredGroupList = PatchComponent(
const searchFocus = useFocus();
const {
defaultSort,
filterHook,
view,
alterQuery,
manualSortBy,
onMove,
fromGroupId,
otherOperations: providedOperations = [],
defaultFilter,
} = props;
const withSidebar = view !== View.GroupSubGroups;
const filterable = view !== View.GroupSubGroups;
const sortable = view !== View.GroupSubGroups;
// States
const {
showSidebar,
@@ -230,7 +229,7 @@ export const FilteredGroupList = PatchComponent(
useFilteredItemList({
filterStateProps: {
filterMode: GQL.FilterMode.Groups,
defaultFilter,
defaultSort,
view,
useURL: alterQuery,
},
@@ -379,6 +378,8 @@ export const FilteredGroupList = PatchComponent(
// render
if (sidebarStateLoading) return null;
const canMove = manualSortBy && onMove && filter.sortBy === manualSortBy;
const operations = (
<ListOperations
items={items.length}
@@ -402,8 +403,6 @@ export const FilteredGroupList = PatchComponent(
operationComponent={operations}
view={view}
zoomable
filterable={filterable}
sortable={sortable}
/>
<FilterTags
@@ -435,7 +434,7 @@ export const FilteredGroupList = PatchComponent(
selectedIds={selectedIds}
onSelectChange={onSelectChange}
fromGroupId={fromGroupId}
onMove={onMove}
onMove={canMove ? onMove : undefined}
/>
</LoadedContent>
@@ -455,10 +454,6 @@ export const FilteredGroupList = PatchComponent(
</>
);
if (!withSidebar) {
return content;
}
return (
<div
className={cx("item-list-container group-list", {

View File

@@ -88,7 +88,7 @@ export const GroupSelect: React.FC<
}
const query = await queryFindGroupsForSelect(filter);
let ret = query.data.findGroups.groups.filter((group) => {
const ret = query.data.findGroups.groups.filter((group) => {
// HACK - we should probably exclude these in the backend query, but
// this will do in the short-term
return !exclude.includes(group.id.toString());
@@ -276,14 +276,14 @@ const _GroupIDSelect: React.FC<IFilterProps & IFilterIDProps<Group>> = (
onSelectValues?.(items);
}
async function loadObjectsByID(idsToLoad: string[]): Promise<Group[]> {
const query = await queryFindGroupsByIDForSelect(idsToLoad);
const { groups: loadedGroups } = query.data.findGroups;
return loadedGroups;
}
useEffect(() => {
async function loadObjectsByID(idsToLoad: string[]): Promise<Group[]> {
const query = await queryFindGroupsByIDForSelect(idsToLoad);
const { groups: loadedGroups } = query.data.findGroups;
return loadedGroups;
}
if (!idsChanged) {
return;
}

View File

@@ -183,7 +183,7 @@ export const Manual: React.FC<IManualProps> = ({
) {
if (event.target instanceof HTMLAnchorElement) {
const href = event.target.getAttribute("href");
if (href && href.startsWith("/help")) {
if (href?.startsWith("/help")) {
const newKey = event.target.pathname.substring("/help/".length);
setActiveTab(newKey);
event.preventDefault();

View File

@@ -27,7 +27,7 @@ export const ManualProvider: React.FC = ({ children }) => {
openManual,
}}
>
<Suspense fallback={<></>}>
<Suspense fallback={null}>
{showManual && (
<Manual
show={showManual}

View File

@@ -176,9 +176,9 @@ const ImageCardImage = PatchComponent(
}
const source =
props.image.paths.preview != ""
? props.image.paths.preview ?? ""
: props.image.paths.thumbnail ?? "";
props.image.paths.preview !== ""
? (props.image.paths.preview ?? "")
: (props.image.paths.thumbnail ?? "");
const video = source.includes("preview");
const ImagePreview = video ? "video" : "img";

View File

@@ -80,7 +80,7 @@ const ImagePage: React.FC<IProps> = ({ image }) => {
}
async function onRescan() {
if (!image || !image.visual_files.length) {
if (!image?.visual_files.length) {
return;
}
@@ -379,13 +379,13 @@ const ImagePage: React.FC<IProps> = ({ image }) => {
<div className="image-container">
{image.visual_files.length > 0 && (
<ImageView
loop={image.visual_files[0].__typename == "VideoFile"}
autoPlay={image.visual_files[0].__typename == "VideoFile"}
playsInline={image.visual_files[0].__typename == "VideoFile"}
controls={image.visual_files[0].__typename == "VideoFile"}
loop={image.visual_files[0].__typename === "VideoFile"}
autoPlay={image.visual_files[0].__typename === "VideoFile"}
playsInline={image.visual_files[0].__typename === "VideoFile"}
controls={image.visual_files[0].__typename === "VideoFile"}
className="m-sm-auto no-gutter image-image"
style={
image.visual_files[0].__typename == "VideoFile"
image.visual_files[0].__typename === "VideoFile"
? { width: "100%", height: "100%" }
: {}
}

View File

@@ -200,12 +200,12 @@ export const ImageEditPanel: React.FC<IProps> = ({
}
async function onScrapeClicked(s: GQL.ScraperSourceInput) {
if (!image || !image.id) return;
if (!image?.id) return;
setIsLoading(true);
try {
const result = await queryScrapeImage(s.scraper_id!, image.id);
if (!result.data || !result.data.scrapeSingleImage?.length) {
if (!result.data?.scrapeSingleImage?.length) {
Toast.success("No images found");
return;
}
@@ -304,7 +304,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
setIsLoading(true);
try {
const result = await queryScrapeImageURL(url);
if (!result || !result.data || !result.data.scrapeImageURL) {
if (!result.data?.scrapeImageURL) {
return;
}
setScrapedImage(result.data.scrapeImageURL);
@@ -383,7 +383,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
const date = (() => {
try {
return schema.validateSyncAt("date", formik.values);
} catch (e) {
} catch (_e) {
return undefined;
}
})();

View File

@@ -109,7 +109,7 @@ export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (
>();
if (props.image.visual_files.length === 0) {
return <></>;
return null;
}
if (props.image.visual_files.length === 1) {

View File

@@ -140,7 +140,7 @@ export const ImageScrapeDialog: React.FC<IImageScrapeDialogProps> = ({
newTags.length === 0
) {
onClose();
return <></>;
return null;
}
function makeNewScrapedItem(): GQL.ScrapedImageDataFragment {

View File

@@ -101,7 +101,7 @@ const ImageWall: React.FC<IImageWallProps> = ({
const containerRef = React.useRef<HTMLDivElement>(null);
let photos: {
const photos: {
src: string;
srcSet?: string | string[] | undefined;
sizes?: string | string[] | undefined;
@@ -112,9 +112,9 @@ const ImageWall: React.FC<IImageWallProps> = ({
}[] = [];
images.forEach((image, index) => {
let imageData = {
const imageData = {
src:
image.paths.preview != ""
image.paths.preview !== ""
? image.paths.preview!
: image.paths.thumbnail!,
width: image.visual_files?.[0]?.width ?? 0,
@@ -129,15 +129,15 @@ const ImageWall: React.FC<IImageWallProps> = ({
});
const showLightboxOnClick = useCallback(
(event, { index }) => {
(_event, { index }) => {
handleImageOpen(index);
},
[handleImageOpen]
);
function columns(containerWidth: number) {
let preferredSize = zoomWidths[zoomIndex];
let columnCount = containerWidth / preferredSize;
const preferredSize = zoomWidths[zoomIndex];
const columnCount = containerWidth / preferredSize;
return Math.round(columnCount);
}
@@ -196,8 +196,8 @@ const ImageWall: React.FC<IImageWallProps> = ({
photos={photos}
renderImage={renderImage}
onClick={showLightboxOnClick}
margin={uiConfig?.imageWallOptions?.margin!}
direction={uiConfig?.imageWallOptions?.direction!}
margin={uiConfig?.imageWallOptions?.margin}
direction={uiConfig?.imageWallOptions?.direction}
columns={columns}
targetRowHeight={targetRowHeight}
/>
@@ -331,7 +331,7 @@ const ImageList: React.FC<IImageListImages> = PatchComponent(
}
// should not happen
return <></>;
return null;
}
);

View File

@@ -101,6 +101,7 @@ const CriterionOptionList: React.FC<ICriterionList> = ({
}
}
// biome-ignore lint/correctness/useExhaustiveDependencies: intentionally triggering on criterion change
useEffect(() => {
// scrolling to the current criterion doesn't work well when the
// dialog is already open, so limit to when we click on the
@@ -151,7 +152,9 @@ const CriterionOptionList: React.FC<ICriterionList> = ({
icon={type === c.type ? faChevronDown : faChevronRight}
/>
<FormattedMessage
id={!sfwContentMode ? c.messageID : c.sfwMessageID ?? c.messageID}
id={
!sfwContentMode ? c.messageID : (c.sfwMessageID ?? c.messageID)
}
/>
</span>
{criteria.some((cc) => c.type === cc) && (
@@ -271,11 +274,13 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
.sort((a, b) => {
return intl
.formatMessage({
id: !sfwContentMode ? a.messageID : a.sfwMessageID ?? a.messageID,
id: !sfwContentMode ? a.messageID : (a.sfwMessageID ?? a.messageID),
})
.localeCompare(
intl.formatMessage({
id: !sfwContentMode ? b.messageID : b.sfwMessageID ?? b.messageID,
id: !sfwContentMode
? b.messageID
: (b.sfwMessageID ?? b.messageID),
})
);
});
@@ -314,7 +319,7 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
return criterionOptions.filter((c) => {
return intl
.formatMessage({
id: !sfwContentMode ? c.messageID : c.sfwMessageID ?? c.messageID,
id: !sfwContentMode ? c.messageID : (c.sfwMessageID ?? c.messageID),
})
.toLowerCase()
.includes(trimmedSearch);

View File

@@ -308,7 +308,7 @@ export const FilterTags: React.FC<IFilterTagsProps> = ({
const className = "wrap-tags filter-tags";
const filterTags = criteria.map((c) => getFilterTags(c)).flat();
const filterTags = criteria.flatMap((c) => getFilterTags(c));
if (searchTerm && searchTerm.length > 0) {
filterTags.unshift(

View File

@@ -99,6 +99,10 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
sortable = true,
}) => {
const filterOptions = filter.options;
// Something in the popper layout for groups.sub-groups tab to double calculates the offset
// causing the dropdown to be misaligned. Portal to document.body to fix this.
const menuPortalTarget =
typeof document !== "undefined" ? document.body : undefined;
const { setDisplayMode, setZoom } = useFilterOperations({
filter,
setFilter,
@@ -142,6 +146,7 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
filter={filter}
onSetFilter={setFilter}
view={view}
menuPortalTarget={menuPortalTarget}
/>
<FilterButton
onClick={() => showEditFilter()}

View File

@@ -121,16 +121,14 @@ export const SidebarBooleanFilter: React.FC<ISidebarFilter> = ({
}
return (
<>
<SidebarListFilter
title={title}
candidates={options}
onSelect={onSelect}
onUnselect={onUnselect}
selected={selected}
singleValue
sectionID={sectionID}
/>
</>
<SidebarListFilter
title={title}
candidates={options}
onSelect={onSelect}
onUnselect={onUnselect}
selected={selected}
singleValue
sectionID={sectionID}
/>
);
};

View File

@@ -23,7 +23,7 @@ interface ICustomFieldCriterionEditor {
function getValue(v: string) {
// if the value is numeric, convert it to a number
const num = Number(v);
if (!isNaN(num)) {
if (!Number.isNaN(num)) {
return num;
} else {
return v;

View File

@@ -19,15 +19,13 @@ export const InputFilter: React.FC<IInputFilterProps> = ({
}
return (
<>
<Form.Group>
<Form.Control
className="btn-secondary"
type={criterion.modifierCriterionOption().inputType}
onChange={onChanged}
value={criterion.value ? criterion.value.toString() : ""}
/>
</Form.Group>
</>
<Form.Group>
<Form.Control
className="btn-secondary"
type={criterion.modifierCriterionOption().inputType}
onChange={onChanged}
value={criterion.value ? criterion.value.toString() : ""}
/>
</Form.Group>
);
};

View File

@@ -20,9 +20,9 @@ import {
FilterMode,
GalleryFilterType,
GroupFilterType,
HierarchicalCountInput,
ImageFilterType,
InputMaybe,
IntCriterionInput,
PerformerFilterType,
SceneFilterType,
SceneMarkerFilterType,
@@ -223,10 +223,10 @@ export function useSelectionState(props: {
}));
}, [criterion.value.excluded]);
const includingOnly = modifier == CriterionModifier.Equals;
const includingOnly = modifier === CriterionModifier.Equals;
const excludingOnly =
modifier == CriterionModifier.Excludes ||
modifier == CriterionModifier.NotEquals;
modifier === CriterionModifier.Excludes ||
modifier === CriterionModifier.NotEquals;
const onSelect = useCallback(
(v: Option, exclude: boolean) => {
@@ -508,7 +508,7 @@ export function useLabeledIdFilterState(props: {
};
}
export function makeQueryVariables(query: string, extraProps: {}) {
export function makeQueryVariables(query: string, extraProps: object) {
return {
filter: {
q: query,
@@ -520,18 +520,18 @@ export function makeQueryVariables(query: string, extraProps: {}) {
interface IFilterType {
scenes_filter?: InputMaybe<SceneFilterType>;
scene_count?: InputMaybe<IntCriterionInput>;
scene_count?: InputMaybe<HierarchicalCountInput>;
performers_filter?: InputMaybe<PerformerFilterType>;
performer_count?: InputMaybe<IntCriterionInput>;
performer_count?: InputMaybe<HierarchicalCountInput>;
galleries_filter?: InputMaybe<GalleryFilterType>;
gallery_count?: InputMaybe<IntCriterionInput>;
gallery_count?: InputMaybe<HierarchicalCountInput>;
images_filter?: InputMaybe<ImageFilterType>;
image_count?: InputMaybe<IntCriterionInput>;
image_count?: InputMaybe<HierarchicalCountInput>;
groups_filter?: InputMaybe<GroupFilterType>;
group_count?: InputMaybe<IntCriterionInput>;
group_count?: InputMaybe<HierarchicalCountInput>;
studios_filter?: InputMaybe<StudioFilterType>;
studio_count?: InputMaybe<IntCriterionInput>;
marker_count?: InputMaybe<IntCriterionInput>;
studio_count?: InputMaybe<HierarchicalCountInput>;
marker_count?: InputMaybe<HierarchicalCountInput>;
markers_filter?: InputMaybe<SceneMarkerFilterType>;
}
@@ -554,6 +554,7 @@ export function setObjectFilter(
out.scene_count = {
modifier: CriterionModifier.GreaterThan,
value: 0,
depth: -1,
};
break;
}
@@ -565,6 +566,7 @@ export function setObjectFilter(
out.performer_count = {
modifier: CriterionModifier.GreaterThan,
value: 0,
depth: -1,
};
break;
}
@@ -576,6 +578,7 @@ export function setObjectFilter(
out.gallery_count = {
modifier: CriterionModifier.GreaterThan,
value: 0,
depth: -1,
};
break;
}
@@ -587,6 +590,7 @@ export function setObjectFilter(
out.image_count = {
modifier: CriterionModifier.GreaterThan,
value: 0,
depth: -1,
};
break;
}
@@ -598,6 +602,7 @@ export function setObjectFilter(
out.group_count = {
modifier: CriterionModifier.GreaterThan,
value: 0,
depth: -1,
};
break;
}
@@ -609,6 +614,7 @@ export function setObjectFilter(
out.studio_count = {
modifier: CriterionModifier.GreaterThan,
value: 0,
depth: -1,
};
break;
}
@@ -620,6 +626,7 @@ export function setObjectFilter(
out.marker_count = {
modifier: CriterionModifier.GreaterThan,
value: 0,
depth: -1,
};
break;
}

View File

@@ -219,16 +219,14 @@ export const SidebarOptionFilter: React.FC<ISidebarFilter> = ({
}
return (
<>
<SidebarListFilter
title={title}
candidates={options}
onSelect={onSelect}
onUnselect={onUnselect}
selected={selected}
singleValue
sectionID={sectionID}
/>
</>
<SidebarListFilter
title={title}
candidates={options}
onSelect={onSelect}
onUnselect={onUnselect}
selected={selected}
singleValue
sectionID={sectionID}
/>
);
};

View File

@@ -26,8 +26,8 @@ export const PhashFilter: React.FC<IPhashFilterProps> = ({
}
function distanceChanged(event: React.ChangeEvent<HTMLInputElement>) {
let distance = parseInt(event.target.value);
if (distance < 0 || isNaN(distance)) {
let distance = parseInt(event.target.value, 10);
if (distance < 0 || Number.isNaN(distance)) {
distance = 0;
}

View File

@@ -72,7 +72,7 @@ export const RatingFilter: React.FC<IRatingFilterProps> = ({
);
}
return <></>;
return null;
};
interface ISidebarFilter {

View File

@@ -197,10 +197,10 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
);
}, [modifier, queryResults, selected, excluded]);
const includingOnly = modifier == CriterionModifier.Equals;
const includingOnly = modifier === CriterionModifier.Equals;
const excludingOnly =
modifier == CriterionModifier.Excludes ||
modifier == CriterionModifier.NotEquals;
modifier === CriterionModifier.Excludes ||
modifier === CriterionModifier.NotEquals;
const modifierValues = useMemo(() => {
return {
@@ -296,27 +296,24 @@ const SelectableFilter: React.FC<ISelectableFilter> = ({
/>
</li>
))}
{showModifierValues && (
<>
{Object.entries(availableModifierValues).map(([key, value]) => {
if (!value) {
return null;
}
{showModifierValues &&
Object.entries(availableModifierValues).map(([key, value]) => {
if (!value) {
return null;
}
return (
<UnselectedItem
key={key}
onSelect={() => onModifierValueSelect(key as SpecialValue)}
label={`(${intl.formatMessage({
id: `criterion_modifier_values.${key}`,
})})`}
canExclude={false}
modifier
/>
);
})}
</>
)}
return (
<UnselectedItem
key={key}
onSelect={() => onModifierValueSelect(key as SpecialValue)}
label={`(${intl.formatMessage({
id: `criterion_modifier_values.${key}`,
})})`}
canExclude={false}
modifier
/>
);
})}
{objects.map((p) => (
<UnselectedItem
key={p.id}
@@ -338,7 +335,7 @@ interface IObjectsFilter<T extends ModifierCriterion<ILabeledValueListValue>> {
}
export const ObjectsFilter = <
T extends ModifierCriterion<ILabeledValueListValue | IHierarchicalLabelValue>
T extends ModifierCriterion<ILabeledValueListValue | IHierarchicalLabelValue>,
>({
criterion,
setCriterion,
@@ -354,7 +351,7 @@ export const ObjectsFilter = <
setDisplayQuery(input);
debouncedSetQuery(input);
},
[debouncedSetQuery, setDisplayQuery]
[debouncedSetQuery]
);
const [queryResults, setQueryResults] = useState<ILabeledId[]>([]);
@@ -369,7 +366,7 @@ export const ObjectsFilter = <
const [, setInputFocus] = inputFocus;
function onSelect(value: ILabeledId, newExclude: boolean) {
let newCriterion: T = cloneDeep(criterion);
const newCriterion: T = cloneDeep(criterion);
if (newExclude) {
if (newCriterion.value.excluded) {
@@ -399,7 +396,7 @@ export const ObjectsFilter = <
(value: ILabeledId) => {
if (!criterion) return;
let newCriterion: T = cloneDeep(criterion);
const newCriterion: T = cloneDeep(criterion);
newCriterion.value.items = criterion.value.items.filter(
(v) => v.id !== value.id
@@ -418,7 +415,7 @@ export const ObjectsFilter = <
const onSetModifier = useCallback(
(modifier: CriterionModifier) => {
let newCriterion: T = criterion.clone();
const newCriterion: T = criterion.clone();
newCriterion.modifier = modifier;
setCriterion(newCriterion);
},
@@ -504,7 +501,7 @@ interface IHierarchicalObjectsFilter<T extends IHierarchicalLabeledIdCriterion>
extends IObjectsFilter<T> {}
export const HierarchicalObjectsFilter = <
T extends IHierarchicalLabeledIdCriterion
T extends IHierarchicalLabeledIdCriterion,
>(
props: IHierarchicalObjectsFilter<T>
) => {
@@ -519,7 +516,7 @@ export const HierarchicalObjectsFilter = <
});
function onDepthChanged(depth: number) {
let newCriterion: T = cloneDeep(criterion);
const newCriterion: T = cloneDeep(criterion);
newCriterion.value.depth = depth;
setCriterion(newCriterion);
}
@@ -539,8 +536,8 @@ export const HierarchicalObjectsFilter = <
criterion.criterionOption.type === "studios"
? "include_sub_studios"
: criterion.criterionOption.type === "children"
? "include_parent_tags"
: "include_sub_tags";
? "include_parent_tags"
: "include_sub_tags";
return {
id: optionType,
};

View File

@@ -169,8 +169,8 @@ export const SidebarAgeFilter: React.FC<ISidebarFilter> = ({
return MAX_AGE;
}
const age = parseInt(trimmed);
if (isNaN(age) || age < 18 || age > MAX_AGE) {
const age = parseInt(trimmed, 10);
if (Number.isNaN(age) || age < 18 || age > MAX_AGE) {
return null;
}

Some files were not shown because too many files have changed in this diff Show More