Compare commits

...

319 Commits

Author SHA1 Message Date
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
ordureconnoisseur
c637b2931a New: Add data-action Attributes to Interactive Controls for Theme/Plugin Targeting (#6977)
* Add data-action attributes to interactive controls for theme targeting

Add stable data-action attributes so CSS themes and plugins can target these
controls without matching on localized text, which breaks under i18n:

- OCounterButton (scene details) and the shared CountButton: data-action
  "o-counter" on the o-counter button group (ViewCountButton is left untouched)
- SceneDuplicateChecker per-row action buttons: data-action "merge" and
  "delete"

Additive only: existing classes, titles, icons, and behavior are unchanged.
No API/schema change.

* MainNavbar: add data-action="new" to the create button

Gives the navbar New button a stable, locale-independent hook so themes and
plugins can target it without matching on Bootstrap variant classes
(btn-primary) or the localized New label.
2026-06-04 17:41:40 -05:00
ordureconnoisseur
3050d900b0 New: Make StudioPage, TagPage, GroupPage Patchable (#6967)
Wraps the three remaining detail-page components in PatchComponent so
plugins can use PluginApi.patch.{before,instead,after} on them, matching
the pattern already used for PerformerPage and ScenePage.

Requested in #4510.
2026-06-04 17:24:14 -05:00
void-function865
64a104d905 Fix: Warped Image Aspect Ratios in Lightbox on Safari (#6961)
Safari has a rendering bug where `transform: scale` applied to an
`<img>` element with very large intrinsic dimensions (e.g. 10246×13662)
renders the image content distorted, even though the element's
bounding box reports the correct portrait size. The lightbox computed
`defaultZoom` to fit natural-size images into view via `transform:
scale` on the `<picture>` wrapping the img, triggering this bug on
macOS and iOS Safari.

Move the `transform` from the `<picture>` element onto a wrapping
`<div>` so the scale never targets a `<picture>`/`<img>` chain. The
img keeps its natural dimensions inside the wrapper, and Safari
renders it correctly. Also drop the unused `<source srcSet>` (it
pointed at the same URL as the fallback `<img>` and provided no
responsive behavior) and the dead `picture > div` CSS rule that had
no matching element. Remove `object-fit: contain` on the img - it
interacted with the wrapper transform in Safari and was a no-op
anyway since the img has no CSS-constrained dimensions.

Verified in Safari, Chrome, and Firefox.

Co-authored-by: void-function865 <void-function865@users.noreply.github.com>
Co-authored-by: Gykes <24581046+Gykes@users.noreply.github.com>
2026-06-04 10:34:13 -07:00
CynicalAtropos
6bd0123edf Fix: API Context Key Constants (#6989) 2026-06-04 10:02:18 -07:00
WithoutPants
b8c17f780f Replace prettier/eslint with biome (#6996)
* add only biome config
* purge eslint/prettier remnants
* Add biome dependency
* Add fix option
* Adjust rule inclusions
* Rename biome config to jsonc. Turn off many rules until fixed
* Cleanup eslint suppression comments.

Converted some to biome clauses with XX prefix to disable for now. Fixing of issues to be deferred to later.

* Fix pnpm targets
* fix github action error
---------
Co-authored-by: feederbox826 <feederbox826@users.noreply.github.com>
2026-06-04 14:27:53 +10:00
Gykes
294651ebb8 add tag tagger field merge support (#6839) 2026-06-04 13:58:07 +10:00
DogmaDragon
c2902233ad Add AI usage policy, update contributing section (#6995)
* Add reference to architecture document
* Update README badges and improve documentation structure
* Update link to metadata sources documentation in README
* Add AI usage policy
* Refactor CONTRIBUTING.md to enhance clarity and structure, link to AI usage policy
* Add pull request template
* Add checkmark to AI usage disclosure section
* Add that failure to discuss large PRs with devs might lead to closure
2026-06-04 13:36:26 +10:00
WithoutPants
f30ae0a270 Fix parallel builds (#6990)
* Replace npm with pnpm.
* Update go.sum
* Remove various prereqs. Replace build-release targets with orchestrated sequential build instead.
* Fix timestamp generation when cross-compiling
2026-06-03 11:51:50 +10:00
modal-error
d04ecc4f8e feat: use signed urls for airplay videos (#6529)
* feat: use signed urls for videos

When using authentication for stash accesss, cast urls for airplay will not have access to cookies - meaning that Airplay will fail to pass authorization for video stream access.

Updated code to sign urls for videos and streams.

* testing notes: airplay from localhost won’t work, since appletv’s perspective of localhost is different, try casting from IP address (192.168.0.x:9999) or other local DNS name.

* feat: Add signed URLs for scene streaming (AirPlay/Chromecast)

HMAC-signed URLs allow authenticated streaming to devices that cannot pass cookies (AirPlay, Chromecast). Signing is scoped to scene stream using a prefix-based approach so one signature covers all derivative segment URLs.

Credentialid hides username from public network.

When credentials are disabled, signing is bypassed entirely. API key takes precedence over signed params when both are present.

---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-06-02 14:09:45 +10:00
feederbox826
653ac2d7ab add support for scraping with surf (tls impersonation) (#6806)
* add support for scraping with surf (tls impersonation)
* [scraper] copy all headers except for user-agent
* [docs] add surf docs, update
---------
Co-authored-by: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com>
2026-06-02 12:48:22 +10:00
Gykes
752495dc62 New: Skip Autotag Warning Setting (#6859)
---------
Co-authored-by: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com>
2026-06-02 12:22:40 +10:00
CynicalAtropos
76f16aa306 Use accurate seek fallback for scene screenshot generation (#6922)
* Use Accurate Seek as a fallback when generating scene cover, scrubber sprites, and phash
2026-06-02 09:02:10 +10:00
Marco
5646b7932b Fix: Prevent Nil Pointer Dereference in scrapeScene (#6857)
* fix(scraper): prevent nil pointer dereference in scrapeScene

When scrapeScene finds no results, ret remains nil and is passed to
processSceneRelationships, which unconditionally dereferences it at
line 89 (ret.Performers = ...), causing a panic.

Initialize ret to an empty ScrapedScene so processSceneRelationships
always has a valid pointer. This also preserves the intent of #3953:
returning a scene with only relationship fields set when scraped
non-relationship data is absent.

Fixes panic: runtime error: invalid memory address or nil pointer
dereference at pkg/scraper/mapped.go:89 in processSceneRelationships

* Update mapped.go

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* test(scraper): add relationships-only scene regression test

Signed-off-by: Marco <130363067+dutchdevil-83@users.noreply.github.com>

* test(scraper): restore scene test and add relationships regression

---------

Signed-off-by: Marco <130363067+dutchdevil-83@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-28 15:20:33 -05:00
Slick Daddy
4187d164b3 fix: close HTTP response bodies and add nil guards for Primary() calls (#6930)
* Fix HTTP body leaks, nil pointer panics, and file handle cleanup

* Extract unzipFile loop body into unzipFileEntry helper

The unzipFile function had defer o.Close() and defer i.Close() inside
a for loop, which is a Go antipattern — defers are function-scoped and
wouldn't execute until unzipFile returned, leaving file handles open
across iterations. Extracting the per-file logic into unzipFileEntry
ensures each defer fires when that function returns, at the end of each
loop iteration.
2026-05-25 10:09:44 +10:00
Slick Daddy
4a4cd1c5d1 add ARCHITECTURE.MD (#6926)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-05-20 11:11:09 +10:00
Slick Daddy
31570955cd Fix typos in source code and documentation (#6940) 2026-05-20 09:08:09 +10:00
sprayidle
70437b4866 Stop pagination footer blocking clicks on elements horizontal to it (#6895) 2026-05-18 17:20:17 +10:00
WithoutPants
bb67152f95 Add related object resolves to file graphql types (#6938)
* Add scenes/images/galleries fields to graphql file types
* Consolidate file id loaders
2026-05-18 16:52:14 +10:00
WithoutPants
9b21f2bb28 Workaround content not showing when tabKey is unset. (#6923)
Don't render tabs at all until tabKey is set.
2026-05-13 14:59:17 +10:00
Slick Daddy
80df250e36 Add list view for Studios page (#6913)
* Implement list view for Studios page

- Add StudioListTable component with columns for logo, name, aliases, rating, scene count, image count, gallery count, performer count, and related studios
- Update StudioList component to use StudioListTable for List display mode
- Add DisplayMode.List to StudioListFilterOptions to enable list view option in UI

* Remove aliases from NameCell in StudioListTable

* Update StudioListTable: conditional image rendering, intl labels, add related_studios key
* Add StudioListTable.scss and import it

---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-05-12 14:23:34 +10:00
WithoutPants
fc0b2a5d96 Fix OR sub-filter not using correct table join type (#6920)
* Remove invalid duplicate named objects from test setup

Studios and tags are enforced to have unique names, so it doesn't make sense to allow them in the datalayer tests

* Convert inner joins to left joins when using or sub-filter
2026-05-12 14:04:07 +10:00
dev-null-life
01a7583364 Refresh file info counter after deleting a file (#6841)
The mutateDeleteFiles Apollo cache update evicted the plural list
queries (findScenes/findImages/findGalleries) but not the singular
detail queries, so the "File Info" counter on a scene/image/gallery
detail page stayed stale until a manual refresh.

Co-authored-by: dev-null-life <264850222+dev-null-life@users.noreply.github.com>
2026-05-05 14:45:33 +10:00
feederbox826
9234979084 [ci] add explicit flow for makefile, add make install (#6877)
* [ci] add explicit flow for makefile, add make install
* [ci] re-add touch index.html
* [ci] run integration tests without generate
* [ci] switch from spaces to tabs
---------
Co-authored-by: feederbox826 <feederbox826@users.noreply.github.com>
2026-05-05 14:44:47 +10:00
feederbox826
46f72e5574 [docker] bump cuda, node version (#6890)
12.8 was superceded by 12.8.3 and 13 has since been released.

Co-authored-by: feederbox826 <feederbox826@users.noreply.github.com>
2026-05-05 14:03:01 +10:00
Stash-KennyG
3afe29215d Align release Dockerfiles with Go 1.25 for backend builds. (#6889)
The x86_64 and CUDA backend stages still used golang:1.24.3 while go.mod requires Go 1.25, which broke make docker-build under GOTOOLCHAIN=local. Bump both images to golang:1.25.9 to match docker/compiler/Dockerfile and PR #6869.

Verified with: make docker-build

Fixes https://github.com/stashapp/stash/issues/6887

Co-authored-by: KennyG <kennyg@kennyg.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-05 14:02:33 +10:00
Gykes
db4eabea81 New: Allow Description and Alias on Tag Creation in Scene Tagger (#6872) 2026-05-03 15:32:28 -07:00
dependabot[bot]
1ec5583931 Bump golang.org/x/image from 0.18.0 to 0.38.0 (#6774)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.18.0 to 0.38.0.
- [Commits](https://github.com/golang/image/compare/v0.18.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.38.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-29 10:45:36 +10:00
WithoutPants
2b29207f1e Upgrade go to 1.25.9 and golangci-lint (#6869)
* Bump go version in go.mod
* Update compiler image.

Changed github download url since existing one didn't have version 12 of the SDK.

* Update macOS requirements in README for v0.32.0
* Update lint action
* Bump golangci-lint version
* Migrate golangci-lint config
* Fix QF1012 errors

(Use fmt.Fprintf(...) instead of WriteString(fmt.Sprintf(...)))

* Fix QF1003 errors

(could use tagged switch)

* Fix ST1005 errors

(error string capitalisation)

* Fix ST1011 errors

(seconds suffix)

* Fix QF1006 errors

(lift into loop condition)

* Fix QF1002 errors

(switch condition)

* Fix gocritic error

(deprecated paragraph)

* Fix incorrect nolint directive

* Ignore specific checks

noctx should be addressed in a later PR
---------
Co-authored-by: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com>
Co-authored-by: feederbox826 <me@feederbox.cc>
2026-04-29 10:13:58 +10:00
WithoutPants
98fd0267d0 Update go.sum 2026-04-28 14:01:50 +10:00
dependabot[bot]
3f83a84afb Bump github.com/antchfx/xpath from 1.3.5 to 1.3.6 (#6763)
Bumps [github.com/antchfx/xpath](https://github.com/antchfx/xpath) from 1.3.5 to 1.3.6.
- [Release notes](https://github.com/antchfx/xpath/releases)
- [Commits](https://github.com/antchfx/xpath/compare/v1.3.5...v1.3.6)

---
updated-dependencies:
- dependency-name: github.com/antchfx/xpath
  dependency-version: 1.3.6
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 13:50:43 +10:00
WithoutPants
af6491a36f Pnpm dedupe and overrides (#6868)
* Run pnpm dedupe
* Override alerted transitive dependencies
2026-04-28 13:45:45 +10:00
dependabot[bot]
cea3c0383f Bump lodash-es from 4.17.23 to 4.18.1 in /ui/v2.5 (#6790)
Bumps [lodash-es](https://github.com/lodash/lodash) from 4.17.23 to 4.18.1.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.23...4.18.1)

---
updated-dependencies:
- dependency-name: lodash-es
  dependency-version: 4.18.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 11:50:30 +10:00
dependabot[bot]
2c98ad4d78 Bump vite from 7.3.1 to 7.3.2 in /ui/v2.5 (#6862)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 7.3.1 to 7.3.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v7.3.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v7.3.2/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-version: 7.3.2
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-28 11:45:31 +10:00
Niklas Wagner
103181a6d2 feat: include api key in funscript url (#6760) 2026-04-24 14:59:24 +10:00
WithoutPants
8e070717e5 Optimise table joins (#6648)
* Use inner joins where it makes sense to do so
* Don't trim stash ids
2026-04-24 14:38:11 +10:00
feederbox826
6004ed52af switch to gosx-notifier fork (#6714)
* switch to gosx-notifier fork
* [ci] add macos bundle
---------
Co-authored-by: feederbox826 <feederbox826@users.noreply.github.com>
2026-04-24 14:33:23 +10:00
(Moai Emoji)
0b811e13b9 Remove empty directories on Clean Generated Files with Blobs (#6749) 2026-04-24 14:29:12 +10:00
feederbox826
083ba25d04 ui package updates sprint 1 (#6777)
* minor package version bumps, remove string.replaceAll polyfill
* update universal-cookie
* bump flag-icons
* [apollo] replace cloneDeep with lodash-es/CloneDeep
* [apollo] partial upgrade to 3.14
* remove dom-screen-wake-lock
* switch videojs-vr library for xvr support. minor bumps
* vite 7
* bump ua-parser-js
* bump postcss
* bump polyfills
* partial bump eslint to v8, otherwise we lose airbnb
* bump typescript to 5.9
* ensure node engine, remove homepage
2026-04-24 14:14:07 +10:00
Gykes
a33cca6033 Fix: Release DB Lock on Large Image Libraries (#6845) 2026-04-23 14:57:53 +10:00
Gykes
22d2dbc46b Merge pull request #6838 from smith113-p/link
Fix: Correct stash ID links in merge dialogs
2026-04-21 17:11:48 -07:00
DogmaDragon
2c8a0ad192 Add architecture section 2026-04-19 23:48:59 +03:00
Gykes
443de78260 Merge pull request #6802 from stashapp/docs-normalize-manual-headers
Normalize manual headers
2026-04-17 13:40:03 -07:00
smith113-p
ada05a59d0 Format 2026-04-16 20:00:22 -04:00
smith113-p
fb1a548be1 Correct stash ID links in merge dialogs
The <StashIDList/> element hardcoded a link type of "scenes", so the
tag and performer merge dialogs had incorrect links.

Reported in Discord #bugs
2026-04-16 19:55:53 -04:00
Gykes
26cd867a6a Merge pull request #6773 from stashapp/docs-6673
Document details being searchable field on images
2026-04-13 22:36:43 -07:00
Gykes
f26ae0724b Merge pull request #6772 from stashapp/docs-6449
Update object fields in scraper documentation
2026-04-13 22:36:29 -07:00
DogmaDragon
4de2351e7c Clarify caption file naming conventions in documentation (#6821) 2026-04-13 11:03:09 +10:00
WithoutPants
82d12145cc Fix typo in tag export (#6819) 2026-04-13 10:53:12 +10:00
WithoutPants
968a97aa45 Update changelog 2026-04-10 16:06:29 +10:00
dev-null-life
f920bd8b8e Fix WebSocket UTF-8 error for non-UTF-8 file paths in subscriptions (#6810)
* Fix WebSocket UTF-8 error for non-UTF-8 file paths in subscriptions

Sanitize log messages and job fields (description, subtasks, error)
before sending over WebSocket. File paths with non-UTF-8 characters
caused the browser to close the connection with "Could not decode a
text frame as UTF-8." Invalid bytes are replaced with U+FFFD.

Only the API response layer is affected — underlying stored data is
unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Replace direct ToValidUTF8 calls to new sanitiseWebsocketString function
---------
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-04-10 13:42:42 +10:00
WithoutPants
9b5c0b0e48 Match tag names/aliases exactly when testing uniqueness (#6809)
* Add tagStore.FindByAlias method
* Change tag.ByName and ByAlias to use exact queries instead of fuzzy matching
2026-04-08 13:11:12 +10:00
WithoutPants
034ae1a141 Try to create backup directory during migrate. Log warning on failure (#6808) 2026-04-08 11:30:32 +10:00
smith113-p
3af546db92 Let the stash ID pill shrink in tagger (#6807)
* Let the stash ID pill shrink in tagger

On very narrow viewports (e.g. mobile), the stash ID pill will
overflow its container. With this PR, it will instead limit itself
to the width of the container and display with an ellipsis if
necessary.

Fixes #6786
2026-04-08 10:17:57 +10:00
DogmaDragon
3b90e5191a Merge branch 'develop' into docs-normalize-manual-headers 2026-04-07 09:42:40 +03:00
WithoutPants
60ce007c02 Show warning when creating parent tag without remote_site_id (#6805) 2026-04-07 16:34:43 +10:00
WithoutPants
f81053ae7d Reset page when setting filter criteria (#6804)
Fixes sidebar folder filter not resetting page when selecting folders
2026-04-07 16:33:50 +10:00
WithoutPants
98074e3b57 Fix clicking on scene/marker wall item pushing to history twice (#6803) 2026-04-07 16:33:33 +10:00
Gykes
57ddec93e0 Fix: Update Postmigration 84 to Handle De-Duplicate of Folders. (#6792)
* update postmigration to handle deduplicate folders.
* Split post-migration to perform some tasks before the schema migration
* Reparent files and delete duplicate folder if possible
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-04-07 16:28:01 +10:00
DogmaDragon
5edd299b10 Clarify scene fingerprint submission details (#6784) 2026-04-07 15:32:53 +10:00
feederbox826
672147deaf fix memory leak (#6796)
* allow channels to passively drain, empty fileQueue, scanner after scanning
* Prevent job executor retention in subscription channels
---------
Co-authored-by: feederbox826 <feederbox826@users.noreply.github.com>
Co-authored-by: Gykes <Gykes@pm.me>
2026-04-07 09:39:30 +10:00
DogmaDragon
6aaf3fe1b7 Add GitHub Sponsors to Contributing.md 2026-04-07 01:35:46 +03:00
DogmaDragon
5b857663f1 Normalize header casing in TroubleshootingMode.md 2026-04-07 01:33:53 +03:00
DogmaDragon
dd8126206c Normalize header casing in KeyboardShortcuts.md 2026-04-07 01:33:26 +03:00
DogmaDragon
059aa96b51 Normalize header casing in Deduplication.md 2026-04-07 01:31:50 +03:00
DogmaDragon
02dba484cc Normalize header casing in Tagger.md 2026-04-07 01:31:07 +03:00
DogmaDragon
3feabcbf8b Normalize header casing in UIPluginApi.md 2026-04-07 01:30:23 +03:00
DogmaDragon
b4bcb5fe6a Normalize header casing in EmbeddedPlugins.md 2026-04-07 01:28:45 +03:00
DogmaDragon
fcf1a47920 Normalize header casing in ExternalPlugins.md 2026-04-07 01:26:52 +03:00
DogmaDragon
512cf03be9 Normalize header casing in Plugins.md 2026-04-07 01:24:02 +03:00
DogmaDragon
9b346e42f3 Normalize header casing in ScraperDevelopment.md 2026-04-07 01:22:40 +03:00
DogmaDragon
7f20e91687 Normalize header casing in Scraping.md 2026-04-07 01:03:50 +03:00
DogmaDragon
a2cfd090b5 Normalize header casing in Images.md 2026-04-07 01:00:37 +03:00
DogmaDragon
692086d138 Normalize header casing and improve formatting in Browsing.md 2026-04-07 00:59:48 +03:00
DogmaDragon
c648fc3a89 Normalize header casing in JSONSpec.md 2026-04-07 00:32:07 +03:00
DogmaDragon
6db4988042 Normalize header casing in SceneFilenameParser.md 2026-04-07 00:31:56 +03:00
DogmaDragon
e405871749 Normalize header casing in AutoTagging.md 2026-04-07 00:22:59 +03:00
DogmaDragon
299e3c2a42 Normalize header casing and improve consistency in Tasks.md 2026-04-07 00:21:42 +03:00
DogmaDragon
93f4cfdba1 Normalize header casing in Interface.md 2026-04-07 00:11:55 +03:00
DogmaDragon
0ed2992a72 Fix typo in the manual (#6771) 2026-03-31 18:23:40 +11:00
DogmaDragon
3c06df402b Document changes from https://github.com/stashapp/stash/pull/6673 2026-03-30 15:33:19 +03:00
DogmaDragon
1a8f7e8494 Document changes from https://github.com/stashapp/stash/pull/6449 2026-03-30 15:30:17 +03:00
DogmaDragon
e6e87d64d6 Add troubleshooting mode confirmation to bug report
Added a checkbox to confirm troubleshooting mode is enabled before filing a bug report.
2026-03-30 11:53:30 +03:00
WithoutPants
2da8074316 Codeberg weblate translation update (#6767)
* Translated using Weblate (French)

Currently translated at 100.0% (1341 of 1341 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Turkish)

Currently translated at 75.3% (1010 of 1341 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/tr/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1341 of 1341 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (French)

Currently translated at 99.9% (1345 of 1346 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1346 of 1346 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (French)

Currently translated at 100.0% (1346 of 1346 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1346 of 1346 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1346 of 1346 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 67.3% (906 of 1346 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/pt_BR/

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/

* Translated using Weblate (French)

Currently translated at 100.0% (1348 of 1348 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Czech)

Currently translated at 100.0% (1351 of 1351 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1351 of 1351 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1351 of 1351 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1351 of 1351 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (French)

Currently translated at 100.0% (1351 of 1351 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1351 of 1351 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (Arabic)

Currently translated at 56.9% (769 of 1351 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ar/

* Translated using Weblate (Polish)

Currently translated at 80.1% (1083 of 1351 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/pl/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1351 of 1351 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (Arabic)

Currently translated at 58.0% (784 of 1351 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ar/

---------

Co-authored-by: doodoo <doodoo@noreply.codeberg.org>
Co-authored-by: slickdaddy <slickdaddy@noreply.codeberg.org>
Co-authored-by: Saenko <saenko@noreply.codeberg.org>
Co-authored-by: lugged9922 <lugged9922@noreply.codeberg.org>
Co-authored-by: wql219 <wql219@noreply.codeberg.org>
Co-authored-by: tiagodamian <tiagodamian@noreply.codeberg.org>
Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: NymeriaCZ <nymeriacz@noreply.codeberg.org>
Co-authored-by: donlothario <donlothario@noreply.codeberg.org>
Co-authored-by: gallegonovato <gallegonovato@noreply.codeberg.org>
Co-authored-by: interj4 <interj4@noreply.codeberg.org>
Co-authored-by: brnd <brnd@noreply.codeberg.org>
2026-03-30 12:26:04 +11:00
WithoutPants
b70922488b Update changelog 2026-03-30 11:57:28 +11:00
WithoutPants
0d9ad38bfe Clear search results and states from taggers when source changes (#6766) 2026-03-30 11:43:39 +11:00
WithoutPants
a4b0a7a194 Exclude source objects from destination select and vice versa in merge dialogs (#6764)
* Add excludeIDs to PerformerSelect
* Exclude src from dest select and vice versa in merge dialogs
2026-03-30 11:43:06 +11:00
(Moai Emoji)
e755b2c24c guard heatmap display on interactive_speed (#6746) 2026-03-30 11:38:46 +11:00
(Moai Emoji)
48ba26e17b Allow unicode characters when stripping filenames for json export (#6748) 2026-03-30 11:38:20 +11:00
(Moai Emoji)
1e0b9902a3 Fix lightbox not reading scale-up setting from config (#6743) 2026-03-30 11:18:45 +11:00
smith113-p
0a4b427e1d Show stash-box name in studio/performer tagger (#6759) 2026-03-30 11:09:17 +11:00
smith113-p
86188e5ff7 Use StashIDPill for displaying the scraped stash ID (#6761)
This is more consistent with other places that stash IDs are shown,
simplifies the code a bit, and lets you see at a glance which stash
box is being used.
2026-03-30 11:07:04 +11:00
eb2292
fe2a8eb0fd Add keyboard shortcut "d d" to delete scene (#6755)
Co-authored-by: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com>
2026-03-30 11:04:10 +11:00
feederbox826
af07fea289 [CI] add vips-heif (#6765) 2026-03-30 10:57:16 +11:00
(Moai Emoji)
8af2cfe525 Add mutex to repositoryCache for thread safety (#6741)
* Add mutex to package cache to prevent concurrent map write crash
* use sync.Once for cache init
2026-03-30 09:09:28 +11:00
Gykes
020c242ea6 Fix: Remove padFuzzyDate From Performer (#6757) 2026-03-30 09:07:54 +11:00
(Moai Emoji)
c861d3991a Fix 'not equals' custom field to include unset objects (#6742)
* Fix custom field 'not equals' to include unset objects
* also fix Excludes and NotBetween null handling
2026-03-26 09:01:43 +11:00
DogmaDragon
eeee081eb7 Refactor README.md for better clarity and structure 2026-03-25 13:36:31 +02:00
WithoutPants
fd480c5a3e Exclude zip folders when browsing scenes and galleries (#6740)
* Add short cuts when only getting zip/folder ids
* Don't show zip folders when viewing scenes and galleries.

Zip folders have no results for scenes and galleries, but will for images.
2026-03-24 15:03:58 +11:00
WithoutPants
2e48dbfc63 Update changelog 2026-03-23 17:32:30 +11:00
WithoutPants
87eabf0871 Show studio name if studio image not set on detail pages (#6716)
* Add StudioLogo component

If no studio image is set, shows the studio icon with the studio name.

* Add option to always show studio text
* Implement studio as text option
* Add studio logo to image
* Clarify existing show studio as text option
2026-03-23 17:13:34 +11:00
WithoutPants
b4c7ad4b81 Match exact tag names for batch tagger and show exact matches first for query (#6739)
* Enforce exact name matching for tag batch tagger
* Sort exact matches first for tag stashbox query
2026-03-23 16:29:49 +11:00
Gykes
e0f2c8e96d FR: Auto Tag Confirmation Modal (#6735)
* Improve folder list styling
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-03-23 16:16:59 +11:00
WithoutPants
c5034422cb Expand folder select hierarchy based on initial selected folder (#6738)
* Add sub_folders field to Folder type
* Expand folder select for the initial value
2026-03-23 16:15:23 +11:00
WithoutPants
c9d0afee56 Fix tagger modal issues (#6736)
* Make modal field/value styling consistent

Fixes URL list in studio list styling

* Add stash id pill to studio and tag modals

* Fix create parent check box

* Allow excluding parent studio

Disabled the create checkbox if parent studio is not excluded and does not exist.

* Don't render modal on every studio

* Show dialog when refreshing tags
2026-03-23 16:14:25 +11:00
feederbox826
3dbb0fcfc9 [hwaccel] add envvar for /dev/dri device (#6728) 2026-03-23 16:10:22 +11:00
WithoutPants
2bb1df8443 Fix incorrect where clause for gallery parent folder filter (#6737) 2026-03-23 13:45:31 +11:00
WithoutPants
feb4346e13 Maintain sub-folders selection when reselecting folder in filter 2026-03-23 12:31:48 +11:00
feederbox826
11f9e7ac51 [ci] add macos bundle (#6727) 2026-03-23 09:07:47 +11:00
feederbox826
b11be4807a fetch full depth of git history for compiler (#6726)
[ci] run generate with fetch depth
2026-03-23 09:07:13 +11:00
DogmaDragon
7a18b5310b Add GitHub Sponsors and forum links to about section (#6718)
* Add GitHub sponsors link to about section
* Add forum link to about section
* Fix casing in 'latest_version_build_hash' string in localization file
2026-03-23 09:06:20 +11:00
feederbox826
865c50d615 [ui] Fix Tag Modal cutting off (#6734) 2026-03-23 09:02:38 +11:00
feederbox826
c832e1a8a2 remove phasher target from bundle (#6717) [skip ci] 2026-03-19 18:31:14 +11:00
WithoutPants
ee9a852ec9 Remove phasher from build target [skip ci] 2026-03-19 16:35:12 +11:00
feederbox826
640d62cf59 [CI] ensure artifacts have +x bit set (#6715) 2026-03-19 15:10:04 +11:00
WithoutPants
58cf6307cb Update changelog 2026-03-19 13:59:42 +11:00
feederbox826
79b6cb6fd2 Lint + build update and retooling (#6638)
* update compiler and build process

- assemble cross-builds in multi-build steps
- clean up unnecessary dependences
- use node docker image instead of nodesource (unsupported)
- downgrade to freebsd12 to match compiler

Co-authored-by: Gykes <Gykes@pm.me>

* [compiler] use new image instead of placeholder

removes .gitignore, update README

* [CI] lock pnpm action-setup to SHA hash

* bump @actions/upload-artifact
---------
Co-authored-by: feederbox826 <feederbox826@users.noreply.github.com>
Co-authored-by: Gykes <Gykes@pm.me>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-03-19 13:36:58 +11:00
WithoutPants
18eda31933 Make tagger views consistent (#6713)
* Show performer/studio tagger when no results
* Separate stash-box selector and config buttons
2026-03-19 13:35:18 +11:00
WithoutPants
5fd0d7bd68 Make hover volume configurable (#6712) 2026-03-19 13:16:20 +11:00
WithoutPants
c583e88caf Replace "Source" with "Combined" in merge dialogs (#6711) 2026-03-19 12:10:42 +11:00
Stash-KennyG
4167224107 Feature: Add StashID guid consideration into select boxes (#6709)
* Add GUID search for performers in PerformerSelect component
* Refactor and apply to all objects with stash ids
---------
Co-authored-by: KennyG <kennyg@kennyg.com>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-03-19 11:03:36 +11:00
WithoutPants
8f3188ff74 Make gallery/scene association during scan more consistent (#6705) 2026-03-19 08:54:44 +11:00
WithoutPants
b47134112a Focus search field when clicking on scraper menu (#6704)
* Focus search field when opening scraper menu
* Improve styling of search header in scraper menu
2026-03-19 08:51:04 +11:00
WithoutPants
208c19a81d Replace tag list view with tag list table (#6703)
* Replace tag list view with tag list table

Uses same styling as performer list table

* Remove "count" suffix from count headers in performer list
2026-03-19 08:50:42 +11:00
WithoutPants
b76dd089f5 Update changelog 2026-03-18 16:04:18 +11:00
WithoutPants
93fbb4be80 Add option to ignore zip contents during clean (#6700)
* Add option to ignore zip file contents while cleaning

Speeds up the clean process with the assumption that files within zip files are not deleted.

* Add UI for new option
2026-03-18 15:58:32 +11:00
dev-null-life
f7b66c7ff9 Keep tag/entity select input focused after creating a new item (#6697)
* Keep creatable select input focused after creating a new item

When onCreateOption fires, setLoading(true) sets isDisabled on
AsyncCreatableSelect, which triggers a blur and the user loses focus.
Remove isLoading from the isDisabled condition so the input stays
interactive during the async creation; the loading spinner still shows.

Fixes #3998

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Remove unused isLoading destructure in SelectComponent

isLoading flows through via ...props spread in componentProps and
no longer needs to be explicitly destructured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* Apply Prettier formatting to FilterSelect.tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 15:57:54 +11:00
feederbox826
679f49e400 add {phash} argument to queryURLParameters (#6701) 2026-03-18 15:46:52 +11:00
WithoutPants
63b1132897 Update weblate translations (#6698)
* Translated using Weblate (German)

Currently translated at 100.0% (1251 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1251 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1251 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1251 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/

* Translated using Weblate (Japanese)

Currently translated at 82.2% (1029 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ja/

* Translated using Weblate (Finnish)

Currently translated at 79.3% (993 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fi/

* Translated using Weblate (Japanese)

Currently translated at 83.2% (1042 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ja/

* Translated using Weblate (Polish)

Currently translated at 95.2% (1192 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/pl/

* Translated using Weblate (Danish)

Currently translated at 86.1% (1078 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/da/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.6% (871 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/pt_BR/

* Translated using Weblate (Danish)

Currently translated at 86.2% (1079 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/da/

* Translated using Weblate (Danish)

Currently translated at 86.1% (1080 of 1253 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/da/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1253 of 1253 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/

* Translated using Weblate (French)

Currently translated at 100.0% (1250 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (1250 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1250 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1250 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.9% (874 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/pt_BR/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1251 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (French)

Currently translated at 100.0% (1251 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1251 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/

* Translated using Weblate (German)

Currently translated at 100.0% (1251 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/

* Translated using Weblate (French)

Currently translated at 100.0% (1253 of 1253 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1253 of 1253 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1253 of 1253 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (German)

Currently translated at 100.0% (1253 of 1253 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1253 of 1253 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1253 of 1253 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1253 of 1253 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1253 of 1253 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1253 of 1253 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 70.0% (878 of 1253 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/pt_BR/

* Translated using Weblate (French)

Currently translated at 100.0% (1271 of 1271 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (1271 of 1271 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1271 of 1271 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1271 of 1271 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1271 of 1271 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.0% (877 of 1271 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/pt_BR/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1271 of 1271 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1271 of 1271 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/

* Translated using Weblate (Dutch)

Currently translated at 76.7% (975 of 1271 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nl/

* Translated using Weblate (Czech)

Currently translated at 100.0% (1271 of 1271 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/

* Translated using Weblate (Japanese)

Currently translated at 86.7% (1102 of 1271 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ja/

* Translated using Weblate (French)

Currently translated at 100.0% (1273 of 1273 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1273 of 1273 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (French)

Currently translated at 100.0% (1273 of 1273 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (1273 of 1273 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1273 of 1273 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1273 of 1273 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1273 of 1273 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Ukrainian)

Currently translated at 91.0% (1159 of 1273 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1273 of 1273 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (French)

Currently translated at 100.0% (1278 of 1278 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1278 of 1278 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (German)

Currently translated at 86.5% (1106 of 1278 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/

* Translated using Weblate (Spanish)

Currently translated at 88.1% (1127 of 1278 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (Italian)

Currently translated at 65.9% (843 of 1278 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/it/

* Translated using Weblate (Russian)

Currently translated at 82.7% (1057 of 1278 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ru/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1278 of 1278 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (French)

Currently translated at 100.0% (1280 of 1280 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1280 of 1280 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/

* Translated using Weblate (French)

Currently translated at 100.0% (1299 of 1299 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1299 of 1299 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1300 of 1300 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1300 of 1300 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1300 of 1300 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (French)

Currently translated at 100.0% (1300 of 1300 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1300 of 1300 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1300 of 1300 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/

* Translated using Weblate (Estonian)

Currently translated at 85.0% (1122 of 1320 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1320 of 1320 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/

* Translated using Weblate (French)

Currently translated at 100.0% (1320 of 1320 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Ukrainian)

Currently translated at 98.0% (1294 of 1320 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (French)

Currently translated at 100.0% (1322 of 1322 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1322 of 1322 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1322 of 1322 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1322 of 1322 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1322 of 1322 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (Spanish)

Currently translated at 97.4% (1288 of 1322 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1322 of 1322 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1322 of 1322 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1322 of 1322 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1323 of 1323 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (German)

Currently translated at 85.1% (1130 of 1327 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/

* Translated using Weblate (French)

Currently translated at 100.0% (1327 of 1327 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1327 of 1327 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1332 of 1332 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1332 of 1332 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (French)

Currently translated at 100.0% (1332 of 1332 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1332 of 1332 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1332 of 1332 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1332 of 1332 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Added translation using Weblate (Arabic)

* Translated using Weblate (Arabic)

Currently translated at 29.8% (397 of 1332 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ar/

* Translated using Weblate (French)

Currently translated at 100.0% (1334 of 1334 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1334 of 1334 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (French)

Currently translated at 100.0% (1338 of 1338 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1338 of 1338 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (Estonian)

Currently translated at 99.7% (1335 of 1338 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1338 of 1338 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1338 of 1338 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/uk/

* Translated using Weblate (Arabic)

Currently translated at 40.1% (537 of 1338 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ar/

* Add arabic language option

---------

Co-authored-by: Alex-NBY <alex-nby@noreply.codeberg.org>
Co-authored-by: donlothario <donlothario@noreply.codeberg.org>
Co-authored-by: wql219 <wql219@noreply.codeberg.org>
Co-authored-by: AlpacaSerious <alpacaserious@noreply.codeberg.org>
Co-authored-by: Anpontant <anpontant@noreply.codeberg.org>
Co-authored-by: gimmeliina <gimmeliina@noreply.codeberg.org>
Co-authored-by: minerh <minerh@noreply.codeberg.org>
Co-authored-by: warchlak <warchlak@noreply.codeberg.org>
Co-authored-by: powdom <powdom@noreply.codeberg.org>
Co-authored-by: qeepoo <qeepoo@noreply.codeberg.org>
Co-authored-by: lugged9922 <lugged9922@noreply.codeberg.org>
Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: doodoo <doodoo@noreply.codeberg.org>
Co-authored-by: bittin <bittin@noreply.codeberg.org>
Co-authored-by: diegoml <diegoml@noreply.codeberg.org>
Co-authored-by: BSSPM <bsspm@noreply.codeberg.org>
Co-authored-by: Zesty6249 <zesty6249@noreply.codeberg.org>
Co-authored-by: Saenko <saenko@noreply.codeberg.org>
Co-authored-by: arkvenom <arkvenom@noreply.codeberg.org>
Co-authored-by: andersondn <andersondn@noreply.codeberg.org>
Co-authored-by: xantror <xantror@noreply.codeberg.org>
Co-authored-by: NymeriaCZ <nymeriacz@noreply.codeberg.org>
Co-authored-by: shimanchu <shimanchu@noreply.codeberg.org>
Co-authored-by: Anonymous <anonymous@noreply.codeberg.org>
Co-authored-by: yec <yec@noreply.codeberg.org>
Co-authored-by: LostUser <lostuser@noreply.codeberg.org>
Co-authored-by: gallegonovato <gallegonovato@noreply.codeberg.org>
Co-authored-by: Strambino <strambino@noreply.codeberg.org>
Co-authored-by: PiskaBoomGonit <piskaboomgonit@noreply.codeberg.org>
Co-authored-by: Super_L <super_l@noreply.codeberg.org>
Co-authored-by: Lauri Lepik <laurilepik@noreply.codeberg.org>
Co-authored-by: interj4 <interj4@noreply.codeberg.org>
2026-03-18 14:04:14 +11:00
WithoutPants
b4dd7e3f15 Changelog v0.31 (#6692)
* Modify styling to improve readability
2026-03-18 13:01:50 +11:00
WithoutPants
de6c0bace5 Don't read stashignore files in zip files (#6693) 2026-03-18 13:01:22 +11:00
dev-null-life
f7b04fba61 Sort performers and studios by scenes file size (#6642)
* feat: add scenes_size sort option for performers

Adds sorting performers by total file size of associated scenes.
Follows the existing scenes_duration pattern.

Ref: stashapp/stash#5530

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add scenes_size sort option for studios

Adds sorting studios by total file size of associated scenes.
Follows the existing scenes_duration pattern.

Ref: stashapp/stash#5530

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(ui): add Scenes Size sort option for performers and studios

Adds 'Scenes Size' to the sort dropdown for performer and studio
list views, with i18n label.

Ref: stashapp/stash#5530

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: extend scenes_size sort to tags

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-18 11:51:56 +11:00
WithoutPants
b2179cd723 Add stash ids to performer merge dialog (#6688)
* Move reused functions/components to separate files
* Add alwaysShow field to ScrapedDialogRow
* Add stash ids to performer merge dialog
* Reuse StashIDsField in TagMergeDialog
* Always show stash ids when available on scene and tag merge dialogs
2026-03-17 15:48:56 +11:00
WithoutPants
f3c8e7ac9c Convert career length fields to dates (#6682)
* Convert career start/end to date
* Update UI to accept dates for career length fields
* Fix date filtering
---------
Co-authored-by: Gykes <24581046+Gykes@users.noreply.github.com>
2026-03-17 15:48:30 +11:00
notsafeforgit
c2e80d2676 feat: add image details to search filter (#6673)
* feat: add image details to search filter

This change adds the image details field to the image search filter, allowing for better discovery of images based on their description.
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-03-17 10:18:44 +11:00
feederbox826
7872029ffa add patching for scene-specs-overlay (#6684) 2026-03-16 18:05:02 +11:00
dev-null-life
8d1aeede1c fix: correct typos in GraphQL schema (#6679)
* fix: correct typos in GraphQL schema comments

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: rename CircumisedEnum to CircumcisedEnum across codebase

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix: gofmt performer model files after enum rename

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 17:48:59 +11:00
WithoutPants
e851d8d3bf Treat bad network path error as non-existing folder during folder rename check (#6680)
* treat any error as missing folder
2026-03-16 17:32:43 +11:00
Gykes
b4fab0ac48 Add parent tag hierarchy support to tag tagger (#6620) 2026-03-16 11:34:57 +11:00
WithoutPants
b8bd8953f7 Refactor bulk edit dialogs (#6647)
* Add BulkUpdateDateInput
* Refactor edit scenes dialog
* Improve bulk date input styling
* Make fields inline in edit performers dialog
* Refactor edit images dialog
* Refactor edit galleries dialog
* Add date and synopsis to bulk update group input
* Refactor edit groups dialog
* Change edit dialog titles to 'Edit x entities'
* Update styling of bulk fields to be consistent with other UI
* Rename BulkUpdateTextInput to generic BulkUpdate

We'll collect other bulk inputs here

* Add and use BulkUpdateFormGroup
* Handle null dates correctly
* Add date clear button and validation
2026-03-14 17:56:31 +11:00
hyper440
300e7edb75 fix: support string-based fingerprints in hashes filter (#6654)
* fix: support string-based fingerprints in hashes filter
* Fix tests and add phash test

File fingerprints weren't using correct types. Filter test wasn't using correct types. Add phash to general files.
---------

Co-authored-by: hyper440 <hyper440@users.noreply.github.com>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-03-10 15:07:46 +11:00
smith113-p
490fa3ea14 Show scene resolution and duration in tagger (#6663)
* Show scene resolution and duration in tagger

A scene's duration and resolution is often useful to ensure you have
found the right scene. This PR adds the same resolution/duration
overlay from the grid view to the tagger view.
2026-03-10 14:53:20 +11:00
smith113-p
69a49c9ab8 Show the stash box for each stash ID in the scene merge dialog (#6656)
* Show the stash box for each stash ID in the scene merge dialog

Currently, this dialog only shows the ID but not the stash box it
corresponds to. This is not very useful because the ID does not mean
anything to a user.

This renders the ID as "Stashdb | 1234...", mimicing the StashIDPill.

* Use StashIDPill instead
2026-03-10 14:12:17 +11:00
Gykes
ae5d065da1 Fix infinite re-render loop in gallery image list (#6651) 2026-03-10 13:50:57 +11:00
smith113-p
cacaf36347 Use StashIDPill in the performer modal dialog (#6655)
Currently, this dialog just shows a text "Stash-Box Source".
This change instead re-uses the StashIDPill, with the main advantage
that you can immediately tell which stash box is being used.
2026-03-10 08:01:46 +11:00
WithoutPants
74a8f2e5d5 Disable links on wall items when selecting (#6649) 2026-03-06 08:27:25 +11:00
WithoutPants
717f968a2c Add folder criteria to scenes, images and galleries and sidebars (#6636)
* Add useDebouncedState hook
* Add basename to folder sort whitelist
* Add parent_folder criterion to gallery
* Add selection on enter if single result
2026-03-05 08:02:13 +11:00
WithoutPants
697c66ae62 Allow stash path to non-existing directory (#6644) 2026-03-05 07:59:13 +11:00
WithoutPants
69e781b0ee Use ffmpeg as a general fallback when generating phash (#6641) 2026-03-05 07:58:51 +11:00
Gykes
fbf91b2526 New: Add From Clipboard to Set Image (#6637)
* add from clipboard to UI
* only trigger when input not focused
2026-03-04 12:01:31 +11:00
WithoutPants
f7da37400b Fix preview scrubber scaling on smaller sizes (#6640) 2026-03-04 10:10:07 +11:00
Matt Stone
cd0980201c feat: Add .stashignore support for gitignore-style scan exclusions (#6485)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-03-04 08:17:14 +11:00
puc9
1457ad590d Add Selective generate (#6621) 2026-03-03 09:11:28 +11:00
WithoutPants
b9baa7ea9f Fix gallery image list styling 2026-03-03 08:26:04 +11:00
puc9
99a0d01371 Fix new panic in IsFsPathCaseSensitive: Use filepath operations to check for file system case sensitivity (#6635)
* Use filepath operations to check for file system case sensitivity
2026-03-03 08:11:55 +11:00
Abdu Dihan
52bd9392fb Fix stale browser-cached thumbnails after file content changes during scan. (#6622)
* Fix stale thumbnails after file content changes

When a file's content changed (e.g. after renaming files in a gallery),
the scan handler updated fingerprints but did not bump the entity's
updated_at timestamp. Since thumbnail URLs use updated_at as a cache
buster and are served with immutable/1-year cache headers, browsers
would indefinitely serve the old cached thumbnail.

Update image, scene, and gallery scan handlers to call UpdatePartial
(which sets updated_at to now) whenever file content changes, not only
when a new file association is created.
2026-03-02 15:53:02 +11:00
dev-null-life
b8dff73696 Fix datepicker button border radius in input groups (#6630)
Add missing .input-group-append .btn border-radius rule to zero out
the left-side radius, matching the existing .input-group-prepend rule.

Fixes #6518

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 15:47:43 +11:00
dev-null-life
784795660b Skip scanning zip contents when fingerprint is unchanged (#6633)
* Skip scanning zip contents when fingerprint is unchanged

When a zip-based gallery's modification time changes but its content
hash (oshash/md5) remains the same, skip walking and rescanning every
file inside the zip. This avoids expensive per-file fingerprint
recalculation when zip metadata changes without actual content changes.

Closes #6512

* Log a debug message when skipping a zip scan due to unchanged
  fingerprint

---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-03-02 15:47:23 +11:00
WithoutPants
09e2b2bd4e Wrap CleanCaptions with database. Refactor AssociateCaptions. 2026-03-02 15:45:37 +11:00
dev-null-life
bc75d47f15 Fix edit modal not opening inside gallery view (#6629)
* Fix edit modal not opening inside gallery view

The modal element was only rendered in the sidebar layout branch, but
gallery images use the non-sidebar path which returned content without
the modal. Also stabilize onEdit/onDelete with useCallback and add
missing dependency array to the Mousetrap useEffect.

Closes #6624

* Render modal once above sidebar conditional

Move {modal} above the withSidebar ternary so it is rendered
exactly once, avoiding the duplication that caused the original bug.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 14:45:33 +11:00
WithoutPants
681ccbf380 Fix caption handling during scan and check before correcting path (#6634)
* Handle case where folder entry exists for corrected path in correctSubFolderHierarchy
* Log scan start
* Handle caption files during scan
2026-03-02 14:44:20 +11:00
DogmaDragon
b46fbb2e7a Update capitalization for sprite generation heading (#6623) 2026-03-02 14:30:38 +11:00
Gykes
c874bd560e Fix: Custom Field Filtering (#6614)
* add tests
* Refactor queryBuilder: split args into per-clause fields
2026-02-28 11:05:13 +11:00
WithoutPants
c7e1c3da69 Fix panic when library path has trailing path separator (#6619)
* Replace panic with warning if creating a folder hierarchy where parent is equal to current
* Clean stash paths so that comparison works correctly when creating folder hierarchies
2026-02-28 10:51:02 +11:00
Gykes
3b8f6bd94c update logs and fix UNIQUE constraint failure (#6617) 2026-02-28 09:11:13 +11:00
WithoutPants
d8448ba37e Add basename and parent_folders fields to Folder graphql interface (#6494)
* Add basename field to folder
* Add parent_folders field to folder
* Add basename column to folder table
* Add basename filter field
* Create missing folder hierarchies during migration
* Treat files/folders in zips where path can't be made relative as not found

Addresses an issue during clean where corrupt folder entries in zip files could not be removed due to an error during the call to Rel.
2026-02-27 10:58:11 +11:00
WithoutPants
ead0c7fe07 Add sidebar to Tag list (#6610)
* Fix image export dialog
* Add sidebar to TagList
* Update plugin docs and types
* Remove ItemList as it is no longer referenced
2026-02-27 07:44:23 +11:00
WithoutPants
660feabced Update minimatch and ajv dependencies (#6609)
* Update minimatch
* Update ajv
2026-02-27 07:43:16 +11:00
WithoutPants
e52ac14d56 Fix missing folder corruption during scanning (#6608)
* Add root paths parameter to GetOrCreateFolderHierarchy

Ensures that folders are only created up to the root library paths.

* Create full folder hierarchy when scanning a new folder

During a recursive scan, folders should be created as they are encountered (folders are handled in a single thread). This change applies only during a selective scan. Creates up to the root library folder.

* Create folder hierarchy on new file scan

This should only apply when scanning a specific file, as parent folders should be been created during a recursive scan.

* Fix existing folders with missing parents during scan
2026-02-27 07:42:53 +11:00
Gykes
b77abd64e2 FR: Add Missing is-missing Filter Options Across all Object Types (#6565)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-02-26 16:36:54 +11:00
WithoutPants
ed58d18334 Add sidebar to images list (#6607)
* Use effective filter for keybinds/view random
* Refactor ImageList to use sidebar
* Add performer age filter to gallery sidebar
* Port metadata info changes
* Fix incorrect patch component parameter
* Update plugin doc and types
2026-02-26 14:13:15 +11:00
WithoutPants
c522e54805 Show unsupported filter criteria in filter tags (#6604)
* Show unsupported filter criteria in filter tags

Shows a warning coloured filter tag, with warning icon and text "<type> (unsupported) ...". Cannot be edited, can only be removed. Won't be saved to saved filters.

* Generalise filtered recommendation rows. Include warning popover for unsupported criteria
2026-02-26 07:55:26 +11:00
WithoutPants
5734ee43ff Add sidebar to scene markers list (#6603)
* Add tag markers filter
* Add marker count and markers filter to performer filter
* Add sidebar to marker list
2026-02-26 07:54:40 +11:00
DogmaDragon
c9f0dba62f Fix capitalization in custom localisation heading [skip-ci] (#6606) 2026-02-26 07:54:12 +11:00
Gykes
01d351c85d FR: Custom Fields Frontend (#6601)
* Add "custom-field-" prefix to custom field detail item ids
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-02-25 14:56:24 +11:00
WithoutPants
cf04e854d6 Fix missing message id changes from #6600 2026-02-25 14:21:16 +11:00
Gykes
0103fe4751 FR: Tags Tagger (#6559)
* Refactor Tagger components
* condense localization
* add alias and description to model and schema
2026-02-25 11:39:14 +11:00
WithoutPants
14105a2d54 Rename checksum and hash fields (#6600)
Checksum -> MD5 Checksum
Hash -> oshash with hover showing OpenSubtitles Hash.
Also internationalised perceptual hash hover text.
2026-02-25 10:54:40 +11:00
WithoutPants
410dd27d93 Fix misclicks resulting in navigating to new page during selection (#6599)
* Disable studio overlay link if selecting
* Prevent scene preview scrubber click navigating during selection
* Prevent gallery preview scrubber click navigating during selection
2026-02-25 10:54:20 +11:00
WithoutPants
86abe7b24c Backend support for image custom fields (#6598)
* Initialise maps in bulk get custom fields to fix graphql validation error
2026-02-24 07:41:40 +11:00
WithoutPants
aff6db1500 Fix scene player scrubber when custom sprite size used (#6597) 2026-02-23 16:51:36 +11:00
1509x
9a1b1fb718 [Feature] Reveal file in system file manager from file info panel (#6587)
* Add reveal in file manager button to file info panel

Adds a folder icon button next to the path field in the Scene, Image,
and Gallery file info panels. Clicking it calls a new GraphQL mutation
that opens the file's enclosing directory in the system file manager
(Finder on macOS, Explorer on Windows, xdg-open on Linux).

Also fixes the existing revealInFileManager implementations which were
constructing exec.Command but never calling Run(), making them no-ops:
- darwin: add Run() to open -R
- windows: add Run() and fix flag from \select to /select,<path>
- linux: implement with xdg-open on the parent directory
- desktop.go: use os.Stat instead of FileExists so folders work too

* Disallow reveal operation if request not from loopback
---------
Co-authored-by: 1509x <1509x@users.noreply.github.com>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-02-23 12:51:35 +11:00
WithoutPants
ca5178f05e Backend support for Group custom fields (#6596) 2026-02-23 11:53:12 +11:00
WithoutPants
47dcdd439c Backend support for gallery custom fields (#6592) 2026-02-23 07:39:28 +11:00
WithoutPants
076032ff8b Custom sprite generation (#6588)
* configurable minimum/maximum number of sprites
* configurable sprite size
---------
Co-authored-by: cacheflush <github.stoneware268@passmail.com>
2026-02-20 15:09:59 +11:00
WithoutPants
843806247d Add group scene count filter (#6593) 2026-02-20 09:14:25 +11:00
WithoutPants
c15e6a5b63 Include blobs in backup (#6586)
* Optionally backup blobs into zip
* Add backup dialog
2026-02-20 09:13:55 +11:00
Gykes
3dc86239d2 Feature Request: Add organized flag to studios (#6303) 2026-02-19 09:05:17 +11:00
WithoutPants
8bc4107e54 Skip directory after deleting it during generated files clean (#6590) 2026-02-19 08:09:58 +11:00
WithoutPants
b653e91fae Fix panic in IsFsPathCaseSensitive (#6589)
* Add crashing unit test
* Fix IsFsPathCaseSensitive to use runes
2026-02-19 08:09:06 +11:00
WithoutPants
0164d7ad31 Fix marker form start time not being set when abLoop disabled 2026-02-18 17:30:52 +11:00
WithoutPants
e289199911 Scene custom field backend support (#6584)
* Add custom fields to scenes
* Generalise set custom fields tests to other object types
2026-02-18 16:50:32 +11:00
Gykes
adaadee368 FR: Change Career Length to Career Start and Career End (#6449)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-02-17 13:44:03 +11:00
WithoutPants
bede849fa6 Add sidebar to group list (#6573)
* Add group filter criteria to tag and studio
* Add sidebar to groups list
* Refactor ListOperations to accept buttons
* Move create new button back to navbar

Having the create new button with a plus icon conflicted with the add sub-group button in the sub-groups view.

* Simplify group-sub-groups view
2026-02-16 17:28:41 +11:00
DogmaDragon
fc31823fd2 docs:update links for custom CSS and themes in Interface.md (#6581) 2026-02-16 15:06:40 +11:00
feederbox826
b1f3bbe5b0 Performer image rewrite (#6566)
* SVGs and attribute male performers
* SVG, cleanup and attribute female performer images
2026-02-16 15:06:10 +11:00
WithoutPants
c8a8154e83 Only use infinite scrolling where there are more items than can be displayed (#6575)
Also show dots on small viewports, up to a limit of 5
2026-02-13 17:54:58 +11:00
WithoutPants
3ae3ea6102 Default card width before container width is calculated (#6574) 2026-02-13 17:06:55 +11:00
WithoutPants
6ef599e894 Make recommendation row width selector more specific.
Fixes issue where the media overrides would set the card width to the wrong value on small viewports.
2026-02-13 17:05:20 +11:00
Gykes
d1479ca4e5 Feature: Scene Duplicate Filter (#6344) 2026-02-11 11:52:44 +11:00
Gykes
26db935fad FR: Change Identify Settings to Use Gender Checkboxes (#6557) 2026-02-11 11:43:18 +11:00
Gykes
7aa7276fa3 Bugfix: AVIF Image PHash Support (#6556)
* AVIF phash support
* add avif check for zips
2026-02-11 11:38:57 +11:00
WithoutPants
5628fbc5d3 Merge tag values dialog (#6552)
* Change tag merge to accept values.

MergeHierarchy is removed as it is no longer needed

* Add tag merge value dialog to choose values when merging
2026-02-11 11:27:57 +11:00
InfiniteStash
5cf41c8c8e Remove unused stash-box fingerprint queries (#6561)
* Remove unused stash-box fingerprint query
* Remove findSceneByFingerprint
2026-02-11 11:26:05 +11:00
DogmaDragon
07b483038a docs: standardize letter casing in settings page (#6548)
* Standardize letter casing in settings page for headings, options and buttons
* Add localized messages for changelog header and select directory
2026-02-09 10:55:12 +11:00
WithoutPants
8dec195c2d Quick fix for front page card styling (#6553) 2026-02-06 15:53:04 +11:00
WithoutPants
d64b3b711c Revamp studio list with sidebar (#6549)
* Add studios_filter to TagFilterType
* Convert studio list to use sidebar
2026-02-06 12:37:38 +11:00
WithoutPants
2b38361a26 Revamp performer list with sidebar (#6547)
* Add favourite filter
* Add gender sidebar filter
* Remove new performer button from navbar
2026-02-06 12:36:56 +11:00
WithoutPants
b278525647 Tag custom fields support for backend (#6546)
* Fix custom field import/export for studio
* Update studio unit tests
* Add tag create and update unit tests
* Add custom fields to tag filter graphql
* Add unit tests for tag filtering
* Add filter unit tests for studio
2026-02-06 12:35:05 +11:00
CJ
f629191b28 Future support for filtering tags list by current filter on Performers page (#6091) 2026-02-05 13:35:58 +11:00
WithoutPants
9eda7c2f60 Studio custom fields backend support (#6156) 2026-02-05 09:01:29 +11:00
WithoutPants
b5de30a295 Revamp gallery list with sidebar (#6157)
* Make list operation utility component
* Add defaults for sidebar filters
* Refactor gallery list for sidebar
* Fix gallery styling
* Fix sidebar state issues
* Auto-populate query string into name on create
* Remove new gallery button from navbar
* Make components patchable
2026-02-04 16:45:59 +11:00
WithoutPants
88eb46380c Refactor scraper package (#6495)
* Remove reflection from mapped value processing
* AI generated unit tests
* Move mappedConfig to separate file
* Rename group to configScraper
* Separate mapped post-processing code into separate file
* Update test after group rename
* Check map entry when returning scraper
* Refactor config into definition
* Support single string for string slice translation
* Rename config.go to definition.go
* Rename configScraper to definedScraper
* Rename config_scraper.go to defined_scraper.go
2026-02-04 11:07:51 +11:00
Hans Evers
ed0fb53ae0 feat: auto-remove duplicate aliases (#6514) 2026-02-04 10:37:15 +11:00
GammelSami
cf5d60f511 Added loop feature for markers + AB prefill (#6510)
* add loop feature for markers + AB prefill
* chore(ui): type ab loop plugin access
2026-02-04 10:18:39 +11:00
Gykes
b76edffc5d FR: Add Generate Task to Galleries (#6442) 2026-02-04 09:34:56 +11:00
Gykes
badf9ec35e add cover check (#6542) 2026-02-04 09:24:08 +11:00
DogmaDragon
0e54a5ceb0 docs: add warning emojis to important notes across multiple documentation files (#6531) 2026-01-27 17:53:39 +11:00
Valkyr-JS
fe85b1eff9 Image count added to gallery data fragment (#6527) 2026-01-27 17:42:58 +11:00
WithoutPants
d252a416d0 Refactor file scanning and handling logic (#6498)
- Moved directory walking and queuing functionality into scan task code
2026-01-27 17:42:15 +11:00
Gykes
244d70e20e Feature: Stash ID Count Filter (#6347)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-01-27 17:26:42 +11:00
WeedLordVegeta420
6f5a7d1f0a Add latest scene sort for performers and studios. (#6501) 2026-01-27 17:24:14 +11:00
dependabot[bot]
b8c5e15217 Bump lodash-es from 4.17.21 to 4.17.23 in /ui/v2.5 (#6511)
Bumps [lodash-es](https://github.com/lodash/lodash) from 4.17.21 to 4.17.23.
- [Release notes](https://github.com/lodash/lodash/releases)
- [Commits](https://github.com/lodash/lodash/compare/4.17.21...4.17.23)

---
updated-dependencies:
- dependency-name: lodash-es
  dependency-version: 4.17.23
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-27 17:13:06 +11:00
WithoutPants
a05500342a Image phash generation (#6497)
* Add image phash generation
* Add phash image filter
* Add phash to image file info and phash image filtering in ui
* Add options to generate image phash for generate/scan tasks
* Add imageIDs input to generate task
* Add generate option to image menus
* Add ellipses to generate
2026-01-27 17:00:56 +11:00
CJ
6bb22146b2 make ImageCard patchable for plugin extensibility (#6470)
* refactor(ui): make ImageCard patchable for plugin extensibility

Refactor ImageCard component to use PatchComponent wrapper.

Changes:
- Wrap ImageCard and sub-components with PatchComponent
- Extract ImageCardPopovers, ImageCardDetails, ImageCardOverlays,
  ImageCardImage as separate patchable components

* Add documentation
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-01-27 16:10:49 +11:00
DogmaDragon
09044b92bf docs: add missing patchable components and library (#6517) 2026-01-27 16:06:27 +11:00
Gykes
2c8e7d709f FR: Add Interfaces to Destroy File Database Entries (#6437) 2026-01-27 16:02:47 +11:00
Gykes
bef4e3fbd5 Feature: Add "Troubleshooting Mode" (#6343)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
Co-authored-by: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com>
2026-01-27 14:26:26 +11:00
WithoutPants
5b3785f164 Revert stashIDCriterionHandler changes. Reimplement stashIDsCriterionHandler to not use stringCriterionHandler (#6496) 2026-01-16 10:21:15 +11:00
sashapp
ed3a239366 Implement stash_ids_endpoint for the SceneFilterType (#6401)
* Implement stash_ids_endpoint for the SceneFilterType
* Reduce code duplication by calling the stashIDsCriterionHandler from the stashIDCriterionHandler
* Mark stash_id_endpoint in SceneFilterType, StudioFilterType, and PerformerFilterType as deprecated
2026-01-14 14:53:40 +11:00
moonrise-outshoot
2a5b59a96a Fix duplicate file detection in zip archives (#6493)
When scanning a zip archive duplicate images are being detected as renames rather than duplicates.

This is because in `scanJob.getFileFS` the size of the inner file (`my_archive.zip/001.png`) was being passed to `OpenZip` rather than the size of the zip archive (`my_archive.zip`), causing it to fail when opening the archive. This caused `handleRename` to incorrectly detect it as a rename.

The effects of that are:
- no info on duplicates in the file data
- the file will take the name/path of the final duplicate scanned rather than the first
2026-01-14 14:52:50 +11:00
WithoutPants
d7d7530c78 Add non-binary gender icon and colour transgender icons (#6489)
* Add data-gender to gender icon and color transgender gender icons
* Upgrade fontawesome to 7.1
* Add non-binary icon and fix title not showing
2026-01-14 14:28:44 +11:00
RyanAtNight
211f06963e Add Invert Selection feature to list toolbars (#6491) 2026-01-14 14:20:07 +11:00
Gykes
0fa132cf60 FR: Add Delete Button For Scene Covers (#6444) 2026-01-14 14:13:41 +11:00
Gykes
77d0008c6d FR: Save & New Button on Objects (#6438) 2026-01-14 14:06:21 +11:00
Valkyr-JS
b4969add27 Plugin API - recommendation row components (#6492)
* Patched RecommendationRow component
* Patched @ant-design/react-slick library to ReactSlick
* Patched GalleryRecommendationRow component
* Patched GroupRecommendationRow component
* Patched ImageRecommendationRow component
* Patched PerformerRecommendationRow component
* Patched SceneRecommendationRow component
* Patched SceneMarkerRecommendationRow component
* Patched StudioRecommendationRow component
* Patched TagRecommendationRow component
2026-01-14 09:29:57 +11:00
Valkyr-JS
6049b21d22 Plugin API - card grid components (#6482)
* SceneCardsGrid plugin API patch
* GalleryCardGrid plugin API patch
* GroupCardGrid plugin API patch
* ImageGridCard plugin API patch
* PerformerCardGrid plugin API patch
* ImageGridCard name corrected
* SceneMarkerCardsGrid plugin API patch
* StudioCardGrid plugin API patch
* TagCardGrid plugin API patch
* GalleryGridCard.tsx renamed to GalleryCardGrid.tsx
* ImageGridCard renamed to ImageCardGrid
* SceneCardsGrid renamed to SceneCardGrid
* SceneMarkerCardsGrid renamed to SceneMarkerCardGrid
2026-01-13 15:49:50 +11:00
ghuds540
deada580e5 fix: align card images to center (#6481) 2026-01-12 11:17:41 +11:00
CJ
95b1bce917 fix(dlna): improve activity tracking accuracy and efficiency (#6483)
* fix(dlna): improve activity tracking accuracy and efficiency

- Remove play duration tracking: DLNA clients buffer aggressively and
  don't report playback position, making duration estimates unreliable.
  Saving inaccurate values corrupts analytics.

- Combine database transactions: Resume time and view count updates
  now happen in a single transaction for atomicity and performance.

- Keep resume time tracking: While imprecise, it provides useful
  "continue watching" hints. The cost of being wrong is low (user
  just seeks).

* remove elasped time check
2026-01-12 11:12:03 +11:00
RyanAtNight
579fc66275 Add checkbox controls to Wall View and Tagger for Scenes, Scene Markers, Images, and Galleries (#6476) 2026-01-12 11:06:57 +11:00
funntime
c9fa3b76d9 Update chromedp and cdproto dependencies (#6486) 2026-01-12 10:46:50 +11:00
Valkyr-JS
cf3489efdc Plugin API - React Font Awesome library (#6487)
* ReactFontAwesome added to plugin API libraries
* ReactFontAwesome added to plugin API export
2026-01-11 18:07:53 +11:00
CJ
9b709ef614 Perf: Add lightweight ListGroupData fragment for groups list (#6478)
Create a new ListGroupData fragment that excludes expensive recursive
count fields (scene_count_all, sub_group_count_all, etc. with depth: -1).
These fields cause 10+ second queries on large databases when loading
the groups list page.

The full GroupData fragment is preserved for detail views where the
recursive counts are needed.
2026-01-06 11:48:16 +11:00
CJ
c0260781a5 fix(scraper): handle base64 data URIs in processImageField (#6480)
Add check to skip HTTP fetch for non-HTTP URLs in processImageField(),
matching the existing behavior in setPerformerImage() and setStudioImage().

This allows scrapers to return base64 data URIs (e.g.,
`data:image/jpeg;base64,...`) directly without triggering an HTTP fetch
error. Previously, processImageField() would attempt to create an HTTP
request with the data URI as the URL, causing "Could not set image using
URL" warnings.
2026-01-06 11:47:32 +11:00
hckrman101
fa80454891 Resume after scrubbing, hide player UI faster (#6336) 2026-01-06 11:46:29 +11:00
Valkyr-JS
1e6bf74385 Plugin API more patchable components (#6463)
* StudioDetailsPanel
* StudioCard
* GridCard
* ImageGridCard
* ImageCard
* GroupCard
* SceneMarkerCard.Popovers
* SceneMarkerCard.Details
* SceneMarkerCard.Image
* SceneMarkerCard
2026-01-06 11:14:48 +11:00
CJ
3b5e1db2aa feat(ui): make CustomFieldsInput patchable via PluginApi (#6468)
Wrap the CustomFieldsInput component with PatchComponent to allow
plugins to modify custom field input behavior. This enables plugins
to inject default fields, modify the onChange handler, or customize
the component rendering.
2026-01-06 11:12:59 +11:00
feederbox826
6eed5390e1 specify URL for stash ID endpoint (#6464) 2026-01-06 11:12:03 +11:00
Gykes
81e8ccb5a9 FR: Autopopulate Stash-ID Search Box (#6447) 2026-01-05 17:34:43 +11:00
Gykes
45dc892a54 FR: Hide Already Installed Plugins or Scrapers (#6443) 2026-01-05 17:04:28 +11:00
Gykes
dc7ebadb16 FR: Update Tray Notification to Include Port (#6448) 2026-01-05 16:37:18 +11:00
Gykes
956af44a29 FR: Sort Scenes and Images by Resolution (#6441) 2026-01-05 16:36:21 +11:00
Gykes
09ba41b2bb Chore: Update htmlQuery and Xpath dependencies (#6434) 2026-01-05 16:21:55 +11:00
Gykes
91e1ec520f FR: Allow Marker Screenshot Generation Unconditionally (#6433) 2026-01-05 16:20:01 +11:00
ayaya
56822dbdc5 Fix hardware decoding detection for 10-bit videos on rkmpp (#6420) 2026-01-05 16:12:44 +11:00
bob12224
39d3e63cbf Remove hiding the age in SFW mode (#6450) 2026-01-05 16:11:42 +11:00
CJ
66ceceeaf1 feat(dlna): add activity tracking for DLNA playback (#6407)
Adds time-based activity tracking for scenes played via DLNA, enabling
play count, play duration, and resume time tracking similar to the
web frontend.

Key features:
- Uses existing 'trackActivity' UI setting (no new config needed)
- Time-based tracking (elapsed session time / video duration)
- 5-minute session timeout to handle aggressive client buffering
- Minimum thresholds before saving (1% watched or 5 seconds)
- Respects minimumPlayPercent setting for play count increment

Implementation:
- New ActivityTracker in internal/dlna/activity.go
- Session management with automatic expiration
- Integration via DLNA service initialization

Limitations:
- Cannot detect actual playback position (only elapsed time)
- Cannot detect seeking or pause state
- Designed for upstream compatibility (no complex dependencies)
2026-01-05 16:10:52 +11:00
sezzim
65e82a0cf6 Performer merge (#5910)
* Implement merging of performers
* Make the tag merge UI consistent with other types of merges
* Add merge action in scene menu
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-01-05 15:54:19 +11:00
WithoutPants
d962247016 Custom favicon and title (#6366)
* Load favicon if provided
* Add custom title setting
2026-01-05 11:30:31 +11:00
WithoutPants
08b87431c3 Safely handle panic in scan queue goroutine (#6431) 2026-01-05 11:28:00 +11:00
WithoutPants
b23b0267ad Merge remote-tracking branch 'upstream/master' into develop 2025-12-18 14:57:33 +11:00
WithoutPants
772c69c359 Update changelog for 0.30.1 2025-12-18 14:54:46 +11:00
WithoutPants
e9f5e7d6b4 Fix handy not working correctly (#6425) 2025-12-18 14:52:42 +11:00
WithoutPants
af11189718 Set organised flag in gallery create (#6418) 2025-12-17 17:13:03 +11:00
WithoutPants
5b62cc66d4 Initialise IsDesktop early to avoid confusion due to ffmpeg checks (#6417) 2025-12-17 13:42:42 +11:00
WithoutPants
857e673d3e Codeberg weblate (#6416)
* Translated using Weblate (Czech)

Currently translated at 100.0% (1250 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/cs/

* Translated using Weblate (Japanese)

Currently translated at 82.0% (1026 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ja/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.1% (864 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.1% (864 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/pt_BR/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1250 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (Indonesian)

Currently translated at 48.3% (604 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/id/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1251 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/

* Translated using Weblate (French)

Currently translated at 100.0% (1251 of 1251 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

---------

Co-authored-by: NymeriaCZ <nymeriacz@noreply.codeberg.org>
Co-authored-by: hirokazuk <hirokazuk@noreply.codeberg.org>
Co-authored-by: BimboStarsFan <bimbostarsfan@noreply.codeberg.org>
Co-authored-by: icaro <icaro@noreply.codeberg.org>
Co-authored-by: wql219 <wql219@noreply.codeberg.org>
Co-authored-by: koukyoukoku <koukyoukoku@noreply.codeberg.org>
Co-authored-by: Zesty6249 <zesty6249@noreply.codeberg.org>
Co-authored-by: doodoo <doodoo@noreply.codeberg.org>
2025-12-17 10:02:35 +11:00
WithoutPants
b2df819283 Update changelog 2025-12-17 09:28:46 +11:00
WithoutPants
f71d0ac2dd Fix scrape result row showing when results are same (#6415) 2025-12-17 08:19:45 +11:00
WithoutPants
b23c3cd618 Perform hardware codec checks on separate go routine (#6414)
Warn if tests are taking a long time
Add WaitDelay to try to kill process if hanging
2025-12-15 14:57:00 +11:00
WithoutPants
1691280d1b Fix excludes handling in performer studio filter (#6413) 2025-12-15 11:49:30 +11:00
WithoutPants
7a8a2c7687 Send inner props to CheckboxSelect Option (#6411)
Fixes onChange handler not being called
2025-12-15 08:45:28 +11:00
WithoutPants
f64cd5bfac Add sfw label for o-count in stats page (#6410) 2025-12-15 08:16:58 +11:00
WithoutPants
65327a6102 Add useInitialState to studio in search result (#6409) 2025-12-15 08:16:46 +11:00
WithoutPants
62babfb332 Add more patchable components (#6404) 2025-12-15 07:28:58 +11:00
WithoutPants
67b1dd8dd0 Add tag stash ids filter criterion (#6403)
* Add stash id filter to tag filter
* Add tag stash id criterion in UI
2025-12-12 08:54:57 +11:00
WithoutPants
25fdf676d2 Handle linking tags in non-stash-box environment (#6402) 2025-12-12 07:49:07 +11:00
WithoutPants
1580cf9bd9 Provide more information when scraper loadURL fails 2025-12-12 07:17:41 +11:00
WithoutPants
badebfd8f9 Codeberg weblate (#6399)
* Translated using Weblate (Swedish)

Currently translated at 100.0% (1233 of 1233 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/

* Translated using Weblate (French)

Currently translated at 100.0% (1236 of 1236 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (1236 of 1236 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/

* Update translation files

Updated by "Cleanup translation files" add-on in Weblate.

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/

* Translated using Weblate (French)

Currently translated at 100.0% (1242 of 1242 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1242 of 1242 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1242 of 1242 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1242 of 1242 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1243 of 1243 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (Dutch)

Currently translated at 78.6% (978 of 1243 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/nl/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1243 of 1243 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/sv/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 100.0% (1243 of 1243 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

* Translated using Weblate (French)

Currently translated at 100.0% (1243 of 1243 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (1245 of 1245 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Hindi)

Currently translated at 5.3% (67 of 1245 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/hi/

* Translated using Weblate (Korean)

Currently translated at 100.0% (1245 of 1245 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/ko/

* Translated using Weblate (German)

Currently translated at 98.0% (1221 of 1245 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/de/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1245 of 1245 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/es/

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1250 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/et/

* Translated using Weblate (French)

Currently translated at 100.0% (1250 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/fr/

* Translated using Weblate (Chinese (Simplified Han script))

Currently translated at 99.9% (1249 of 1250 strings)

Translation: stash/stash
Translate-URL: https://translate.codeberg.org/projects/stash/stash/zh_Hans/

---------

Co-authored-by: AlpacaSerious <alpacaserious@noreply.codeberg.org>
Co-authored-by: doodoo <doodoo@noreply.codeberg.org>
Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: donlothario <donlothario@noreply.codeberg.org>
Co-authored-by: Zesty6249 <zesty6249@noreply.codeberg.org>
Co-authored-by: yec <yec@noreply.codeberg.org>
Co-authored-by: PhilipWaldman <philipwaldman@noreply.codeberg.org>
Co-authored-by: wql219 <wql219@noreply.codeberg.org>
Co-authored-by: asasin235 <asasin235@noreply.codeberg.org>
Co-authored-by: lugged9922 <lugged9922@noreply.codeberg.org>
Co-authored-by: upstairs <upstairs@noreply.codeberg.org>
2025-12-11 14:12:07 +11:00
CJ
f1e54bfc73 Optimize Tag List Page Performance (#6398) 2025-12-11 13:59:19 +11:00
WithoutPants
ebfe5c4b5c Update changelog 2025-12-11 13:47:28 +11:00
WithoutPants
11417590ee Allow string list input to be orderable (#6397)
* Allow string list input to be orderable
* Make alias fields not orderable
* Adjust styling for URL list controls
2025-12-11 13:07:05 +11:00
WithoutPants
0980daa99e Fix issues linking a tag that already exists in the tag list (#6395)
* Add stash-id to existing when linking tag
* Validate id list for duplicates in find queries
* Filter out duplicate ids after linking tag
2025-12-11 11:45:56 +11:00
WithoutPants
5f0d4e811d Revert "Feature Request: Sort All Urls Alphabetically (#6352)" (#6396)
This reverts commit 061d21dede.
2025-12-11 11:38:20 +11:00
WithoutPants
e92a0cb126 Merge pull request #6242 from stashapp/releases/0.29.3
Merge 0.29.3 to master
2025-11-06 17:20:22 +11:00
WithoutPants
49ee2b1cf0 Merge pull request #6189 from stashapp/develop
Merge 0.29.1 to master
2025-10-28 11:16:52 +11:00
DogmaDragon
714afd98b4 Update README to mention official forum [skip ci] 2025-04-20 11:22:59 +03:00
WithoutPants
ab77a9334c Merge pull request #4028 from stashapp/develop
Merge to master for release
2023-08-14 17:35:32 +10:00
WithoutPants
d7bc248cf4 Merge pull request #3821 from stashapp/develop
Merge to master
2023-06-14 12:07:49 +10:00
WithoutPants
22dc0bbf77 Merge pull request #3667 from stashapp/develop
Merge develop to master for 0.20.2 release
2023-04-17 15:13:41 +10:00
WithoutPants
be8f57d6ca Merge pull request #3217 from stashapp/develop
Merge to master for 0.18
2022-11-30 14:19:41 +11:00
WithoutPants
c3702c5bd2 Merge pull request #3018 from stashapp/develop
Merge to master for release
2022-10-19 11:22:11 +11:00
WithoutPants
38ade2b4b6 Merge pull request #2714 from stashapp/develop
Post-release merge to master
2022-07-05 10:58:13 +10:00
WithoutPants
c7b53777dc Merge pull request #2602 from stashapp/develop
Merge 0.15 to master
2022-05-20 11:35:56 +10:00
techie2000
8fe32fd778 Correct 'reload scrapes' path (#2583) 2022-05-13 12:03:20 -07:00
WithoutPants
1ced75a45e Merge pull request #2498 from stashapp/develop
Merge to master for release
2022-04-12 14:17:59 +10:00
926 changed files with 48930 additions and 21079 deletions

View File

@@ -4,8 +4,17 @@ labels: ["bug report"]
body: body:
- type: markdown - type: markdown
attributes: attributes:
value: | 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.
Thanks for taking the time to fill out this bug report! - 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.
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
- type: textarea - type: textarea
id: description id: description
attributes: attributes:
@@ -47,13 +56,13 @@ body:
placeholder: (e.g. v0.28.1) placeholder: (e.g. v0.28.1)
validations: validations:
required: true required: true
- type: input - type: textarea
id: devicedetails id: devicedetails
attributes: attributes:
label: Device details label: Device details
description: | 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. Please provide details about the device you are using, including the operating system and browser (if applicable).
placeholder: (e.g. Firefox 97 (64-bit) on Windows 11) placeholder: Firefox 97 (64-bit) on Windows 11
validations: validations:
required: false required: false
- type: textarea - type: textarea
@@ -61,4 +70,4 @@ body:
attributes: attributes:
label: Relevant log output label: Relevant log output
description: Please copy and paste any relevant log output from Settings > Logs. This will be automatically formatted into code, so no need for backticks. description: Please copy and paste any relevant log output from Settings > Logs. This will be automatically formatted into code, so no need for backticks.
render: shell render: shell

View File

@@ -2,6 +2,9 @@ name: Feature Request
description: Request a new feature or idea to be added to Stash description: Request a new feature or idea to be added to Stash
labels: ["feature request"] labels: ["feature request"]
body: 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 - type: textarea
id: description id: description
attributes: attributes:

View File

@@ -1,18 +0,0 @@
---
name: Bug Fix
about: Add a bug fix this project!
title: "[Bug Fix] Short Form Title (50 chars or less.)"
labels: bug
assignees: 'WithoutPants, bnkai, Leopere'
---
<!-- Please make sure to read https://github.com/stashapp/stash/docs/CONTRIBUTING.md and check that you understand and have followed it as best as possible -->
<!-- Explain what your bugfix seeks to remedy in a short paragraph. -->
# Scope
<!-- Declare any issues by typing `fixes #1` or `closes #1` for example so that the automation can kick in when this is merged -->
## Closes/Fixes Issues
<!-- What have you tested specifically and what possible impacts/areas there are that may need retesting by others. -->
## Other testing QA Notes

View File

@@ -1,17 +0,0 @@
---
name: Feature Addition
about: Add a feature to this project!
title: "[Feature] Short Form Title (50 chars or less.)"
labels: enhancement
assignees: 'WithoutPants, bnkai, Leopere'
---
<!-- Please make sure to read https://github.com/stashapp/stash/docs/CONTRIBUTING.md and check that you understand and have followed it as best as possible
Explain what your feature does in a short paragraph. -->
# Scope
<!-- Declare any issues by typing `fixes #1` or `closes #1` for example so that the automation can kick in when this is merged -->
## Closes/Fixes Issues
<!-- What have you tested specifically and what possible impacts/areas there are that may need retesting by others. -->
## Other testing QA Notes

39
.github/pull_request_template.md vendored Normal file
View File

@@ -0,0 +1,39 @@
<!-- Thank you for submitting a pull request! Make sure to follow the contributing guidelines and this template. -->
## Description
<!-- Please write a clear and concise description of what the pull request does. -->
## Related Issue
<!-- Please link the issue your pull request is referring to. -->
## Testing
<!-- Describe the testing steps you have performed. -->
## Screenshots
<!-- For visual changes, please add before and after screenshots. -->
## Checklist
<!-- Mark [x] to indicate completion. -->
- [ ] 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
<!-- Mark [x] to indicate completion. -->
- [ ] I have used AI tools to assist with this pull request, and I have disclosed the tools and how I used them below.
<!-- If you used AI to assist with this pull request, please disclose what tools you used and how you used them. -->
## Additional Context
<!-- Add any other context about the pull request here. -->

28
.github/workflows/build-compiler.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Compiler Build
on:
workflow_dispatch:
env:
COMPILER_IMAGE: ghcr.io/stashapp/compiler:14
jobs:
build-compiler:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3
- uses: docker/build-push-action@v6
with:
push: true
context: "{{defaultContext}}:docker/compiler"
tags: |
${{ env.COMPILER_IMAGE }}
ghcr.io/stashapp/compiler:latest
cache-from: type=gha,scope=all,mode=max
cache-to: type=gha,scope=all,mode=max

View File

@@ -2,7 +2,7 @@ name: Build
on: on:
push: push:
branches: branches:
- develop - develop
- master - master
- 'releases/**' - 'releases/**'
@@ -15,50 +15,175 @@ concurrency:
cancel-in-progress: true cancel-in-progress: true
env: env:
COMPILER_IMAGE: stashapp/compiler:12 COMPILER_IMAGE: ghcr.io/stashapp/compiler:14
jobs: jobs:
build: # Job 1: Generate code and build UI
runs-on: ubuntu-22.04 # Runs natively (no Docker) — go generate/gqlgen and node don't need cross-compilers.
# Produces artifacts (generated Go files + UI build) consumed by test and build jobs.
generate:
runs-on: ubuntu-24.04
steps: steps:
- uses: actions/checkout@v2 - uses: actions/checkout@v6
with:
- name: Checkout fetch-depth: 0
run: git fetch --prune --unshallow --tags fetch-tags: true
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v6
with: with:
go-version-file: 'go.mod' go-version-file: 'go.mod'
- name: Pull compiler image # pnpm version is read from the packageManager field in package.json
run: docker pull $COMPILER_IMAGE # very broken (4.3, 4.4)
- name: Install pnpm
- name: Cache node modules uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
uses: actions/cache@v3
env:
cache-name: cache-node_modules
with: with:
path: ui/v2.5/node_modules package_json_file: ui/v2.5/package.json
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/pnpm-lock.yaml') }}
# ensure pnpm store path exists to fix post setup node.js error
# https://github.com/actions/setup-node/issues/1137#issuecomment-2508963254
- name: Ensure pnpm store path exists
run: |
PNPM_STORE_PATH="$( pnpm store path --silent )"
if [ ! -d "$PNPM_STORE_PATH" ]; then
echo "PNPM store directory does not exist, creating it."
mkdir -p "$PNPM_STORE_PATH"
fi
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: '20'
cache: 'pnpm'
cache-dependency-path: ui/v2.5/pnpm-lock.yaml
- name: Install UI dependencies
run: make pre-ui
- name: Generate
run: make generate
- name: Cache UI build - name: Cache UI build
uses: actions/cache@v3 uses: actions/cache@v5
id: cache-ui id: cache-ui
env:
cache-name: cache-ui
with: with:
path: ui/v2.5/build path: ui/v2.5/build
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/pnpm-lock.yaml', 'ui/v2.5/public/**', 'ui/v2.5/src/**', 'graphql/**/*.graphql') }} key: ${{ runner.os }}-ui-build-${{ hashFiles('ui/v2.5/pnpm-lock.yaml', 'ui/v2.5/public/**', 'ui/v2.5/src/**', 'graphql/**/*.graphql') }}
- name: Cache go build - name: Validate UI
uses: actions/cache@v3 # skip UI validation for pull requests if UI is unchanged
env: if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }}
# increment the number suffix to bump the cache run: make validate-ui
cache-name: cache-go-cache-1
- name: Build UI
# skip UI build for pull requests if UI is unchanged (UI was cached)
if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }}
run: make ui
# Bundle generated Go files + UI build for downstream jobs (test + build)
- name: Upload generated artifacts
uses: actions/upload-artifact@v7
with:
name: generated
retention-days: 1
path: |
internal/api/generated_exec.go
internal/api/generated_models.go
ui/v2.5/build/
ui/login/locales/
# Job 2: Integration tests
# Runs natively (no Docker) — only needs Go + GCC (for CGO/SQLite), both on ubuntu-22.04.
# Runs in parallel with the build matrix jobs.
test:
needs: generate
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
# Places generated Go files + UI build into the working tree so the build compiles
- name: Download generated artifacts
uses: actions/download-artifact@v8
with:
name: generated
- name: Test Backend
run: make it
# Job 3: Cross-compile for all platforms
# Each platform gets its own runner and Docker container (ghcr.io/stashapp/compiler:13).
# Each build-cc-* make target is self-contained (sets its own GOOS/GOARCH/CC),
# so running them in separate containers is functionally identical to one container.
# Runs in parallel with the test job.
build:
needs: generate
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- platform: windows
make-target: build-cc-windows
artifact-paths: |
dist/stash-win.exe
tag: win
- platform: macos
make-target: build-cc-macos
artifact-paths: |
dist/stash-macos
dist/Stash.app.zip
tag: osx
- platform: linux
make-target: build-cc-linux
artifact-paths: |
dist/stash-linux
tag: linux
- platform: linux-arm64v8
make-target: build-cc-linux-arm64v8
artifact-paths: |
dist/stash-linux-arm64v8
tag: arm
- platform: linux-arm32v7
make-target: build-cc-linux-arm32v7
artifact-paths: |
dist/stash-linux-arm32v7
tag: arm
- platform: linux-arm32v6
make-target: build-cc-linux-arm32v6
artifact-paths: |
dist/stash-linux-arm32v6
tag: arm
- platform: freebsd
make-target: build-cc-freebsd
artifact-paths: |
dist/stash-freebsd
tag: freebsd
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
- name: Download generated artifacts
uses: actions/download-artifact@v8
with:
name: generated
- name: Cache Go build
uses: actions/cache@v5
with: with:
path: .go-cache path: .go-cache
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('go.mod', '**/go.sum') }} key: ${{ runner.os }}-go-cache-${{ matrix.platform }}-${{ hashFiles('go.mod', '**/go.sum') }}
# kept separate to test timings
- name: pull compiler image
run: docker pull $COMPILER_IMAGE
- name: Start build container - name: Start build container
env: env:
@@ -67,45 +192,50 @@ jobs:
mkdir -p .go-cache mkdir -p .go-cache
docker run -d --name build --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated --mount type=bind,source="$(pwd)/.go-cache",target=/root/.cache/go-build,consistency=delegated --env OFFICIAL_BUILD=${{ env.official-build }} -w /stash $COMPILER_IMAGE tail -f /dev/null docker run -d --name build --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated --mount type=bind,source="$(pwd)/.go-cache",target=/root/.cache/go-build,consistency=delegated --env OFFICIAL_BUILD=${{ env.official-build }} -w /stash $COMPILER_IMAGE tail -f /dev/null
- name: Pre-install - name: Build (${{ matrix.platform }})
run: docker exec -t build /bin/bash -c "make CI=1 pre-ui" run: docker exec -t build /bin/bash -c "make ${{ matrix.make-target }}"
- name: Generate
run: docker exec -t build /bin/bash -c "make generate"
- name: Validate UI
# skip UI validation for pull requests if UI is unchanged
if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }}
run: docker exec -t build /bin/bash -c "make validate-ui"
# Static validation happens in the linter workflow in parallel to this workflow
# Run Dynamic validation here, to make sure we pass all the projects integration tests
- name: Test Backend
run: docker exec -t build /bin/bash -c "make it"
- name: Build UI
# skip UI build for pull requests if UI is unchanged (UI was cached)
# this means that the build version/time may be incorrect if the UI is
# not changed in a pull request
if: ${{ github.event_name != 'pull_request' || steps.cache-ui.outputs.cache-hit != 'true' }}
run: docker exec -t build /bin/bash -c "make ui"
- name: Compile for all supported platforms
run: |
docker exec -t build /bin/bash -c "make build-cc-windows"
docker exec -t build /bin/bash -c "make build-cc-macos"
docker exec -t build /bin/bash -c "make build-cc-linux"
docker exec -t build /bin/bash -c "make build-cc-linux-arm64v8"
docker exec -t build /bin/bash -c "make build-cc-linux-arm32v7"
docker exec -t build /bin/bash -c "make build-cc-linux-arm32v6"
docker exec -t build /bin/bash -c "make build-cc-freebsd"
- name: Zip UI
run: docker exec -t build /bin/bash -c "make zip-ui"
- name: Cleanup build container - name: Cleanup build container
run: docker rm -f -v build run: docker rm -f -v build
- name: Upload build artifact
uses: actions/upload-artifact@v7
with:
name: build-${{ matrix.platform }}
retention-days: 1
path: ${{ matrix.artifact-paths }}
# Job 4: Release
# Waits for both test and build to pass, then collects all platform artifacts
# into dist/ for checksums, GitHub releases, and multi-arch Docker push.
release:
needs: [test, build]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
fetch-tags: true
# Downloads all artifacts (generated + 7 platform builds) into artifacts/ subdirectories
- name: Download all build artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
# Reassemble platform binaries from matrix job artifacts into a single dist/ directory
# make sure that artifacts have executable bit set
# upload-artifact@v4 strips the common path prefix (dist/), so files are at the artifact root
- name: Collect binaries
run: |
mkdir -p dist
cp artifacts/build-*/* dist/
chmod +x dist/*
- name: Zip UI
run: |
cd artifacts/generated/ui/v2.5/build && zip -r ../../../../../dist/stash-ui.zip .
- name: Generate checksums - name: Generate checksums
run: | run: |
git describe --tags --exclude latest_develop | tee CHECKSUMS_SHA1 git describe --tags --exclude latest_develop | tee CHECKSUMS_SHA1
@@ -116,7 +246,7 @@ jobs:
- name: Upload Windows binary - name: Upload Windows binary
# only upload binaries for pull requests # only upload binaries for pull requests
if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: stash-win.exe name: stash-win.exe
path: dist/stash-win.exe path: dist/stash-win.exe
@@ -124,15 +254,23 @@ jobs:
- name: Upload macOS binary - name: Upload macOS binary
# only upload binaries for pull requests # only upload binaries for pull requests
if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: stash-macos name: stash-macos
path: dist/stash-macos path: dist/stash-macos
- name: Upload macOS bundle
# only upload binaries for pull requests
if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}
uses: actions/upload-artifact@v7
with:
name: Stash.app.zip
path: dist/Stash.app.zip
- name: Upload Linux binary - name: Upload Linux binary
# only upload binaries for pull requests # only upload binaries for pull requests
if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: stash-linux name: stash-linux
path: dist/stash-linux path: dist/stash-linux
@@ -140,14 +278,14 @@ jobs:
- name: Upload UI - name: Upload UI
# only upload for pull requests # only upload for pull requests
if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}} if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v7
with: with:
name: stash-ui.zip name: stash-ui.zip
path: dist/stash-ui.zip path: dist/stash-ui.zip
- name: Update latest_develop tag - name: Update latest_develop tag
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
run : git tag -f latest_develop; git push -f --tags run: git tag -f latest_develop; git push -f --tags
- name: Development Release - name: Development Release
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }} if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
@@ -197,7 +335,7 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: | run: |
docker run --rm --privileged docker/binfmt:a7996909642ee92942dcd6cff44b9b95f08dad64 docker run --rm --privileged tonistiigi/binfmt
docker info docker info
docker buildx create --name builder --use docker buildx create --name builder --use
docker buildx inspect --bootstrap docker buildx inspect --bootstrap
@@ -213,7 +351,7 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }} DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }} DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: | run: |
docker run --rm --privileged docker/binfmt:a7996909642ee92942dcd6cff44b9b95f08dad64 docker run --rm --privileged tonistiigi/binfmt
docker info docker info
docker buildx create --name builder --use docker buildx create --name builder --use
docker buildx inspect --bootstrap docker buildx inspect --bootstrap

View File

@@ -9,65 +9,24 @@ on:
- 'releases/**' - 'releases/**'
pull_request: pull_request:
env:
COMPILER_IMAGE: stashapp/compiler:12
jobs: jobs:
golangci: golangci:
name: lint name: lint
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v2 # no tags or depth needed for lint
- uses: actions/checkout@v6
- name: Checkout - uses: actions/setup-go@v6
run: git fetch --prune --unshallow --tags
- name: Setup Go
uses: actions/setup-go@v5
with: with:
go-version-file: 'go.mod' go-version-file: 'go.mod'
- name: Pull compiler image # generate-backend runs natively (just go generate + touch-ui) — no Docker needed
run: docker pull $COMPILER_IMAGE
- name: Start build container
run: |
mkdir -p .go-cache
docker run -d --name build --mount type=bind,source="$(pwd)",target=/stash,consistency=delegated --mount type=bind,source="$(pwd)/.go-cache",target=/root/.cache/go-build,consistency=delegated -w /stash $COMPILER_IMAGE tail -f /dev/null
- name: Generate Backend - name: Generate Backend
run: docker exec -t build /bin/bash -c "make generate-backend" run: make generate-backend
## WARN
## using v1, update in a later PR
- name: Run golangci-lint - name: Run golangci-lint
uses: golangci/golangci-lint-action@v6 uses: golangci/golangci-lint-action@v8
with: with:
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version version: v2.11.4
version: latest
# Optional: working directory, useful for monorepos
# working-directory: somedir
# Optional: golangci-lint command line arguments.
#
# Note: By default, the `.golangci.yml` file should be at the root of the repository.
# The location of the configuration file can be changed by using `--config=`
args: --timeout=5m
# Optional: show only new issues if it's a pull request. The default value is `false`.
# only-new-issues: true
# Optional: if set to true, then all caching functionality will be completely disabled,
# takes precedence over all other caching options.
# skip-cache: true
# Optional: if set to true, then the action won't cache or restore ~/go/pkg.
# skip-pkg-cache: true
# Optional: if set to true, then the action won't cache or restore ~/.cache/go-build.
# skip-build-cache: true
# Optional: The mode to install golangci-lint. It can be 'binary' or 'goinstall'.
# install-mode: "goinstall"
- name: Cleanup build container
run: docker rm -f -v build

2
.gitignore vendored
View File

@@ -63,4 +63,4 @@ node_modules
/phasher /phasher
dist dist
.DS_Store .DS_Store
/.local* /.local*

View File

@@ -1,87 +1,100 @@
# options for analysis running version: "2"
run:
timeout: 5m
linters: linters:
disable-all: true default: none
enable: enable:
# Default set of linters from golangci-lint
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- typecheck
- unused
# Linters added by the stash project.
# - contextcheck
- copyloopvar - copyloopvar
- dogsled - dogsled
- errcheck
- errchkjson - errchkjson
- errorlint - errorlint
# - exhaustive
- gocritic - gocritic
# - goerr113 - govet
- gofmt - ineffassign
# - gomnd
# - ifshort
- misspell - misspell
# - nakedret
- noctx # TODO - fix these in a later PR
# - noctx
- revive - revive
- rowserrcheck - rowserrcheck
- sqlclosecheck - sqlclosecheck
- staticcheck
# Project-specific linter overrides - unused
linters-settings:
gofmt: settings:
simplify: false staticcheck:
checks:
errorlint: - all
# Disable errorf because there are false positives, where you don't want to wrap
# an error. # we specify (unnecessary) embedded fields for clarity in many places
errorf: false - -QF1008
asserts: true
comparison: true # there's lots of misnamed (eg intId instead of intID) fields in the code.
# it's not exactly world-ending, so I'm deferring fixing these for now
revive: - -ST1003
ignore-generated-header: true errorlint:
severity: error errorf: false
confidence: 0.8 asserts: true
rules: comparison: true
- name: blank-imports revive:
disabled: true confidence: 0.8
- name: context-as-argument severity: error
- name: context-keys-type rules:
- name: dot-imports - name: blank-imports
- name: error-return disabled: true
- name: error-strings - name: context-as-argument
- name: error-naming - name: context-keys-type
- name: exported - name: dot-imports
disabled: true - name: error-return
- name: if-return - name: error-strings
disabled: true - name: error-naming
- name: increment-decrement - name: exported
- name: var-naming disabled: true
disabled: true - name: if-return
- name: var-declaration disabled: true
- name: package-comments - name: increment-decrement
- name: range - name: var-naming
- name: receiver-naming disabled: true
- name: time-naming - name: var-declaration
- name: unexported-return - name: package-comments
disabled: true - name: range
- name: indent-error-flow - name: receiver-naming
disabled: true - name: time-naming
- name: errorf - name: unexported-return
- name: empty-block disabled: true
disabled: true - name: indent-error-flow
- name: superfluous-else disabled: true
- name: unused-parameter - name: errorf
disabled: true - name: empty-block
- name: unreachable-code disabled: true
- name: redefines-builtin-id - name: superfluous-else
- name: unused-parameter
rowserrcheck: disabled: true
packages: - name: unreachable-code
- github.com/jmoiron/sqlx - name: redefines-builtin-id
rowserrcheck:
packages:
- github.com/jmoiron/sqlx
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters:
enable:
- gofmt
settings:
gofmt:
simplify: false
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@@ -10,10 +10,12 @@ ifdef IS_WIN_SHELL
RM := del /s /q RM := del /s /q
RMDIR := rmdir /s /q RMDIR := rmdir /s /q
NOOP := @@ NOOP := @@
PREFIX := $(USERPROFILE)\\bin
else else
RM := rm -f RM := rm -f
RMDIR := rm -rf RMDIR := rm -rf
NOOP := @: NOOP := @:
PREFIX := $(HOME)/.local
endif endif
# set LDFLAGS environment variable to any extra ldflags required # set LDFLAGS environment variable to any extra ldflags required
@@ -40,9 +42,6 @@ GO_BUILD_FLAGS := $(GO_BUILD_FLAGS)
GO_BUILD_TAGS := $(GO_BUILD_TAGS) GO_BUILD_TAGS := $(GO_BUILD_TAGS)
GO_BUILD_TAGS += sqlite_stat4 sqlite_math_functions GO_BUILD_TAGS += sqlite_stat4 sqlite_math_functions
# set STASH_NOLEGACY environment variable or uncomment to disable legacy browser support
# STASH_NOLEGACY := true
# set STASH_SOURCEMAPS environment variable or uncomment to enable UI sourcemaps # set STASH_SOURCEMAPS environment variable or uncomment to enable UI sourcemaps
# STASH_SOURCEMAPS := true # STASH_SOURCEMAPS := true
@@ -50,11 +49,17 @@ export CGO_ENABLED := 1
# define COMPILER_IMAGE for cross-compilation docker container # define COMPILER_IMAGE for cross-compilation docker container
ifndef COMPILER_IMAGE ifndef COMPILER_IMAGE
COMPILER_IMAGE := stashapp/compiler:latest COMPILER_IMAGE := ghcr.io/stashapp/compiler:latest
endif endif
# cannot really parallelise the release target
# generate requires pre-ui, ui requires generate and build-release requires ui, so they must be run sequentially
.PHONY: release .PHONY: release
release: pre-ui generate ui build-release release:
$(MAKE) pre-ui
$(MAKE) generate
$(MAKE) ui
$(MAKE) build-release
# targets to set various build flags # targets to set various build flags
# use combinations on the make command-line to configure a build, e.g.: # use combinations on the make command-line to configure a build, e.g.:
@@ -98,7 +103,7 @@ flags-static-windows:
.PHONY: build-info .PHONY: build-info
build-info: build-info:
ifndef BUILD_DATE ifndef BUILD_DATE
$(eval BUILD_DATE := $(shell go run scripts/getDate.go)) $(eval BUILD_DATE := $(shell GOOS=$$(go env GOHOSTOS) GOARCH=$$(go env GOHOSTARCH) go run scripts/getDate.go))
endif endif
ifndef GITHASH ifndef GITHASH
$(eval GITHASH := $(shell git rev-parse --short HEAD)) $(eval GITHASH := $(shell git rev-parse --short HEAD))
@@ -129,7 +134,7 @@ phasher: build-flags
# builds dynamically-linked debug binaries # builds dynamically-linked debug binaries
.PHONY: build .PHONY: build
build: stash phasher build: stash
# builds dynamically-linked PIE release binaries # builds dynamically-linked PIE release binaries
.PHONY: build-release .PHONY: build-release
@@ -187,8 +192,6 @@ build-cc-macos:
# Combine into universal binaries # Combine into universal binaries
lipo -create -output dist/stash-macos dist/stash-macos-intel dist/stash-macos-arm lipo -create -output dist/stash-macos dist/stash-macos-intel dist/stash-macos-arm
rm dist/stash-macos-intel dist/stash-macos-arm rm dist/stash-macos-intel dist/stash-macos-arm
lipo -create -output dist/phasher-macos dist/phasher-macos-intel dist/phasher-macos-arm
rm dist/phasher-macos-intel dist/phasher-macos-arm
# Place into bundle and zip up # Place into bundle and zip up
rm -rf dist/Stash.app rm -rf dist/Stash.app
@@ -198,6 +201,16 @@ build-cc-macos:
cd dist && rm -f Stash.app.zip && zip -r Stash.app.zip Stash.app cd dist && rm -f Stash.app.zip && zip -r Stash.app.zip Stash.app
rm -rf dist/Stash.app rm -rf dist/Stash.app
.PHONY: build-cc-macos-phasher
build-cc-macos-phasher:
make build-cc-macos-arm
make build-cc-macos-intel
# Combine into universal binaries
lipo -create -output dist/phasher-macos dist/phasher-macos-intel dist/phasher-macos-arm
rm dist/phasher-macos-intel dist/phasher-macos-arm
# do not bundle phasher
.PHONY: build-cc-freebsd .PHONY: build-cc-freebsd
build-cc-freebsd: export GOOS := freebsd build-cc-freebsd: export GOOS := freebsd
build-cc-freebsd: export GOARCH := amd64 build-cc-freebsd: export GOARCH := amd64
@@ -275,7 +288,7 @@ generate: generate-backend generate-ui
.PHONY: generate-ui .PHONY: generate-ui
generate-ui: generate-ui:
cd ui/v2.5 && npm run gqlgen cd ui/v2.5 && pnpm run gqlgen
.PHONY: generate-backend .PHONY: generate-backend
generate-backend: touch-ui generate-backend: touch-ui
@@ -357,9 +370,6 @@ ui-env: build-info
$(eval export VITE_APP_DATE := $(BUILD_DATE)) $(eval export VITE_APP_DATE := $(BUILD_DATE))
$(eval export VITE_APP_GITHASH := $(GITHASH)) $(eval export VITE_APP_GITHASH := $(GITHASH))
$(eval export VITE_APP_STASH_VERSION := $(STASH_VERSION)) $(eval export VITE_APP_STASH_VERSION := $(STASH_VERSION))
ifdef STASH_NOLEGACY
$(eval export VITE_APP_NOLEGACY := true)
endif
ifdef STASH_SOURCEMAPS ifdef STASH_SOURCEMAPS
$(eval export VITE_APP_SOURCEMAPS := true) $(eval export VITE_APP_SOURCEMAPS := true)
endif endif
@@ -369,7 +379,7 @@ ui: ui-only generate-login-locale
.PHONY: ui-only .PHONY: ui-only
ui-only: ui-env ui-only: ui-env
cd ui/v2.5 && npm run build cd ui/v2.5 && pnpm run build
.PHONY: zip-ui .PHONY: zip-ui
zip-ui: zip-ui:
@@ -378,23 +388,23 @@ zip-ui:
.PHONY: ui-start .PHONY: ui-start
ui-start: ui-env ui-start: ui-env
cd ui/v2.5 && npm run start -- --host cd ui/v2.5 && pnpm run start --host
.PHONY: fmt-ui .PHONY: fmt-ui
fmt-ui: fmt-ui:
cd ui/v2.5 && npm run format cd ui/v2.5 && pnpm run format
# runs all of the frontend PR-acceptance steps # runs all of the frontend PR-acceptance steps
.PHONY: validate-ui .PHONY: validate-ui
validate-ui: validate-ui:
cd ui/v2.5 && npm run validate cd ui/v2.5 && pnpm run validate
# these targets run the same steps as fmt-ui and validate-ui, but only on files that have changed # these targets run the same steps as fmt-ui and validate-ui, but only on files that have changed
fmt-ui-quick: fmt-ui-quick:
cd ui/v2.5 && \ cd ui/v2.5 && \
files=$$(git diff --name-only --relative --diff-filter d . ../../graphql); \ files=$$(git diff --name-only --relative --diff-filter d . ../../graphql); \
if [ -n "$$files" ]; then \ if [ -n "$$files" ]; then \
npm run prettier -- --write $$files; \ pnpm exec biome format --write $$files; \
fi fi
# does not run tsc checks, as they are slow # does not run tsc checks, as they are slow
@@ -403,9 +413,9 @@ validate-ui-quick:
tsfiles=$$(git diff --name-only --relative --diff-filter d src | grep -e "\.tsx\?\$$"); \ tsfiles=$$(git diff --name-only --relative --diff-filter d src | grep -e "\.tsx\?\$$"); \
scssfiles=$$(git diff --name-only --relative --diff-filter d src | grep "\.scss"); \ scssfiles=$$(git diff --name-only --relative --diff-filter d src | grep "\.scss"); \
prettyfiles=$$(git diff --name-only --relative --diff-filter d . ../../graphql); \ prettyfiles=$$(git diff --name-only --relative --diff-filter d . ../../graphql); \
if [ -n "$$tsfiles" ]; then npm run eslint -- $$tsfiles; fi && \ if [ -n "$$tsfiles" ]; then pnpm exec biome check $$tsfiles; fi && \
if [ -n "$$scssfiles" ]; then npm run stylelint -- $$scssfiles; fi && \ if [ -n "$$scssfiles" ]; then pnpm exec stylelint $$scssfiles; fi && \
if [ -n "$$prettyfiles" ]; then npm run prettier -- --check $$prettyfiles; fi if [ -n "$$prettyfiles" ]; then pnpm exec biome format $$prettyfiles; fi
# runs all of the backend PR-acceptance steps # runs all of the backend PR-acceptance steps
.PHONY: validate-backend .PHONY: validate-backend
@@ -436,4 +446,14 @@ start-compiler-container:
.PHONY: remove-compiler-container .PHONY: remove-compiler-container
remove-compiler-container: remove-compiler-container:
docker rm -f -v build docker rm -f -v build
.PHONY: install
install:
ifdef IS_WIN_SHELL
@if not exist "$(PREFIX)" mkdir $(PREFIX)
@copy "dist\\stash-win.exe" "$(PREFIX)\\stash-win.exe"
else
@mkdir -p $(PREFIX)/bin
@install -m 755 $(STASH_OUTPUT) $(PREFIX)/bin/stash
endif

111
README.md
View File

@@ -1,40 +1,44 @@
# Stash # Stash
[![Build](https://github.com/stashapp/stash/actions/workflows/build.yml/badge.svg?branch=develop&event=push)](https://github.com/stashapp/stash/actions/workflows/build.yml) [![Build](https://github.com/stashapp/stash/actions/workflows/build.yml/badge.svg?branch=develop&event=push)](https://github.com/stashapp/stash/actions/workflows/build.yml)
[![Docker pulls](https://img.shields.io/docker/pulls/stashapp/stash.svg)](https://hub.docker.com/r/stashapp/stash 'DockerHub') [![Docker pulls](https://img.shields.io/docker/pulls/stashapp/stash?logo=docker)](https://hub.docker.com/r/stashapp/stash 'DockerHub')
[![GitHub Sponsors](https://img.shields.io/github/sponsors/stashapp?logo=github)](https://github.com/sponsors/stashapp) [![GitHub Sponsors](https://img.shields.io/github/sponsors/stashapp?logo=github)](https://github.com/sponsors/stashapp)
[![Open Collective backers](https://img.shields.io/opencollective/backers/stashapp?logo=opencollective)](https://opencollective.com/stashapp) [![Open Collective backers](https://img.shields.io/opencollective/backers/stashapp?logo=opencollective)](https://opencollective.com/stashapp)
[![Go Report Card](https://goreportcard.com/badge/github.com/stashapp/stash)](https://goreportcard.com/report/github.com/stashapp/stash) [![Go Report Card](https://goreportcard.com/badge/github.com/stashapp/stash)](https://goreportcard.com/report/github.com/stashapp/stash)
[![Discord](https://img.shields.io/discord/559159668438728723.svg?logo=discord)](https://discord.gg/2TsNFKt) [![Discord](https://img.shields.io/discord/559159668438728723.svg?logo=discord)](https://discord.gg/2TsNFKt)
[![GitHub release (latest by date)](https://img.shields.io/github/v/release/stashapp/stash?logo=github)](https://github.com/stashapp/stash/releases/latest) [![GitHub release (latest by date)](https://img.shields.io/github/v/release/stashapp/stash?logo=github)](https://github.com/stashapp/stash/releases/latest)
[![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty)](https://github.com/stashapp/stash/labels/bounty) [![Codeberg Translate](https://img.shields.io/weblate/progress/stash?server=https%3A%2F%2Ftranslate.codeberg.org&logo=weblate)](https://translate.codeberg.org/engage/stash/)
[![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty?logo=github)](https://github.com/stashapp/stash/labels/bounty)
### **Stash is a self-hosted webapp written in Go which organizes and serves your diverse content collection, catering to both your SFW and NSFW needs.** <h3>Stash is a self-hosted webapp written in Go which organizes and serves your diverse content collection, catering to both your SFW and NSFW needs.</h3>
![Screenshot of Stash web application interface](docs/readme_assets/demo_image.png) ![Screenshot of Stash web application interface](docs/readme_assets/demo_image.png)
* Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites. - Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites.
* Stash supports a wide variety of both video and image formats. - Stash supports a wide variety of both video and image formats.
* You can tag videos and find them later. - You can tag videos and find them later.
* Stash provides statistics about performers, tags, studios and more. - Stash provides statistics about performers, tags, studios and more.
You can [watch a SFW demo video](https://vimeo.com/545323354) to see it in action. You can [watch a SFW demo video](https://vimeo.com/545323354) to see it in action.
For further information you can consult the [documentation](https://docs.stashapp.cc) or access the in-app manual from within the application (also available at [docs.stashapp.cc/in-app-manual](https://docs.stashapp.cc/in-app-manual)). For further information see [Support & Resources](#support--resources) section.
# Installing Stash ## Installing Stash
> [!tip]
Step-by-step instructions are available at [docs.stashapp.cc/installation](https://docs.stashapp.cc/installation/). Step-by-step instructions are available at [docs.stashapp.cc/installation](https://docs.stashapp.cc/installation/).
#### Windows Users: > [!important]
> **Windows Users**
As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._ >
At least Windows 10 or Server 2016 is required. > As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._
> At least Windows 10 or Server 2016 is required.
#### Mac Users: >
> **macOS Users**
As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later. >
Stash can still be run through docker on older versions of macOS. > As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later.
> As of version 0.32.0, Stash requires _macOS 12 Monterey_ or later.
> Older versions can still be run through Docker.
<img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS | <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker <img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS | <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker
:---:|:---:|:---:|:---: :---:|:---:|:---:|:---:
@@ -42,7 +46,7 @@ Stash can still be run through docker on older versions of macOS.
Download links for other platforms and architectures are available on the [Releases](https://github.com/stashapp/stash/releases) page. Download links for other platforms and architectures are available on the [Releases](https://github.com/stashapp/stash/releases) page.
## First Run ### First Run
#### Windows/macOS Users: Security Prompt #### Windows/macOS Users: Security Prompt
@@ -55,9 +59,9 @@ On Windows or macOS, running the app might present a security prompt since the a
Stash requires FFmpeg. If you don't have it installed, Stash will prompt you to download a copy during setup. It is recommended that Linux users install `ffmpeg` from their distro's package manager. Stash requires FFmpeg. If you don't have it installed, Stash will prompt you to download a copy during setup. It is recommended that Linux users install `ffmpeg` from their distro's package manager.
# Usage ## Usage
## Quickstart Guide ### Quickstart Guide
Stash is a web-based application. Once the application is running, the interface is available (by default) from `http://localhost:9999`. Stash is a web-based application. Once the application is running, the interface is available (by default) from `http://localhost:9999`.
@@ -65,46 +69,51 @@ On first run, Stash will prompt you for some configuration options and media dir
Stash can pull metadata (performers, tags, descriptions, studios, and more) directly from many sites through the use of [scrapers](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/docs/en/Manual/Scraping.md), which integrate directly into Stash. Identifying an entire collection will typically require a mix of multiple sources: Stash can pull metadata (performers, tags, descriptions, studios, and more) directly from many sites through the use of [scrapers](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/docs/en/Manual/Scraping.md), which integrate directly into Stash. Identifying an entire collection will typically require a mix of multiple sources:
- The stashapp team maintains [StashDB](https://stashdb.org/), a crowd-sourced repository of scene, studio, and performer information. Connecting it to Stash will allow you to automatically identify much of a typical media collection. It runs on our stash-box software and is primarily focused on mainstream digital scenes and studios. Instructions, invite codes, and more can be found in this guide to [Accessing StashDB](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stashdb/). - The stashapp team maintains [StashDB](https://stashdb.org/), a crowd-sourced repository of scene, studio, and performer information. Connecting it to Stash will allow you to automatically identify much of a typical media collection. It runs on our stash-box software and is primarily focused on mainstream digital scenes and studios. Instructions, invite codes, and more can be found in this guide to [Accessing StashDB](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stashdb/).
- Several community-managed stash-box databases can also be connected to Stash in a similar manner. Each one serves a slightly different niche and follows their own methodology. A rundown of each stash-box, their differences, and the information you need to sign up can be found in this guide to [Accessing Stash-Boxes](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stash-boxes/). - Several community-managed stash-box databases can also be connected to Stash in a similar manner. Each one serves a slightly different niche and follows their own methodology. A rundown of each stash-box, their differences, and the information you need to sign up can be found in the [Metadata Sources](https://docs.stashapp.cc/metadata-sources/stash-box-instances/) section of the documentation.
- Many community-maintained scrapers can also be downloaded, installed, and updated from within Stash, allowing you to pull data from a wide range of other websites and databases. They can be found by navigating to `Settings → Metadata Providers → Available Scrapers → Community (stable)`. These can be trickier to use than a stash-box because every scraper works a little differently. For more information, please visit the [CommunityScrapers repository](https://github.com/stashapp/CommunityScrapers). - Many community-maintained scrapers can also be downloaded, installed, and updated from within Stash, allowing you to pull data from a wide range of other websites and databases. They can be found by navigating to `Settings → Metadata Providers → Available Scrapers → Community (stable)`. These can be trickier to use than a stash-box because every scraper works a little differently. For more information, please visit the [CommunityScrapers repository](https://github.com/stashapp/CommunityScrapers).
- All of the above methods of scraping data into Stash are also covered in more detail in our [Guide to Scraping](https://docs.stashapp.cc/beginner-guides/guide-to-scraping/). - All of the above methods of scraping data into Stash are also covered in more detail in our [Guide to Scraping](https://docs.stashapp.cc/beginner-guides/guide-to-scraping/).
<sub>[StashDB](http://stashdb.org) is the canonical instance of our open source metadata API, [stash-box](https://github.com/stashapp/stash-box).</sub> <sub>[StashDB](http://stashdb.org) is the canonical instance of our open source metadata API, [stash-box](https://github.com/stashapp/stash-box).</sub>
# Translation ## Support & Resources
[![Translate](https://translate.codeberg.org/widget/stash/stash/svg-badge.svg)](https://translate.codeberg.org/engage/stash/)
Stash is available in 32 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash, you can make an account at [Codeberg's Weblate](https://translate.codeberg.org/projects/stash/stash/) to contribute to new or existing languages. Thanks!
The badge below shows the current translation status of Stash across all supported languages:
[![Translation status](https://translate.codeberg.org/widget/stash/stash/multi-auto.svg)](https://translate.codeberg.org/engage/stash/)
# Support & Resources
Need help or want to get involved? Start with the documentation, then reach out to the community if you need further assistance. Need help or want to get involved? Start with the documentation, then reach out to the community if you need further assistance.
- Documentation ### Documentation
- Official docs: https://docs.stashapp.cc - official guides guides and troubleshooting.
- In-app manual: press <kbd>Shift</kbd> + <kbd>?</kbd> in the app or view the manual online: https://docs.stashapp.cc/in-app-manual. - [Official documentation](https://docs.stashapp.cc) - official guides guides and troubleshooting.
- FAQ: https://discourse.stashapp.cc/c/support/faq/28 - common questions and answers. - [In-app manual](https://docs.stashapp.cc/in-app-manual) press <kbd>Shift</kbd> + <kbd>?</kbd> in the app or view the manual online.
- Community wiki: https://discourse.stashapp.cc/tags/c/community-wiki/22/stash - guides, how-tos and tips. - [FAQ](https://discourse.stashapp.cc/c/support/faq/28) - common questions and answers.
- [Community wiki](https://discourse.stashapp.cc/tags/c/community-wiki/22/stash) - guides, how-tos and tips.
- Community & discussion ### Community & Discussion
- Community forum: https://discourse.stashapp.cc - community support, feature requests and discussions.
- Discord: https://discord.gg/2TsNFKt - real-time chat and community support.
- GitHub discussions: https://github.com/stashapp/stash/discussions - community support and feature discussions.
- Lemmy community: https://discuss.online/c/stashapp - Reddit-style community space.
- Community scrapers & plugins - [Community forum](https://discourse.stashapp.cc) - community support, feature requests and discussions.
- Metadata sources: https://docs.stashapp.cc/metadata-sources/ - [Discord](https://discord.gg/2TsNFKt) - real-time chat and community support.
- Plugins: https://docs.stashapp.cc/plugins/ - [GitHub discussions](https://github.com/stashapp/stash/discussions) - community support and feature discussions.
- Themes: https://docs.stashapp.cc/themes/ - [Lemmy community](https://discuss.online/c/stashapp) - board-style community space.
- Other projects: https://docs.stashapp.cc/other-projects/
# For Developers ### Community Scrapers & Plugins
Pull requests are welcome! - [Metadata sources](https://docs.stashapp.cc/metadata-sources/)
- [Plugins](https://docs.stashapp.cc/plugins/)
- [Themes](https://docs.stashapp.cc/themes/)
- [Other projects](https://docs.stashapp.cc/other-projects/)
See [Development](docs/DEVELOPMENT.md) and [Contributing](docs/CONTRIBUTING.md) for information on working with the codebase, getting a local development setup, and contributing changes. ## Architecture
You can find an overview of Stash's architecture in the [ARCHITECTURE.md](docs/ARCHITECTURE.md) document.
## Contributing
We welcome contributions and help from all humans who want to improve the project.
Before contributing, please read the [Contributing](docs/CONTRIBUTING.md) document to understand our guidelines and processes for contributing to the project.
You can learn about setting up a local development environment in the [Development](docs/DEVELOPMENT.md) document.
## Translation
The widget below shows the current translation status of Stash across all supported languages. If you want to help us translate Stash, you can make an account at [Codeberg Translate](https://translate.codeberg.org/projects/stash/stash/) to contribute to new or existing languages. Thanks!
[![Translation status](https://translate.codeberg.org/widget/stash/stash/multi-auto.svg)](https://translate.codeberg.org/engage/stash/)

View File

@@ -5,20 +5,39 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/hash/imagephash"
"github.com/stashapp/stash/pkg/hash/videophash" "github.com/stashapp/stash/pkg/hash/videophash"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
func customUsage() { func customUsage() {
fmt.Fprintf(os.Stderr, "Usage:\n") fmt.Fprintf(os.Stderr, "Usage:\n")
fmt.Fprintf(os.Stderr, "%s [OPTIONS] VIDEOFILE...\n\nOptions:\n", os.Args[0]) fmt.Fprintf(os.Stderr, "%s [OPTIONS] FILE...\n\nOptions:\n", os.Args[0])
flag.PrintDefaults() flag.PrintDefaults()
} }
func printPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet *bool) error { func printPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet *bool) error {
// Determine if this is a video or image file based on extension
ext := filepath.Ext(inputfile)
ext = ext[1:] // remove the leading dot
// Common image extensions
imageExts := map[string]bool{
"jpg": true, "jpeg": true, "png": true, "gif": true, "webp": true, "bmp": true, "avif": true,
}
if imageExts[ext] {
return printImagePhash(ff, inputfile, quiet)
}
return printVideoPhash(ff, ffp, inputfile, quiet)
}
func printVideoPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet *bool) error {
ffvideoFile, err := ffp.NewVideoFile(inputfile) ffvideoFile, err := ffp.NewVideoFile(inputfile)
if err != nil { if err != nil {
return err return err
@@ -46,6 +65,24 @@ func printPhash(ff *ffmpeg.FFMpeg, ffp *ffmpeg.FFProbe, inputfile string, quiet
return nil return nil
} }
func printImagePhash(ff *ffmpeg.FFMpeg, inputfile string, quiet *bool) error {
imgFile := &models.ImageFile{
BaseFile: &models.BaseFile{Path: inputfile},
}
phash, err := imagephash.Generate(ff, imgFile)
if err != nil {
return err
}
if *quiet {
fmt.Printf("%x\n", *phash)
} else {
fmt.Printf("%x %v\n", *phash, imgFile.Path)
}
return nil
}
func getPaths() (string, string) { func getPaths() (string, string) {
ffmpegPath, _ := exec.LookPath("ffmpeg") ffmpegPath, _ := exec.LookPath("ffmpeg")
ffprobePath, _ := exec.LookPath("ffprobe") ffprobePath, _ := exec.LookPath("ffprobe")
@@ -67,7 +104,7 @@ func main() {
args := flag.Args() args := flag.Args()
if len(args) < 1 { if len(args) < 1 {
fmt.Fprintf(os.Stderr, "Missing VIDEOFILE argument.\n") fmt.Fprintf(os.Stderr, "Missing FILE argument.\n")
flag.Usage() flag.Usage()
os.Exit(2) os.Exit(2)
} }
@@ -87,4 +124,5 @@ func main() {
fmt.Fprintln(os.Stderr, err) fmt.Fprintln(os.Stderr, err)
} }
} }
} }

View File

@@ -76,6 +76,10 @@ func main() {
defer pprof.StopCPUProfile() defer pprof.StopCPUProfile()
} }
// initialise desktop.IsDesktop here so that it doesn't get affected by
// ffmpeg hardware checks later on
desktop.InitIsDesktop()
mgr, err := manager.Initialize(cfg, l) mgr, err := manager.Initialize(cfg, l)
if err != nil { if err != nil {
exitError(fmt.Errorf("manager initialization error: %w", err)) exitError(fmt.Errorf("manager initialization error: %w", err))
@@ -144,7 +148,7 @@ func recoverPanic() {
exitCode = 1 exitCode = 1
logger.Errorf("panic: %v\n%s", err, debug.Stack()) logger.Errorf("panic: %v\n%s", err, debug.Stack())
if desktop.IsDesktop() { if desktop.IsDesktop() {
desktop.FatalError(fmt.Errorf("Panic: %v", err)) desktop.FatalError(fmt.Errorf("panic: %v", err))
} }
} }
} }

View File

@@ -18,7 +18,7 @@ ARG STASH_VERSION
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
# Build Backend # Build Backend
FROM golang:1.24.3-alpine AS backend FROM golang:1.25.9-alpine AS backend
RUN apk add --no-cache make alpine-sdk RUN apk add --no-cache make alpine-sdk
WORKDIR /stash WORKDIR /stash
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/ COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/

View File

@@ -1,8 +1,8 @@
# This dockerfile should be built with `make docker-cuda-build` from the stash root. # This dockerfile should be built with `make docker-cuda-build` from the stash root.
ARG CUDA_VERSION=12.8.0 ARG CUDA_VERSION=13.2.1
# Build Frontend # Build Frontend
FROM node:20-alpine AS frontend FROM node:24-alpine AS frontend
RUN apk add --no-cache make git RUN apk add --no-cache make git
## cache node_modules separately ## cache node_modules separately
COPY ./ui/v2.5/package.json ./ui/v2.5/pnpm-lock.yaml /stash/ui/v2.5/ COPY ./ui/v2.5/package.json ./ui/v2.5/pnpm-lock.yaml /stash/ui/v2.5/
@@ -19,7 +19,7 @@ ARG STASH_VERSION
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
# Build Backend # Build Backend
FROM golang:1.24.3-bullseye AS backend FROM golang:1.25.9-bullseye AS backend
RUN apt update && apt install -y build-essential golang RUN apt update && apt install -y build-essential golang
WORKDIR /stash WORKDIR /stash
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/ COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/

View File

@@ -12,7 +12,7 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \
FROM --platform=$TARGETPLATFORM alpine:latest AS app FROM --platform=$TARGETPLATFORM alpine:latest AS app
COPY --from=binary /stash /usr/bin/ COPY --from=binary /stash /usr/bin/
RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools \ RUN apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg tzdata vips vips-tools vips-heif \
&& pip install --break-system-packages mechanicalsoup cloudscraper stashapp-tools && pip install --break-system-packages mechanicalsoup cloudscraper stashapp-tools
ENV STASH_CONFIG_FILE=/root/.stash/config.yml ENV STASH_CONFIG_FILE=/root/.stash/config.yml

View File

@@ -1 +0,0 @@
*.sdk.tar.*

View File

@@ -1,82 +1,86 @@
FROM golang:1.24.3 ### OSXCROSS
FROM debian:bookworm AS osxcross
# add osxcross
WORKDIR /tmp/osxcross
ARG OSXCROSS_REVISION=5e1b71fcceb23952f3229995edca1b6231525b5b
ADD --checksum=sha256:d3f771bbc20612fea577b18a71be3af2eb5ad2dd44624196cf55de866d008647 https://codeload.github.com/tpoechtrager/osxcross/tar.gz/${OSXCROSS_REVISION} /tmp/osxcross.tar.gz
LABEL maintainer="https://discord.gg/2TsNFKt" ARG OSX_SDK_VERSION=12.3
ARG OSX_SDK_DOWNLOAD_FILE=MacOSX${OSX_SDK_VERSION}.sdk.tar.xz
ARG OSX_SDK_DOWNLOAD_URL=https://github.com/joseluisq/macosx-sdks/releases/download/${OSX_SDK_VERSION}/${OSX_SDK_DOWNLOAD_FILE}
ADD --checksum=sha256:3abd261ceb483c44295a6623fdffe5d44fc4ac2c872526576ec5ab5ad0f6e26c ${OSX_SDK_DOWNLOAD_URL} /tmp/osxcross/tarballs/${OSX_SDK_DOWNLOAD_FILE}
RUN apt-get update && apt-get install -y apt-transport-https ca-certificates gnupg ENV UNATTENDED=yes \
SDK_VERSION=${OSX_SDK_VERSION} \
OSX_VERSION_MIN=12.0
RUN apt update && \
apt install -y --no-install-recommends \
bash ca-certificates clang cmake git patch libssl-dev bzip2 cpio libbz2-dev libxml2-dev make python3 xz-utils zlib1g-dev
# lzma-dev libxml2-dev xz
RUN tar --strip=1 -C /tmp/osxcross -xf /tmp/osxcross.tar.gz
RUN ./build.sh
RUN mkdir -p /etc/apt/keyrings ### FREEBSD cross-compilation stage
# use alpine for cacheable image since apt is notorous for not caching
FROM alpine:3 AS freebsd
# match golang latest
# https://go.dev/wiki/FreeBSD
ARG FREEBSD_VERSION=12.4
ADD --checksum=sha256:581c7edacfd2fca2bdf5791f667402d22fccd8a5e184635e0cac075564d57aa8 \
http://ftp-archive.freebsd.org/mirror/FreeBSD-Archive/old-releases/amd64/${FREEBSD_VERSION}-RELEASE/base.txz \
/tmp/base.txz
ADD https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key nodesource.gpg.key WORKDIR /opt/cross-freebsd
RUN cat nodesource.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && rm nodesource.gpg.key RUN apk add --no-cache tar xz
RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_24.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list RUN tar -xf /tmp/base.txz --strip-components=1 ./usr/lib ./usr/include ./lib
RUN cd /opt/cross-freebsd/usr/lib && \
find . -type l -exec sh -c ' \
for link; do \
target=$(readlink "$link"); \
case "$target" in \
/lib/*) ln -sf "/opt/cross-freebsd$target" "$link";; \
esac; \
done \
' sh {} + && \
ln -s libc++.a libstdc++.a && \
ln -s libc++.so libstdc++.so
RUN apt-get update && \ ### BUILDER
apt-get install -y --no-install-recommends \ FROM golang:1.25.9 AS builder
git make tar bash nodejs zip \ ENV PATH=/opt/osx-ndk-x86/bin:$PATH
clang llvm-dev cmake patch libxml2-dev uuid-dev libssl-dev xz-utils \
bzip2 gzip sed cpio libbz2-dev zlib1g-dev \ # copy in nodejs instead of using nodesource :thumbsup:
gcc-mingw-w64 \ COPY --from=docker.io/library/node:24-bookworm /usr/local /usr/local
gcc-arm-linux-gnueabi libc-dev-armel-cross linux-libc-dev-armel-cross \ # copy in osxcross
gcc-aarch64-linux-gnu libc-dev-arm64-cross && \ COPY --from=osxcross /tmp/osxcross/target/lib /usr/lib
rm -rf /var/lib/apt/lists/*; COPY --from=osxcross /tmp/osxcross/target /opt/osx-ndk-x86
# copy in cross-freebsd
COPY --from=freebsd /opt/cross-freebsd /opt/cross-freebsd
# pnpm install with npm # pnpm install with npm
RUN npm install -g pnpm RUN npm install -g pnpm
# FreeBSD cross-compilation setup # git for getting hash
# https://github.com/smartmontools/docker-build/blob/6b8c92560d17d325310ba02d9f5a4b250cb0764a/Dockerfile#L66 # make and bash for building
ENV FREEBSD_VERSION 13.4
ENV FREEBSD_DOWNLOAD_URL http://ftp.plusline.de/FreeBSD/releases/amd64/${FREEBSD_VERSION}-RELEASE/base.txz
ENV FREEBSD_SHA 8e13b0a93daba349b8d28ad246d7beb327659b2ef4fe44d89f447392daec5a7c
RUN cd /tmp && \ # clang for macos
curl -o base.txz $FREEBSD_DOWNLOAD_URL && \ # zip for stashapp.zip
echo "$FREEBSD_SHA base.txz" | sha256sum -c - && \ # gcc-extensions for cross-arch build
mkdir -p /opt/cross-freebsd && \ # we still target arm soft float?
cd /opt/cross-freebsd && \ RUN apt-get update && \
tar -xf /tmp/base.txz ./lib/ ./usr/lib/ ./usr/include/ && \ apt-get install -y --no-install-recommends \
rm -f /tmp/base.txz && \ git make bash \
cd /opt/cross-freebsd/usr/lib && \ clang zip \
find . -xtype l | xargs ls -l | grep ' /lib/' | awk '{print "ln -sf /opt/cross-freebsd"$11 " " $9}' | /bin/sh && \ gcc-mingw-w64 \
ln -s libc++.a libstdc++.a && \ gcc-arm-linux-gnueabi \
ln -s libc++.so libstdc++.so libc-dev-armel-cross linux-libc-dev-armel-cross \
gcc-aarch64-linux-gnu libc-dev-arm64-cross && \
# macOS cross-compilation setup rm -rf /var/lib/apt/lists/*;
ENV OSX_SDK_VERSION 11.3
ENV OSX_SDK_DOWNLOAD_FILE MacOSX${OSX_SDK_VERSION}.sdk.tar.xz
ENV OSX_SDK_DOWNLOAD_URL https://github.com/phracker/MacOSX-SDKs/releases/download/${OSX_SDK_VERSION}/${OSX_SDK_DOWNLOAD_FILE}
ENV OSX_SDK_SHA cd4f08a75577145b8f05245a2975f7c81401d75e9535dcffbb879ee1deefcbf4
ENV OSXCROSS_REVISION 5e1b71fcceb23952f3229995edca1b6231525b5b
ENV OSXCROSS_DOWNLOAD_URL https://codeload.github.com/tpoechtrager/osxcross/tar.gz/${OSXCROSS_REVISION}
ENV OSXCROSS_SHA d3f771bbc20612fea577b18a71be3af2eb5ad2dd44624196cf55de866d008647
RUN cd /tmp && \
curl -o osxcross.tar.gz $OSXCROSS_DOWNLOAD_URL && \
echo "$OSXCROSS_SHA osxcross.tar.gz" | sha256sum -c - && \
mkdir osxcross && \
tar --strip=1 -C osxcross -xf osxcross.tar.gz && \
rm -f osxcross.tar.gz && \
curl -Lo $OSX_SDK_DOWNLOAD_FILE $OSX_SDK_DOWNLOAD_URL && \
echo "$OSX_SDK_SHA $OSX_SDK_DOWNLOAD_FILE" | sha256sum -c - && \
mv $OSX_SDK_DOWNLOAD_FILE osxcross/tarballs/ && \
UNATTENDED=yes SDK_VERSION=$OSX_SDK_VERSION OSX_VERSION_MIN=10.10 osxcross/build.sh && \
cp osxcross/target/lib/* /usr/lib/ && \
mv osxcross/target /opt/osx-ndk-x86 && \
rm -rf /tmp/osxcross
ENV PATH /opt/osx-ndk-x86/bin:$PATH
RUN mkdir -p /root/.ssh && \
chmod 0700 /root/.ssh && \
ssh-keyscan github.com > /root/.ssh/known_hosts
# ignore "dubious ownership" errors
RUN git config --global safe.directory '*' RUN git config --global safe.directory '*'
# To test locally: # To test locally:
# make generate # make generate
# make ui # make ui
# cd docker/compiler # cd docker/compiler
# make build # docker build . -t ghcr.io/stashapp/compiler:latest
# docker run --rm -v /PATH_TO_STASH:/stash -w /stash -i -t stashapp/compiler:latest make build-cc-all # docker run --rm -v /PATH_TO_STASH:/stash -w /stash -i -t ghcr.io/stashapp/compiler:latest make build-cc-all
# # binaries will show up in /dist # # binaries will show up in /dist

View File

@@ -1,16 +1,22 @@
host=ghcr.io
user=stashapp user=stashapp
repo=compiler repo=compiler
version=12 version=14
VERSION_IMAGE = ${host}/${user}/${repo}:${version}
LATEST_IMAGE = ${host}/${user}/${repo}:latest
latest: latest:
docker build -t ${user}/${repo}:latest . docker build -t ${LATEST_IMAGE} .
build: build:
docker build -t ${user}/${repo}:${version} -t ${user}/${repo}:latest . docker build -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} .
build-no-cache: build-no-cache:
docker build --no-cache -t ${user}/${repo}:${version} -t ${user}/${repo}:latest . docker build --no-cache -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} .
install: build # requires docker login ghcr.io
docker push ${user}/${repo}:${version} # echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin
docker push ${user}/${repo}:latest push:
docker push ${VERSION_IMAGE}
docker push ${LATEST_IMAGE}

View File

@@ -1,3 +1,3 @@
Modified from https://github.com/bep/dockerfiles/tree/master/ci-goreleaser Modified from https://github.com/bep/dockerfiles/tree/master/ci-goreleaser
When the Dockerfile is changed, the version number should be incremented in the Makefile and the new version tag should be pushed to Docker Hub. The GitHub workflow files also need to be updated to pull the correct image tag. When the Dockerfile is changed, the version number should be incremented in [.github/workflows/build-compiler.yml](../../.github/workflows/build-compiler.yml) and the workflow [manually ran](). `env: COMPILER_IMAGE` in [.github/workflows/build.yml](../../.github/workflows/build.yml) also needs to be updated to pull the correct image tag.

View File

@@ -7,7 +7,7 @@ Only `docker` is required. For the most part your understanding of the technolog
Installation instructions are available below, and if your distributions's repository ships a current version of docker, you may use that. Installation instructions are available below, and if your distributions's repository ships a current version of docker, you may use that.
https://docs.docker.com/engine/install/ https://docs.docker.com/engine/install/
On some distributions, `docker compose` is shipped seperately, usually as `docker-cli-compose`. docker-compose is not recommended. On some distributions, `docker compose` is shipped separately, usually as `docker-cli-compose`. docker-compose is not recommended.
### Get the docker-compose.yml file ### Get the docker-compose.yml file

22
docs/AI_POLICY.md Normal file
View File

@@ -0,0 +1,22 @@
# AI Usage Policy
- AI agents are not welcome to contribute to this project.
- All issues, pull request descriptions and comments must be written by humans.
- Fully AI-generated contributions will be closed without comment.
## AI-Assisted Code Contributions
AI-assisted code contributions generated with the use of LLMs are permitted under the following conditions:
- AI usage and scope must be openly disclosed in the PR description.
- You must be able to explain any line of code and design decision during the review process.
- You must perform manual testing and describe the steps taken to sufficiently verify the changes.
- You must take full responsibility for the code, including license compliance.
We are not accepting large, complex features generated by LLMs by outside contributors at this time.
## Respect Maintainers Time
Reviewing PRs takes a significant amount of time. Anyone with zero effort can generate code with an LLM, but it takes a human to understand it, test it, and ensure it fits with the overall design of the project.
We ask that contributors respect this by ensuring their PRs meet these standards before submitting them for review.

415
docs/ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,415 @@
# Architecture
This document provides an overview of the Stash codebase architecture for new contributors.
## Project Overview
Stash is a self-hosted web application written in Go that organizes and serves diverse media collections, catering to both SFW and NSFW needs. It gathers information about videos and images from the internet through extensible community-built plugins and scrapers, supports a wide variety of formats, enables tagging and filtering, and provides statistics about performers, tags, studios, and more.
**Core purpose**: Manage local media libraries with automatic metadata scraping, tagging, and organization.
**Key design philosophy**:
- Backend: Go with GraphQL API and SQLite database
- Frontend: React/TypeScript with Apollo Client
- Extensibility: Plugin and scraper systems for community contributions
- Self-hosted: Single binary deployment with embedded frontend assets
## Repository Structure
```
stash/
├── cmd/ # Application entry points
│ ├── phasher/ # Perceptual hash utility
│ └── stash/ # Main application (cmd/stash/main.go)
├── docker/ # Docker configuration
│ ├── build/ # Build configurations
│ ├── ci/ # CI configurations
│ ├── compiler/ # Compiler Docker setup
│ └── production/ # Production Docker setup
├── graphql/ # GraphQL schema definitions
│ ├── schema/ # Main schema files
│ │ └── types/ # GraphQL type definitions
│ └── stash-box/ # Stash-box integration schema
├── internal/ # Internal application code
│ ├── api/ # GraphQL API layer (resolvers, server)
│ ├── autotag/ # Auto-tagging functionality
│ ├── desktop/ # Desktop integration
│ ├── dlna/ # DLNA media server
│ ├── identify/ # Scene identification
│ ├── log/ # Implementation of log system
│ ├── manager/ # Core application manager and services
│ └── static/ # Static asset serving
├── pkg/ # Reusable Go packages
│ ├── ffmpeg/ # FFmpeg integration for media processing
│ ├── file/ # File system operations and scanning
│ ├── gallery/ # Gallery-specific business logic
│ ├── group/ # Group (movie) business logic
│ ├── hash/ # Hashing utilities (MD5, oshash, phash)
│ ├── image/ # Image-specific business logic
│ ├── job/ # Background job management
│ ├── logger/ # Logging utilities
│ ├── models/ # Interface definitions for data entities
│ ├── performer/ # Performer-specific business logic
│ ├── plugin/ # Plugin system
│ ├── scene/ # Scene-specific business logic
│ ├── scraper/ # Metadata scraping system
│ ├── sqlite/ # SQLite implementations of datalayer interfaces
│ ├── studio/ # Studio-specific business logic
│ ├── tag/ # Tag-specific business logic
│ └── ... # Other utility packages
├── ui/ # React/TypeScript frontend
│ ├── login/ # Login page
│ └── v2.5/ # Main frontend application
├── docs/ # Documentation
├── scripts/ # Utility scripts
├── go.mod # Go module definition
├── go.sum # Go dependency checksums
├── gqlgen.yml # GraphQL code generation config
└── Makefile # Build automation
```
## Backend Architecture
### Go Package Organization
The backend follows a layered architecture with clear separation of concerns:
**`pkg/models/` - Interface Layer**
- Defines interfaces for each entity (Scene, Image, Gallery, Performer, Studio, Tag, etc.)
- Each entity has `Reader`, `Writer`, and `ReaderWriter` interfaces
- Contains data model structs and query/filter types
- Example: `repository.go` defines the main `Repository` struct with all entity repositories
- Example: `repository_scene.go` defines `SceneReaderWriter` interface
**`pkg/sqlite/` - Implementation of datalayer interfaces**
- Implements the interfaces defined in `pkg/models/`
- Uses `goqu` for CRUD operations and standard queries, and a custom query builder for complex filtering/listing
- Contains all database access logic
- Example: `scene.go` implements `SceneStore` with CRUD operations
- Example: `scene_filter.go` implements filtering logic
- Handles transactions and connection pooling
**`internal/api/` - API Layer**
- GraphQL resolvers that implement the schema
- Each resolver method calls repository methods
- Handles authentication, authorization, and validation
- Example: `resolver_query_find_scene.go` implements scene query resolvers
- Example: `resolver_mutation_scene.go` implements scene mutation resolvers
- `server.go` sets up the HTTP server and GraphQL handler
### Layering Pattern
```
GraphQL Query/Mutation
Resolver (internal/api/resolver_*.go)
↓ (complex entities: Scene, Gallery, Image, Group)
Service Layer (pkg/scene/, pkg/gallery/, pkg/image/, pkg/group/)
↓ (simpler entities: Performer, Studio, Tag)
Validation (pkg/performer/, pkg/studio/, pkg/tag/)
Repository Interface (pkg/models/repository_*.go)
SQLite Implementation (pkg/sqlite/*.go)
SQLite Database
```
Note: `gqlgen.yml` maps GraphQL types to Go structs and controls code generation. Update it when adding new types or fields.
### GraphQL Request Lifecycle
1. **Request**: Frontend sends GraphQL query to `/graphql` endpoint
2. **Routing**: `internal/api/server.go` routes to GraphQL handler (gqlgen)
3. **Parsing**: gqlgen parses the query and validates against schema
4. **Resolver Execution**: Appropriate resolver method in `internal/api/` is called
5. **Transaction**: Resolver wraps operation in read or write transaction via `withReadTxn()` or `withTxn()`
6. **Business Logic** (mutations only):
- Complex entities (Scene, Gallery, Image, Group): resolver delegates to service layer (`pkg/scene/`, `pkg/gallery/`, etc.)
- Simpler entities (Performer, Studio, Tag): resolver calls validation functions (`pkg/performer/`, `pkg/studio/`, etc.) then proceeds directly to repository
- Queries and model field resolvers skip this step entirely
7. **Repository Call**: Resolver or service calls repository method (e.g., `r.repository.Scene.Find()`)
8. **SQL Execution**: SQLite implementation executes SQL query using a mix of goqu and a custom query builder
9. **Response**: Data flows back through layers to frontend as JSON
### Plugin System
**Location**: `pkg/plugin/`
- Defines the plugin spec for UI-based plugins (including JavaScript), and supports executing external scripts, commands, and binaries via raw or RPC interface
- Plugins are configured via YAML files in the plugins directory
- Support for hooks that trigger on events (e.g., `Scene.Create.Post`)
- Plugin cache in manager for performance
- RPC communication between Go and JavaScript plugins
- Example hooks: `Scene.Create.Post`, `Scene.Update.Post`, `Scan.Post`
Key files:
- `plugins.go` - Plugin loading and execution
- `hooks.go` - Hook system implementation
- `config.go` - Plugin configuration parsing
### Scraper System
**Location**: `pkg/scraper/`
- YAML-configured scrapers for fetching metadata from websites
- Supports multiple scraper types: XPath, JSON, GraphQL, script-based
- Scrapers can fetch performers, scenes, galleries, studios, tags
- Stash-box integration for crowd-sourced metadata
- Cache for scraper definitions
- Post-processing for transforming scraped data
Key files:
- `cache.go` - Scraper caching
- `definition.go` - Scraper configuration parsing
- `xpath.go` - XPath-based scraping
- `json.go` - JSON-based scraping
- `mapped.go` - Mapping scraped data to Stash models
### Task/Job System
**Location**: `pkg/job/`
- Background job management for long-running operations
- Progress reporting via GraphQL subscriptions
- Task queue with parallel execution
- Cancellation support
- Job types: Scan, Generate, Clean, Auto-tag, Identify, Export, Import
Key files:
- `manager.go` - Job manager implementation
- `job.go` - Job interface and progress tracking
- `subscribe.go` - Subscription support for job updates
Task implementations in `internal/manager/task/`:
- `task_scan.go` - File scanning
- `task_generate.go` - Thumbnail/sprite generation
- `task_clean.go` - Orphaned file cleanup
- `task_autotag.go` - Automatic tagging
- `task_identify.go` - Scene identification
## Frontend Architecture
### React/TypeScript Structure
**Location**: `ui/v2.5/`
```
ui/v2.5/
├── src/
│ ├── core/ # Core services and GraphQL client
│ │ ├── StashService.ts # Main GraphQL client
│ │ ├── generated-graphql.ts # Auto-generated TypeScript types
│ │ ├── createClient.ts # Apollo client setup
│ │ ├── config.ts # Configuration
│ │ ├── scenes.ts # Scene-specific queries
│ │ ├── performers.ts # Performer-specific queries
│ │ └── ...
│ ├── components/ # React components
│ │ ├── Scenes/ # Scene-related components
│ │ ├── Performers/ # Performer-related components
│ │ ├── Galleries/ # Gallery-related components
│ │ ├── Images/ # Image-related components
│ │ ├── Studios/ # Studio-related components
│ │ ├── Tags/ # Tag-related components
│ │ ├── Settings/ # Settings components
│ │ ├── Shared/ # Shared/reusable components
│ │ └── ...
│ ├── hooks/ # Custom React hooks
│ │ ├── data.ts # Data fetching hooks
│ │ ├── LocalForage.ts # Local storage hooks
│ │ ├── Toast.tsx # Toast notifications
│ │ └── ...
│ ├── models/ # Frontend data models
│ │ └── list-filter/ # Filter and list models
│ ├── locales/ # i18n translations
│ │ ├── en-GB.json # English
│ │ ├── de-DE.json # German
│ │ └── ...
│ ├── utils/ # Utility functions
│ ├── App.tsx # Main application component
│ └── index.tsx # Application entry point
├── graphql/ # GraphQL queries and fragments
├── public/ # Static assets
├── package.json # Dependencies and scripts
├── codegen.ts # GraphQL codegen configuration
└── vite.config.js # Vite build configuration
```
### Communication with Backend
**GraphQL via Apollo Client**:
- Frontend uses Apollo Client (`@apollo/client`) for GraphQL communication
- GraphQL queries defined in `graphql/` directory
- Code generation via `@graphql-codegen/cli` generates TypeScript types
- Generated types in `src/core/generated-graphql.ts`
- WebSocket subscriptions for real-time updates (job progress, logging)
**Service Layer** (`src/core/`):
- `StashService.ts` - Main GraphQL client with typed queries/mutations
- Domain-specific files (scenes.ts, performers.ts, etc.) - Organized queries
- `createClient.ts` - Apollo client setup with authentication and uploads
## Database Layer
### SQLite Usage
**Database**: Single SQLite database file (default: `stash-go.sqlite`)
- WAL (Write-Ahead Logging) mode for concurrency
- Connection pooling: 1 write connection, 10 read connections
- 30-second idle connection timeout
- Configurable cache size via `STASH_SQLITE_CACHE_SIZE` environment variable
**Blob Storage**:
- Configurable storage for cover images and other binary data
- Options: Database (BLOB columns) or Filesystem (separate directory)
- Managed via `BlobStore` in `pkg/sqlite/blob.go`
### Migration System
**Location**: `pkg/sqlite/migrations/`
**Migration Files**:
- Numbered `.up.sql` files (e.g., `32_files.up.sql`)
- Current schema version is defined in `pkg/sqlite/database.go`
- Migrations embedded via `//go:embed migrations/*.sql`
- Uses `golang-migrate/migrate` library
**Custom Migrations**:
- Pre-migration Go files (e.g., `32_premigrate.go`) - Run before SQL
- Post-migration Go files (e.g., `32_postmigrate.go`) - Run after SQL
- Used for data transformations that SQL cannot handle
**Migration Process** (`pkg/sqlite/migrate.go`):
- The migrator runs pre-migration Go code, executes the SQL migration, then runs post-migration Go code for each version increment.
**Key Migrations**:
- `32_files.up.sql` - Introduced file/folder abstraction
- `45_blobs.up.sql` - Blob storage system
- `71_custom_fields.up.sql` - Custom fields support
### Query Patterns
**Repository Pattern**:
- All database access goes through repository interfaces
- SQLite implementations use a mix of goqu and a custom query builder within the `sqlite` package
- Transactions managed via `txn.Manager`
**Example Query** (`pkg/sqlite/scene.go`):
```go
func (qb *SceneStore) Find(ctx context.Context, id int) (*models.Scene, error) {
var scene models.Scene
err := qb.repository.queryStruct(ctx, qb.sceneQuery(), []interface{}{id}, &scene)
if err != nil {
return nil, err
}
return &scene, nil
}
```
**Filtering**:
- Complex filtering via a custom query builder system (`query.go`, `filter.go`) that constructs raw SQL
- Criterion handlers in `criterion_handlers.go` dynamically build WHERE, HAVING, and WITH clauses
- Supports hierarchical filters (tags, studios) via recursive CTEs
- Simpler queries (CRUD, join-table lookups) use `goqu` via the `table` abstraction
## Key Data Flows
### Example 1: GraphQL Query (findScene)
**Flow**:
1. Frontend sends GraphQL query requesting scene data by ID
2. Request hits `internal/api/server.go` at `/graphql` endpoint
3. gqlgen routes to the appropriate resolver in `resolver_query_find_scene.go`
4. Resolver wraps the operation in a read transaction using `withReadTxn()` to ensure consistent database access
5. Repository calls the SQLite implementation in `pkg/sqlite/scene.go` to execute the query
6. SQLite generates and executes the SQL query (using goqu or the custom queryBuilder depending on operation) to fetch the scene record
7. Scene object flows back through layers: SQLite → Repository → Resolver → GraphQL → Frontend
8. Frontend receives JSON response with the requested scene data
### Example 2: Scanning a File
**Flow**:
1. User triggers scan via UI (Settings → Metadata → Scan)
2. Frontend sends GraphQL mutation to start the scan job
3. Mutation resolver in `internal/api/resolver_mutation_metadata.go` creates a background job
4. Job manager queues `ScanJob` from `internal/manager/task_scan.go`
5. `ScanJob.Execute()` runs the scan operation with progress tracking
6. Filesystem walk traverses configured paths using `file.SymWalk`, queues files for processing, and filters based on modification time and .stashignore
7. File handlers process each file type: videos become Scenes, images become Images, zip files become Galleries, and folders get Folder records
8. For each video file, the system calculates checksums (MD5, oshash, phash), extracts metadata via FFmpeg, creates File and Scene records, and generates thumbnails, sprites, previews, and interactive heatmaps
9. Progress updates flow via GraphQL subscription with real-time updates on files processed
10. Scan completes with updated statistics, subscription notifies completion, and UI refreshes with new content
**Key Files**:
- `internal/manager/task_scan.go` - Main scan logic
- `pkg/file/` - File system operations
- `pkg/scene/scan.go` - Scene-specific scan logic
- `pkg/image/scan.go` - Image-specific scan logic
- `pkg/gallery/scan.go` - Gallery-specific scan logic
## Development Workflow
### Adding a New GraphQL Field
1. Define field in `graphql/schema/schema.graphql`
2. Run `make generate-backend` to regenerate types
3. Implement resolver in `internal/api/resolver_*.go`
4. If query requires new repository method:
- Add interface to `pkg/models/repository_*.go`
- Implement in `pkg/sqlite/*.go`
5. Add frontend query in `ui/v2.5/graphql/`
6. Run `make generate-ui` to regenerate frontend types
- Frontend type checking runs in CI — you do not need to run `tsc` locally.
### Adding a Database Migration
1. Create new migration file: `pkg/sqlite/migrations/{version}_description.up.sql`
2. If needed, create `{version}_premigrate.go` for pre-migration logic
3. If needed, create `{version}_postmigrate.go` for post-migration logic
4. Update `appSchemaVersion` in `pkg/sqlite/database.go`
5. Test migration on development database
### Running Tests
```bash
# Backend test
make it
```
### Building
```bash
# Build frontend
make ui
# Develop frontend with hot-reload
make ui-start
# Build backend (requires frontend to be built first)
make build
```
## Additional Resources
- **Development Guide**: See `docs/DEVELOPMENT.md`
- **Contributing**: See `docs/CONTRIBUTING.md`
- **GraphQL Schema**: `graphql/schema/schema.graphql`
- **In-app Manual**: Available in-app via Shift+?
- **GraphQL Playground**: Available at `/playground`
- **Community**: [Discord](https://discord.gg/2TsNFKt) and [Discourse](https://discourse.stashapp.cc)

View File

@@ -1,35 +1,48 @@
## Goals and design vision # Contributing to Stash
The goal of stash is to be: ## AI Usage Policy
- an application for organising and viewing adult content - currently this is videos and images, in future this will be extended to include audio and text content
- organising includes scraping of metadata from websites and metadata repositories
- free and open-source
- portable and offline - can be run on a USB stick without needing to install dependencies (with the exception of ffmpeg)
- minimal, but highly extensible. The core feature set should be the minimum required to achieve the primary goal, while being extensible enough to extend via plugins
- easy to learn and use, with minimal technical knowledge required
The core stash system is not intended for: Please see our [AI Usage Policy](/docs/AI_POLICY.md) for guidelines on the use of AI in contributions to this project.
- managing downloading of content
- managing content on external websites ## Issues
- publically sharing content
Bug reports and feature requests must use descriptive and concise titles and follow the provided templates. Please use the search function to make sure that you are not submitting duplicates, and that a similar report or request has not already been resolved or rejected.
All issues must be written by humans. Fully AI-generated issues will be closed without comment.
## Pull Requests
All pull requests must use descriptive and concise titles and follow the provided templates. In addition, they must follow the the following guidelines:
- You must link to an open issue that pull request addresses (see [GitHub documentation](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/linking-a-pull-request-to-an-issue) on how to do that).
- Pull requests must be focused on a single issue or feature. Large, multi-purpose pull requests will be rejected.
- Large features 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.
- 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.
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.
## Goals and Design Vision
The goal of Stash is to be:
- An application for organising and viewing NSFW and SFW content - currently this is videos and images, in future this will be extended to include audio and text content
- Organising includes scraping of metadata from websites and metadata repositories
- Free and open-source
- Portable and offline - can be run on a USB stick without needing to install dependencies (with the exception of FFmpeg)
- Minimal, but highly extensible. The core feature set should be the minimum required to achieve the primary goal, while being extensible enough to extend via plugins
- Easy to learn and use, with minimal technical knowledge required
The core Stash system is not intended for:
- Managing downloading of content
- Managing content on external websites
- Publicly sharing content
Other requirements: Other requirements:
- support as many video and image formats as possible - Support as many video and image formats as possible
- interfaces with external systems (for example stash-box) should be made as generic as possible. - Interfaces with external systems (for example stash-box) should be made as generic as possible.
Design considerations: Design considerations:
- features are easy to add and difficult to remove. Large superfluous features should be scrutinised and avoided where possible (eg 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.
## Technical Debt
Please be sure to consider how heavily your contribution impacts the maintainability of the project long term, sometimes less is more. We don't want to merge collossal pull requests with hundreds of dependencies by a driveby contributor.
## Contributor Checklist
Please make sure that you've considered the following before you submit your Pull Requests as ready for merging.
* I've run Code linters and [gofmt](https://golang.org/cmd/gofmt/) to make sure that my code is readable.
* I have read through formerly submitted [pull requests](https://github.com/stashapp/stash/pulls) and [git issues](https://github.com/stashapp/stash/issues) to make sure that this contribution is required and isn't a duplicate. Also, so that I can manage to close any git Issues needing closed relating to this feature submission.
* I commented adequately on my code with the expectation in mind that anyone else should be able to look at this code I've submitted and know exactly what's happening and what the expectations are.
### Legal Agreements
* I acknowledge that if applicable to me, submitting and subsequent acceptance of this Pull Request I, the code contributor of this Pull Request, agree and acknowledge my understanding that the new code license has now been updated to [AGPL](/LICENSE.md). I agree that all code before this Pull Request, which I've previously submitted, is now to be re-licensed under the new license AGPL and no longer the former MIT license.
**In case you were unable to follow any of the above include an explanation as to why not in your Pull Request.**

View File

@@ -118,8 +118,8 @@ This project uses a modification of the [CI-GoReleaser](https://github.com/bep/d
To cross-compile the app yourself: To cross-compile the app yourself:
1. Run `make pre-ui`, `make generate` and `make ui` outside the container, to generate files and build the UI. 1. Run `make pre-ui`, `make generate` and `make ui` outside the container, to generate files and build the UI.
2. Pull the latest compiler image from Docker Hub: `docker pull stashapp/compiler` 2. Pull the latest compiler image from GHCR: `docker pull ghcr.io/stashapp/compiler`
3. Run `docker run --rm --mount type=bind,source="$(pwd)",target=/stash -w /stash -it stashapp/compiler /bin/bash` to open a shell inside the container. 3. Run `docker run --rm --mount type=bind,source="$(pwd)",target=/stash -w /stash -it ghcr.io/stashapp/compiler /bin/bash` to open a shell inside the container.
4. From inside the container, run `make build-cc-all` to build for all platforms, or run `make build-cc-{platform}` to build for a specific platform (have a look at the `Makefile` for the list of targets). 4. From inside the container, run `make build-cc-all` to build for all platforms, or run `make build-cc-{platform}` to build for a specific platform (have a look at the `Makefile` for the list of targets).
5. You will find the compiled binaries in `dist/`. 5. You will find the compiled binaries in `dist/`.

52
go.mod
View File

@@ -1,20 +1,23 @@
module github.com/stashapp/stash module github.com/stashapp/stash
go 1.24.3 go 1.25.0
require ( require (
github.com/99designs/gqlgen v0.17.73 github.com/99designs/gqlgen v0.17.73
github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552 github.com/WithoutPants/sortorder v0.0.0-20230616003020-921c9ef69552
github.com/Yamashou/gqlgenc v0.32.1 github.com/Yamashou/gqlgenc v0.32.1
github.com/anacrolix/dms v1.2.2 github.com/anacrolix/dms v1.2.2
github.com/antchfx/htmlquery v1.3.0 github.com/antchfx/htmlquery v1.3.5
github.com/asticode/go-astisub v0.25.1 github.com/asticode/go-astisub v0.25.1
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617 github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.9.2 github.com/chromedp/chromedp v0.14.2
github.com/corona10/goimagehash v1.1.0 github.com/corona10/goimagehash v1.1.0
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
github.com/doug-martin/goqu/v9 v9.18.0 github.com/doug-martin/goqu/v9 v9.18.0
github.com/enetx/g v1.0.224
github.com/enetx/surf v1.0.199
github.com/feederbox826/gosx-notifier v0.2.2
github.com/go-chi/chi/v5 v5.2.2 github.com/go-chi/chi/v5 v5.2.2
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
github.com/go-chi/httplog v0.3.1 github.com/go-chi/httplog v0.3.1
@@ -30,7 +33,6 @@ require (
github.com/jinzhu/copier v0.4.0 github.com/jinzhu/copier v0.4.0
github.com/jmoiron/sqlx v1.4.0 github.com/jmoiron/sqlx v1.4.0
github.com/json-iterator/go v1.1.12 github.com/json-iterator/go v1.1.12
github.com/kermieisinthehouse/gosx-notifier v0.1.2
github.com/kermieisinthehouse/systray v1.2.4 github.com/kermieisinthehouse/systray v1.2.4
github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env v1.1.0 github.com/knadh/koanf/providers/env v1.1.0
@@ -44,10 +46,11 @@ require (
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/remeh/sizedwaitgroup v1.0.0 github.com/remeh/sizedwaitgroup v1.0.0
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/spf13/cast v1.6.0 github.com/spf13/cast v1.6.0
github.com/spf13/pflag v1.0.6 github.com/spf13/pflag v1.0.6
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.11.1
github.com/tidwall/gjson v1.16.0 github.com/tidwall/gjson v1.16.0
github.com/vearutop/statigz v1.4.0 github.com/vearutop/statigz v1.4.0
github.com/vektah/dataloaden v0.3.0 github.com/vektah/dataloaden v0.3.0
@@ -55,12 +58,12 @@ require (
github.com/vektra/mockery/v2 v2.10.0 github.com/vektra/mockery/v2 v2.10.0
github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e
github.com/zencoder/go-dash/v3 v3.0.2 github.com/zencoder/go-dash/v3 v3.0.2
golang.org/x/crypto v0.45.0 golang.org/x/crypto v0.48.0
golang.org/x/image v0.18.0 golang.org/x/image v0.38.0
golang.org/x/net v0.47.0 golang.org/x/net v0.50.0
golang.org/x/sys v0.38.0 golang.org/x/sys v0.41.0
golang.org/x/term v0.37.0 golang.org/x/term v0.40.0
golang.org/x/text v0.31.0 golang.org/x/text v0.35.0
golang.org/x/time v0.10.0 golang.org/x/time v0.10.0
gopkg.in/guregu/null.v4 v4.0.0 gopkg.in/guregu/null.v4 v4.0.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
@@ -69,20 +72,26 @@ require (
require ( require (
github.com/agnivade/levenshtein v1.2.1 // indirect github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/antchfx/xpath v1.2.3 // indirect github.com/andybalholm/brotli v1.2.1 // indirect
github.com/antchfx/xpath v1.3.6 // indirect
github.com/asticode/go-astikit v0.20.0 // indirect github.com/asticode/go-astikit v0.20.0 // indirect
github.com/asticode/go-astits v1.8.0 // indirect github.com/asticode/go-astits v1.8.0 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect github.com/chromedp/sysutil v1.1.0 // indirect
github.com/coder/websocket v1.8.12 // indirect github.com/coder/websocket v1.8.12 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/enetx/http v1.0.28 // indirect
github.com/enetx/http2 v1.0.26 // indirect
github.com/enetx/http3 v1.0.7 // indirect
github.com/enetx/iter v0.0.0-20250912135656-f1583323588f // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.3.0 // indirect github.com/gobwas/ws v1.4.0 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
@@ -90,10 +99,9 @@ require (
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.5 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/magiconair/properties v1.8.7 // indirect github.com/magiconair/properties v1.8.7 // indirect
github.com/mailru/easyjson v0.7.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect
@@ -106,6 +114,9 @@ require (
github.com/pelletier/go-toml/v2 v2.1.0 // indirect github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // 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/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
github.com/rs/zerolog v1.30.0 // indirect github.com/rs/zerolog v1.30.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sosodev/duration v1.3.1 // indirect github.com/sosodev/duration v1.3.1 // indirect
@@ -118,12 +129,13 @@ require (
github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect
github.com/urfave/cli/v2 v2.27.6 // indirect github.com/urfave/cli/v2 v2.27.6 // indirect
github.com/wzshiming/socks5 v0.7.0 // indirect
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.3 // indirect go.yaml.in/yaml/v3 v3.0.3 // indirect
golang.org/x/mod v0.29.0 // indirect golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.18.0 // indirect golang.org/x/sync v0.20.0 // indirect
golang.org/x/tools v0.38.0 // indirect golang.org/x/tools v0.42.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

159
go.sum
View File

@@ -81,14 +81,15 @@ github.com/anacrolix/missinggo v1.1.0/go.mod h1:MBJu3Sk/k3ZfGYcS7z18gwfu72Ey/xop
github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw= github.com/anacrolix/tagflag v0.0.0-20180109131632-2146c8d41bf0/go.mod h1:1m2U/K6ZT+JZG0+bdMK6qauP49QT4wE5pmhJXOKKCHw=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/antchfx/htmlquery v1.3.0 h1:5I5yNFOVI+egyia5F2s/5Do2nFWxJz41Tr3DyfKD25E= github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=
github.com/antchfx/htmlquery v1.3.0/go.mod h1:zKPDVTMhfOmcwxheXUsx4rKJy8KEY/PU6eXr/2SebQ8= github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=
github.com/antchfx/xpath v1.2.3 h1:CCZWOzv5bAqjVv0offZ2LVgVYFbeldKQVuLNbViZdes= github.com/antchfx/xpath v1.3.5/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antchfx/xpath v1.2.3/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= github.com/antchfx/xpath v1.3.6 h1:s0y+ElRRtTQdfHP609qFu0+c6bglDv20pqOViQjjdPI=
github.com/antchfx/xpath v1.3.6/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs=
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
@@ -116,13 +117,12 @@ github.com/census-instrumentation/opencensus-proto v0.3.0/go.mod h1:f6KPmirojxKA
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617 h1:/5dwcyi5WOawM1Iz6MjrYqB90TRIdZv3O0fVHEJb86w= github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgfKw= github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY= github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
@@ -174,6 +174,18 @@ github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8
github.com/doug-martin/goqu/v9 v9.18.0 h1:/6bcuEtAe6nsSMVK/M+fOiXUNfyFF3yYtE07DBPFMYY= github.com/doug-martin/goqu/v9 v9.18.0 h1:/6bcuEtAe6nsSMVK/M+fOiXUNfyFF3yYtE07DBPFMYY=
github.com/doug-martin/goqu/v9 v9.18.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ= github.com/doug-martin/goqu/v9 v9.18.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=
github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk= github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/enetx/g v1.0.224 h1:H/uonguFE4qG8YCn5bSpZX5Wh+wTSb+jgf3I2ZM25XM=
github.com/enetx/g v1.0.224/go.mod h1:lxhby3LjP8jOTGbxJ/PCd+2Zq1gYiSBbtL/llPhAg5c=
github.com/enetx/http v1.0.28 h1:IaNSSDFlAVVdHnYhNIR9wAN7GY4TWL/kkvYC3jOaueY=
github.com/enetx/http v1.0.28/go.mod h1:1f4mytfF/SfjATEJnynpwGS6aa1ALjb8DtmYgFVblY0=
github.com/enetx/http2 v1.0.26 h1:wy3lYGVwnIUY4Q+gyPPQCJ1a+BMXD1B7Unpyc/Csrxc=
github.com/enetx/http2 v1.0.26/go.mod h1:t54ex5HIS8V1+2j6cvEOv6umlrHsbUPFKQ54nYB58Nk=
github.com/enetx/http3 v1.0.7 h1:daFhveKBtv8rRallCjaHErzzSHIrq07ovoSvVkvhcMM=
github.com/enetx/http3 v1.0.7/go.mod h1:sqpVGZ9F1/wCiW6sjBUS2errKAh3SUYn6VlWE7LL6KM=
github.com/enetx/iter v0.0.0-20250912135656-f1583323588f h1:GUW+4AWfECIEJ9oAxgEAVGCpaozMCjRiUYnuR6Q0bCQ=
github.com/enetx/iter v0.0.0-20250912135656-f1583323588f/go.mod h1:oMZN8hGLUpi7QBlMEUqailocNy0NFAO/7Lu+Nwh9HMM=
github.com/enetx/surf v1.0.199 h1:RtqcwlyLM8O4U+43laNnNJwx5hALkH5cJRxDX1F2VjM=
github.com/enetx/surf v1.0.199/go.mod h1:c6g53gi273RBiZFO4THWIqpn5n9RLC6vw5WpUwHrT4U=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -188,6 +200,8 @@ github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU= github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/feederbox826/gosx-notifier v0.2.2 h1:26NkaJZ8Wzptx82R46c9pkVAcFwGSU7kxWrOKmRWlC0=
github.com/feederbox826/gosx-notifier v0.2.2/go.mod h1:R6rqw7VuwuiCuvsr7EOONmWq++CRA5Ijmkmx75/C3Fs=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU= github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
@@ -206,6 +220,8 @@ github.com/go-chi/httplog v0.3.1/go.mod h1:UoiQQ/MTZH5V6JbNB2FzF0DynTh5okpXxlhsy
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE= github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
@@ -224,9 +240,8 @@ github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
@@ -286,6 +301,7 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -379,8 +395,6 @@ github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o=
github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
@@ -390,12 +404,12 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kermieisinthehouse/gosx-notifier v0.1.2 h1:KV0KBeKK2B24kIHY7iK0jgS64Q05f4oB+hUZmsPodxQ=
github.com/kermieisinthehouse/gosx-notifier v0.1.2/go.mod h1:xyWT07azFtUOcHl96qMVvKhvKzsMcS7rKTHQyv8WTho=
github.com/kermieisinthehouse/systray v1.2.4 h1:pdH5vnl+KKjRrVCRU4g/2W1/0HVzuuJ6WXHlPPHYY6s= github.com/kermieisinthehouse/systray v1.2.4 h1:pdH5vnl+KKjRrVCRU4g/2W1/0HVzuuJ6WXHlPPHYY6s=
github.com/kermieisinthehouse/systray v1.2.4/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h+h41HNoMySoLVrhVR4= github.com/kermieisinthehouse/systray v1.2.4/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h+h41HNoMySoLVrhVR4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=
@@ -432,8 +446,6 @@ github.com/lyft/protoc-gen-star v0.5.3/go.mod h1:V0xaHgaf5oCCqmcxYcWiDfTiKsZsRc8
github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
@@ -520,13 +532,19 @@ github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8b
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A= 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/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= github.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E=
github.com/remeh/sizedwaitgroup v1.0.0/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo= github.com/remeh/sizedwaitgroup v1.0.0/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
@@ -540,6 +558,8 @@ github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM= github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
@@ -590,8 +610,8 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw= github.com/subosito/gotenv v1.2.0/go.mod h1:N0PQaV/YGNqwC0u51sEeR/aUtSLEXKX9iv69rRypqCw=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
@@ -615,10 +635,14 @@ github.com/vektah/gqlparser/v2 v2.5.27/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0
github.com/vektra/mockery/v2 v2.10.0 h1:MiiQWxwdq7/ET6dCXLaJzSGEN17k758H7JHS9kOdiks= github.com/vektra/mockery/v2 v2.10.0 h1:MiiQWxwdq7/ET6dCXLaJzSGEN17k758H7JHS9kOdiks=
github.com/vektra/mockery/v2 v2.10.0/go.mod h1:m/WO2UzWzqgVX3nvqpRQq70I4Z7jbSCRhdmkgtp+Ab4= github.com/vektra/mockery/v2 v2.10.0/go.mod h1:m/WO2UzWzqgVX3nvqpRQq70I4Z7jbSCRhdmkgtp+Ab4=
github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4= github.com/willf/bitset v1.1.9/go.mod h1:RjeCKbqT1RxIR/KWY6phxZiaY1IyutSBfGjNPySAYV4=
github.com/wzshiming/socks5 v0.7.0 h1:euJ+U48WrvVngi+opC8vAnpZ5sK12y1C2hPvb1f48Rg=
github.com/wzshiming/socks5 v0.7.0/go.mod h1:BvCAqlzocQN5xwLjBZDBbvWlrx8sCYSSbHEOf2wZgT0=
github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e h1:GruPsb+44XvYAzuAgJW1d1WHqmcI73L2XSjsbx/eJZw= github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e h1:GruPsb+44XvYAzuAgJW1d1WHqmcI73L2XSjsbx/eJZw=
github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e/go.mod h1:wA8kQ8WFipMciY9WcWzqQgZordm/P7l8IZdvx1crwmc= github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e/go.mod h1:wA8kQ8WFipMciY9WcWzqQgZordm/P7l8IZdvx1crwmc=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4=
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -643,6 +667,8 @@ go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqe
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo= go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE= go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
@@ -664,8 +690,12 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -679,8 +709,8 @@ golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMk
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ= golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E= golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -707,8 +737,12 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro= golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -757,9 +791,14 @@ golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -789,8 +828,13 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -869,16 +913,27 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -889,9 +944,14 @@ golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -956,8 +1016,11 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU= golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -140,4 +140,8 @@ models:
fields: fields:
plugins: plugins:
resolver: true resolver: true
Performer:
fields:
career_length:
resolver: true

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. 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 duration_diff: Float
scene_filter: SceneFilterType
): [[Scene!]!]! ): [[Scene!]!]!
"Return valid stream paths" "Return valid stream paths"
@@ -373,6 +374,7 @@ type Mutation {
performerDestroy(input: PerformerDestroyInput!): Boolean! performerDestroy(input: PerformerDestroyInput!): Boolean!
performersDestroy(ids: [ID!]!): Boolean! performersDestroy(ids: [ID!]!): Boolean!
bulkPerformerUpdate(input: BulkPerformerUpdateInput!): [Performer!] bulkPerformerUpdate(input: BulkPerformerUpdateInput!): [Performer!]
performerMerge(input: PerformerMergeInput!): Performer!
studioCreate(input: StudioCreateInput!): Studio studioCreate(input: StudioCreateInput!): Studio
studioUpdate(input: StudioUpdateInput!): Studio studioUpdate(input: StudioUpdateInput!): Studio
@@ -421,8 +423,14 @@ type Mutation {
""" """
moveFiles(input: MoveFilesInput!): Boolean! moveFiles(input: MoveFilesInput!): Boolean!
deleteFiles(ids: [ID!]!): Boolean! deleteFiles(ids: [ID!]!): Boolean!
"Deletes file entries from the database without deleting the files from the filesystem"
destroyFiles(ids: [ID!]!): Boolean!
fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean! fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean!
"Reveal the file in the system file manager"
revealFileInFileManager(id: ID!): Boolean!
"Reveal the folder in the system file manager"
revealFolderInFileManager(id: ID!): Boolean!
# Saved filters # Saved filters
saveFilter(input: SaveFilterInput!): SavedFilter! saveFilter(input: SaveFilterInput!): SavedFilter!
@@ -576,6 +584,8 @@ type Mutation {
stashBoxBatchPerformerTag(input: StashBoxBatchTagInput!): String! stashBoxBatchPerformerTag(input: StashBoxBatchTagInput!): String!
"Run batch studio tag task. Returns the job ID." "Run batch studio tag task. Returns the job ID."
stashBoxBatchStudioTag(input: StashBoxBatchTagInput!): String! stashBoxBatchStudioTag(input: StashBoxBatchTagInput!): String!
"Run batch tag tag task. Returns the job ID."
stashBoxBatchTagTag(input: StashBoxBatchTagInput!): String!
"Enables DLNA for an optional duration. Has no effect if DLNA is enabled by default" "Enables DLNA for an optional duration. Has no effect if DLNA is enabled by default"
enableDLNA(input: EnableDLNAInput!): Boolean! enableDLNA(input: EnableDLNAInput!): Boolean!

View File

@@ -184,6 +184,18 @@ input ConfigGeneralInput {
scraperPackageSources: [PackageSourceInput!] scraperPackageSources: [PackageSourceInput!]
"Source of plugin packages" "Source of plugin packages"
pluginPackageSources: [PackageSourceInput!] pluginPackageSources: [PackageSourceInput!]
"Size of the longest dimension for each sprite in pixels"
spriteScreenshotSize: Int
"True if sprite generation should use the sprite interval and min/max sprites settings instead of the default"
useCustomSpriteInterval: Boolean
"Time between two different scrubber sprites in seconds - only used if useCustomSpriteInterval is true"
spriteInterval: Float
"Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true"
minimumSprites: Int
"Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true"
maximumSprites: Int
} }
type ConfigGeneralResult { type ConfigGeneralResult {
@@ -287,6 +299,16 @@ type ConfigGeneralResult {
logAccess: Boolean! logAccess: Boolean!
"Maximum log size" "Maximum log size"
logFileMaxSize: Int! logFileMaxSize: Int!
"True if sprite generation should use the sprite interval and min/max sprites settings instead of the default"
useCustomSpriteInterval: Boolean!
"Time between two different scrubber sprites in seconds - only used if useCustomSpriteInterval is true"
spriteInterval: Float!
"Minimum number of sprites to be generated - only used if useCustomSpriteInterval is true"
minimumSprites: Int!
"Maximum number of sprites to be generated - only used if useCustomSpriteInterval is true"
maximumSprites: Int!
"Size of the longest dimension for each sprite in pixels"
spriteScreenshotSize: Int!
"Array of video file extensions" "Array of video file extensions"
videoExtensions: [String!]! videoExtensions: [String!]!
"Array of image file extensions" "Array of image file extensions"
@@ -395,6 +417,9 @@ input ConfigInterfaceInput {
customLocales: String customLocales: String
customLocalesEnabled: Boolean customLocalesEnabled: Boolean
"When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting"
disableCustomizations: Boolean
"Interface language" "Interface language"
language: String language: String
@@ -469,6 +494,9 @@ type ConfigInterfaceResult {
customLocales: String customLocales: String
customLocalesEnabled: Boolean customLocalesEnabled: Boolean
"When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting"
disableCustomizations: Boolean
"Interface language" "Interface language"
language: String language: String

View File

@@ -6,13 +6,19 @@ type Fingerprint {
type Folder { type Folder {
id: ID! id: ID!
path: String! path: String!
basename: String!
parent_folder_id: ID @deprecated(reason: "Use parent_folder instead") parent_folder_id: ID @deprecated(reason: "Use parent_folder instead")
zip_file_id: ID @deprecated(reason: "Use zip_file instead") zip_file_id: ID @deprecated(reason: "Use zip_file instead")
parent_folder: Folder parent_folder: Folder
"Returns all parent folders in order from immediate parent to top-level"
parent_folders: [Folder!]!
zip_file: BasicFile zip_file: BasicFile
"Returns direct sub-folders"
sub_folders: [Folder!]!
mod_time: Time! mod_time: Time!
created_at: Time! created_at: Time!
@@ -87,6 +93,8 @@ type VideoFile implements BaseFile {
frame_rate: Float! frame_rate: Float!
bit_rate: Int! bit_rate: Int!
scenes: [Scene!]!
created_at: Time! created_at: Time!
updated_at: Time! updated_at: Time!
} }
@@ -112,6 +120,8 @@ type ImageFile implements BaseFile {
width: Int! width: Int!
height: Int! height: Int!
images: [Image!]!
created_at: Time! created_at: Time!
updated_at: Time! updated_at: Time!
} }
@@ -135,6 +145,8 @@ type GalleryFile implements BaseFile {
fingerprint(type: String!): String fingerprint(type: String!): String
fingerprints: [Fingerprint!]! fingerprints: [Fingerprint!]!
galleries: [Gallery!]!
created_at: Time! created_at: Time!
updated_at: Time! updated_at: Time!
} }
@@ -153,7 +165,7 @@ input MoveFilesInput {
input SetFingerprintsInput { input SetFingerprintsInput {
type: String! type: String!
"an null value will remove the fingerprint" "a null value will remove the fingerprint"
value: String value: String
} }

View File

@@ -75,22 +75,48 @@ input OrientationCriterionInput {
value: [OrientationEnum!]! value: [OrientationEnum!]!
} }
input PHashDuplicationCriterionInput { input DuplicationCriterionInput {
duplicated: Boolean duplicated: Boolean @deprecated(reason: "Use phash field instead")
"Currently unimplemented" "Currently unimplemented. Intended for phash distance matching."
distance: Int distance: Int
"Filter by phash duplication"
phash: Boolean
"Filter by URL duplication"
url: Boolean
"Filter by Stash ID duplication"
stash_id: Boolean
"Filter by title duplication"
title: Boolean
}
input FileDuplicationCriterionInput {
duplicated: Boolean @deprecated(reason: "Use phash field instead")
"Currently unimplemented. Intended for phash distance matching."
distance: Int
"Filter by phash duplication"
phash: Boolean
} }
input StashIDCriterionInput { input StashIDCriterionInput {
""" """
If present, this value is treated as a predicate. If present, this value is treated as a predicate.
That is, it will filter based on stash_ids with the matching endpoint That is, it will filter based on stash_id with the matching endpoint
""" """
endpoint: String endpoint: String
stash_id: String stash_id: String
modifier: CriterionModifier! modifier: CriterionModifier!
} }
input StashIDsCriterionInput {
"""
If present, this value is treated as a predicate.
That is, it will filter based on stash_ids with the matching endpoint
"""
endpoint: String
stash_ids: [String]
modifier: CriterionModifier!
}
input CustomFieldCriterionInput { input CustomFieldCriterionInput {
field: String! field: String!
value: [Any!] value: [Any!]
@@ -126,10 +152,15 @@ input PerformerFilterType {
fake_tits: StringCriterionInput fake_tits: StringCriterionInput
"Filter by penis length value" "Filter by penis length value"
penis_length: FloatCriterionInput penis_length: FloatCriterionInput
"Filter by ciricumcision" "Filter by circumcision"
circumcised: CircumcisionCriterionInput circumcised: CircumcisionCriterionInput
"Filter by career length" "Deprecated: use career_start and career_end. This filter is non-functional."
career_length: StringCriterionInput career_length: StringCriterionInput
@deprecated(reason: "Use career_start and career_end")
"Filter by career start"
career_start: DateCriterionInput
"Filter by career end"
career_end: DateCriterionInput
"Filter by tattoos" "Filter by tattoos"
tattoos: StringCriterionInput tattoos: StringCriterionInput
"Filter by piercings" "Filter by piercings"
@@ -146,6 +177,8 @@ input PerformerFilterType {
tag_count: IntCriterionInput tag_count: IntCriterionInput
"Filter by scene count" "Filter by scene count"
scene_count: IntCriterionInput scene_count: IntCriterionInput
"Filter by marker count (via scene)"
marker_count: IntCriterionInput
"Filter by image count" "Filter by image count"
image_count: IntCriterionInput image_count: IntCriterionInput
"Filter by gallery count" "Filter by gallery count"
@@ -156,6 +189,9 @@ input PerformerFilterType {
o_counter: IntCriterionInput o_counter: IntCriterionInput
"Filter by StashID" "Filter by StashID"
stash_id_endpoint: StashIDCriterionInput stash_id_endpoint: StashIDCriterionInput
@deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashIDs"
stash_ids_endpoint: StashIDsCriterionInput
# rating expressed as 1-100 # rating expressed as 1-100
rating100: IntCriterionInput rating100: IntCriterionInput
"Filter by url" "Filter by url"
@@ -186,6 +222,8 @@ input PerformerFilterType {
galleries_filter: GalleryFilterType galleries_filter: GalleryFilterType
"Filter by related tags that meet this criteria" "Filter by related tags that meet this criteria"
tags_filter: TagFilterType tags_filter: TagFilterType
"Filter by related scene markers (via scene) that meet this criteria"
markers_filter: SceneMarkerFilterType
"Filter by creation time" "Filter by creation time"
created_at: TimestampCriterionInput created_at: TimestampCriterionInput
"Filter by last update time" "Filter by last update time"
@@ -211,9 +249,9 @@ input SceneMarkerFilterType {
updated_at: TimestampCriterionInput updated_at: TimestampCriterionInput
"Filter by scene date" "Filter by scene date"
scene_date: DateCriterionInput scene_date: DateCriterionInput
"Filter by cscene reation time" "Filter by scene creation time"
scene_created_at: TimestampCriterionInput scene_created_at: TimestampCriterionInput
"Filter by lscene ast update time" "Filter by scene last update time"
scene_updated_at: TimestampCriterionInput scene_updated_at: TimestampCriterionInput
"Filter by related scenes that meet this criteria" "Filter by related scenes that meet this criteria"
scene_filter: SceneFilterType scene_filter: SceneFilterType
@@ -248,8 +286,8 @@ input SceneFilterType {
organized: Boolean organized: Boolean
"Filter by o-counter" "Filter by o-counter"
o_counter: IntCriterionInput o_counter: IntCriterionInput
"Filter Scenes that have an exact phash match available" "Filter Scenes by duplication criteria"
duplicated: PHashDuplicationCriterionInput duplicated: DuplicationCriterionInput
"Filter by resolution" "Filter by resolution"
resolution: ResolutionCriterionInput resolution: ResolutionCriterionInput
"Filter by orientation" "Filter by orientation"
@@ -292,6 +330,11 @@ input SceneFilterType {
performer_count: IntCriterionInput performer_count: IntCriterionInput
"Filter by StashID" "Filter by StashID"
stash_id_endpoint: StashIDCriterionInput stash_id_endpoint: StashIDCriterionInput
@deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashIDs"
stash_ids_endpoint: StashIDsCriterionInput
"Filter by StashID count"
stash_id_count: IntCriterionInput
"Filter by url" "Filter by url"
url: StringCriterionInput url: StringCriterionInput
"Filter by interactive" "Filter by interactive"
@@ -332,6 +375,8 @@ input SceneFilterType {
markers_filter: SceneMarkerFilterType markers_filter: SceneMarkerFilterType
"Filter by related files that meet this criteria" "Filter by related files that meet this criteria"
files_filter: FileFilterType files_filter: FileFilterType
custom_fields: [CustomFieldCriterionInput!]
} }
input MovieFilterType { input MovieFilterType {
@@ -414,11 +459,16 @@ input GroupFilterType {
containing_group_count: IntCriterionInput containing_group_count: IntCriterionInput
"Filter by number of sub-groups the group has" "Filter by number of sub-groups the group has"
sub_group_count: IntCriterionInput sub_group_count: IntCriterionInput
"Filter by number of scenes the group has"
scene_count: IntCriterionInput
"Filter by related scenes that meet this criteria" "Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType scenes_filter: SceneFilterType
"Filter by related studios that meet this criteria" "Filter by related studios that meet this criteria"
studios_filter: StudioFilterType studios_filter: StudioFilterType
"Filter by custom fields"
custom_fields: [CustomFieldCriterionInput!]
} }
input StudioFilterType { input StudioFilterType {
@@ -432,6 +482,9 @@ input StudioFilterType {
parents: MultiCriterionInput parents: MultiCriterionInput
"Filter by StashID" "Filter by StashID"
stash_id_endpoint: StashIDCriterionInput stash_id_endpoint: StashIDCriterionInput
@deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashIDs"
stash_ids_endpoint: StashIDsCriterionInput
"Filter to only include studios with these tags" "Filter to only include studios with these tags"
tags: HierarchicalMultiCriterionInput tags: HierarchicalMultiCriterionInput
"Filter to only include studios missing this property" "Filter to only include studios missing this property"
@@ -446,6 +499,8 @@ input StudioFilterType {
image_count: IntCriterionInput image_count: IntCriterionInput
"Filter by gallery count" "Filter by gallery count"
gallery_count: IntCriterionInput gallery_count: IntCriterionInput
"Filter by group count"
group_count: IntCriterionInput
"Filter by tag count" "Filter by tag count"
tag_count: IntCriterionInput tag_count: IntCriterionInput
"Filter by url" "Filter by url"
@@ -456,16 +511,22 @@ input StudioFilterType {
child_count: IntCriterionInput child_count: IntCriterionInput
"Filter by autotag ignore value" "Filter by autotag ignore value"
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
"Filter by organized"
organized: Boolean
"Filter by related scenes that meet this criteria" "Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType scenes_filter: SceneFilterType
"Filter by related images that meet this criteria" "Filter by related images that meet this criteria"
images_filter: ImageFilterType images_filter: ImageFilterType
"Filter by related galleries that meet this criteria" "Filter by related galleries that meet this criteria"
galleries_filter: GalleryFilterType galleries_filter: GalleryFilterType
"Filter by related groups that meet this criteria"
groups_filter: GroupFilterType
"Filter by creation time" "Filter by creation time"
created_at: TimestampCriterionInput created_at: TimestampCriterionInput
"Filter by last update time" "Filter by last update time"
updated_at: TimestampCriterionInput updated_at: TimestampCriterionInput
custom_fields: [CustomFieldCriterionInput!]
} }
input GalleryFilterType { input GalleryFilterType {
@@ -542,6 +603,10 @@ input GalleryFilterType {
files_filter: FileFilterType files_filter: FileFilterType
"Filter by related folders that meet this criteria" "Filter by related folders that meet this criteria"
folders_filter: FolderFilterType folders_filter: FolderFilterType
"Filter by parent folder of the zip or folder the gallery is in"
parent_folder: HierarchicalMultiCriterionInput
custom_fields: [CustomFieldCriterionInput!]
} }
input TagFilterType { input TagFilterType {
@@ -568,28 +633,28 @@ input TagFilterType {
is_missing: String is_missing: String
"Filter by number of scenes with this tag" "Filter by number of scenes with this tag"
scene_count: IntCriterionInput scene_count: HierarchicalCountInput
"Filter by number of images with this tag" "Filter by number of images with this tag"
image_count: IntCriterionInput image_count: HierarchicalCountInput
"Filter by number of galleries with this tag" "Filter by number of galleries with this tag"
gallery_count: IntCriterionInput gallery_count: HierarchicalCountInput
"Filter by number of performers with this tag" "Filter by number of performers with this tag"
performer_count: IntCriterionInput performer_count: HierarchicalCountInput
"Filter by number of studios with this tag" "Filter by number of studios with this tag"
studio_count: IntCriterionInput studio_count: HierarchicalCountInput
"Filter by number of movies with this tag" "Filter by number of movies with this tag"
movie_count: IntCriterionInput movie_count: HierarchicalCountInput
"Filter by number of group with this tag" "Filter by number of group with this tag"
group_count: IntCriterionInput group_count: HierarchicalCountInput
"Filter by number of markers with this tag" "Filter by number of markers with this tag"
marker_count: IntCriterionInput marker_count: HierarchicalCountInput
"Filter by parent tags" "Filter by parent tags"
parents: HierarchicalMultiCriterionInput parents: HierarchicalMultiCriterionInput
@@ -600,24 +665,41 @@ input TagFilterType {
"Filter by number of parent tags the tag has" "Filter by number of parent tags the tag has"
parent_count: IntCriterionInput parent_count: IntCriterionInput
"Filter by number f child tags the tag has" "Filter by number of child tags the tag has"
child_count: IntCriterionInput child_count: IntCriterionInput
"Filter by autotag ignore value" "Filter by autotag ignore value"
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
"Filter by StashID"
stash_id_endpoint: StashIDCriterionInput
@deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashID"
stash_ids_endpoint: StashIDsCriterionInput
"Filter by related scenes that meet this criteria" "Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType scenes_filter: SceneFilterType
"Filter by related images that meet this criteria" "Filter by related images that meet this criteria"
images_filter: ImageFilterType images_filter: ImageFilterType
"Filter by related galleries that meet this criteria" "Filter by related galleries that meet this criteria"
galleries_filter: GalleryFilterType galleries_filter: GalleryFilterType
"Filter by related groups that meet this criteria"
groups_filter: GroupFilterType
"Filter by related performers that meet this criteria"
performers_filter: PerformerFilterType
"Filter by related studios that meet this criteria"
studios_filter: StudioFilterType
"Filter by related scene markers that meet this criteria"
markers_filter: SceneMarkerFilterType
"Filter by creation time" "Filter by creation time"
created_at: TimestampCriterionInput created_at: TimestampCriterionInput
"Filter by last update time" "Filter by last update time"
updated_at: TimestampCriterionInput updated_at: TimestampCriterionInput
custom_fields: [CustomFieldCriterionInput!]
} }
input ImageFilterType { input ImageFilterType {
@@ -632,6 +714,8 @@ input ImageFilterType {
id: IntCriterionInput id: IntCriterionInput
"Filter by file checksum" "Filter by file checksum"
checksum: StringCriterionInput checksum: StringCriterionInput
"Filter by file phash distance"
phash_distance: PhashDistanceCriterionInput
"Filter by path" "Filter by path"
path: StringCriterionInput path: StringCriterionInput
"Filter by file count" "Filter by file count"
@@ -689,6 +773,8 @@ input ImageFilterType {
tags_filter: TagFilterType tags_filter: TagFilterType
"Filter by related files that meet this criteria" "Filter by related files that meet this criteria"
files_filter: FileFilterType files_filter: FileFilterType
"Filter by custom fields"
custom_fields: [CustomFieldCriterionInput!]
} }
input FileFilterType { input FileFilterType {
@@ -706,8 +792,8 @@ input FileFilterType {
"Filter by modification time" "Filter by modification time"
mod_time: TimestampCriterionInput mod_time: TimestampCriterionInput
"Filter files that have an exact match available" "Filter files by duplication criteria (only phash applies to files)"
duplicated: PHashDuplicationCriterionInput duplicated: FileDuplicationCriterionInput
"find files based on hash" "find files based on hash"
hashes: [FingerprintFilterInput!] hashes: [FingerprintFilterInput!]
@@ -738,6 +824,7 @@ input FolderFilterType {
NOT: FolderFilterType NOT: FolderFilterType
path: StringCriterionInput path: StringCriterionInput
basename: StringCriterionInput
parent_folder: HierarchicalMultiCriterionInput parent_folder: HierarchicalMultiCriterionInput
zip_file: MultiCriterionInput zip_file: MultiCriterionInput
@@ -827,6 +914,14 @@ input IntCriterionInput {
modifier: CriterionModifier! 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 { input FloatCriterionInput {
value: Float! value: Float!
value2: Float value2: Float
@@ -846,7 +941,7 @@ input GenderCriterionInput {
} }
input CircumcisionCriterionInput { input CircumcisionCriterionInput {
value: [CircumisedEnum!] value: [CircumcisedEnum!]
modifier: CriterionModifier! modifier: CriterionModifier!
} }

View File

@@ -32,6 +32,7 @@ type Gallery {
cover: Image cover: Image
paths: GalleryPathsType! # Resolver paths: GalleryPathsType! # Resolver
custom_fields: Map!
image(index: Int!): Image! image(index: Int!): Image!
} }
@@ -50,6 +51,8 @@ input GalleryCreateInput {
studio_id: ID studio_id: ID
tag_ids: [ID!] tag_ids: [ID!]
performer_ids: [ID!] performer_ids: [ID!]
custom_fields: Map
} }
input GalleryUpdateInput { input GalleryUpdateInput {
@@ -71,6 +74,8 @@ input GalleryUpdateInput {
performer_ids: [ID!] performer_ids: [ID!]
primary_file_id: ID primary_file_id: ID
custom_fields: CustomFieldsInput
} }
input BulkGalleryUpdateInput { input BulkGalleryUpdateInput {
@@ -89,6 +94,8 @@ input BulkGalleryUpdateInput {
studio_id: ID studio_id: ID
tag_ids: BulkUpdateIds tag_ids: BulkUpdateIds
performer_ids: BulkUpdateIds performer_ids: BulkUpdateIds
custom_fields: CustomFieldsInput
} }
input GalleryDestroyInput { input GalleryDestroyInput {
@@ -100,6 +107,8 @@ input GalleryDestroyInput {
""" """
delete_file: Boolean delete_file: Boolean
delete_generated: Boolean delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
} }
type FindGalleriesResultType { type FindGalleriesResultType {

View File

@@ -31,6 +31,7 @@ type Group {
sub_group_count(depth: Int): Int! # Resolver sub_group_count(depth: Int): Int! # Resolver
scenes: [Scene!]! scenes: [Scene!]!
o_counter: Int # Resolver o_counter: Int # Resolver
custom_fields: Map!
} }
input GroupDescriptionInput { input GroupDescriptionInput {
@@ -59,6 +60,8 @@ input GroupCreateInput {
front_image: String front_image: String
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
back_image: String back_image: String
custom_fields: Map
} }
input GroupUpdateInput { input GroupUpdateInput {
@@ -82,6 +85,8 @@ input GroupUpdateInput {
front_image: String front_image: String
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
back_image: String back_image: String
custom_fields: CustomFieldsInput
} }
input BulkUpdateGroupDescriptionsInput { input BulkUpdateGroupDescriptionsInput {
@@ -94,6 +99,8 @@ input BulkGroupUpdateInput {
ids: [ID!] ids: [ID!]
# rating expressed as 1-100 # rating expressed as 1-100
rating100: Int rating100: Int
date: String
synopsis: String
studio_id: ID studio_id: ID
director: String director: String
urls: BulkUpdateStrings urls: BulkUpdateStrings
@@ -101,6 +108,8 @@ input BulkGroupUpdateInput {
containing_groups: BulkUpdateGroupDescriptionsInput containing_groups: BulkUpdateGroupDescriptionsInput
sub_groups: BulkUpdateGroupDescriptionsInput sub_groups: BulkUpdateGroupDescriptionsInput
custom_fields: CustomFieldsInput
} }
input GroupDestroyInput { input GroupDestroyInput {

View File

@@ -21,6 +21,7 @@ type Image {
studio: Studio studio: Studio
tags: [Tag!]! tags: [Tag!]!
performers: [Performer!]! performers: [Performer!]!
custom_fields: Map!
} }
type ImageFileType { type ImageFileType {
@@ -56,6 +57,7 @@ input ImageUpdateInput {
gallery_ids: [ID!] gallery_ids: [ID!]
primary_file_id: ID primary_file_id: ID
custom_fields: CustomFieldsInput
} }
input BulkImageUpdateInput { input BulkImageUpdateInput {
@@ -76,18 +78,23 @@ input BulkImageUpdateInput {
performer_ids: BulkUpdateIds performer_ids: BulkUpdateIds
tag_ids: BulkUpdateIds tag_ids: BulkUpdateIds
gallery_ids: BulkUpdateIds gallery_ids: BulkUpdateIds
custom_fields: CustomFieldsInput
} }
input ImageDestroyInput { input ImageDestroyInput {
id: ID! id: ID!
delete_file: Boolean delete_file: Boolean
delete_generated: Boolean delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
} }
input ImagesDestroyInput { input ImagesDestroyInput {
ids: [ID!]! ids: [ID!]!
delete_file: Boolean delete_file: Boolean
delete_generated: Boolean delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
} }
type FindImagesResultType { type FindImagesResultType {

View File

@@ -10,8 +10,11 @@ input GenerateMetadataInput {
transcodes: Boolean transcodes: Boolean
"Generate transcodes even if not required" "Generate transcodes even if not required"
forceTranscodes: Boolean forceTranscodes: Boolean
"Generate video phashes during scan"
phashes: Boolean phashes: Boolean
interactiveHeatmapsSpeeds: Boolean interactiveHeatmapsSpeeds: Boolean
"Generate image phashes during scan"
imagePhashes: Boolean
imageThumbnails: Boolean imageThumbnails: Boolean
clipPreviews: Boolean clipPreviews: Boolean
@@ -19,6 +22,12 @@ input GenerateMetadataInput {
sceneIDs: [ID!] sceneIDs: [ID!]
"marker ids to generate for" "marker ids to generate for"
markerIDs: [ID!] markerIDs: [ID!]
"image ids to generate for"
imageIDs: [ID!]
"gallery ids to generate for"
galleryIDs: [ID!]
"paths to run generate on, in addition to the other ID lists"
paths: [String!]
"overwrite existing media" "overwrite existing media"
overwrite: Boolean overwrite: Boolean
@@ -85,8 +94,10 @@ input ScanMetadataInput {
scanGenerateImagePreviews: Boolean scanGenerateImagePreviews: Boolean
"Generate sprites during scan" "Generate sprites during scan"
scanGenerateSprites: Boolean scanGenerateSprites: Boolean
"Generate phashes during scan" "Generate video phashes during scan"
scanGeneratePhashes: Boolean scanGeneratePhashes: Boolean
"Generate image phashes during scan"
scanGenerateImagePhashes: Boolean
"Generate image thumbnails during scan" "Generate image thumbnails during scan"
scanGenerateThumbnails: Boolean scanGenerateThumbnails: Boolean
"Generate image clip previews during scan" "Generate image clip previews during scan"
@@ -107,8 +118,10 @@ type ScanMetadataOptions {
scanGenerateImagePreviews: Boolean! scanGenerateImagePreviews: Boolean!
"Generate sprites during scan" "Generate sprites during scan"
scanGenerateSprites: Boolean! scanGenerateSprites: Boolean!
"Generate phashes during scan" "Generate video phashes during scan"
scanGeneratePhashes: Boolean! scanGeneratePhashes: Boolean!
"Generate image phashes during scan"
scanGenerateImagePhashes: Boolean
"Generate image thumbnails during scan" "Generate image thumbnails during scan"
scanGenerateThumbnails: Boolean! scanGenerateThumbnails: Boolean!
"Generate image clip previews during scan" "Generate image clip previews during scan"
@@ -118,6 +131,14 @@ type ScanMetadataOptions {
input CleanMetadataInput { input CleanMetadataInput {
paths: [String!] paths: [String!]
"""
Don't check zip file contents when determining whether to clean a file.
This can significantly speed up the clean process, but will potentially miss removed files within zip files.
Where users do not modify zip files contents directly, this should be safe to use.
Defaults to false.
"""
ignoreZipFileContents: Boolean
"Do a dry run. Don't delete any files" "Do a dry run. Don't delete any files"
dryRun: Boolean! dryRun: Boolean!
} }
@@ -204,7 +225,9 @@ input IdentifyMetadataOptionsInput {
setCoverImage: Boolean setCoverImage: Boolean
setOrganized: Boolean setOrganized: Boolean
"defaults to true if not provided" "defaults to true if not provided"
includeMalePerformers: Boolean includeMalePerformers: Boolean @deprecated(reason: "Use performerGenders")
"Filter to only include performers with these genders. If not provided, all genders are included."
performerGenders: [GenderEnum!]
"defaults to true if not provided" "defaults to true if not provided"
skipMultipleMatches: Boolean skipMultipleMatches: Boolean
"tag to tag skipped multiple matches with" "tag to tag skipped multiple matches with"
@@ -249,7 +272,9 @@ type IdentifyMetadataOptions {
setCoverImage: Boolean setCoverImage: Boolean
setOrganized: Boolean setOrganized: Boolean
"defaults to true if not provided" "defaults to true if not provided"
includeMalePerformers: Boolean includeMalePerformers: Boolean @deprecated(reason: "Use performerGenders")
"Filter to only include performers with these genders. If not provided, all genders are included."
performerGenders: [GenderEnum!]
"defaults to true if not provided" "defaults to true if not provided"
skipMultipleMatches: Boolean skipMultipleMatches: Boolean
"tag to tag skipped multiple matches with" "tag to tag skipped multiple matches with"
@@ -310,6 +335,8 @@ input ImportObjectsInput {
input BackupDatabaseInput { input BackupDatabaseInput {
download: Boolean download: Boolean
"If true, blob files will be included in the backup. This can significantly increase the size of the backup and the time it takes to create it, but allows for a complete backup of the system that can be restored without needing access to the original media files."
includeBlobs: Boolean
} }
input AnonymiseDatabaseInput { input AnonymiseDatabaseInput {

View File

@@ -7,7 +7,7 @@ enum GenderEnum {
NON_BINARY NON_BINARY
} }
enum CircumisedEnum { enum CircumcisedEnum {
CUT CUT
UNCUT UNCUT
} }
@@ -29,8 +29,10 @@ type Performer {
measurements: String measurements: String
fake_tits: String fake_tits: String
penis_length: Float penis_length: Float
circumcised: CircumisedEnum circumcised: CircumcisedEnum
career_length: String career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
tattoos: String tattoos: String
piercings: String piercings: String
alias_list: [String!]! alias_list: [String!]!
@@ -76,10 +78,13 @@ input PerformerCreateInput {
measurements: String measurements: String
fake_tits: String fake_tits: String
penis_length: Float penis_length: Float
circumcised: CircumisedEnum circumcised: CircumcisedEnum
career_length: String career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
tattoos: String tattoos: String
piercings: String piercings: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
alias_list: [String!] alias_list: [String!]
twitter: String @deprecated(reason: "Use urls") twitter: String @deprecated(reason: "Use urls")
instagram: String @deprecated(reason: "Use urls") instagram: String @deprecated(reason: "Use urls")
@@ -114,10 +119,13 @@ input PerformerUpdateInput {
measurements: String measurements: String
fake_tits: String fake_tits: String
penis_length: Float penis_length: Float
circumcised: CircumisedEnum circumcised: CircumcisedEnum
career_length: String career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
tattoos: String tattoos: String
piercings: String piercings: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
alias_list: [String!] alias_list: [String!]
twitter: String @deprecated(reason: "Use urls") twitter: String @deprecated(reason: "Use urls")
instagram: String @deprecated(reason: "Use urls") instagram: String @deprecated(reason: "Use urls")
@@ -157,10 +165,13 @@ input BulkPerformerUpdateInput {
measurements: String measurements: String
fake_tits: String fake_tits: String
penis_length: Float penis_length: Float
circumcised: CircumisedEnum circumcised: CircumcisedEnum
career_length: String career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
tattoos: String tattoos: String
piercings: String piercings: String
"Duplicate aliases and those equal to name will result in an error (case-insensitive)"
alias_list: BulkUpdateStrings alias_list: BulkUpdateStrings
twitter: String @deprecated(reason: "Use urls") twitter: String @deprecated(reason: "Use urls")
instagram: String @deprecated(reason: "Use urls") instagram: String @deprecated(reason: "Use urls")
@@ -185,3 +196,10 @@ type FindPerformersResultType {
count: Int! count: Int!
performers: [Performer!]! performers: [Performer!]!
} }
input PerformerMergeInput {
source: [ID!]!
destination: ID!
# values defined here will override values in the destination
values: PerformerUpdateInput
}

View File

@@ -79,6 +79,8 @@ type Scene {
performers: [Performer!]! performers: [Performer!]!
stash_ids: [StashID!]! stash_ids: [StashID!]!
custom_fields: Map!
"Return valid stream paths" "Return valid stream paths"
sceneStreams: [SceneStreamEndpoint!]! sceneStreams: [SceneStreamEndpoint!]!
} }
@@ -120,6 +122,8 @@ input SceneCreateInput {
Files must not already be primary for another scene. Files must not already be primary for another scene.
""" """
file_ids: [ID!] file_ids: [ID!]
custom_fields: Map
} }
input SceneUpdateInput { input SceneUpdateInput {
@@ -158,6 +162,8 @@ input SceneUpdateInput {
) )
primary_file_id: ID primary_file_id: ID
custom_fields: CustomFieldsInput
} }
enum BulkUpdateIdMode { enum BulkUpdateIdMode {
@@ -190,18 +196,24 @@ input BulkSceneUpdateInput {
tag_ids: BulkUpdateIds tag_ids: BulkUpdateIds
group_ids: BulkUpdateIds group_ids: BulkUpdateIds
movie_ids: BulkUpdateIds @deprecated(reason: "Use group_ids") movie_ids: BulkUpdateIds @deprecated(reason: "Use group_ids")
custom_fields: CustomFieldsInput
} }
input SceneDestroyInput { input SceneDestroyInput {
id: ID! id: ID!
delete_file: Boolean delete_file: Boolean
delete_generated: Boolean delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
} }
input ScenesDestroyInput { input ScenesDestroyInput {
ids: [ID!]! ids: [ID!]!
delete_file: Boolean delete_file: Boolean
delete_generated: Boolean delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
} }
type FindScenesResultType { type FindScenesResultType {

View File

@@ -18,7 +18,9 @@ type ScrapedPerformer {
fake_tits: String fake_tits: String
penis_length: String penis_length: String
circumcised: String circumcised: String
career_length: String career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
tattoos: String tattoos: String
piercings: String piercings: String
# aliases must be comma-delimited to be parsed correctly # aliases must be comma-delimited to be parsed correctly
@@ -54,7 +56,9 @@ input ScrapedPerformerInput {
fake_tits: String fake_tits: String
penis_length: String penis_length: String
circumcised: String circumcised: String
career_length: String career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: String
career_end: String
tattoos: String tattoos: String
piercings: String piercings: String
aliases: String aliases: String

View File

@@ -71,6 +71,9 @@ type ScrapedTag {
"Set if tag matched" "Set if tag matched"
stored_id: ID stored_id: ID
name: String! name: String!
description: String
alias_list: [String!]
parent: ScrapedTag
"Remote site ID, if applicable" "Remote site ID, if applicable"
remote_site_id: String remote_site_id: String
} }
@@ -299,6 +302,8 @@ input StashBoxBatchTagInput {
stash_box_endpoint: String stash_box_endpoint: String
"Fields to exclude when executing the tagging" "Fields to exclude when executing the tagging"
exclude_fields: [String!] 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 items already tagged by StashBox if true. Only tag items with no StashBox tagging if false"
refresh: Boolean! refresh: Boolean!
"If batch adding studios, should their parent studios also be created?" "If batch adding studios, should their parent studios also be created?"

View File

@@ -8,6 +8,7 @@ type Studio {
aliases: [String!]! aliases: [String!]!
tags: [Tag!]! tags: [Tag!]!
ignore_auto_tag: Boolean! ignore_auto_tag: Boolean!
organized: Boolean!
image_path: String # Resolver image_path: String # Resolver
scene_count(depth: Int): Int! # Resolver scene_count(depth: Int): Int! # Resolver
@@ -26,6 +27,8 @@ type Studio {
groups: [Group!]! groups: [Group!]!
movies: [Movie!]! @deprecated(reason: "use groups instead") movies: [Movie!]! @deprecated(reason: "use groups instead")
o_counter: Int o_counter: Int
custom_fields: Map!
} }
input StudioCreateInput { input StudioCreateInput {
@@ -33,6 +36,7 @@ input StudioCreateInput {
url: String @deprecated(reason: "Use urls") url: String @deprecated(reason: "Use urls")
urls: [String!] urls: [String!]
parent_id: ID parent_id: ID
child_ids: [ID!]
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
image: String image: String
stash_ids: [StashIDInput!] stash_ids: [StashIDInput!]
@@ -40,9 +44,13 @@ input StudioCreateInput {
rating100: Int rating100: Int
favorite: Boolean favorite: Boolean
details: String details: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
aliases: [String!] aliases: [String!]
tag_ids: [ID!] tag_ids: [ID!]
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
organized: Boolean
custom_fields: Map
} }
input StudioUpdateInput { input StudioUpdateInput {
@@ -51,6 +59,7 @@ input StudioUpdateInput {
url: String @deprecated(reason: "Use urls") url: String @deprecated(reason: "Use urls")
urls: [String!] urls: [String!]
parent_id: ID parent_id: ID
child_ids: [ID!]
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
image: String image: String
stash_ids: [StashIDInput!] stash_ids: [StashIDInput!]
@@ -58,9 +67,13 @@ input StudioUpdateInput {
rating100: Int rating100: Int
favorite: Boolean favorite: Boolean
details: String details: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
aliases: [String!] aliases: [String!]
tag_ids: [ID!] tag_ids: [ID!]
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
organized: Boolean
custom_fields: CustomFieldsInput
} }
input BulkStudioUpdateInput { input BulkStudioUpdateInput {
@@ -74,6 +87,7 @@ input BulkStudioUpdateInput {
details: String details: String
tag_ids: BulkUpdateIds tag_ids: BulkUpdateIds
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
organized: Boolean
} }
input StudioDestroyInput { input StudioDestroyInput {

View File

@@ -24,6 +24,7 @@ type Tag {
parent_count: Int! # Resolver parent_count: Int! # Resolver
child_count: Int! # Resolver child_count: Int! # Resolver
custom_fields: Map!
} }
input TagCreateInput { input TagCreateInput {
@@ -31,6 +32,7 @@ input TagCreateInput {
"Value that does not appear in the UI but overrides name for sorting" "Value that does not appear in the UI but overrides name for sorting"
sort_name: String sort_name: String
description: String description: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
aliases: [String!] aliases: [String!]
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
favorite: Boolean favorite: Boolean
@@ -40,6 +42,8 @@ input TagCreateInput {
parent_ids: [ID!] parent_ids: [ID!]
child_ids: [ID!] child_ids: [ID!]
custom_fields: Map
} }
input TagUpdateInput { input TagUpdateInput {
@@ -48,6 +52,7 @@ input TagUpdateInput {
"Value that does not appear in the UI but overrides name for sorting" "Value that does not appear in the UI but overrides name for sorting"
sort_name: String sort_name: String
description: String description: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
aliases: [String!] aliases: [String!]
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
favorite: Boolean favorite: Boolean
@@ -57,6 +62,8 @@ input TagUpdateInput {
parent_ids: [ID!] parent_ids: [ID!]
child_ids: [ID!] child_ids: [ID!]
custom_fields: CustomFieldsInput
} }
input TagDestroyInput { input TagDestroyInput {
@@ -71,11 +78,14 @@ type FindTagsResultType {
input TagsMergeInput { input TagsMergeInput {
source: [ID!]! source: [ID!]!
destination: ID! destination: ID!
# values defined here will override values in the destination
values: TagUpdateInput
} }
input BulkTagUpdateInput { input BulkTagUpdateInput {
ids: [ID!] ids: [ID!]
description: String description: String
"Duplicate aliases and those equal to name will result in an error (case-insensitive)"
aliases: BulkUpdateStrings aliases: BulkUpdateStrings
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
favorite: Boolean favorite: Boolean

View File

@@ -29,6 +29,13 @@ fragment StudioFragment on Studio {
fragment TagFragment on Tag { fragment TagFragment on Tag {
name name
id id
description
aliases
category {
id
name
description
}
} }
fragment MeasurementsFragment on Measurements { fragment MeasurementsFragment on Measurements {
@@ -120,18 +127,6 @@ fragment SceneFragment on Scene {
} }
} }
query FindSceneByFingerprint($fingerprint: FingerprintQueryInput!) {
findSceneByFingerprint(fingerprint: $fingerprint) {
...SceneFragment
}
}
query FindScenesByFullFingerprints($fingerprints: [FingerprintQueryInput!]!) {
findScenesByFullFingerprints(fingerprints: $fingerprints) {
...SceneFragment
}
}
query FindScenesBySceneFingerprints( query FindScenesBySceneFingerprints(
$fingerprints: [[FingerprintQueryInput!]!]! $fingerprints: [[FingerprintQueryInput!]!]!
) { ) {

View File

@@ -12,6 +12,7 @@ import (
"github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/session" "github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/signedurl"
) )
const ( const (
@@ -29,6 +30,46 @@ func allowUnauthenticated(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, loginEndpoint) || r.URL.Path == logoutEndpoint || r.URL.Path == "/css" || strings.HasPrefix(r.URL.Path, "/assets") return strings.HasPrefix(r.URL.Path, loginEndpoint) || r.URL.Path == logoutEndpoint || r.URL.Path == "/css" || strings.HasPrefix(r.URL.Path, "/assets")
} }
// authenticateSignedRequest checks if the request is a valid signed media request.
// Returns the matched username and true if valid, or empty string and false otherwise.
func authenticateSignedRequest(r *http.Request) (string, bool) {
// Only apply to scene stream paths (used by AirPlay/Chromecast devices that can't pass cookies)
if !strings.HasPrefix(r.URL.Path, "/scene/") {
return "", false
}
c := config.GetInstance()
// Signed URLs are only relevant when credentials are configured
if !c.HasCredentials() {
return "", false
}
// Check for signed URL parameters
q := r.URL.Query()
if q.Get(signedurl.CIDParam) == "" || q.Get(signedurl.ExpiresParam) == "" || q.Get(signedurl.SigParam) == "" {
return "", false
}
// Extract the credential ID and look up the user's signing key.
// We need the key before we can verify the signature, since in a
// multi-user setup each user could have their own signing key.
cid := q.Get(signedurl.CIDParam)
username, secret, found := resolveCredentialID(c, cid)
if !found {
logger.Warnf("signed URL credential ID mismatch")
return "", false
}
// Verify the signature using the user's signing key
if _, err := signedurl.VerifyURL(r.URL.Path, q, secret); err != nil {
logger.Warnf("signed URL verification failed: %v", err)
return "", false
}
return username, true
}
func authenticateHandler() func(http.Handler) http.Handler { func authenticateHandler() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -40,6 +81,17 @@ func authenticateHandler() func(http.Handler) http.Handler {
return return
} }
r = session.SetLocalRequest(r)
// Check for signed media requests
if username, ok := authenticateSignedRequest(r); ok {
ctx := r.Context()
ctx = session.SetCurrentUserID(ctx, username)
r = r.WithContext(ctx)
next.ServeHTTP(w, r)
return
}
userID, err := manager.GetInstance().SessionStore.Authenticate(w, r) userID, err := manager.GetInstance().SessionStore.Authenticate(w, r)
if err != nil { if err != nil {
if !errors.Is(err, session.ErrUnauthorized) { if !errors.Is(err, session.ErrUnauthorized) {

View File

@@ -148,12 +148,12 @@ func makeGithubRequest(ctx context.Context, url string, output interface{}) erro
response, err := client.Do(req) response, err := client.Do(req)
if err != nil { if err != nil {
//lint:ignore ST1005 Github is a proper capitalized noun //nolint:staticcheck // ST1005 Github is a proper capitalized noun
return fmt.Errorf("Github API request failed: %w", err) return fmt.Errorf("Github API request failed: %w", err)
} }
if response.StatusCode != http.StatusOK { if response.StatusCode != http.StatusOK {
//lint:ignore ST1005 Github is a proper capitalized noun //nolint:staticcheck // ST1005 Github is a proper capitalized noun
return fmt.Errorf("Github API request failed: %s", response.Status) return fmt.Errorf("Github API request failed: %s", response.Status)
} }
@@ -161,7 +161,7 @@ func makeGithubRequest(ctx context.Context, url string, output interface{}) erro
data, err := io.ReadAll(response.Body) data, err := io.ReadAll(response.Body)
if err != nil { if err != nil {
//lint:ignore ST1005 Github is a proper capitalized noun //nolint:staticcheck // ST1005 Github is a proper capitalized noun
return fmt.Errorf("Github API read response failed: %w", err) return fmt.Errorf("Github API read response failed: %w", err)
} }
@@ -295,10 +295,10 @@ func printLatestVersion(ctx context.Context) {
logger.Errorf("Couldn't retrieve latest version: %v", err) logger.Errorf("Couldn't retrieve latest version: %v", err)
} else { } else {
_, githash, _ := build.Version() _, githash, _ := build.Version()
switch { switch githash {
case githash == "": case "":
logger.Infof("Latest version: %s (%s)", latestRelease.Version, latestRelease.ShortHash) logger.Infof("Latest version: %s (%s)", latestRelease.Version, latestRelease.ShortHash)
case githash == latestRelease.ShortHash: case latestRelease.ShortHash:
logger.Infof("Version %s (%s) is already the latest released", latestRelease.Version, latestRelease.ShortHash) logger.Infof("Version %s (%s) is already the latest released", latestRelease.Version, latestRelease.ShortHash)
default: default:
logger.Infof("New version available: %s (%s)", latestRelease.Version, latestRelease.ShortHash) logger.Infof("New version available: %s (%s)", latestRelease.Version, latestRelease.ShortHash)

View File

@@ -5,7 +5,7 @@ package api
type key int type key int
const ( const (
galleryKey key = 0 galleryKey key = iota
performerKey performerKey
sceneKey sceneKey
studioKey studioKey

35
internal/api/input.go Normal file
View File

@@ -0,0 +1,35 @@
package api
import (
"fmt"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
// TODO - apply handleIDs to other resolvers that accept ID lists
// handleIDList validates and converts a list of string IDs to integers
func handleIDList(idList []string, field string) ([]int, error) {
if err := validateIDList(idList); err != nil {
return nil, fmt.Errorf("validating %s: %w", field, err)
}
ids, err := stringslice.StringSliceToIntSlice(idList)
if err != nil {
return nil, fmt.Errorf("converting %s: %w", field, err)
}
return ids, nil
}
// validateIDList returns an error if there are any duplicate ids in the list
func validateIDList(ids []string) error {
seen := make(map[string]struct{})
for _, id := range ids {
if _, exists := seen[id]; exists {
return fmt.Errorf("duplicate id found: %s", id)
}
seen[id] = struct{}{}
}
return nil
}

View File

@@ -11,9 +11,9 @@
//go:generate go run github.com/vektah/dataloaden GroupLoader int *github.com/stashapp/stash/pkg/models.Group //go:generate go run github.com/vektah/dataloaden GroupLoader int *github.com/stashapp/stash/pkg/models.Group
//go:generate go run github.com/vektah/dataloaden FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File //go:generate go run github.com/vektah/dataloaden FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File
//go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder //go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder
//go:generate go run github.com/vektah/dataloaden SceneFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID //go:generate go run github.com/vektah/dataloaden FolderRelatedFolderIDsLoader github.com/stashapp/stash/pkg/models.FolderID []github.com/stashapp/stash/pkg/models.FolderID
//go:generate go run github.com/vektah/dataloaden ImageFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID //go:generate go run github.com/vektah/dataloaden RelatedFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
//go:generate go run github.com/vektah/dataloaden GalleryFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID //go:generate go run github.com/vektah/dataloaden FileIDsRelatedIDsLoader github.com/stashapp/stash/pkg/models.FileID []int
//go:generate go run github.com/vektah/dataloaden CustomFieldsLoader int github.com/stashapp/stash/pkg/models.CustomFieldMap //go:generate go run github.com/vektah/dataloaden CustomFieldsLoader int github.com/stashapp/stash/pkg/models.CustomFieldMap
//go:generate go run github.com/vektah/dataloaden SceneOCountLoader int int //go:generate go run github.com/vektah/dataloaden SceneOCountLoader int int
//go:generate go run github.com/vektah/dataloaden ScenePlayCountLoader int int //go:generate go run github.com/vektah/dataloaden ScenePlayCountLoader int int
@@ -42,28 +42,43 @@ const (
) )
type Loaders struct { type Loaders struct {
SceneByID *SceneLoader SceneByID *SceneLoader
SceneFiles *SceneFileIDsLoader SceneIDsByFileID *FileIDsRelatedIDsLoader
ScenePlayCount *ScenePlayCountLoader SceneFiles *RelatedFileIDsLoader
SceneOCount *SceneOCountLoader ScenePlayCount *ScenePlayCountLoader
ScenePlayHistory *ScenePlayHistoryLoader SceneOCount *SceneOCountLoader
SceneOHistory *SceneOHistoryLoader ScenePlayHistory *ScenePlayHistoryLoader
SceneLastPlayed *SceneLastPlayedLoader SceneOHistory *SceneOHistoryLoader
SceneLastPlayed *SceneLastPlayedLoader
SceneCustomFields *CustomFieldsLoader
ImageFiles *ImageFileIDsLoader ImageFiles *RelatedFileIDsLoader
GalleryFiles *GalleryFileIDsLoader GalleryFiles *RelatedFileIDsLoader
GalleryByID *GalleryLoader GalleryByID *GalleryLoader
ImageByID *ImageLoader GalleryIDsByFileID *FileIDsRelatedIDsLoader
GalleryCustomFields *CustomFieldsLoader
ImageByID *ImageLoader
ImageIDsByFileID *FileIDsRelatedIDsLoader
ImageCustomFields *CustomFieldsLoader
PerformerByID *PerformerLoader PerformerByID *PerformerLoader
PerformerCustomFields *CustomFieldsLoader PerformerCustomFields *CustomFieldsLoader
StudioByID *StudioLoader StudioByID *StudioLoader
TagByID *TagLoader StudioCustomFields *CustomFieldsLoader
GroupByID *GroupLoader
FileByID *FileLoader TagByID *TagLoader
FolderByID *FolderLoader TagCustomFields *CustomFieldsLoader
GroupByID *GroupLoader
GroupCustomFields *CustomFieldsLoader
FileByID *FileLoader
FolderByID *FolderLoader
FolderParentFolderIDs *FolderRelatedFolderIDsLoader
FolderSubFolderIDs *FolderRelatedFolderIDsLoader
} }
type Middleware struct { type Middleware struct {
@@ -79,16 +94,41 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch, maxBatch: maxBatch,
fetch: m.fetchScenes(ctx), fetch: m.fetchScenes(ctx),
}, },
SceneIDsByFileID: &FileIDsRelatedIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchSceneIDsByFileID(ctx),
},
GalleryByID: &GalleryLoader{ GalleryByID: &GalleryLoader{
wait: wait, wait: wait,
maxBatch: maxBatch, maxBatch: maxBatch,
fetch: m.fetchGalleries(ctx), fetch: m.fetchGalleries(ctx),
}, },
GalleryIDsByFileID: &FileIDsRelatedIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchGalleryIDsByFileID(ctx),
},
GalleryCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchGalleryCustomFields(ctx),
},
ImageByID: &ImageLoader{ ImageByID: &ImageLoader{
wait: wait, wait: wait,
maxBatch: maxBatch, maxBatch: maxBatch,
fetch: m.fetchImages(ctx), fetch: m.fetchImages(ctx),
}, },
ImageIDsByFileID: &FileIDsRelatedIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchImageIDsByFileID(ctx),
},
ImageCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchImageCustomFields(ctx),
},
PerformerByID: &PerformerLoader{ PerformerByID: &PerformerLoader{
wait: wait, wait: wait,
maxBatch: maxBatch, maxBatch: maxBatch,
@@ -99,6 +139,16 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch, maxBatch: maxBatch,
fetch: m.fetchPerformerCustomFields(ctx), fetch: m.fetchPerformerCustomFields(ctx),
}, },
StudioCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchStudioCustomFields(ctx),
},
SceneCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchSceneCustomFields(ctx),
},
StudioByID: &StudioLoader{ StudioByID: &StudioLoader{
wait: wait, wait: wait,
maxBatch: maxBatch, maxBatch: maxBatch,
@@ -109,11 +159,21 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch, maxBatch: maxBatch,
fetch: m.fetchTags(ctx), fetch: m.fetchTags(ctx),
}, },
TagCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchTagCustomFields(ctx),
},
GroupByID: &GroupLoader{ GroupByID: &GroupLoader{
wait: wait, wait: wait,
maxBatch: maxBatch, maxBatch: maxBatch,
fetch: m.fetchGroups(ctx), fetch: m.fetchGroups(ctx),
}, },
GroupCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchGroupCustomFields(ctx),
},
FileByID: &FileLoader{ FileByID: &FileLoader{
wait: wait, wait: wait,
maxBatch: maxBatch, maxBatch: maxBatch,
@@ -124,17 +184,27 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch, maxBatch: maxBatch,
fetch: m.fetchFolders(ctx), fetch: m.fetchFolders(ctx),
}, },
SceneFiles: &SceneFileIDsLoader{ FolderParentFolderIDs: &FolderRelatedFolderIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchFoldersParentFolderIDs(ctx),
},
FolderSubFolderIDs: &FolderRelatedFolderIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchFoldersSubFolderIDs(ctx),
},
SceneFiles: &RelatedFileIDsLoader{
wait: wait, wait: wait,
maxBatch: maxBatch, maxBatch: maxBatch,
fetch: m.fetchScenesFileIDs(ctx), fetch: m.fetchScenesFileIDs(ctx),
}, },
ImageFiles: &ImageFileIDsLoader{ ImageFiles: &RelatedFileIDsLoader{
wait: wait, wait: wait,
maxBatch: maxBatch, maxBatch: maxBatch,
fetch: m.fetchImagesFileIDs(ctx), fetch: m.fetchImagesFileIDs(ctx),
}, },
GalleryFiles: &GalleryFileIDsLoader{ GalleryFiles: &RelatedFileIDsLoader{
wait: wait, wait: wait,
maxBatch: maxBatch, maxBatch: maxBatch,
fetch: m.fetchGalleriesFileIDs(ctx), fetch: m.fetchGalleriesFileIDs(ctx),
@@ -194,6 +264,29 @@ func (m Middleware) fetchScenes(ctx context.Context) func(keys []int) ([]*models
} }
} }
func (m Middleware) fetchSceneIDsByFileID(ctx context.Context) func(keys []models.FileID) ([][]int, []error) {
return func(keys []models.FileID) (ret [][]int, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Scene.GetManyIDsByFileIDs(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchSceneCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Scene.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchImages(ctx context.Context) func(keys []int) ([]*models.Image, []error) { func (m Middleware) fetchImages(ctx context.Context) func(keys []int) ([]*models.Image, []error) {
return func(keys []int) (ret []*models.Image, errs []error) { return func(keys []int) (ret []*models.Image, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error { err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
@@ -206,6 +299,29 @@ func (m Middleware) fetchImages(ctx context.Context) func(keys []int) ([]*models
} }
} }
func (m Middleware) fetchImageIDsByFileID(ctx context.Context) func(keys []models.FileID) ([][]int, []error) {
return func(keys []models.FileID) (ret [][]int, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Image.GetManyIDsByFileIDs(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchImageCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Image.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchGalleries(ctx context.Context) func(keys []int) ([]*models.Gallery, []error) { func (m Middleware) fetchGalleries(ctx context.Context) func(keys []int) ([]*models.Gallery, []error) {
return func(keys []int) (ret []*models.Gallery, errs []error) { return func(keys []int) (ret []*models.Gallery, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error { err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
@@ -218,6 +334,17 @@ func (m Middleware) fetchGalleries(ctx context.Context) func(keys []int) ([]*mod
} }
} }
func (m Middleware) fetchGalleryIDsByFileID(ctx context.Context) func(keys []models.FileID) ([][]int, []error) {
return func(keys []models.FileID) (ret [][]int, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Gallery.GetManyIDsByFileIDs(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchPerformers(ctx context.Context) func(keys []int) ([]*models.Performer, []error) { func (m Middleware) fetchPerformers(ctx context.Context) func(keys []int) ([]*models.Performer, []error) {
return func(keys []int) (ret []*models.Performer, errs []error) { return func(keys []int) (ret []*models.Performer, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error { err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
@@ -253,6 +380,18 @@ func (m Middleware) fetchStudios(ctx context.Context) func(keys []int) ([]*model
} }
} }
func (m Middleware) fetchStudioCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Studio.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchTags(ctx context.Context) func(keys []int) ([]*models.Tag, []error) { func (m Middleware) fetchTags(ctx context.Context) func(keys []int) ([]*models.Tag, []error) {
return func(keys []int) (ret []*models.Tag, errs []error) { return func(keys []int) (ret []*models.Tag, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error { err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
@@ -264,6 +403,42 @@ func (m Middleware) fetchTags(ctx context.Context) func(keys []int) ([]*models.T
} }
} }
func (m Middleware) fetchTagCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Tag.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchGroupCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Group.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchGalleryCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Gallery.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchGroups(ctx context.Context) func(keys []int) ([]*models.Group, []error) { func (m Middleware) fetchGroups(ctx context.Context) func(keys []int) ([]*models.Group, []error) {
return func(keys []int) (ret []*models.Group, errs []error) { return func(keys []int) (ret []*models.Group, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error { err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
@@ -297,6 +472,28 @@ func (m Middleware) fetchFolders(ctx context.Context) func(keys []models.FolderI
} }
} }
func (m Middleware) fetchFoldersParentFolderIDs(ctx context.Context) func(keys []models.FolderID) ([][]models.FolderID, []error) {
return func(keys []models.FolderID) (ret [][]models.FolderID, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Folder.GetManyParentFolderIDs(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchFoldersSubFolderIDs(ctx context.Context) func(keys []models.FolderID) ([][]models.FolderID, []error) {
return func(keys []models.FolderID) (ret [][]models.FolderID, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Folder.GetManySubFolderIDs(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) { func (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) {
return func(keys []int) (ret [][]models.FileID, errs []error) { return func(keys []int) (ret [][]models.FileID, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error { err := m.Repository.WithDB(ctx, func(ctx context.Context) error {

View File

@@ -9,10 +9,10 @@ import (
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
// ImageFileIDsLoaderConfig captures the config to create a new ImageFileIDsLoader // FileIDsRelatedIDsLoaderConfig captures the config to create a new FileIDsRelatedIDsLoader
type ImageFileIDsLoaderConfig struct { type FileIDsRelatedIDsLoaderConfig struct {
// Fetch is a method that provides the data for the loader // Fetch is a method that provides the data for the loader
Fetch func(keys []int) ([][]models.FileID, []error) Fetch func(keys []models.FileID) ([][]int, []error)
// Wait is how long wait before sending a batch // Wait is how long wait before sending a batch
Wait time.Duration Wait time.Duration
@@ -21,19 +21,19 @@ type ImageFileIDsLoaderConfig struct {
MaxBatch int MaxBatch int
} }
// NewImageFileIDsLoader creates a new ImageFileIDsLoader given a fetch, wait, and maxBatch // NewFileIDsRelatedIDsLoader creates a new FileIDsRelatedIDsLoader given a fetch, wait, and maxBatch
func NewImageFileIDsLoader(config ImageFileIDsLoaderConfig) *ImageFileIDsLoader { func NewFileIDsRelatedIDsLoader(config FileIDsRelatedIDsLoaderConfig) *FileIDsRelatedIDsLoader {
return &ImageFileIDsLoader{ return &FileIDsRelatedIDsLoader{
fetch: config.Fetch, fetch: config.Fetch,
wait: config.Wait, wait: config.Wait,
maxBatch: config.MaxBatch, maxBatch: config.MaxBatch,
} }
} }
// ImageFileIDsLoader batches and caches requests // FileIDsRelatedIDsLoader batches and caches requests
type ImageFileIDsLoader struct { type FileIDsRelatedIDsLoader struct {
// this method provides the data for the loader // this method provides the data for the loader
fetch func(keys []int) ([][]models.FileID, []error) fetch func(keys []models.FileID) ([][]int, []error)
// how long to done before sending a batch // how long to done before sending a batch
wait time.Duration wait time.Duration
@@ -44,51 +44,51 @@ type ImageFileIDsLoader struct {
// INTERNAL // INTERNAL
// lazily created cache // lazily created cache
cache map[int][]models.FileID cache map[models.FileID][]int
// the current batch. keys will continue to be collected until timeout is hit, // the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners // then everything will be sent to the fetch method and out to the listeners
batch *imageFileIDsLoaderBatch batch *fileIDsRelatedIDsLoaderBatch
// mutex to prevent races // mutex to prevent races
mu sync.Mutex mu sync.Mutex
} }
type imageFileIDsLoaderBatch struct { type fileIDsRelatedIDsLoaderBatch struct {
keys []int keys []models.FileID
data [][]models.FileID data [][]int
error []error error []error
closing bool closing bool
done chan struct{} done chan struct{}
} }
// Load a FileID by key, batching and caching will be applied automatically // Load a int by key, batching and caching will be applied automatically
func (l *ImageFileIDsLoader) Load(key int) ([]models.FileID, error) { func (l *FileIDsRelatedIDsLoader) Load(key models.FileID) ([]int, error) {
return l.LoadThunk(key)() return l.LoadThunk(key)()
} }
// LoadThunk returns a function that when called will block waiting for a FileID. // LoadThunk returns a function that when called will block waiting for a int.
// This method should be used if you want one goroutine to make requests to many // This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called. // different data loaders without blocking until the thunk is called.
func (l *ImageFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) { func (l *FileIDsRelatedIDsLoader) LoadThunk(key models.FileID) func() ([]int, error) {
l.mu.Lock() l.mu.Lock()
if it, ok := l.cache[key]; ok { if it, ok := l.cache[key]; ok {
l.mu.Unlock() l.mu.Unlock()
return func() ([]models.FileID, error) { return func() ([]int, error) {
return it, nil return it, nil
} }
} }
if l.batch == nil { if l.batch == nil {
l.batch = &imageFileIDsLoaderBatch{done: make(chan struct{})} l.batch = &fileIDsRelatedIDsLoaderBatch{done: make(chan struct{})}
} }
batch := l.batch batch := l.batch
pos := batch.keyIndex(l, key) pos := batch.keyIndex(l, key)
l.mu.Unlock() l.mu.Unlock()
return func() ([]models.FileID, error) { return func() ([]int, error) {
<-batch.done <-batch.done
var data []models.FileID var data []int
if pos < len(batch.data) { if pos < len(batch.data) {
data = batch.data[pos] data = batch.data[pos]
} }
@@ -113,49 +113,49 @@ func (l *ImageFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error)
// LoadAll fetches many keys at once. It will be broken into appropriate sized // LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured // sub batches depending on how the loader is configured
func (l *ImageFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) { func (l *FileIDsRelatedIDsLoader) LoadAll(keys []models.FileID) ([][]int, []error) {
results := make([]func() ([]models.FileID, error), len(keys)) results := make([]func() ([]int, error), len(keys))
for i, key := range keys { for i, key := range keys {
results[i] = l.LoadThunk(key) results[i] = l.LoadThunk(key)
} }
fileIDs := make([][]models.FileID, len(keys)) ints := make([][]int, len(keys))
errors := make([]error, len(keys)) errors := make([]error, len(keys))
for i, thunk := range results { for i, thunk := range results {
fileIDs[i], errors[i] = thunk() ints[i], errors[i] = thunk()
} }
return fileIDs, errors return ints, errors
} }
// LoadAllThunk returns a function that when called will block waiting for a FileIDs. // LoadAllThunk returns a function that when called will block waiting for a ints.
// This method should be used if you want one goroutine to make requests to many // This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called. // different data loaders without blocking until the thunk is called.
func (l *ImageFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) { func (l *FileIDsRelatedIDsLoader) LoadAllThunk(keys []models.FileID) func() ([][]int, []error) {
results := make([]func() ([]models.FileID, error), len(keys)) results := make([]func() ([]int, error), len(keys))
for i, key := range keys { for i, key := range keys {
results[i] = l.LoadThunk(key) results[i] = l.LoadThunk(key)
} }
return func() ([][]models.FileID, []error) { return func() ([][]int, []error) {
fileIDs := make([][]models.FileID, len(keys)) ints := make([][]int, len(keys))
errors := make([]error, len(keys)) errors := make([]error, len(keys))
for i, thunk := range results { for i, thunk := range results {
fileIDs[i], errors[i] = thunk() ints[i], errors[i] = thunk()
} }
return fileIDs, errors return ints, errors
} }
} }
// Prime the cache with the provided key and value. If the key already exists, no change is made // Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned. // and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *ImageFileIDsLoader) Prime(key int, value []models.FileID) bool { func (l *FileIDsRelatedIDsLoader) Prime(key models.FileID, value []int) bool {
l.mu.Lock() l.mu.Lock()
var found bool var found bool
if _, found = l.cache[key]; !found { if _, found = l.cache[key]; !found {
// make a copy when writing to the cache, its easy to pass a pointer in from a loop var // make a copy when writing to the cache, its easy to pass a pointer in from a loop var
// and end up with the whole cache pointing to the same value. // and end up with the whole cache pointing to the same value.
cpy := make([]models.FileID, len(value)) cpy := make([]int, len(value))
copy(cpy, value) copy(cpy, value)
l.unsafeSet(key, cpy) l.unsafeSet(key, cpy)
} }
@@ -164,22 +164,22 @@ func (l *ImageFileIDsLoader) Prime(key int, value []models.FileID) bool {
} }
// Clear the value at key from the cache, if it exists // Clear the value at key from the cache, if it exists
func (l *ImageFileIDsLoader) Clear(key int) { func (l *FileIDsRelatedIDsLoader) Clear(key models.FileID) {
l.mu.Lock() l.mu.Lock()
delete(l.cache, key) delete(l.cache, key)
l.mu.Unlock() l.mu.Unlock()
} }
func (l *ImageFileIDsLoader) unsafeSet(key int, value []models.FileID) { func (l *FileIDsRelatedIDsLoader) unsafeSet(key models.FileID, value []int) {
if l.cache == nil { if l.cache == nil {
l.cache = map[int][]models.FileID{} l.cache = map[models.FileID][]int{}
} }
l.cache[key] = value l.cache[key] = value
} }
// keyIndex will return the location of the key in the batch, if its not found // keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch // it will add the key to the batch
func (b *imageFileIDsLoaderBatch) keyIndex(l *ImageFileIDsLoader, key int) int { func (b *fileIDsRelatedIDsLoaderBatch) keyIndex(l *FileIDsRelatedIDsLoader, key models.FileID) int {
for i, existingKey := range b.keys { for i, existingKey := range b.keys {
if key == existingKey { if key == existingKey {
return i return i
@@ -203,7 +203,7 @@ func (b *imageFileIDsLoaderBatch) keyIndex(l *ImageFileIDsLoader, key int) int {
return pos return pos
} }
func (b *imageFileIDsLoaderBatch) startTimer(l *ImageFileIDsLoader) { func (b *fileIDsRelatedIDsLoaderBatch) startTimer(l *FileIDsRelatedIDsLoader) {
time.Sleep(l.wait) time.Sleep(l.wait)
l.mu.Lock() l.mu.Lock()
@@ -219,7 +219,7 @@ func (b *imageFileIDsLoaderBatch) startTimer(l *ImageFileIDsLoader) {
b.end(l) b.end(l)
} }
func (b *imageFileIDsLoaderBatch) end(l *ImageFileIDsLoader) { func (b *fileIDsRelatedIDsLoaderBatch) end(l *FileIDsRelatedIDsLoader) {
b.data, b.error = l.fetch(b.keys) b.data, b.error = l.fetch(b.keys)
close(b.done) close(b.done)
} }

View File

@@ -9,10 +9,10 @@ import (
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
// SceneFileIDsLoaderConfig captures the config to create a new SceneFileIDsLoader // FolderParentFolderIDsLoaderConfig captures the config to create a new FolderParentFolderIDsLoader
type SceneFileIDsLoaderConfig struct { type FolderParentFolderIDsLoaderConfig struct {
// Fetch is a method that provides the data for the loader // Fetch is a method that provides the data for the loader
Fetch func(keys []int) ([][]models.FileID, []error) Fetch func(keys []models.FolderID) ([][]models.FolderID, []error)
// Wait is how long wait before sending a batch // Wait is how long wait before sending a batch
Wait time.Duration Wait time.Duration
@@ -21,19 +21,19 @@ type SceneFileIDsLoaderConfig struct {
MaxBatch int MaxBatch int
} }
// NewSceneFileIDsLoader creates a new SceneFileIDsLoader given a fetch, wait, and maxBatch // NewFolderParentFolderIDsLoader creates a new FolderParentFolderIDsLoader given a fetch, wait, and maxBatch
func NewSceneFileIDsLoader(config SceneFileIDsLoaderConfig) *SceneFileIDsLoader { func NewFolderParentFolderIDsLoader(config FolderParentFolderIDsLoaderConfig) *FolderRelatedFolderIDsLoader {
return &SceneFileIDsLoader{ return &FolderRelatedFolderIDsLoader{
fetch: config.Fetch, fetch: config.Fetch,
wait: config.Wait, wait: config.Wait,
maxBatch: config.MaxBatch, maxBatch: config.MaxBatch,
} }
} }
// SceneFileIDsLoader batches and caches requests // FolderRelatedFolderIDsLoader batches and caches requests
type SceneFileIDsLoader struct { type FolderRelatedFolderIDsLoader struct {
// this method provides the data for the loader // this method provides the data for the loader
fetch func(keys []int) ([][]models.FileID, []error) fetch func(keys []models.FolderID) ([][]models.FolderID, []error)
// how long to done before sending a batch // how long to done before sending a batch
wait time.Duration wait time.Duration
@@ -44,51 +44,51 @@ type SceneFileIDsLoader struct {
// INTERNAL // INTERNAL
// lazily created cache // lazily created cache
cache map[int][]models.FileID cache map[models.FolderID][]models.FolderID
// the current batch. keys will continue to be collected until timeout is hit, // the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners // then everything will be sent to the fetch method and out to the listeners
batch *sceneFileIDsLoaderBatch batch *folderParentFolderIDsLoaderBatch
// mutex to prevent races // mutex to prevent races
mu sync.Mutex mu sync.Mutex
} }
type sceneFileIDsLoaderBatch struct { type folderParentFolderIDsLoaderBatch struct {
keys []int keys []models.FolderID
data [][]models.FileID data [][]models.FolderID
error []error error []error
closing bool closing bool
done chan struct{} done chan struct{}
} }
// Load a FileID by key, batching and caching will be applied automatically // Load a FolderID by key, batching and caching will be applied automatically
func (l *SceneFileIDsLoader) Load(key int) ([]models.FileID, error) { func (l *FolderRelatedFolderIDsLoader) Load(key models.FolderID) ([]models.FolderID, error) {
return l.LoadThunk(key)() return l.LoadThunk(key)()
} }
// LoadThunk returns a function that when called will block waiting for a FileID. // LoadThunk returns a function that when called will block waiting for a FolderID.
// This method should be used if you want one goroutine to make requests to many // This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called. // different data loaders without blocking until the thunk is called.
func (l *SceneFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) { func (l *FolderRelatedFolderIDsLoader) LoadThunk(key models.FolderID) func() ([]models.FolderID, error) {
l.mu.Lock() l.mu.Lock()
if it, ok := l.cache[key]; ok { if it, ok := l.cache[key]; ok {
l.mu.Unlock() l.mu.Unlock()
return func() ([]models.FileID, error) { return func() ([]models.FolderID, error) {
return it, nil return it, nil
} }
} }
if l.batch == nil { if l.batch == nil {
l.batch = &sceneFileIDsLoaderBatch{done: make(chan struct{})} l.batch = &folderParentFolderIDsLoaderBatch{done: make(chan struct{})}
} }
batch := l.batch batch := l.batch
pos := batch.keyIndex(l, key) pos := batch.keyIndex(l, key)
l.mu.Unlock() l.mu.Unlock()
return func() ([]models.FileID, error) { return func() ([]models.FolderID, error) {
<-batch.done <-batch.done
var data []models.FileID var data []models.FolderID
if pos < len(batch.data) { if pos < len(batch.data) {
data = batch.data[pos] data = batch.data[pos]
} }
@@ -113,49 +113,49 @@ func (l *SceneFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error)
// LoadAll fetches many keys at once. It will be broken into appropriate sized // LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured // sub batches depending on how the loader is configured
func (l *SceneFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) { func (l *FolderRelatedFolderIDsLoader) LoadAll(keys []models.FolderID) ([][]models.FolderID, []error) {
results := make([]func() ([]models.FileID, error), len(keys)) results := make([]func() ([]models.FolderID, error), len(keys))
for i, key := range keys { for i, key := range keys {
results[i] = l.LoadThunk(key) results[i] = l.LoadThunk(key)
} }
fileIDs := make([][]models.FileID, len(keys)) folderIDs := make([][]models.FolderID, len(keys))
errors := make([]error, len(keys)) errors := make([]error, len(keys))
for i, thunk := range results { for i, thunk := range results {
fileIDs[i], errors[i] = thunk() folderIDs[i], errors[i] = thunk()
} }
return fileIDs, errors return folderIDs, errors
} }
// LoadAllThunk returns a function that when called will block waiting for a FileIDs. // LoadAllThunk returns a function that when called will block waiting for a FolderIDs.
// This method should be used if you want one goroutine to make requests to many // This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called. // different data loaders without blocking until the thunk is called.
func (l *SceneFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) { func (l *FolderRelatedFolderIDsLoader) LoadAllThunk(keys []models.FolderID) func() ([][]models.FolderID, []error) {
results := make([]func() ([]models.FileID, error), len(keys)) results := make([]func() ([]models.FolderID, error), len(keys))
for i, key := range keys { for i, key := range keys {
results[i] = l.LoadThunk(key) results[i] = l.LoadThunk(key)
} }
return func() ([][]models.FileID, []error) { return func() ([][]models.FolderID, []error) {
fileIDs := make([][]models.FileID, len(keys)) folderIDs := make([][]models.FolderID, len(keys))
errors := make([]error, len(keys)) errors := make([]error, len(keys))
for i, thunk := range results { for i, thunk := range results {
fileIDs[i], errors[i] = thunk() folderIDs[i], errors[i] = thunk()
} }
return fileIDs, errors return folderIDs, errors
} }
} }
// Prime the cache with the provided key and value. If the key already exists, no change is made // Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned. // and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *SceneFileIDsLoader) Prime(key int, value []models.FileID) bool { func (l *FolderRelatedFolderIDsLoader) Prime(key models.FolderID, value []models.FolderID) bool {
l.mu.Lock() l.mu.Lock()
var found bool var found bool
if _, found = l.cache[key]; !found { if _, found = l.cache[key]; !found {
// make a copy when writing to the cache, its easy to pass a pointer in from a loop var // make a copy when writing to the cache, its easy to pass a pointer in from a loop var
// and end up with the whole cache pointing to the same value. // and end up with the whole cache pointing to the same value.
cpy := make([]models.FileID, len(value)) cpy := make([]models.FolderID, len(value))
copy(cpy, value) copy(cpy, value)
l.unsafeSet(key, cpy) l.unsafeSet(key, cpy)
} }
@@ -164,22 +164,22 @@ func (l *SceneFileIDsLoader) Prime(key int, value []models.FileID) bool {
} }
// Clear the value at key from the cache, if it exists // Clear the value at key from the cache, if it exists
func (l *SceneFileIDsLoader) Clear(key int) { func (l *FolderRelatedFolderIDsLoader) Clear(key models.FolderID) {
l.mu.Lock() l.mu.Lock()
delete(l.cache, key) delete(l.cache, key)
l.mu.Unlock() l.mu.Unlock()
} }
func (l *SceneFileIDsLoader) unsafeSet(key int, value []models.FileID) { func (l *FolderRelatedFolderIDsLoader) unsafeSet(key models.FolderID, value []models.FolderID) {
if l.cache == nil { if l.cache == nil {
l.cache = map[int][]models.FileID{} l.cache = map[models.FolderID][]models.FolderID{}
} }
l.cache[key] = value l.cache[key] = value
} }
// keyIndex will return the location of the key in the batch, if its not found // keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch // it will add the key to the batch
func (b *sceneFileIDsLoaderBatch) keyIndex(l *SceneFileIDsLoader, key int) int { func (b *folderParentFolderIDsLoaderBatch) keyIndex(l *FolderRelatedFolderIDsLoader, key models.FolderID) int {
for i, existingKey := range b.keys { for i, existingKey := range b.keys {
if key == existingKey { if key == existingKey {
return i return i
@@ -203,7 +203,7 @@ func (b *sceneFileIDsLoaderBatch) keyIndex(l *SceneFileIDsLoader, key int) int {
return pos return pos
} }
func (b *sceneFileIDsLoaderBatch) startTimer(l *SceneFileIDsLoader) { func (b *folderParentFolderIDsLoaderBatch) startTimer(l *FolderRelatedFolderIDsLoader) {
time.Sleep(l.wait) time.Sleep(l.wait)
l.mu.Lock() l.mu.Lock()
@@ -219,7 +219,7 @@ func (b *sceneFileIDsLoaderBatch) startTimer(l *SceneFileIDsLoader) {
b.end(l) b.end(l)
} }
func (b *sceneFileIDsLoaderBatch) end(l *SceneFileIDsLoader) { func (b *folderParentFolderIDsLoaderBatch) end(l *FolderRelatedFolderIDsLoader) {
b.data, b.error = l.fetch(b.keys) b.data, b.error = l.fetch(b.keys)
close(b.done) close(b.done)
} }

View File

@@ -9,8 +9,8 @@ import (
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
// GalleryFileIDsLoaderConfig captures the config to create a new GalleryFileIDsLoader // RelatedFileIDsLoaderConfig captures the config to create a new RelatedFileIDsLoader
type GalleryFileIDsLoaderConfig struct { type RelatedFileIDsLoaderConfig struct {
// Fetch is a method that provides the data for the loader // Fetch is a method that provides the data for the loader
Fetch func(keys []int) ([][]models.FileID, []error) Fetch func(keys []int) ([][]models.FileID, []error)
@@ -21,17 +21,17 @@ type GalleryFileIDsLoaderConfig struct {
MaxBatch int MaxBatch int
} }
// NewGalleryFileIDsLoader creates a new GalleryFileIDsLoader given a fetch, wait, and maxBatch // NewRelatedFileIDsLoader creates a new RelatedFileIDsLoader given a fetch, wait, and maxBatch
func NewGalleryFileIDsLoader(config GalleryFileIDsLoaderConfig) *GalleryFileIDsLoader { func NewRelatedFileIDsLoader(config RelatedFileIDsLoaderConfig) *RelatedFileIDsLoader {
return &GalleryFileIDsLoader{ return &RelatedFileIDsLoader{
fetch: config.Fetch, fetch: config.Fetch,
wait: config.Wait, wait: config.Wait,
maxBatch: config.MaxBatch, maxBatch: config.MaxBatch,
} }
} }
// GalleryFileIDsLoader batches and caches requests // RelatedFileIDsLoader batches and caches requests
type GalleryFileIDsLoader struct { type RelatedFileIDsLoader struct {
// this method provides the data for the loader // this method provides the data for the loader
fetch func(keys []int) ([][]models.FileID, []error) fetch func(keys []int) ([][]models.FileID, []error)
@@ -48,13 +48,13 @@ type GalleryFileIDsLoader struct {
// the current batch. keys will continue to be collected until timeout is hit, // the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners // then everything will be sent to the fetch method and out to the listeners
batch *galleryFileIDsLoaderBatch batch *relatedFileIDsLoaderBatch
// mutex to prevent races // mutex to prevent races
mu sync.Mutex mu sync.Mutex
} }
type galleryFileIDsLoaderBatch struct { type relatedFileIDsLoaderBatch struct {
keys []int keys []int
data [][]models.FileID data [][]models.FileID
error []error error []error
@@ -63,14 +63,14 @@ type galleryFileIDsLoaderBatch struct {
} }
// Load a FileID by key, batching and caching will be applied automatically // Load a FileID by key, batching and caching will be applied automatically
func (l *GalleryFileIDsLoader) Load(key int) ([]models.FileID, error) { func (l *RelatedFileIDsLoader) Load(key int) ([]models.FileID, error) {
return l.LoadThunk(key)() return l.LoadThunk(key)()
} }
// LoadThunk returns a function that when called will block waiting for a FileID. // LoadThunk returns a function that when called will block waiting for a FileID.
// This method should be used if you want one goroutine to make requests to many // This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called. // different data loaders without blocking until the thunk is called.
func (l *GalleryFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) { func (l *RelatedFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) {
l.mu.Lock() l.mu.Lock()
if it, ok := l.cache[key]; ok { if it, ok := l.cache[key]; ok {
l.mu.Unlock() l.mu.Unlock()
@@ -79,7 +79,7 @@ func (l *GalleryFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error
} }
} }
if l.batch == nil { if l.batch == nil {
l.batch = &galleryFileIDsLoaderBatch{done: make(chan struct{})} l.batch = &relatedFileIDsLoaderBatch{done: make(chan struct{})}
} }
batch := l.batch batch := l.batch
pos := batch.keyIndex(l, key) pos := batch.keyIndex(l, key)
@@ -113,7 +113,7 @@ func (l *GalleryFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error
// LoadAll fetches many keys at once. It will be broken into appropriate sized // LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured // sub batches depending on how the loader is configured
func (l *GalleryFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) { func (l *RelatedFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) {
results := make([]func() ([]models.FileID, error), len(keys)) results := make([]func() ([]models.FileID, error), len(keys))
for i, key := range keys { for i, key := range keys {
@@ -131,7 +131,7 @@ func (l *GalleryFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error)
// LoadAllThunk returns a function that when called will block waiting for a FileIDs. // LoadAllThunk returns a function that when called will block waiting for a FileIDs.
// This method should be used if you want one goroutine to make requests to many // This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called. // different data loaders without blocking until the thunk is called.
func (l *GalleryFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) { func (l *RelatedFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) {
results := make([]func() ([]models.FileID, error), len(keys)) results := make([]func() ([]models.FileID, error), len(keys))
for i, key := range keys { for i, key := range keys {
results[i] = l.LoadThunk(key) results[i] = l.LoadThunk(key)
@@ -149,7 +149,7 @@ func (l *GalleryFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileI
// Prime the cache with the provided key and value. If the key already exists, no change is made // Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned. // and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) // (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *GalleryFileIDsLoader) Prime(key int, value []models.FileID) bool { func (l *RelatedFileIDsLoader) Prime(key int, value []models.FileID) bool {
l.mu.Lock() l.mu.Lock()
var found bool var found bool
if _, found = l.cache[key]; !found { if _, found = l.cache[key]; !found {
@@ -164,13 +164,13 @@ func (l *GalleryFileIDsLoader) Prime(key int, value []models.FileID) bool {
} }
// Clear the value at key from the cache, if it exists // Clear the value at key from the cache, if it exists
func (l *GalleryFileIDsLoader) Clear(key int) { func (l *RelatedFileIDsLoader) Clear(key int) {
l.mu.Lock() l.mu.Lock()
delete(l.cache, key) delete(l.cache, key)
l.mu.Unlock() l.mu.Unlock()
} }
func (l *GalleryFileIDsLoader) unsafeSet(key int, value []models.FileID) { func (l *RelatedFileIDsLoader) unsafeSet(key int, value []models.FileID) {
if l.cache == nil { if l.cache == nil {
l.cache = map[int][]models.FileID{} l.cache = map[int][]models.FileID{}
} }
@@ -179,7 +179,7 @@ func (l *GalleryFileIDsLoader) unsafeSet(key int, value []models.FileID) {
// keyIndex will return the location of the key in the batch, if its not found // keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch // it will add the key to the batch
func (b *galleryFileIDsLoaderBatch) keyIndex(l *GalleryFileIDsLoader, key int) int { func (b *relatedFileIDsLoaderBatch) keyIndex(l *RelatedFileIDsLoader, key int) int {
for i, existingKey := range b.keys { for i, existingKey := range b.keys {
if key == existingKey { if key == existingKey {
return i return i
@@ -203,7 +203,7 @@ func (b *galleryFileIDsLoaderBatch) keyIndex(l *GalleryFileIDsLoader, key int) i
return pos return pos
} }
func (b *galleryFileIDsLoaderBatch) startTimer(l *GalleryFileIDsLoader) { func (b *relatedFileIDsLoaderBatch) startTimer(l *RelatedFileIDsLoader) {
time.Sleep(l.wait) time.Sleep(l.wait)
l.mu.Lock() l.mu.Lock()
@@ -219,7 +219,7 @@ func (b *galleryFileIDsLoaderBatch) startTimer(l *GalleryFileIDsLoader) {
b.end(l) b.end(l)
} }
func (b *galleryFileIDsLoaderBatch) end(l *GalleryFileIDsLoader) { func (b *relatedFileIDsLoaderBatch) end(l *RelatedFileIDsLoader) {
b.data, b.error = l.fetch(b.keys) b.data, b.error = l.fetch(b.keys)
close(b.done) close(b.done)
} }

View File

@@ -41,7 +41,8 @@ func convertBaseFile(f models.File) BaseFile {
case *models.ImageFile: case *models.ImageFile:
return &ImageFile{ImageFile: f} return &ImageFile{ImageFile: f}
case *models.BaseFile: case *models.BaseFile:
return &BasicFile{BaseFile: f} // assume gallery file if it's not a video or image file
return &GalleryFile{BaseFile: f}
default: default:
panic("unknown file type") panic("unknown file type")
} }
@@ -57,8 +58,6 @@ type GalleryFile struct {
func (GalleryFile) IsBaseFile() {} func (GalleryFile) IsBaseFile() {}
func (GalleryFile) IsVisualFile() {}
func (f *GalleryFile) Fingerprints() []models.Fingerprint { func (f *GalleryFile) Fingerprints() []models.Fingerprint {
return f.BaseFile.Fingerprints return f.BaseFile.Fingerprints
} }

View File

@@ -7,6 +7,7 @@ import (
"sort" "sort"
"strconv" "strconv"
"github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/internal/build" "github.com/stashapp/stash/internal/build"
"github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
@@ -145,6 +146,13 @@ func (r *Resolver) withReadTxn(ctx context.Context, fn func(ctx context.Context)
return r.repository.WithReadTxn(ctx, fn) return r.repository.WithReadTxn(ctx, fn)
} }
// idOnly returns true if the query is only asking for the id field.
// This can be used to optimize certain queries where we don't need to load the full object if we're only getting the id.
func (r *Resolver) idOnly(ctx context.Context) bool {
fields := graphql.CollectAllFields(ctx)
return len(fields) == 1 && fields[0] == "id"
}
func (r *queryResolver) MarkerWall(ctx context.Context, q *string) (ret []*models.SceneMarker, err error) { func (r *queryResolver) MarkerWall(ctx context.Context, q *string) (ret []*models.SceneMarker, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error { if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.SceneMarker.Wall(ctx, q) ret, err = r.repository.SceneMarker.Wall(ctx, q)

View File

@@ -40,10 +40,32 @@ func (r *imageFileResolver) ParentFolder(ctx context.Context, obj *ImageFile) (*
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID) return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
} }
func (r *imageFileResolver) Images(ctx context.Context, obj *ImageFile) ([]*models.Image, error) {
imageIDs, err := loaders.From(ctx).ImageIDsByFileID.Load(obj.ID)
if err != nil {
return nil, err
}
var errs []error
ret, errs := loaders.From(ctx).ImageByID.LoadAll(imageIDs)
return ret, firstError(errs)
}
func (r *videoFileResolver) ParentFolder(ctx context.Context, obj *VideoFile) (*models.Folder, error) { func (r *videoFileResolver) ParentFolder(ctx context.Context, obj *VideoFile) (*models.Folder, error) {
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID) return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
} }
func (r *videoFileResolver) Scenes(ctx context.Context, obj *VideoFile) ([]*models.Scene, error) {
sceneIDs, err := loaders.From(ctx).SceneIDsByFileID.Load(obj.ID)
if err != nil {
return nil, err
}
var errs []error
ret, errs := loaders.From(ctx).SceneByID.LoadAll(sceneIDs)
return ret, firstError(errs)
}
func (r *basicFileResolver) ParentFolder(ctx context.Context, obj *BasicFile) (*models.Folder, error) { func (r *basicFileResolver) ParentFolder(ctx context.Context, obj *BasicFile) (*models.Folder, error) {
return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID) return loaders.From(ctx).FolderByID.Load(obj.ParentFolderID)
} }
@@ -67,6 +89,17 @@ func (r *galleryFileResolver) ZipFile(ctx context.Context, obj *GalleryFile) (*B
return zipFileResolver(ctx, obj.ZipFileID) return zipFileResolver(ctx, obj.ZipFileID)
} }
func (r *galleryFileResolver) Galleries(ctx context.Context, obj *GalleryFile) ([]*models.Gallery, error) {
galleryIDs, err := loaders.From(ctx).GalleryIDsByFileID.Load(obj.ID)
if err != nil {
return nil, err
}
var errs []error
ret, errs := loaders.From(ctx).GalleryByID.LoadAll(galleryIDs)
return ret, firstError(errs)
}
func (r *imageFileResolver) ZipFile(ctx context.Context, obj *ImageFile) (*BasicFile, error) { func (r *imageFileResolver) ZipFile(ctx context.Context, obj *ImageFile) (*BasicFile, error) {
return zipFileResolver(ctx, obj.ZipFileID) return zipFileResolver(ctx, obj.ZipFileID)
} }

View File

@@ -2,19 +2,77 @@ package api
import ( import (
"context" "context"
"path/filepath"
"github.com/stashapp/stash/internal/api/loaders" "github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
func (r *folderResolver) Basename(ctx context.Context, obj *models.Folder) (string, error) {
return filepath.Base(obj.Path), nil
}
func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) (*models.Folder, error) { func (r *folderResolver) ParentFolder(ctx context.Context, obj *models.Folder) (*models.Folder, error) {
if obj.ParentFolderID == nil { if obj.ParentFolderID == nil {
return nil, nil return nil, nil
} }
if r.idOnly(ctx) {
return &models.Folder{ID: *obj.ParentFolderID}, nil
}
return loaders.From(ctx).FolderByID.Load(*obj.ParentFolderID) return loaders.From(ctx).FolderByID.Load(*obj.ParentFolderID)
} }
func foldersFromIDs(ids []models.FolderID) []*models.Folder {
ret := make([]*models.Folder, len(ids))
for i, id := range ids {
ret[i] = &models.Folder{ID: id}
}
return ret
}
func (r *folderResolver) ParentFolders(ctx context.Context, obj *models.Folder) ([]*models.Folder, error) {
ids, err := loaders.From(ctx).FolderParentFolderIDs.Load(obj.ID)
if err != nil {
return nil, err
}
if r.idOnly(ctx) {
return foldersFromIDs(ids), nil
}
var errs []error
ret, errs := loaders.From(ctx).FolderByID.LoadAll(ids)
return ret, firstError(errs)
}
func (r *folderResolver) SubFolders(ctx context.Context, obj *models.Folder) ([]*models.Folder, error) {
ids, err := loaders.From(ctx).FolderSubFolderIDs.Load(obj.ID)
if err != nil {
return nil, err
}
if r.idOnly(ctx) {
return foldersFromIDs(ids), nil
}
var errs []error
ret, errs := loaders.From(ctx).FolderByID.LoadAll(ids)
return ret, firstError(errs)
}
func (r *folderResolver) ZipFile(ctx context.Context, obj *models.Folder) (*BasicFile, error) { func (r *folderResolver) ZipFile(ctx context.Context, obj *models.Folder) (*BasicFile, error) {
// shortcut for id only queries
if r.idOnly(ctx) {
if obj.ZipFileID == nil {
return nil, nil
}
return &BasicFile{
BaseFile: &models.BaseFile{ID: *obj.ZipFileID},
}, nil
}
return zipFileResolver(ctx, obj.ZipFileID) return zipFileResolver(ctx, obj.ZipFileID)
} }

View File

@@ -216,3 +216,16 @@ func (r *galleryResolver) Image(ctx context.Context, obj *models.Gallery, index
return return
} }
func (r *galleryResolver) CustomFields(ctx context.Context, obj *models.Gallery) (map[string]interface{}, error) {
m, err := loaders.From(ctx).GalleryCustomFields.Load(obj.ID)
if err != nil {
return nil, err
}
if m == nil {
return make(map[string]interface{}), nil
}
return m, nil
}

View File

@@ -161,3 +161,12 @@ func (r *imageResolver) Urls(ctx context.Context, obj *models.Image) ([]string,
return obj.URLs.List(), nil return obj.URLs.List(), nil
} }
func (r *imageResolver) CustomFields(ctx context.Context, obj *models.Image) (map[string]interface{}, error) {
customFields, err := loaders.From(ctx).ImageCustomFields.Load(obj.ID)
if err != nil {
return nil, err
}
return customFields, nil
}

View File

@@ -215,3 +215,16 @@ func (r *groupResolver) OCounter(ctx context.Context, obj *models.Group) (ret *i
} }
return &count, nil return &count, nil
} }
func (r *groupResolver) CustomFields(ctx context.Context, obj *models.Group) (map[string]interface{}, error) {
m, err := loaders.From(ctx).GroupCustomFields.Load(obj.ID)
if err != nil {
return nil, err
}
if m == nil {
return make(map[string]interface{}), nil
}
return m, nil
}

View File

@@ -109,6 +109,31 @@ func (r *performerResolver) HeightCm(ctx context.Context, obj *models.Performer)
return obj.Height, nil return obj.Height, nil
} }
func (r *performerResolver) CareerStart(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.CareerStart != nil {
ret := obj.CareerStart.String()
return &ret, nil
}
return nil, nil
}
func (r *performerResolver) CareerEnd(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.CareerEnd != nil {
ret := obj.CareerEnd.String()
return &ret, nil
}
return nil, nil
}
func (r *performerResolver) CareerLength(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.CareerStart == nil && obj.CareerEnd == nil {
return nil, nil
}
ret := models.FormatYearRange(obj.CareerStart, obj.CareerEnd)
return &ret, nil
}
func (r *performerResolver) Birthdate(ctx context.Context, obj *models.Performer) (*string, error) { func (r *performerResolver) Birthdate(ctx context.Context, obj *models.Performer) (*string, error) {
if obj.Birthdate != nil { if obj.Birthdate != nil {
ret := obj.Birthdate.String() ret := obj.Birthdate.String()

View File

@@ -9,6 +9,8 @@ import (
"github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/signedurl"
) )
func convertVideoFile(f models.File) (*models.VideoFile, error) { func convertVideoFile(f models.File) (*models.VideoFile, error) {
@@ -107,15 +109,38 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*ScenePat
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
config := manager.GetInstance().Config config := manager.GetInstance().Config
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj) builder := urlbuilders.NewSceneURLBuilder(baseURL, obj)
var streamPath string
var captionBasePath string
if config.HasCredentials() {
userID := session.GetCurrentUserID(ctx)
if userID == nil {
return nil, fmt.Errorf("user ID not found")
}
// Sign the stream prefix
streamURL := builder.GetStreamURL("")
streamURL.RawQuery = signedParams(config, *userID, signedurl.DerivePrefix(streamURL.Path)).Encode()
streamPath = streamURL.String()
// Sign the caption prefix
captionBase := builder.GetCaptionURL()
captionBasePath = captionBase + "?" + signedParams(config, *userID, builder.GetCaptionPath()).Encode()
} else {
apiKey := config.GetAPIKey()
streamURL := builder.GetStreamURL(apiKey)
streamPath = streamURL.String()
captionBasePath = builder.GetCaptionURL()
}
// Web-only formats: use unsigned URLs (rely on cookie authentication)
screenshotPath := builder.GetScreenshotURL() screenshotPath := builder.GetScreenshotURL()
previewPath := builder.GetStreamPreviewURL() previewPath := builder.GetStreamPreviewURL()
streamPath := builder.GetStreamURL(config.GetAPIKey()).String()
webpPath := builder.GetStreamPreviewImageURL() webpPath := builder.GetStreamPreviewImageURL()
objHash := obj.GetHash(config.GetVideoFileNamingAlgorithm()) objHash := obj.GetHash(config.GetVideoFileNamingAlgorithm())
vttPath := builder.GetSpriteVTTURL(objHash) vttPath := builder.GetSpriteVTTURL(objHash)
spritePath := builder.GetSpriteURL(objHash) spritePath := builder.GetSpriteURL(objHash)
funscriptPath := builder.GetFunscriptURL() funscriptPath := builder.GetFunscriptURL(config.GetAPIKey()).String()
captionBasePath := builder.GetCaptionURL()
interactiveHeatmap := builder.GetInteractiveHeatmapURL() interactiveHeatmap := builder.GetInteractiveHeatmapURL()
return &ScenePathsType{ return &ScenePathsType{
@@ -294,9 +319,25 @@ func (r *sceneResolver) SceneStreams(ctx context.Context, obj *models.Scene) ([]
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewSceneURLBuilder(baseURL, obj) builder := urlbuilders.NewSceneURLBuilder(baseURL, obj)
apiKey := config.GetAPIKey()
return manager.GetSceneStreamPaths(obj, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize()) // Build the base stream URL with signing params or apikey
streamURL := builder.GetStreamURL("")
if config.HasCredentials() {
userID := session.GetCurrentUserID(ctx)
if userID == nil {
return nil, fmt.Errorf("user ID not found")
}
streamURL.RawQuery = signedParams(config, *userID, signedurl.DerivePrefix(streamURL.Path)).Encode()
} else {
apiKey := config.GetAPIKey()
if apiKey != "" {
v := streamURL.Query()
v.Set("apikey", apiKey)
streamURL.RawQuery = v.Encode()
}
}
return manager.GetSceneStreamPaths(obj, streamURL, config.GetMaxStreamingTranscodeSize())
} }
func (r *sceneResolver) Interactive(ctx context.Context, obj *models.Scene) (bool, error) { func (r *sceneResolver) Interactive(ctx context.Context, obj *models.Scene) (bool, error) {
@@ -410,3 +451,16 @@ func (r *sceneResolver) OHistory(ctx context.Context, obj *models.Scene) ([]*tim
return ptrRet, nil return ptrRet, nil
} }
func (r *sceneResolver) CustomFields(ctx context.Context, obj *models.Scene) (map[string]interface{}, error) {
m, err := loaders.From(ctx).SceneCustomFields.Load(obj.ID)
if err != nil {
return nil, err
}
if m == nil {
return make(map[string]interface{}), nil
}
return m, nil
}

View File

@@ -207,6 +207,19 @@ func (r *studioResolver) Groups(ctx context.Context, obj *models.Studio) (ret []
return ret, nil return ret, nil
} }
func (r *studioResolver) CustomFields(ctx context.Context, obj *models.Studio) (map[string]interface{}, error) {
m, err := loaders.From(ctx).StudioCustomFields.Load(obj.ID)
if err != nil {
return nil, err
}
if m == nil {
return make(map[string]interface{}), nil
}
return m, nil
}
// deprecated // deprecated
func (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret []*models.Group, err error) { func (r *studioResolver) Movies(ctx context.Context, obj *models.Studio) (ret []*models.Group, err error) {
return r.Groups(ctx, obj) return r.Groups(ctx, obj)

View File

@@ -181,3 +181,16 @@ func (r *tagResolver) ChildCount(ctx context.Context, obj *models.Tag) (ret int,
return ret, nil return ret, nil
} }
func (r *tagResolver) CustomFields(ctx context.Context, obj *models.Tag) (map[string]interface{}, error) {
m, err := loaders.From(ctx).TagCustomFields.Load(obj.ID)
if err != nil {
return nil, err
}
if m == nil {
return make(map[string]interface{}), nil
}
return m, nil
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"path/filepath" "path/filepath"
"regexp" "regexp"
"strconv" "strconv"
@@ -85,6 +86,8 @@ func (r *mutationResolver) setConfigFloat(key string, value *float64) {
func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGeneralInput) (*ConfigGeneralResult, error) { func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGeneralInput) (*ConfigGeneralResult, error) {
c := config.GetInstance() c := config.GetInstance()
// #4709 - allow stash paths even if they do not exist, so that users may configure stash
// for disconnected drives or network storage.
existingPaths := c.GetStashPaths() existingPaths := c.GetStashPaths()
if input.Stashes != nil { if input.Stashes != nil {
for _, s := range input.Stashes { for _, s := range input.Stashes {
@@ -97,8 +100,12 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
} }
} }
if isNew { if isNew {
s.Path = filepath.Clean(s.Path)
// if it exists, it must be directory
exists, err := fsutil.DirExists(s.Path) exists, err := fsutil.DirExists(s.Path)
if !exists { // allow it to not exist but if it does exist it must be a directory
if !exists && !errors.Is(err, fs.ErrNotExist) {
return makeConfigGeneralResult(), err return makeConfigGeneralResult(), err
} }
} }
@@ -287,6 +294,11 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
if input.PreviewPreset != nil { if input.PreviewPreset != nil {
c.SetString(config.PreviewPreset, input.PreviewPreset.String()) c.SetString(config.PreviewPreset, input.PreviewPreset.String())
} }
r.setConfigBool(config.UseCustomSpriteInterval, input.UseCustomSpriteInterval)
r.setConfigFloat(config.SpriteInterval, input.SpriteInterval)
r.setConfigInt(config.MinimumSprites, input.MinimumSprites)
r.setConfigInt(config.MaximumSprites, input.MaximumSprites)
r.setConfigInt(config.SpriteScreenshotSize, input.SpriteScreenshotSize)
r.setConfigBool(config.TranscodeHardwareAcceleration, input.TranscodeHardwareAcceleration) r.setConfigBool(config.TranscodeHardwareAcceleration, input.TranscodeHardwareAcceleration)
if input.MaxTranscodeSize != nil { if input.MaxTranscodeSize != nil {
@@ -515,6 +527,8 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI
r.setConfigBool(config.CustomLocalesEnabled, input.CustomLocalesEnabled) r.setConfigBool(config.CustomLocalesEnabled, input.CustomLocalesEnabled)
r.setConfigBool(config.DisableCustomizations, input.DisableCustomizations)
if input.DisableDropdownCreate != nil { if input.DisableDropdownCreate != nil {
ddc := input.DisableDropdownCreate ddc := input.DisableDropdownCreate
r.setConfigBool(config.DisableDropdownCreatePerformer, ddc.Performer) r.setConfigBool(config.DisableDropdownCreatePerformer, ddc.Performer)

View File

@@ -5,10 +5,14 @@ import (
"fmt" "fmt"
"strconv" "strconv"
"github.com/stashapp/stash/internal/desktop"
"github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/sliceutil/stringslice"
) )
@@ -16,7 +20,7 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput)
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
fileStore := r.repository.File fileStore := r.repository.File
folderStore := r.repository.Folder folderStore := r.repository.Folder
mover := file.NewMover(fileStore, folderStore) mover := file.NewMover(fileStore, folderStore, manager.GetInstance().Config.GetStashPaths().Paths())
mover.RegisterHooks(ctx) mover.RegisterHooks(ctx)
var ( var (
@@ -54,13 +58,14 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput)
folderPath := *input.DestinationFolder folderPath := *input.DestinationFolder
// ensure folder path is within the library // ensure folder path is within the library
if err := r.validateFolderPath(folderPath); err != nil { stashPaths := manager.GetInstance().Config.GetStashPaths()
if err := r.validateFolderPath(stashPaths, folderPath); err != nil {
return err return err
} }
// get or create folder hierarchy // get or create folder hierarchy
var err error var err error
folder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath) folder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath, stashPaths.Paths())
if err != nil { if err != nil {
return fmt.Errorf("getting or creating folder hierarchy: %w", err) return fmt.Errorf("getting or creating folder hierarchy: %w", err)
} }
@@ -109,8 +114,7 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput)
return true, nil return true, nil
} }
func (r *mutationResolver) validateFolderPath(folderPath string) error { func (r *mutationResolver) validateFolderPath(paths config.StashConfigs, folderPath string) error {
paths := manager.GetInstance().Config.GetStashPaths()
if l := paths.GetStashFromDirPath(folderPath); l == nil { if l := paths.GetStashFromDirPath(folderPath); l == nil {
return fmt.Errorf("folder path %s must be within a stash library path", folderPath) return fmt.Errorf("folder path %s must be within a stash library path", folderPath)
} }
@@ -210,6 +214,58 @@ func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret b
return true, nil return true, nil
} }
func (r *mutationResolver) DestroyFiles(ctx context.Context, ids []string) (ret bool, err error) {
fileIDs, err := stringslice.StringSliceToIntSlice(ids)
if err != nil {
return false, fmt.Errorf("converting ids: %w", err)
}
destroyer := &file.ZipDestroyer{
FileDestroyer: r.repository.File,
FolderDestroyer: r.repository.Folder,
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.File
for _, fileIDInt := range fileIDs {
fileID := models.FileID(fileIDInt)
f, err := qb.Find(ctx, fileID)
if err != nil {
return err
}
if len(f) == 0 {
return fmt.Errorf("file with id %d not found", fileID)
}
path := f[0].Base().Path
// ensure not a primary file
isPrimary, err := qb.IsPrimary(ctx, fileID)
if err != nil {
return fmt.Errorf("checking if file %s is primary: %w", path, err)
}
if isPrimary {
return fmt.Errorf("cannot destroy primary file entry %s", path)
}
// destroy DB entries only (no filesystem deletion)
const deleteFile = false
if err := destroyer.DestroyZip(ctx, f[0], nil, deleteFile); err != nil {
return fmt.Errorf("destroying file entry %s: %w", path, err)
}
}
return nil
}); err != nil {
return false, err
}
return true, nil
}
func (r *mutationResolver) FileSetFingerprints(ctx context.Context, input FileSetFingerprintsInput) (bool, error) { func (r *mutationResolver) FileSetFingerprints(ctx context.Context, input FileSetFingerprintsInput) (bool, error) {
fileIDInt, err := strconv.Atoi(input.ID) fileIDInt, err := strconv.Atoi(input.ID)
if err != nil { if err != nil {
@@ -274,3 +330,71 @@ func (r *mutationResolver) FileSetFingerprints(ctx context.Context, input FileSe
return true, nil return true, nil
} }
func (r *mutationResolver) RevealFileInFileManager(ctx context.Context, id string) (bool, error) {
// disallow if request did not come from localhost
if !session.IsLocalRequest(ctx) {
logger.Warnf("Attempt to reveal file in file manager from non-local request")
return false, fmt.Errorf("access denied")
}
fileIDInt, err := strconv.Atoi(id)
if err != nil {
return false, fmt.Errorf("converting id: %w", err)
}
var filePath string
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
files, err := r.repository.File.Find(ctx, models.FileID(fileIDInt))
if err != nil {
return fmt.Errorf("finding file: %w", err)
}
if len(files) == 0 {
return fmt.Errorf("file with id %d not found", fileIDInt)
}
filePath = files[0].Base().Path
return nil
}); err != nil {
return false, err
}
if err := desktop.RevealInFileManager(filePath); err != nil {
return false, err
}
return true, nil
}
func (r *mutationResolver) RevealFolderInFileManager(ctx context.Context, id string) (bool, error) {
// disallow if request did not come from localhost
if !session.IsLocalRequest(ctx) {
logger.Warnf("Attempt to reveal folder in file manager from non-local request")
return false, fmt.Errorf("access denied")
}
folderIDInt, err := strconv.Atoi(id)
if err != nil {
return false, fmt.Errorf("converting id: %w", err)
}
var folderPath string
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
folder, err := r.repository.Folder.Find(ctx, models.FolderID(folderIDInt))
if err != nil {
return fmt.Errorf("finding folder: %w", err)
}
if folder == nil {
return fmt.Errorf("folder with id %d not found", folderIDInt)
}
folderPath = folder.Path
return nil
}); err != nil {
return false, err
}
if err := desktop.RevealInFileManager(folderPath); err != nil {
return false, err
}
return true, nil
}

View File

@@ -42,13 +42,17 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
} }
// Populate a new gallery from the input // Populate a new gallery from the input
newGallery := models.NewGallery() newGallery := models.CreateGalleryInput{
Gallery: &models.Gallery{},
}
*newGallery.Gallery = models.NewGallery()
newGallery.Title = strings.TrimSpace(input.Title) newGallery.Title = strings.TrimSpace(input.Title)
newGallery.Code = translator.string(input.Code) newGallery.Code = translator.string(input.Code)
newGallery.Details = translator.string(input.Details) newGallery.Details = translator.string(input.Details)
newGallery.Photographer = translator.string(input.Photographer) newGallery.Photographer = translator.string(input.Photographer)
newGallery.Rating = input.Rating100 newGallery.Rating = input.Rating100
newGallery.Organized = translator.bool(input.Organized)
var err error var err error
@@ -80,10 +84,12 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
newGallery.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)}) newGallery.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)})
} }
newGallery.CustomFields = convertMapJSONNumbers(input.CustomFields)
// Start the transaction and save the gallery // Start the transaction and save the gallery
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Gallery qb := r.repository.Gallery
if err := qb.Create(ctx, &newGallery, nil); err != nil { if err := qb.Create(ctx, &newGallery); err != nil {
return err return err
} }
@@ -240,6 +246,10 @@ func (r *mutationResolver) galleryUpdate(ctx context.Context, input models.Galle
return nil, fmt.Errorf("converting scene ids: %w", err) return nil, fmt.Errorf("converting scene ids: %w", err)
} }
if input.CustomFields != nil {
updatedGallery.CustomFields = handleUpdateCustomFields(*input.CustomFields)
}
// gallery scene is set from the scene only // gallery scene is set from the scene only
gallery, err := qb.UpdatePartial(ctx, galleryID, updatedGallery) gallery, err := qb.UpdatePartial(ctx, galleryID, updatedGallery)
@@ -292,6 +302,10 @@ func (r *mutationResolver) BulkGalleryUpdate(ctx context.Context, input BulkGall
return nil, fmt.Errorf("converting scene ids: %w", err) return nil, fmt.Errorf("converting scene ids: %w", err)
} }
if input.CustomFields != nil {
updatedGallery.CustomFields = handleUpdateCustomFields(*input.CustomFields)
}
ret := []*models.Gallery{} ret := []*models.Gallery{}
// Start the transaction and save the galleries // Start the transaction and save the galleries
@@ -345,6 +359,7 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall
deleteGenerated := utils.IsTrue(input.DeleteGenerated) deleteGenerated := utils.IsTrue(input.DeleteGenerated)
deleteFile := utils.IsTrue(input.DeleteFile) deleteFile := utils.IsTrue(input.DeleteFile)
destroyFileEntry := utils.IsTrue(input.DestroyFileEntry)
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Gallery qb := r.repository.Gallery
@@ -365,7 +380,7 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall
galleries = append(galleries, gallery) galleries = append(galleries, gallery)
imgsDestroyed, err = r.galleryService.Destroy(ctx, gallery, fileDeleter, deleteGenerated, deleteFile) imgsDestroyed, err = r.galleryService.Destroy(ctx, gallery, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -14,13 +14,17 @@ import (
"github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/pkg/utils"
) )
func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*models.Group, error) { func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*models.CreateGroupInput, error) {
translator := changesetTranslator{ translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx), inputMap: getUpdateInputMap(ctx),
} }
// Populate a new group from the input // Populate a new group from the input
newGroup := models.NewGroup() newGroupInput := &models.CreateGroupInput{
Group: &models.Group{},
}
*newGroupInput.Group = models.NewGroup()
newGroup := newGroupInput.Group
newGroup.Name = strings.TrimSpace(input.Name) newGroup.Name = strings.TrimSpace(input.Name)
newGroup.Aliases = translator.string(input.Aliases) newGroup.Aliases = translator.string(input.Aliases)
@@ -59,28 +63,19 @@ func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*mo
newGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls)) newGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
} }
return &newGroup, nil newGroupInput.CustomFields = convertMapJSONNumbers(input.CustomFields)
}
func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInput) (*models.Group, error) {
newGroup, err := groupFromGroupCreateInput(ctx, input)
if err != nil {
return nil, err
}
// Process the base 64 encoded image string // Process the base 64 encoded image string
var frontimageData []byte
if input.FrontImage != nil { if input.FrontImage != nil {
frontimageData, err = utils.ProcessImageInput(ctx, *input.FrontImage) newGroupInput.FrontImageData, err = utils.ProcessImageInput(ctx, *input.FrontImage)
if err != nil { if err != nil {
return nil, fmt.Errorf("processing front image: %w", err) return nil, fmt.Errorf("processing front image: %w", err)
} }
} }
// Process the base 64 encoded image string // Process the base 64 encoded image string
var backimageData []byte
if input.BackImage != nil { if input.BackImage != nil {
backimageData, err = utils.ProcessImageInput(ctx, *input.BackImage) newGroupInput.BackImageData, err = utils.ProcessImageInput(ctx, *input.BackImage)
if err != nil { if err != nil {
return nil, fmt.Errorf("processing back image: %w", err) return nil, fmt.Errorf("processing back image: %w", err)
} }
@@ -88,13 +83,22 @@ func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInp
// HACK: if back image is being set, set the front image to the default. // HACK: if back image is being set, set the front image to the default.
// This is because we can't have a null front image with a non-null back image. // This is because we can't have a null front image with a non-null back image.
if len(frontimageData) == 0 && len(backimageData) != 0 { if len(newGroupInput.FrontImageData) == 0 && len(newGroupInput.BackImageData) != 0 {
frontimageData = static.ReadAll(static.DefaultGroupImage) newGroupInput.FrontImageData = static.ReadAll(static.DefaultGroupImage)
}
return newGroupInput, nil
}
func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInput) (*models.Group, error) {
createGroupInput, err := groupFromGroupCreateInput(ctx, input)
if err != nil {
return nil, err
} }
// Start the transaction and save the group // Start the transaction and save the group
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
if err = r.groupService.Create(ctx, newGroup, frontimageData, backimageData); err != nil { if err = r.groupService.Create(ctx, createGroupInput); err != nil {
return err return err
} }
@@ -104,9 +108,9 @@ func (r *mutationResolver) GroupCreate(ctx context.Context, input GroupCreateInp
} }
// for backwards compatibility - run both movie and group hooks // for backwards compatibility - run both movie and group hooks
r.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.GroupCreatePost, input, nil) r.hookExecutor.ExecutePostHooks(ctx, createGroupInput.Group.ID, hook.GroupCreatePost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, newGroup.ID, hook.MovieCreatePost, input, nil) r.hookExecutor.ExecutePostHooks(ctx, createGroupInput.Group.ID, hook.MovieCreatePost, input, nil)
return r.getGroup(ctx, newGroup.ID) return r.getGroup(ctx, createGroupInput.Group.ID)
} }
func groupPartialFromGroupUpdateInput(translator changesetTranslator, input GroupUpdateInput) (ret models.GroupPartial, err error) { func groupPartialFromGroupUpdateInput(translator changesetTranslator, input GroupUpdateInput) (ret models.GroupPartial, err error) {
@@ -150,6 +154,12 @@ func groupPartialFromGroupUpdateInput(translator changesetTranslator, input Grou
} }
updatedGroup.URLs = translator.updateStrings(input.Urls, "urls") updatedGroup.URLs = translator.updateStrings(input.Urls, "urls")
if input.CustomFields != nil {
updatedGroup.CustomFields = *input.CustomFields
// convert json.Numbers to int/float
updatedGroup.CustomFields.Full = convertMapJSONNumbers(updatedGroup.CustomFields.Full)
updatedGroup.CustomFields.Partial = convertMapJSONNumbers(updatedGroup.CustomFields.Partial)
}
return updatedGroup, nil return updatedGroup, nil
} }
@@ -217,6 +227,12 @@ func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInp
func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.GroupPartial, err error) { func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.GroupPartial, err error) {
updatedGroup := models.NewGroupPartial() updatedGroup := models.NewGroupPartial()
updatedGroup.Date, err = translator.optionalDate(input.Date, "date")
if err != nil {
err = fmt.Errorf("converting date: %w", err)
return
}
updatedGroup.Synopsis = translator.optionalString(input.Synopsis, "synopsis")
updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100") updatedGroup.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedGroup.Director = translator.optionalString(input.Director, "director") updatedGroup.Director = translator.optionalString(input.Director, "director")
@@ -246,6 +262,13 @@ func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input
updatedGroup.URLs = translator.optionalURLsBulk(input.Urls, nil) updatedGroup.URLs = translator.optionalURLsBulk(input.Urls, nil)
if input.CustomFields != nil {
updatedGroup.CustomFields = *input.CustomFields
// convert json.Numbers to int/float
updatedGroup.CustomFields.Full = convertMapJSONNumbers(updatedGroup.CustomFields.Full)
updatedGroup.CustomFields.Partial = convertMapJSONNumbers(updatedGroup.CustomFields.Partial)
}
return updatedGroup, nil return updatedGroup, nil
} }

View File

@@ -177,6 +177,13 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input models.ImageUp
return nil, fmt.Errorf("converting tag ids: %w", err) return nil, fmt.Errorf("converting tag ids: %w", err)
} }
if input.CustomFields != nil {
updatedImage.CustomFields = *input.CustomFields
// convert json.Numbers to int/float
updatedImage.CustomFields.Full = convertMapJSONNumbers(updatedImage.CustomFields.Full)
updatedImage.CustomFields.Partial = convertMapJSONNumbers(updatedImage.CustomFields.Partial)
}
qb := r.repository.Image qb := r.repository.Image
image, err := qb.UpdatePartial(ctx, imageID, updatedImage) image, err := qb.UpdatePartial(ctx, imageID, updatedImage)
if err != nil { if err != nil {
@@ -237,6 +244,13 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU
return nil, fmt.Errorf("converting tag ids: %w", err) return nil, fmt.Errorf("converting tag ids: %w", err)
} }
if input.CustomFields != nil {
updatedImage.CustomFields = *input.CustomFields
// convert json.Numbers to int/float
updatedImage.CustomFields.Full = convertMapJSONNumbers(updatedImage.CustomFields.Full)
updatedImage.CustomFields.Partial = convertMapJSONNumbers(updatedImage.CustomFields.Partial)
}
// Start the transaction and save the images // Start the transaction and save the images
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
var updatedGalleryIDs []int var updatedGalleryIDs []int
@@ -325,7 +339,7 @@ func (r *mutationResolver) ImageDestroy(ctx context.Context, input models.ImageD
return fmt.Errorf("image with id %d not found", imageID) return fmt.Errorf("image with id %d not found", imageID)
} }
return r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile)) return r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile), utils.IsTrue(input.DestroyFileEntry))
}); err != nil { }); err != nil {
fileDeleter.Rollback() fileDeleter.Rollback()
return false, err return false, err
@@ -372,7 +386,7 @@ func (r *mutationResolver) ImagesDestroy(ctx context.Context, input models.Image
images = append(images, i) images = append(images, i)
if err := r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile)); err != nil { if err := r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile), utils.IsTrue(input.DestroyFileEntry)); err != nil {
return err return err
} }
} }

View File

@@ -122,9 +122,10 @@ func (r *mutationResolver) MigrateHashNaming(ctx context.Context) (string, error
func (r *mutationResolver) BackupDatabase(ctx context.Context, input BackupDatabaseInput) (*string, error) { func (r *mutationResolver) BackupDatabase(ctx context.Context, input BackupDatabaseInput) (*string, error) {
// if download is true, then backup to temporary file and return a link // if download is true, then backup to temporary file and return a link
download := input.Download != nil && *input.Download download := input.Download != nil && *input.Download
includeBlobs := input.IncludeBlobs != nil && *input.IncludeBlobs
mgr := manager.GetInstance() mgr := manager.GetInstance()
backupPath, backupName, err := mgr.BackupDatabase(download) backupPath, backupName, err := mgr.BackupDatabase(download, includeBlobs)
if err != nil { if err != nil {
logger.Errorf("Error backing up database: %v", err) logger.Errorf("Error backing up database: %v", err)
return nil, err return nil, err

View File

@@ -47,6 +47,10 @@ func (r *mutationResolver) Migrate(ctx context.Context, input manager.MigrateInp
Database: mgr.Database, Database: mgr.Database,
} }
if err := t.PreExecute(); err != nil {
return "", err
}
jobID := mgr.JobManager.Add(ctx, "Migrating database...", t) jobID := mgr.JobManager.Add(ctx, "Migrating database...", t)
return strconv.Itoa(jobID), nil return strconv.Itoa(jobID), nil

View File

@@ -12,9 +12,10 @@ import (
func refreshPackageType(typeArg PackageType) { func refreshPackageType(typeArg PackageType) {
mgr := manager.GetInstance() mgr := manager.GetInstance()
if typeArg == PackageTypePlugin { switch typeArg {
case PackageTypePlugin:
mgr.RefreshPluginCache() mgr.RefreshPluginCache()
} else if typeArg == PackageTypeScraper { case PackageTypeScraper:
mgr.RefreshScraperCache() mgr.RefreshScraperCache()
} }
} }

View File

@@ -2,13 +2,16 @@ package api
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"slices"
"strconv" "strconv"
"strings" "strings"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/pkg/utils"
) )
@@ -40,7 +43,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
newPerformer.Name = strings.TrimSpace(input.Name) newPerformer.Name = strings.TrimSpace(input.Name)
newPerformer.Disambiguation = translator.string(input.Disambiguation) newPerformer.Disambiguation = translator.string(input.Disambiguation)
newPerformer.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.AliasList)) newPerformer.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.AliasList), newPerformer.Name))
newPerformer.Gender = input.Gender newPerformer.Gender = input.Gender
newPerformer.Ethnicity = translator.string(input.Ethnicity) newPerformer.Ethnicity = translator.string(input.Ethnicity)
newPerformer.Country = translator.string(input.Country) newPerformer.Country = translator.string(input.Country)
@@ -49,7 +52,6 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
newPerformer.FakeTits = translator.string(input.FakeTits) newPerformer.FakeTits = translator.string(input.FakeTits)
newPerformer.PenisLength = input.PenisLength newPerformer.PenisLength = input.PenisLength
newPerformer.Circumcised = input.Circumcised newPerformer.Circumcised = input.Circumcised
newPerformer.CareerLength = translator.string(input.CareerLength)
newPerformer.Tattoos = translator.string(input.Tattoos) newPerformer.Tattoos = translator.string(input.Tattoos)
newPerformer.Piercings = translator.string(input.Piercings) newPerformer.Piercings = translator.string(input.Piercings)
newPerformer.Favorite = translator.bool(input.Favorite) newPerformer.Favorite = translator.bool(input.Favorite)
@@ -87,6 +89,25 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
return nil, fmt.Errorf("converting death date: %w", err) return nil, fmt.Errorf("converting death date: %w", err)
} }
newPerformer.CareerStart, err = translator.datePtr(input.CareerStart)
if err != nil {
return nil, fmt.Errorf("converting career start: %w", err)
}
newPerformer.CareerEnd, err = translator.datePtr(input.CareerEnd)
if err != nil {
return nil, fmt.Errorf("converting career end: %w", err)
}
// if career_start/career_end not provided, parse deprecated career_length
if newPerformer.CareerStart == nil && newPerformer.CareerEnd == nil && input.CareerLength != nil {
start, end, err := models.ParseYearRangeString(*input.CareerLength)
if err != nil {
return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err)
}
newPerformer.CareerStart = start
newPerformer.CareerEnd = end
}
newPerformer.TagIDs, err = translator.relatedIds(input.TagIds) newPerformer.TagIDs, err = translator.relatedIds(input.TagIds)
if err != nil { if err != nil {
return nil, fmt.Errorf("converting tag ids: %w", err) return nil, fmt.Errorf("converting tag ids: %w", err)
@@ -136,7 +157,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
return r.getPerformer(ctx, newPerformer.ID) return r.getPerformer(ctx, newPerformer.ID)
} }
func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator) error { func validateNoLegacyURLs(translator changesetTranslator) error {
// ensure url/twitter/instagram are not included in the input // ensure url/twitter/instagram are not included in the input
if translator.hasField("url") { if translator.hasField("url") {
return fmt.Errorf("url field must not be included if urls is included") return fmt.Errorf("url field must not be included if urls is included")
@@ -151,7 +172,7 @@ func (r *mutationResolver) validateNoLegacyURLs(translator changesetTranslator)
return nil return nil
} }
func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURL, legacyTwitter, legacyInstagram models.OptionalString, updatedPerformer *models.PerformerPartial) error { func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int, legacyURLs legacyPerformerURLs, updatedPerformer *models.PerformerPartial) error {
qb := r.repository.Performer qb := r.repository.Performer
// we need to be careful with URL/Twitter/Instagram // we need to be careful with URL/Twitter/Instagram
@@ -170,23 +191,23 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
existingURLs := p.URLs.List() existingURLs := p.URLs.List()
// performer partial URLs should be empty // performer partial URLs should be empty
if legacyURL.Set { if legacyURLs.URL.Set {
replaced := false replaced := false
for i, url := range existingURLs { for i, url := range existingURLs {
if !performer.IsTwitterURL(url) && !performer.IsInstagramURL(url) { if !performer.IsTwitterURL(url) && !performer.IsInstagramURL(url) {
existingURLs[i] = legacyURL.Value existingURLs[i] = legacyURLs.URL.Value
replaced = true replaced = true
break break
} }
} }
if !replaced { if !replaced {
existingURLs = append(existingURLs, legacyURL.Value) existingURLs = append(existingURLs, legacyURLs.URL.Value)
} }
} }
if legacyTwitter.Set { if legacyURLs.Twitter.Set {
value := utils.URLFromHandle(legacyTwitter.Value, twitterURL) value := utils.URLFromHandle(legacyURLs.Twitter.Value, twitterURL)
found := false found := false
// find and replace the first twitter URL // find and replace the first twitter URL
for i, url := range existingURLs { for i, url := range existingURLs {
@@ -201,9 +222,9 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
existingURLs = append(existingURLs, value) existingURLs = append(existingURLs, value)
} }
} }
if legacyInstagram.Set { if legacyURLs.Instagram.Set {
found := false found := false
value := utils.URLFromHandle(legacyInstagram.Value, instagramURL) value := utils.URLFromHandle(legacyURLs.Instagram.Value, instagramURL)
// find and replace the first instagram URL // find and replace the first instagram URL
for i, url := range existingURLs { for i, url := range existingURLs {
if performer.IsInstagramURL(url) { if performer.IsInstagramURL(url) {
@@ -226,16 +247,25 @@ func (r *mutationResolver) handleLegacyURLs(ctx context.Context, performerID int
return nil return nil
} }
func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) { type legacyPerformerURLs struct {
performerID, err := strconv.Atoi(input.ID) URL models.OptionalString
if err != nil { Twitter models.OptionalString
return nil, fmt.Errorf("converting id: %w", err) Instagram models.OptionalString
} }
translator := changesetTranslator{ func (u *legacyPerformerURLs) AnySet() bool {
inputMap: getUpdateInputMap(ctx), return u.URL.Set || u.Twitter.Set || u.Instagram.Set
} }
func legacyPerformerURLsFromInput(input models.PerformerUpdateInput, translator changesetTranslator) legacyPerformerURLs {
return legacyPerformerURLs{
URL: translator.optionalString(input.URL, "url"),
Twitter: translator.optionalString(input.Twitter, "twitter"),
Instagram: translator.optionalString(input.Instagram, "instagram"),
}
}
func performerPartialFromInput(input models.PerformerUpdateInput, translator changesetTranslator) (*models.PerformerPartial, error) {
// Populate performer from the input // Populate performer from the input
updatedPerformer := models.NewPerformerPartial() updatedPerformer := models.NewPerformerPartial()
@@ -249,7 +279,29 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits")
updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length")
updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised") updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised")
updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") // prefer career_start/career_end over deprecated career_length
if translator.hasField("career_start") || translator.hasField("career_end") {
var err error
updatedPerformer.CareerStart, err = translator.optionalDate(input.CareerStart, "career_start")
if err != nil {
return nil, fmt.Errorf("converting career start: %w", err)
}
updatedPerformer.CareerEnd, err = translator.optionalDate(input.CareerEnd, "career_end")
if err != nil {
return nil, fmt.Errorf("converting career end: %w", err)
}
} else if translator.hasField("career_length") && input.CareerLength != nil {
start, end, err := models.ParseYearRangeString(*input.CareerLength)
if err != nil {
return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err)
}
if start != nil {
updatedPerformer.CareerStart = models.NewOptionalDate(*start)
}
if end != nil {
updatedPerformer.CareerEnd = models.NewOptionalDate(*end)
}
}
updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos")
updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings")
updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite")
@@ -260,19 +312,17 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") updatedPerformer.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
updatedPerformer.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids") updatedPerformer.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids")
var err error
if translator.hasField("urls") { if translator.hasField("urls") {
// ensure url/twitter/instagram are not included in the input // ensure url/twitter/instagram are not included in the input
if err := r.validateNoLegacyURLs(translator); err != nil { if err := validateNoLegacyURLs(translator); err != nil {
return nil, err return nil, err
} }
updatedPerformer.URLs = translator.updateStrings(input.Urls, "urls") updatedPerformer.URLs = translator.updateStrings(input.Urls, "urls")
} }
legacyURL := translator.optionalString(input.URL, "url")
legacyTwitter := translator.optionalString(input.Twitter, "twitter")
legacyInstagram := translator.optionalString(input.Instagram, "instagram")
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate") updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
if err != nil { if err != nil {
return nil, fmt.Errorf("converting birthdate: %w", err) return nil, fmt.Errorf("converting birthdate: %w", err)
@@ -299,6 +349,26 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
updatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields) updatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields)
return &updatedPerformer, nil
}
func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.PerformerUpdateInput) (*models.Performer, error) {
performerID, err := strconv.Atoi(input.ID)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)
}
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
updatedPerformer, err := performerPartialFromInput(input, translator)
if err != nil {
return nil, err
}
legacyURLs := legacyPerformerURLsFromInput(input, translator)
var imageData []byte var imageData []byte
imageIncluded := translator.hasField("image") imageIncluded := translator.hasField("image")
if input.Image != nil { if input.Image != nil {
@@ -312,17 +382,38 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Performer qb := r.repository.Performer
if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set { if legacyURLs.AnySet() {
if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil { if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, updatedPerformer); err != nil {
return err return err
} }
} }
if err := performer.ValidateUpdate(ctx, performerID, updatedPerformer, qb); err != nil { if updatedPerformer.Aliases != nil {
p, err := qb.Find(ctx, performerID)
if err != nil {
return err
}
if p != nil {
if err := p.LoadAliases(ctx, qb); err != nil {
return err
}
effectiveAliases := updatedPerformer.Aliases.Apply(p.Aliases.List())
name := p.Name
if updatedPerformer.Name.Set {
name = updatedPerformer.Name.Value
}
sanitized := stringslice.UniqueExcludeFold(effectiveAliases, name)
updatedPerformer.Aliases.Values = sanitized
updatedPerformer.Aliases.Mode = models.RelationshipUpdateModeSet
}
}
if err := performer.ValidateUpdate(ctx, performerID, *updatedPerformer, qb); err != nil {
return err return err
} }
_, err = qb.UpdatePartial(ctx, performerID, updatedPerformer) _, err = qb.UpdatePartial(ctx, performerID, *updatedPerformer)
if err != nil { if err != nil {
return err return err
} }
@@ -366,7 +457,28 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits")
updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length")
updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised") updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised")
updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") // prefer career_start/career_end over deprecated career_length
if translator.hasField("career_start") || translator.hasField("career_end") {
updatedPerformer.CareerStart, err = translator.optionalDate(input.CareerStart, "career_start")
if err != nil {
return nil, fmt.Errorf("converting career start: %w", err)
}
updatedPerformer.CareerEnd, err = translator.optionalDate(input.CareerEnd, "career_end")
if err != nil {
return nil, fmt.Errorf("converting career end: %w", err)
}
} else if translator.hasField("career_length") && input.CareerLength != nil {
start, end, err := models.ParseYearRangeString(*input.CareerLength)
if err != nil {
return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err)
}
if start != nil {
updatedPerformer.CareerStart = models.NewOptionalDate(*start)
}
if end != nil {
updatedPerformer.CareerEnd = models.NewOptionalDate(*end)
}
}
updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos")
updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings")
@@ -379,16 +491,18 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
if translator.hasField("urls") { if translator.hasField("urls") {
// ensure url/twitter/instagram are not included in the input // ensure url/twitter/instagram are not included in the input
if err := r.validateNoLegacyURLs(translator); err != nil { if err := validateNoLegacyURLs(translator); err != nil {
return nil, err return nil, err
} }
updatedPerformer.URLs = translator.updateStringsBulk(input.Urls, "urls") updatedPerformer.URLs = translator.updateStringsBulk(input.Urls, "urls")
} }
legacyURL := translator.optionalString(input.URL, "url") legacyURLs := legacyPerformerURLs{
legacyTwitter := translator.optionalString(input.Twitter, "twitter") URL: translator.optionalString(input.URL, "url"),
legacyInstagram := translator.optionalString(input.Instagram, "instagram") Twitter: translator.optionalString(input.Twitter, "twitter"),
Instagram: translator.optionalString(input.Instagram, "instagram"),
}
updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate") updatedPerformer.Birthdate, err = translator.optionalDate(input.Birthdate, "birthdate")
if err != nil { if err != nil {
@@ -425,8 +539,8 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
qb := r.repository.Performer qb := r.repository.Performer
for _, performerID := range performerIDs { for _, performerID := range performerIDs {
if legacyURL.Set || legacyTwitter.Set || legacyInstagram.Set { if legacyURLs.AnySet() {
if err := r.handleLegacyURLs(ctx, performerID, legacyURL, legacyTwitter, legacyInstagram, &updatedPerformer); err != nil { if err := r.handleLegacyURLs(ctx, performerID, legacyURLs, &updatedPerformer); err != nil {
return err return err
} }
} }
@@ -506,3 +620,87 @@ func (r *mutationResolver) PerformersDestroy(ctx context.Context, performerIDs [
return true, nil return true, nil
} }
func (r *mutationResolver) PerformerMerge(ctx context.Context, input PerformerMergeInput) (*models.Performer, error) {
srcIDs, err := stringslice.StringSliceToIntSlice(input.Source)
if err != nil {
return nil, fmt.Errorf("converting source ids: %w", err)
}
// ensure source ids are unique
srcIDs = sliceutil.AppendUniques(nil, srcIDs)
destID, err := strconv.Atoi(input.Destination)
if err != nil {
return nil, fmt.Errorf("converting destination id: %w", err)
}
// ensure destination is not in source list
if slices.Contains(srcIDs, destID) {
return nil, errors.New("destination performer cannot be in source list")
}
var values *models.PerformerPartial
var imageData []byte
if input.Values != nil {
translator := changesetTranslator{
inputMap: getNamedUpdateInputMap(ctx, "input.values"),
}
values, err = performerPartialFromInput(*input.Values, translator)
if err != nil {
return nil, err
}
legacyURLs := legacyPerformerURLsFromInput(*input.Values, translator)
if legacyURLs.AnySet() {
return nil, errors.New("merging legacy performer URLs is not supported")
}
if input.Values.Image != nil {
var err error
imageData, err = utils.ProcessImageInput(ctx, *input.Values.Image)
if err != nil {
return nil, fmt.Errorf("processing cover image: %w", err)
}
}
} else {
v := models.NewPerformerPartial()
values = &v
}
var dest *models.Performer
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Performer
dest, err = qb.Find(ctx, destID)
if err != nil {
return fmt.Errorf("finding destination performer ID %d: %w", destID, err)
}
// ensure source performers exist
if _, err := qb.FindMany(ctx, srcIDs); err != nil {
return fmt.Errorf("finding source performers: %w", err)
}
if _, err := qb.UpdatePartial(ctx, destID, *values); err != nil {
return fmt.Errorf("updating performer: %w", err)
}
if err := qb.Merge(ctx, srcIDs, destID); err != nil {
return fmt.Errorf("merging performers: %w", err)
}
if len(imageData) > 0 {
if err := qb.UpdateImage(ctx, destID, imageData); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
}
return dest, nil
}

View File

@@ -103,8 +103,15 @@ func (r *mutationResolver) SceneCreate(ctx context.Context, input models.SceneCr
} }
} }
customFields := convertMapJSONNumbers(input.CustomFields)
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
ret, err = r.Resolver.sceneService.Create(ctx, &newScene, fileIDs, coverImageData) ret, err = r.Resolver.sceneService.Create(ctx, models.CreateSceneInput{
Scene: &newScene,
FileIDs: fileIDs,
CoverImage: coverImageData,
CustomFields: customFields,
})
return err return err
}); err != nil { }); err != nil {
return nil, err return nil, err
@@ -297,6 +304,7 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
} }
var coverImageData []byte var coverImageData []byte
coverImageIncluded := translator.hasField("cover_image")
if input.CoverImage != nil { if input.CoverImage != nil {
var err error var err error
coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage) coverImageData, err = utils.ProcessImageInput(ctx, *input.CoverImage)
@@ -305,26 +313,41 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
} }
} }
var customFields *models.CustomFieldsInput
if input.CustomFields != nil {
cfCopy := *input.CustomFields
customFields = &cfCopy
// convert json.Numbers to int/float
customFields.Full = convertMapJSONNumbers(customFields.Full)
customFields.Partial = convertMapJSONNumbers(customFields.Partial)
}
scene, err := qb.UpdatePartial(ctx, sceneID, *updatedScene) scene, err := qb.UpdatePartial(ctx, sceneID, *updatedScene)
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := r.sceneUpdateCoverImage(ctx, scene, coverImageData); err != nil { if coverImageIncluded {
return nil, err if err := r.sceneUpdateCoverImage(ctx, scene, coverImageData); err != nil {
return nil, err
}
}
if customFields != nil {
if err := qb.SetCustomFields(ctx, scene.ID, *customFields); err != nil {
return nil, err
}
} }
return scene, nil return scene, nil
} }
func (r *mutationResolver) sceneUpdateCoverImage(ctx context.Context, s *models.Scene, coverImageData []byte) error { func (r *mutationResolver) sceneUpdateCoverImage(ctx context.Context, s *models.Scene, coverImageData []byte) error {
if len(coverImageData) > 0 { qb := r.repository.Scene
qb := r.repository.Scene
// update cover table // update cover table - empty data will clear the cover
if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil { if err := qb.UpdateCover(ctx, s.ID, coverImageData); err != nil {
return err return err
}
} }
return nil return nil
@@ -386,6 +409,12 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU
} }
} }
var customFields *models.CustomFieldsInput
if input.CustomFields != nil {
cf := handleUpdateCustomFields(*input.CustomFields)
customFields = &cf
}
ret := []*models.Scene{} ret := []*models.Scene{}
// Start the transaction and save the scenes // Start the transaction and save the scenes
@@ -398,6 +427,12 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU
return err return err
} }
if customFields != nil {
if err := qb.SetCustomFields(ctx, scene.ID, *customFields); err != nil {
return err
}
}
ret = append(ret, scene) ret = append(ret, scene)
} }
@@ -440,6 +475,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
deleteGenerated := utils.IsTrue(input.DeleteGenerated) deleteGenerated := utils.IsTrue(input.DeleteGenerated)
deleteFile := utils.IsTrue(input.DeleteFile) deleteFile := utils.IsTrue(input.DeleteFile)
destroyFileEntry := utils.IsTrue(input.DestroyFileEntry)
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Scene qb := r.repository.Scene
@@ -456,7 +492,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
// kill any running encoders // kill any running encoders
manager.KillRunningStreams(s, fileNamingAlgo) manager.KillRunningStreams(s, fileNamingAlgo)
return r.sceneService.Destroy(ctx, s, fileDeleter, deleteGenerated, deleteFile) return r.sceneService.Destroy(ctx, s, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)
}); err != nil { }); err != nil {
fileDeleter.Rollback() fileDeleter.Rollback()
return false, err return false, err
@@ -494,6 +530,7 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
deleteGenerated := utils.IsTrue(input.DeleteGenerated) deleteGenerated := utils.IsTrue(input.DeleteGenerated)
deleteFile := utils.IsTrue(input.DeleteFile) deleteFile := utils.IsTrue(input.DeleteFile)
destroyFileEntry := utils.IsTrue(input.DestroyFileEntry)
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Scene qb := r.repository.Scene
@@ -512,7 +549,7 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
// kill any running encoders // kill any running encoders
manager.KillRunningStreams(scene, fileNamingAlgo) manager.KillRunningStreams(scene, fileNamingAlgo)
if err := r.sceneService.Destroy(ctx, scene, fileDeleter, deleteGenerated, deleteFile); err != nil { if err := r.sceneService.Destroy(ctx, scene, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
return err return err
} }
} }
@@ -572,6 +609,7 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput
var values *models.ScenePartial var values *models.ScenePartial
var coverImageData []byte var coverImageData []byte
var customFields *models.CustomFieldsInput
if input.Values != nil { if input.Values != nil {
translator := changesetTranslator{ translator := changesetTranslator{
@@ -590,6 +628,11 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput
return nil, fmt.Errorf("processing cover image: %w", err) return nil, fmt.Errorf("processing cover image: %w", err)
} }
} }
if input.Values.CustomFields != nil {
cf := handleUpdateCustomFields(*input.Values.CustomFields)
customFields = &cf
}
} else { } else {
v := models.NewScenePartial() v := models.NewScenePartial()
values = &v values = &v
@@ -621,7 +664,20 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput
return fmt.Errorf("scene with id %d not found", destID) return fmt.Errorf("scene with id %d not found", destID)
} }
return r.sceneUpdateCoverImage(ctx, ret, coverImageData) // only update cover image if one was provided
if len(coverImageData) > 0 {
if err := r.sceneUpdateCoverImage(ctx, ret, coverImageData); err != nil {
return err
}
}
if customFields != nil {
if err := r.Resolver.repository.Scene.SetCustomFields(ctx, ret.ID, *customFields); err != nil {
return err
}
}
return nil
}); err != nil { }); err != nil {
return nil, err return nil, err
} }

View File

@@ -58,6 +58,16 @@ func (r *mutationResolver) StashBoxBatchStudioTag(ctx context.Context, input man
return strconv.Itoa(jobID), nil return strconv.Itoa(jobID), nil
} }
func (r *mutationResolver) StashBoxBatchTagTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) {
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck
if err != nil {
return "", err
}
jobID := manager.GetInstance().StashBoxBatchTagTag(ctx, b, input)
return strconv.Itoa(jobID), nil
}
func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input StashBoxDraftSubmissionInput) (*string, error) { func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input StashBoxDraftSubmissionInput) (*string, error) {
b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint) b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint)
if err != nil { if err != nil {

View File

@@ -14,6 +14,63 @@ import (
) )
// used to refetch studio after hooks run // 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) { func (r *mutationResolver) getStudio(ctx context.Context, id int) (ret *models.Studio, err error) {
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Studio.Find(ctx, id) ret, err = r.repository.Studio.Find(ctx, id)
@@ -31,14 +88,15 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
} }
// Populate a new studio from the input // Populate a new studio from the input
newStudio := models.NewStudio() newStudio := models.NewCreateStudioInput()
newStudio.Name = strings.TrimSpace(input.Name) newStudio.Name = strings.TrimSpace(input.Name)
newStudio.Rating = input.Rating100 newStudio.Rating = input.Rating100
newStudio.Favorite = translator.bool(input.Favorite) newStudio.Favorite = translator.bool(input.Favorite)
newStudio.Details = translator.string(input.Details) newStudio.Details = translator.string(input.Details)
newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
newStudio.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.Aliases)) newStudio.Organized = translator.bool(input.Organized)
newStudio.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newStudio.Name))
newStudio.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs()) newStudio.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs())
var err error var err error
@@ -62,6 +120,12 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
return nil, fmt.Errorf("converting tag ids: %w", err) 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 // Process the base 64 encoded image string
var imageData []byte var imageData []byte
if input.Image != nil { if input.Image != nil {
@@ -91,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 return nil
}); err != nil { }); err != nil {
return nil, err return nil, err
@@ -119,6 +189,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
updatedStudio.Rating = translator.optionalInt(input.Rating100, "rating100") updatedStudio.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedStudio.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedStudio.Favorite = translator.optionalBool(input.Favorite, "favorite")
updatedStudio.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") updatedStudio.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
updatedStudio.Organized = translator.optionalBool(input.Organized, "organized")
updatedStudio.Aliases = translator.updateStrings(input.Aliases, "aliases") updatedStudio.Aliases = translator.updateStrings(input.Aliases, "aliases")
updatedStudio.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids") updatedStudio.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids")
@@ -132,9 +203,14 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
return nil, fmt.Errorf("converting tag ids: %w", err) 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") { if translator.hasField("urls") {
// ensure url not included in the input // ensure url not included in the input
if err := r.validateNoLegacyURLs(translator); err != nil { if err := validateNoLegacyURLs(translator); err != nil {
return nil, err return nil, err
} }
@@ -152,6 +228,11 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
} }
} }
updatedStudio.CustomFields = input.CustomFields
// convert json.Numbers to int/float
updatedStudio.CustomFields.Full = convertMapJSONNumbers(updatedStudio.CustomFields.Full)
updatedStudio.CustomFields.Partial = convertMapJSONNumbers(updatedStudio.CustomFields.Partial)
// Process the base 64 encoded image string // Process the base 64 encoded image string
var imageData []byte var imageData []byte
imageIncluded := translator.hasField("image") imageIncluded := translator.hasField("image")
@@ -167,6 +248,34 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Studio qb := r.repository.Studio
if updatedStudio.Aliases != nil {
s, err := qb.Find(ctx, studioID)
if err != nil {
return err
}
if s != nil {
if err := s.LoadAliases(ctx, qb); err != nil {
return err
}
effectiveAliases := updatedStudio.Aliases.Apply(s.Aliases.List())
name := s.Name
if updatedStudio.Name.Set {
name = updatedStudio.Name.Value
}
sanitized := stringslice.UniqueExcludeFold(effectiveAliases, name)
updatedStudio.Aliases.Values = sanitized
updatedStudio.Aliases.Mode = models.RelationshipUpdateModeSet
}
}
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 { if err := studio.ValidateModify(ctx, updatedStudio, qb); err != nil {
return err return err
} }
@@ -182,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 return nil
}); err != nil { }); err != nil {
return nil, err return nil, err
@@ -211,7 +326,7 @@ func (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudi
if translator.hasField("urls") { if translator.hasField("urls") {
// ensure url/twitter/instagram are not included in the input // ensure url/twitter/instagram are not included in the input
if err := r.validateNoLegacyURLs(translator); err != nil { if err := validateNoLegacyURLs(translator); err != nil {
return nil, err return nil, err
} }
@@ -233,6 +348,7 @@ func (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudi
partial.Rating = translator.optionalInt(input.Rating100, "rating100") partial.Rating = translator.optionalInt(input.Rating100, "rating100")
partial.Details = translator.optionalString(input.Details, "details") partial.Details = translator.optionalString(input.Details, "details")
partial.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") partial.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
partial.Organized = translator.optionalBool(input.Organized, "organized")
partial.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") partial.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids")
if err != nil { if err != nil {

View File

@@ -6,7 +6,6 @@ import (
"strconv" "strconv"
"strings" "strings"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin/hook" "github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/sliceutil/stringslice" "github.com/stashapp/stash/pkg/sliceutil/stringslice"
@@ -31,11 +30,14 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
} }
// Populate a new tag from the input // Populate a new tag from the input
newTag := models.NewTag() newTag := models.CreateTagInput{
Tag: &models.Tag{},
}
*newTag.Tag = models.NewTag()
newTag.Name = strings.TrimSpace(input.Name) newTag.Name = strings.TrimSpace(input.Name)
newTag.SortName = translator.string(input.SortName) newTag.SortName = translator.string(input.SortName)
newTag.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.Aliases)) newTag.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newTag.Name))
newTag.Favorite = translator.bool(input.Favorite) newTag.Favorite = translator.bool(input.Favorite)
newTag.Description = translator.string(input.Description) newTag.Description = translator.string(input.Description)
newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
@@ -60,6 +62,8 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
return nil, fmt.Errorf("converting child tag ids: %w", err) return nil, fmt.Errorf("converting child tag ids: %w", err)
} }
newTag.CustomFields = convertMapJSONNumbers(input.CustomFields)
// Process the base 64 encoded image string // Process the base 64 encoded image string
var imageData []byte var imageData []byte
if input.Image != nil { if input.Image != nil {
@@ -73,7 +77,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Tag qb := r.repository.Tag
if err := tag.ValidateCreate(ctx, newTag, qb); err != nil { if err := tag.ValidateCreate(ctx, *newTag.Tag, qb); err != nil {
return err return err
} }
@@ -98,17 +102,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
return r.getTag(ctx, newTag.ID) return r.getTag(ctx, newTag.ID)
} }
func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) (*models.Tag, error) { func tagPartialFromInput(input TagUpdateInput, translator changesetTranslator) (*models.TagPartial, error) {
tagID, err := strconv.Atoi(input.ID)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)
}
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
// Populate tag from the input
updatedTag := models.NewTagPartial() updatedTag := models.NewTagPartial()
updatedTag.Name = translator.optionalString(input.Name, "name") updatedTag.Name = translator.optionalString(input.Name, "name")
@@ -127,6 +121,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
} }
updatedTag.StashIDs = translator.updateStashIDs(updateStashIDInputs, "stash_ids") updatedTag.StashIDs = translator.updateStashIDs(updateStashIDInputs, "stash_ids")
var err error
updatedTag.ParentIDs, err = translator.updateIds(input.ParentIds, "parent_ids") updatedTag.ParentIDs, err = translator.updateIds(input.ParentIds, "parent_ids")
if err != nil { if err != nil {
return nil, fmt.Errorf("converting parent tag ids: %w", err) return nil, fmt.Errorf("converting parent tag ids: %w", err)
@@ -137,6 +132,32 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
return nil, fmt.Errorf("converting child tag ids: %w", err) return nil, fmt.Errorf("converting child tag ids: %w", err)
} }
if input.CustomFields != nil {
updatedTag.CustomFields = *input.CustomFields
// convert json.Numbers to int/float
updatedTag.CustomFields.Full = convertMapJSONNumbers(updatedTag.CustomFields.Full)
updatedTag.CustomFields.Partial = convertMapJSONNumbers(updatedTag.CustomFields.Partial)
}
return &updatedTag, nil
}
func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) (*models.Tag, error) {
tagID, err := strconv.Atoi(input.ID)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)
}
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
// Populate tag from the input
updatedTag, err := tagPartialFromInput(input, translator)
if err != nil {
return nil, err
}
var imageData []byte var imageData []byte
imageIncluded := translator.hasField("image") imageIncluded := translator.hasField("image")
if input.Image != nil { if input.Image != nil {
@@ -151,11 +172,33 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Tag qb := r.repository.Tag
if err := tag.ValidateUpdate(ctx, tagID, updatedTag, qb); err != nil { if updatedTag.Aliases != nil {
t, err := qb.Find(ctx, tagID)
if err != nil {
return err
}
if t != nil {
if err := t.LoadAliases(ctx, qb); err != nil {
return err
}
newAliases := updatedTag.Aliases.Apply(t.Aliases.List())
name := t.Name
if updatedTag.Name.Set {
name = updatedTag.Name.Value
}
sanitized := stringslice.UniqueExcludeFold(newAliases, name)
updatedTag.Aliases.Values = sanitized
updatedTag.Aliases.Mode = models.RelationshipUpdateModeSet
}
}
if err := tag.ValidateUpdate(ctx, tagID, *updatedTag, qb); err != nil {
return err return err
} }
t, err = qb.UpdatePartial(ctx, tagID, updatedTag) t, err = qb.UpdatePartial(ctx, tagID, *updatedTag)
if err != nil { if err != nil {
return err return err
} }
@@ -303,6 +346,31 @@ func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput)
return nil, nil return nil, nil
} }
var values *models.TagPartial
var imageData []byte
if input.Values != nil {
translator := changesetTranslator{
inputMap: getNamedUpdateInputMap(ctx, "input.values"),
}
values, err = tagPartialFromInput(*input.Values, translator)
if err != nil {
return nil, err
}
if input.Values.Image != nil {
var err error
imageData, err = utils.ProcessImageInput(ctx, *input.Values.Image)
if err != nil {
return nil, fmt.Errorf("processing cover image: %w", err)
}
}
} else {
v := models.NewTagPartial()
values = &v
}
var t *models.Tag var t *models.Tag
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Tag qb := r.repository.Tag
@@ -317,28 +385,22 @@ func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput)
return fmt.Errorf("tag with id %d not found", destination) return fmt.Errorf("tag with id %d not found", destination)
} }
parents, children, err := tag.MergeHierarchy(ctx, destination, source, qb)
if err != nil {
return err
}
if err = qb.Merge(ctx, source, destination); err != nil { if err = qb.Merge(ctx, source, destination); err != nil {
return err return err
} }
err = qb.UpdateParentTags(ctx, destination, parents) if err := tag.ValidateUpdate(ctx, destination, *values, qb); err != nil {
if err != nil {
return err
}
err = qb.UpdateChildTags(ctx, destination, children)
if err != nil {
return err return err
} }
err = tag.ValidateHierarchyExisting(ctx, t, parents, children, qb) if _, err := qb.UpdatePartial(ctx, destination, *values); err != nil {
if err != nil { return fmt.Errorf("updating tag: %w", err)
logger.Errorf("Error merging tag: %s", err) }
return err
if len(imageData) > 0 {
if err := qb.UpdateImage(ctx, destination, imageData); err != nil {
return err
}
} }
return nil return nil

View File

@@ -96,6 +96,11 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
CalculateMd5: config.IsCalculateMD5(), CalculateMd5: config.IsCalculateMD5(),
VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(), VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),
ParallelTasks: config.GetParallelTasks(), ParallelTasks: config.GetParallelTasks(),
UseCustomSpriteInterval: config.GetUseCustomSpriteInterval(),
SpriteInterval: config.GetSpriteInterval(),
SpriteScreenshotSize: config.GetSpriteScreenshotSize(),
MinimumSprites: config.GetMinimumSprites(),
MaximumSprites: config.GetMaximumSprites(),
PreviewAudio: config.GetPreviewAudio(), PreviewAudio: config.GetPreviewAudio(),
PreviewSegments: config.GetPreviewSegments(), PreviewSegments: config.GetPreviewSegments(),
PreviewSegmentDuration: config.GetPreviewSegmentDuration(), PreviewSegmentDuration: config.GetPreviewSegmentDuration(),
@@ -156,6 +161,7 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult {
javascriptEnabled := config.GetJavascriptEnabled() javascriptEnabled := config.GetJavascriptEnabled()
customLocales := config.GetCustomLocales() customLocales := config.GetCustomLocales()
customLocalesEnabled := config.GetCustomLocalesEnabled() customLocalesEnabled := config.GetCustomLocalesEnabled()
disableCustomizations := config.GetDisableCustomizations()
language := config.GetLanguage() language := config.GetLanguage()
handyKey := config.GetHandyKey() handyKey := config.GetHandyKey()
scriptOffset := config.GetFunscriptOffset() scriptOffset := config.GetFunscriptOffset()
@@ -183,6 +189,7 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult {
JavascriptEnabled: &javascriptEnabled, JavascriptEnabled: &javascriptEnabled,
CustomLocales: &customLocales, CustomLocales: &customLocales,
CustomLocalesEnabled: &customLocalesEnabled, CustomLocalesEnabled: &customLocalesEnabled,
DisableCustomizations: &disableCustomizations,
Language: &language, Language: &language,
ImageLightbox: &imageLightboxOptions, ImageLightbox: &imageLightboxOptions,

View File

@@ -6,7 +6,6 @@ import (
"strconv" "strconv"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
) )
func (r *queryResolver) FindFolder(ctx context.Context, id *string, path *string) (*models.Folder, error) { func (r *queryResolver) FindFolder(ctx context.Context, id *string, path *string) (*models.Folder, error) {
@@ -49,7 +48,7 @@ func (r *queryResolver) FindFolders(
) (ret *FindFoldersResultType, err error) { ) (ret *FindFoldersResultType, err error) {
var folderIDs []models.FolderID var folderIDs []models.FolderID
if len(ids) > 0 { if len(ids) > 0 {
folderIDsInt, err := stringslice.StringSliceToIntSlice(ids) folderIDsInt, err := handleIDList(ids, "ids")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -5,7 +5,6 @@ import (
"strconv" "strconv"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
) )
func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models.Gallery, err error) { func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models.Gallery, err error) {
@@ -25,7 +24,7 @@ func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models
} }
func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType, ids []string) (ret *FindGalleriesResultType, err error) { func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType, ids []string) (ret *FindGalleriesResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids) idInts, err := handleIDList(ids, "ids")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -5,7 +5,6 @@ import (
"strconv" "strconv"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
) )
func (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.Group, err error) { func (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.Group, err error) {
@@ -25,7 +24,7 @@ func (r *queryResolver) FindGroup(ctx context.Context, id string) (ret *models.G
} }
func (r *queryResolver) FindGroups(ctx context.Context, groupFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindGroupsResultType, err error) { func (r *queryResolver) FindGroups(ctx context.Context, groupFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindGroupsResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids) idInts, err := handleIDList(ids, "ids")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -7,7 +7,6 @@ import (
"github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
) )
func (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *string) (*models.Image, error) { func (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *string) (*models.Image, error) {
@@ -55,7 +54,7 @@ func (r *queryResolver) FindImages(
filter *models.FindFilterType, filter *models.FindFilterType,
) (ret *FindImagesResultType, err error) { ) (ret *FindImagesResultType, err error) {
if len(ids) > 0 { if len(ids) > 0 {
imageIds, err = stringslice.StringSliceToIntSlice(ids) imageIds, err = handleIDList(ids, "ids")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -5,7 +5,6 @@ import (
"strconv" "strconv"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
) )
func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.Group, err error) { func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.Group, err error) {
@@ -25,7 +24,7 @@ func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.G
} }
func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindMoviesResultType, err error) { func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.GroupFilterType, filter *models.FindFilterType, ids []string) (ret *FindMoviesResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids) idInts, err := handleIDList(ids, "ids")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -5,7 +5,6 @@ import (
"strconv" "strconv"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
) )
func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *models.Performer, err error) { func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *models.Performer, err error) {
@@ -26,7 +25,7 @@ func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *mode
func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType, performerIDs []int, ids []string) (ret *FindPerformersResultType, err error) { func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType, performerIDs []int, ids []string) (ret *FindPerformersResultType, err error) {
if len(ids) > 0 { if len(ids) > 0 {
performerIDs, err = stringslice.StringSliceToIntSlice(ids) performerIDs, err = handleIDList(ids, "ids")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -9,7 +9,6 @@ import (
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
) )
func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *string) (*models.Scene, error) { func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *string) (*models.Scene, error) {
@@ -83,7 +82,7 @@ func (r *queryResolver) FindScenes(
filter *models.FindFilterType, filter *models.FindFilterType,
) (ret *FindScenesResultType, err error) { ) (ret *FindScenesResultType, err error) {
if len(ids) > 0 { if len(ids) > 0 {
sceneIDs, err = stringslice.StringSliceToIntSlice(ids) sceneIDs, err = handleIDList(ids, "ids")
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -228,7 +227,7 @@ func (r *queryResolver) ParseSceneFilenames(ctx context.Context, filter *models.
return ret, nil 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 dist := 0
durDiff := -1. durDiff := -1.
if distance != nil { if distance != nil {
@@ -238,7 +237,7 @@ func (r *queryResolver) FindDuplicateScenes(ctx context.Context, distance *int,
durDiff = *durationDiff durDiff = *durationDiff
} }
if err := r.withReadTxn(ctx, func(ctx context.Context) error { 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 return err
}); err != nil { }); err != nil {
return nil, err return nil, err

View File

@@ -4,11 +4,10 @@ import (
"context" "context"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
) )
func (r *queryResolver) FindSceneMarkers(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, filter *models.FindFilterType, ids []string) (ret *FindSceneMarkersResultType, err error) { func (r *queryResolver) FindSceneMarkers(ctx context.Context, sceneMarkerFilter *models.SceneMarkerFilterType, filter *models.FindFilterType, ids []string) (ret *FindSceneMarkersResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids) idInts, err := handleIDList(ids, "ids")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -5,7 +5,6 @@ import (
"strconv" "strconv"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
) )
func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.Studio, err error) { func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.Studio, err error) {
@@ -26,7 +25,7 @@ func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.
} }
func (r *queryResolver) FindStudios(ctx context.Context, studioFilter *models.StudioFilterType, filter *models.FindFilterType, ids []string) (ret *FindStudiosResultType, err error) { func (r *queryResolver) FindStudios(ctx context.Context, studioFilter *models.StudioFilterType, filter *models.FindFilterType, ids []string) (ret *FindStudiosResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids) idInts, err := handleIDList(ids, "ids")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -5,7 +5,6 @@ import (
"strconv" "strconv"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
) )
func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag, err error) { func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag, err error) {
@@ -25,7 +24,7 @@ func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag
} }
func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType, ids []string) (ret *FindTagsResultType, err error) { func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType, ids []string) (ret *FindTagsResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids) idInts, err := handleIDList(ids, "ids")
if err != nil { if err != nil {
return nil, err return nil, err
} }

View File

@@ -33,15 +33,26 @@ func (r *queryResolver) FindJob(ctx context.Context, input FindJobInput) (*Job,
} }
func jobToJobModel(j job.Job) *Job { func jobToJobModel(j job.Job) *Job {
subTasks := make([]string, len(j.Details))
for i, t := range j.Details {
subTasks[i] = sanitiseWebsocketString(t)
}
var jobError *string
if j.Error != nil {
s := sanitiseWebsocketString(*j.Error)
jobError = &s
}
ret := &Job{ ret := &Job{
ID: strconv.Itoa(j.ID), ID: strconv.Itoa(j.ID),
Status: JobStatus(j.Status), Status: JobStatus(j.Status),
Description: j.Description, Description: sanitiseWebsocketString(j.Description),
SubTasks: j.Details, SubTasks: subTasks,
StartTime: j.StartTime, StartTime: j.StartTime,
EndTime: j.EndTime, EndTime: j.EndTime,
AddTime: j.AddTime, AddTime: j.AddTime,
Error: j.Error, Error: jobError,
} }
if j.Progress != -1 { if j.Progress != -1 {

View File

@@ -8,6 +8,8 @@ import (
"github.com/stashapp/stash/internal/api/urlbuilders" "github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/signedurl"
) )
func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*manager.SceneStreamEndpoint, error) { func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*manager.SceneStreamEndpoint, error) {
@@ -39,7 +41,22 @@ func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*manage
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewSceneURLBuilder(baseURL, scene) builder := urlbuilders.NewSceneURLBuilder(baseURL, scene)
apiKey := config.GetAPIKey()
return manager.GetSceneStreamPaths(scene, builder.GetStreamURL(apiKey), config.GetMaxStreamingTranscodeSize()) streamURL := builder.GetStreamURL("")
if config.HasCredentials() {
userID := session.GetCurrentUserID(ctx)
if userID == nil {
return nil, fmt.Errorf("user ID not found")
}
streamURL.RawQuery = signedParams(config, *userID, signedurl.DerivePrefix(streamURL.Path)).Encode()
} else {
apiKey := config.GetAPIKey()
if apiKey != "" {
v := streamURL.Query()
v.Set("apikey", apiKey)
streamURL.RawQuery = v.Encode()
}
}
return manager.GetSceneStreamPaths(scene, streamURL, config.GetMaxStreamingTranscodeSize())
} }

View File

@@ -6,6 +6,7 @@ import (
"fmt" "fmt"
"slices" "slices"
"strconv" "strconv"
"strings"
"github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
@@ -363,7 +364,8 @@ func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Sour
client := r.newStashBoxClient(*b) client := r.newStashBoxClient(*b)
var ret []*models.ScrapedTag var ret []*models.ScrapedTag
out, err := client.QueryTag(ctx, *input.Query) query := *input.Query
out, err := client.QueryTag(ctx, query)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -383,6 +385,22 @@ func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Sour
}); err != nil { }); err != nil {
return nil, err return nil, err
} }
// tag name query returns results that may not match the query exactly.
// if there is an exact match, it should be first
if query != "" {
for i, result := range ret {
if strings.EqualFold(result.Name, query) {
// prepend exact match to the front of the slice
if i != 0 {
ret = append([]*models.ScrapedTag{result}, append(ret[:i], ret[i+1:]...)...)
}
break
}
}
}
return ret, nil return ret, nil
} }

View File

@@ -2,11 +2,19 @@ package api
import ( import (
"context" "context"
"strings"
"github.com/stashapp/stash/internal/log" "github.com/stashapp/stash/internal/log"
"github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager"
) )
// sanitiseWebsocketString is used to ensure that any strings sent over the websocket are valid UTF-8.
// Any invalid UTF-8 sequences will be replaced with the Unicode replacement character (U+FFFD).
// Invalid UTF-8 sequences can cause the websocket connection to be closed.
func sanitiseWebsocketString(s string) string {
return strings.ToValidUTF8(s, "\uFFFD")
}
func getLogLevel(logType string) LogLevel { func getLogLevel(logType string) LogLevel {
switch logType { switch logType {
case "progress": case "progress":
@@ -33,7 +41,7 @@ func logEntriesFromLogItems(logItems []log.LogItem) []*LogEntry {
ret[i] = &LogEntry{ ret[i] = &LogEntry{
Time: entry.Time, Time: entry.Time,
Level: getLogLevel(entry.Type), Level: getLogLevel(entry.Type),
Message: entry.Message, Message: sanitiseWebsocketString(entry.Message),
} }
} }

View File

@@ -12,6 +12,7 @@ import (
"github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/internal/static"
"github.com/stashapp/stash/pkg/ffmpeg" "github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/file/video" "github.com/stashapp/stash/pkg/file/video"
"github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/fsutil"
@@ -243,6 +244,12 @@ func (rs sceneRoutes) streamSegment(w http.ResponseWriter, r *http.Request, stre
} }
func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) { func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {
// if default flag is set, return the default image
if r.URL.Query().Get("default") == "true" {
utils.ServeImage(w, r, static.ReadAll(static.DefaultSceneImage))
return
}
scene := r.Context().Value(sceneKey).(*models.Scene) scene := r.Context().Value(sceneKey).(*models.Scene)
ss := manager.SceneServer{ ss := manager.SceneServer{

View File

@@ -11,6 +11,7 @@ import (
"net/http" "net/http"
"os" "os"
"path" "path"
"path/filepath"
"runtime/debug" "runtime/debug"
"strconv" "strconv"
"strings" "strings"
@@ -255,6 +256,9 @@ func Initialize() (*Server, error) {
staticUI = statigz.FileServer(ui.UIBox.(fs.ReadDirFS)) staticUI = statigz.FileServer(ui.UIBox.(fs.ReadDirFS))
} }
// handle favicon override
r.HandleFunc("/favicon.ico", handleFavicon(staticUI))
// Serve the web app // Serve the web app
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) { r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
ext := path.Ext(r.URL.Path) ext := path.Ext(r.URL.Path)
@@ -295,6 +299,31 @@ func Initialize() (*Server, error) {
return server, nil return server, nil
} }
func handleFavicon(staticUI *statigz.Server) func(w http.ResponseWriter, r *http.Request) {
mgr := manager.GetInstance()
cfg := mgr.Config
// check if favicon.ico exists in the config directory
// if so, use that
// otherwise, use the embedded one
iconPath := filepath.Join(cfg.GetConfigPath(), "favicon.ico")
exists, _ := fsutil.FileExists(iconPath)
if exists {
logger.Debugf("Using custom favicon at %s", iconPath)
}
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-cache")
if exists {
http.ServeFile(w, r, iconPath)
} else {
staticUI.ServeHTTP(w, r)
}
}
}
// Start starts the server. It listens on the configured address and port. // Start starts the server. It listens on the configured address and port.
// It calls ListenAndServeTLS if TLS is configured, otherwise it calls ListenAndServe. // It calls ListenAndServeTLS if TLS is configured, otherwise it calls ListenAndServe.
// Calls to Start are blocked until the server is shutdown. // Calls to Start are blocked until the server is shutdown.
@@ -421,7 +450,7 @@ func cssHandler(c *config.Config) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var paths []string var paths []string
if c.GetCSSEnabled() { if c.GetCSSEnabled() && !c.GetDisableCustomizations() {
// search for custom.css in current directory, then $HOME/.stash // search for custom.css in current directory, then $HOME/.stash
fn := c.GetCSSPath() fn := c.GetCSSPath()
exists, _ := fsutil.FileExists(fn) exists, _ := fsutil.FileExists(fn)
@@ -439,7 +468,7 @@ func javascriptHandler(c *config.Config) func(w http.ResponseWriter, r *http.Req
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var paths []string var paths []string
if c.GetJavascriptEnabled() { if c.GetJavascriptEnabled() && !c.GetDisableCustomizations() {
// search for custom.js in current directory, then $HOME/.stash // search for custom.js in current directory, then $HOME/.stash
fn := c.GetJavascriptPath() fn := c.GetJavascriptPath()
exists, _ := fsutil.FileExists(fn) exists, _ := fsutil.FileExists(fn)
@@ -457,7 +486,7 @@ func customLocalesHandler(c *config.Config) func(w http.ResponseWriter, r *http.
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
buffer := bytes.Buffer{} buffer := bytes.Buffer{}
if c.GetCustomLocalesEnabled() { if c.GetCustomLocalesEnabled() && !c.GetDisableCustomizations() {
// search for custom-locales.json in current directory, then $HOME/.stash // search for custom-locales.json in current directory, then $HOME/.stash
path := c.GetCustomLocalesPath() path := c.GetCustomLocalesPath()
exists, _ := fsutil.FileExists(path) exists, _ := fsutil.FileExists(path)

View File

@@ -0,0 +1,32 @@
package api
import (
"net/url"
"time"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/signedurl"
)
// userSigningKey returns the HMAC signing key for a given user.
func userSigningKey(c *config.Config, _ string) []byte {
return c.GetJWTSignKey()
}
// signedParams generates signed URL query parameters for the given path prefix and user.
func signedParams(c *config.Config, userID string, prefix string) url.Values {
secret := userSigningKey(c, userID)
cid := signedurl.GenerateCredentialID(secret, userID)
expires := time.Now().Add(c.GetSignedURLExpiry())
return signedurl.SignPrefix(prefix, secret, cid, expires)
}
// resolveCredentialID maps a credential ID back to a username and their signing key.
func resolveCredentialID(c *config.Config, cid string) (string, []byte, bool) {
username := c.GetUsername()
secret := userSigningKey(c, username)
if signedurl.GenerateCredentialID(secret, username) == cid {
return username, secret, true
}
return "", nil, false
}

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 { 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 { 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 { func (b ImageURLBuilder) GetPreviewURL() string {
if exists, err := fsutil.FileExists(manager.GetInstance().Paths.Generated.GetClipPreviewPath(b.Checksum, models.DefaultGthumbWidth)); exists && err == nil { 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 { } else {
return "" 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

@@ -57,12 +57,28 @@ func (b SceneURLBuilder) GetScreenshotURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/screenshot?t=" + b.UpdatedAt return b.BaseURL + "/scene/" + b.SceneID + "/screenshot?t=" + b.UpdatedAt
} }
func (b SceneURLBuilder) GetFunscriptURL() string { func (b SceneURLBuilder) GetFunscriptURL(apiKey string) *url.URL {
return b.BaseURL + "/scene/" + b.SceneID + "/funscript" u, err := url.Parse(fmt.Sprintf("%s/scene/%s/funscript", b.BaseURL, b.SceneID))
if err != nil {
// shouldn't happen
panic(err)
}
if apiKey != "" {
v := u.Query()
v.Set("apikey", apiKey)
u.RawQuery = v.Encode()
}
return u
}
func (b SceneURLBuilder) GetCaptionPath() string {
return "/scene/" + b.SceneID + "/caption"
} }
func (b SceneURLBuilder) GetCaptionURL() string { func (b SceneURLBuilder) GetCaptionURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/caption" return b.BaseURL + b.GetCaptionPath()
} }
func (b SceneURLBuilder) GetInteractiveHeatmapURL() string { func (b SceneURLBuilder) GetInteractiveHeatmapURL() string {

View File

@@ -101,16 +101,15 @@ func createPerformer(ctx context.Context, pqb models.PerformerWriter) error {
func createStudio(ctx context.Context, qb models.StudioWriter, name string) (*models.Studio, error) { func createStudio(ctx context.Context, qb models.StudioWriter, name string) (*models.Studio, error) {
// create the studio // create the studio
studio := models.Studio{ studio := models.NewCreateStudioInput()
Name: name, studio.Name = name
}
err := qb.Create(ctx, &studio) err := qb.Create(ctx, &studio)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return &studio, nil return studio.Studio, nil
} }
func createTag(ctx context.Context, qb models.TagWriter) error { func createTag(ctx context.Context, qb models.TagWriter) error {
@@ -119,7 +118,7 @@ func createTag(ctx context.Context, qb models.TagWriter) error {
Name: testName, Name: testName,
} }
err := qb.Create(ctx, &tag) err := qb.Create(ctx, &models.CreateTagInput{Tag: &tag})
if err != nil { if err != nil {
return err return err
} }
@@ -366,7 +365,10 @@ func makeImage(expectedResult bool) *models.Image {
} }
func createImage(ctx context.Context, w models.ImageWriter, o *models.Image, f *models.ImageFile) error { func createImage(ctx context.Context, w models.ImageWriter, o *models.Image, f *models.ImageFile) error {
err := w.Create(ctx, o, []models.FileID{f.ID}) err := w.Create(ctx, &models.CreateImageInput{
Image: o,
FileIDs: []models.FileID{f.ID},
})
if err != nil { if err != nil {
return fmt.Errorf("Failed to create image with path '%s': %s", f.Path, err.Error()) return fmt.Errorf("Failed to create image with path '%s': %s", f.Path, err.Error())
@@ -469,7 +471,10 @@ func makeGallery(expectedResult bool) *models.Gallery {
} }
func createGallery(ctx context.Context, w models.GalleryWriter, o *models.Gallery, f *models.BaseFile) error { func createGallery(ctx context.Context, w models.GalleryWriter, o *models.Gallery, f *models.BaseFile) error {
err := w.Create(ctx, o, []models.FileID{f.ID}) err := w.Create(ctx, &models.CreateGalleryInput{
Gallery: o,
FileIDs: []models.FileID{f.ID},
})
if err != nil { if err != nil {
return fmt.Errorf("Failed to create gallery with path '%s': %s", f.Path, err.Error()) return fmt.Errorf("Failed to create gallery with path '%s': %s", f.Path, err.Error())
} }

View File

@@ -2,6 +2,7 @@
package desktop package desktop
import ( import (
"fmt"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@@ -17,6 +18,16 @@ import (
"golang.org/x/term" "golang.org/x/term"
) )
var isDesktop bool
// InitIsDesktop sets the value of isDesktop.
// Changed IsDesktop to be evaluated once at startup because if it is
// checked while there are open terminal sessions (such as the ffmpeg hardware
// encoding checks), it may return false.
func InitIsDesktop() {
isDesktop = isDesktopCheck()
}
type FaviconProvider interface { type FaviconProvider interface {
GetFavicon() []byte GetFavicon() []byte
GetFaviconPng() []byte GetFaviconPng() []byte
@@ -59,22 +70,33 @@ func SendNotification(title string, text string) {
} }
func IsDesktop() bool { func IsDesktop() bool {
return isDesktop
}
// isDesktop tries to determine if the application is running in a desktop environment
// where desktop features like system tray and notifications should be enabled.
func isDesktopCheck() bool {
if isDoubleClickLaunched() { if isDoubleClickLaunched() {
logger.Debug("Detected double-click launch")
return true return true
} }
// Check if running under root // Check if running under root
if os.Getuid() == 0 { if os.Getuid() == 0 {
logger.Debug("Running as root, disabling desktop features")
return false return false
} }
// Check if stdin is a terminal // Check if stdin is a terminal
if term.IsTerminal(int(os.Stdin.Fd())) { if term.IsTerminal(int(os.Stdin.Fd())) {
logger.Debug("Running in terminal, disabling desktop features")
return false return false
} }
if isService() { if isService() {
logger.Debug("Running as a service, disabling desktop features")
return false return false
} }
if IsServerDockerized() { if IsServerDockerized() {
logger.Debug("Running in docker, disabling desktop features")
return false return false
} }
@@ -134,15 +156,17 @@ func getIconPath() string {
return path.Join(config.GetInstance().GetConfigPath(), "icon.png") return path.Join(config.GetInstance().GetConfigPath(), "icon.png")
} }
func RevealInFileManager(path string) { func RevealInFileManager(path string) error {
exists, err := fsutil.FileExists(path) info, err := os.Stat(path)
if err != nil { if err != nil {
logger.Errorf("Error checking file: %s", err) return fmt.Errorf("error checking path: %w", err)
return
} }
if exists && IsDesktop() {
revealInFileManager(path) absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("error getting absolute path: %w", err)
} }
return revealInFileManager(absPath, info)
} }
func getServerURL(path string) string { func getServerURL(path string) string {

View File

@@ -4,9 +4,11 @@
package desktop package desktop
import ( import (
"fmt"
"os"
"os/exec" "os/exec"
"github.com/kermieisinthehouse/gosx-notifier" gosxnotifier "github.com/feederbox826/gosx-notifier"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
) )
@@ -32,8 +34,11 @@ func sendNotification(notificationTitle string, notificationText string) {
} }
} }
func revealInFileManager(path string) { func revealInFileManager(path string, _ os.FileInfo) error {
exec.Command(`open`, `-R`, path) if err := exec.Command(`open`, `-R`, path).Run(); err != nil {
return fmt.Errorf("error revealing path in Finder: %w", err)
}
return nil
} }
func isDoubleClickLaunched() bool { func isDoubleClickLaunched() bool {

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