Compare commits

...

102 Commits

Author SHA1 Message Date
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
419 changed files with 8823 additions and 7925 deletions

View File

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

View File

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

View File

@@ -4,7 +4,7 @@ on:
workflow_dispatch:
env:
COMPILER_IMAGE: ghcr.io/stashapp/compiler:13
COMPILER_IMAGE: ghcr.io/stashapp/compiler:14
jobs:
build-compiler:

View File

@@ -15,7 +15,7 @@ concurrency:
cancel-in-progress: true
env:
COMPILER_IMAGE: ghcr.io/stashapp/compiler:13
COMPILER_IMAGE: ghcr.io/stashapp/compiler:14
jobs:
# Job 1: Generate code and build UI
@@ -30,6 +30,8 @@ jobs:
fetch-tags: true
- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
# pnpm version is read from the packageManager field in package.json
# very broken (4.3, 4.4)
@@ -38,6 +40,16 @@ jobs:
with:
package_json_file: ui/v2.5/package.json
# 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:
@@ -46,7 +58,7 @@ jobs:
cache-dependency-path: ui/v2.5/pnpm-lock.yaml
- name: Install UI dependencies
run: cd ui/v2.5 && pnpm install --frozen-lockfile
run: make pre-ui
- name: Generate
run: make generate
@@ -169,7 +181,7 @@ jobs:
path: .go-cache
key: ${{ runner.os }}-go-cache-${{ matrix.platform }}-${{ hashFiles('go.mod', '**/go.sum') }}
# kept seperate to test timings
# kept separate to test timings
- name: pull compiler image
run: docker pull $COMPILER_IMAGE

View File

@@ -17,6 +17,8 @@ jobs:
# no tags or depth needed for lint
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
# generate-backend runs natively (just go generate + touch-ui) — no Docker needed
- name: Generate Backend
@@ -25,4 +27,6 @@ jobs:
## WARN
## using v1, update in a later PR
- name: Run golangci-lint
uses: golangci/golangci-lint-action@v6
uses: golangci/golangci-lint-action@v8
with:
version: v2.11.4

2
.gitignore vendored
View File

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

View File

@@ -1,87 +1,100 @@
# options for analysis running
run:
timeout: 5m
version: "2"
linters:
disable-all: true
default: none
enable:
# Default set of linters from golangci-lint
- errcheck
- gosimple
- govet
- ineffassign
- staticcheck
- typecheck
- unused
# Linters added by the stash project.
# - contextcheck
- copyloopvar
- dogsled
- errcheck
- errchkjson
- errorlint
# - exhaustive
- gocritic
# - goerr113
- gofmt
# - gomnd
# - ifshort
- govet
- ineffassign
- misspell
# - nakedret
- noctx
# TODO - fix these in a later PR
# - noctx
- revive
- rowserrcheck
- sqlclosecheck
# Project-specific linter overrides
linters-settings:
gofmt:
simplify: false
errorlint:
# Disable errorf because there are false positives, where you don't want to wrap
# an error.
errorf: false
asserts: true
comparison: true
revive:
ignore-generated-header: true
severity: error
confidence: 0.8
rules:
- name: blank-imports
disabled: true
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
disabled: true
- name: if-return
disabled: true
- name: increment-decrement
- name: var-naming
disabled: true
- name: var-declaration
- name: package-comments
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
disabled: true
- name: indent-error-flow
disabled: true
- name: errorf
- name: empty-block
disabled: true
- name: superfluous-else
- name: unused-parameter
disabled: true
- name: unreachable-code
- name: redefines-builtin-id
rowserrcheck:
packages:
- github.com/jmoiron/sqlx
- staticcheck
- unused
settings:
staticcheck:
checks:
- all
# we specify (unnecessary) embedded fields for clarity in many places
- -QF1008
# 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
- -ST1003
errorlint:
errorf: false
asserts: true
comparison: true
revive:
confidence: 0.8
severity: error
rules:
- name: blank-imports
disabled: true
- name: context-as-argument
- name: context-keys-type
- name: dot-imports
- name: error-return
- name: error-strings
- name: error-naming
- name: exported
disabled: true
- name: if-return
disabled: true
- name: increment-decrement
- name: var-naming
disabled: true
- name: var-declaration
- name: package-comments
- name: range
- name: receiver-naming
- name: time-naming
- name: unexported-return
disabled: true
- name: indent-error-flow
disabled: true
- name: errorf
- name: empty-block
disabled: true
- name: superfluous-else
- name: unused-parameter
disabled: true
- name: unreachable-code
- 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
RMDIR := rmdir /s /q
NOOP := @@
PREFIX := $(USERPROFILE)\\bin
else
RM := rm -f
RMDIR := rm -rf
NOOP := @:
PREFIX := $(HOME)/.local
endif
# 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 += 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
# STASH_SOURCEMAPS := true
@@ -53,8 +52,14 @@ ifndef COMPILER_IMAGE
COMPILER_IMAGE := ghcr.io/stashapp/compiler:latest
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
release: pre-ui generate ui build-release
release:
$(MAKE) pre-ui
$(MAKE) generate
$(MAKE) ui
$(MAKE) build-release
# targets to set various build flags
# use combinations on the make command-line to configure a build, e.g.:
@@ -98,7 +103,7 @@ flags-static-windows:
.PHONY: build-info
build-info:
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
ifndef GITHASH
$(eval GITHASH := $(shell git rev-parse --short HEAD))
@@ -283,7 +288,7 @@ generate: generate-backend generate-ui
.PHONY: generate-ui
generate-ui:
cd ui/v2.5 && npm run gqlgen
cd ui/v2.5 && pnpm run gqlgen
.PHONY: generate-backend
generate-backend: touch-ui
@@ -365,9 +370,6 @@ ui-env: build-info
$(eval export VITE_APP_DATE := $(BUILD_DATE))
$(eval export VITE_APP_GITHASH := $(GITHASH))
$(eval export VITE_APP_STASH_VERSION := $(STASH_VERSION))
ifdef STASH_NOLEGACY
$(eval export VITE_APP_NOLEGACY := true)
endif
ifdef STASH_SOURCEMAPS
$(eval export VITE_APP_SOURCEMAPS := true)
endif
@@ -377,7 +379,7 @@ ui: ui-only generate-login-locale
.PHONY: ui-only
ui-only: ui-env
cd ui/v2.5 && npm run build
cd ui/v2.5 && pnpm run build
.PHONY: zip-ui
zip-ui:
@@ -386,23 +388,23 @@ zip-ui:
.PHONY: ui-start
ui-start: ui-env
cd ui/v2.5 && npm run start -- --host
cd ui/v2.5 && pnpm run start --host
.PHONY: 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
.PHONY: 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
fmt-ui-quick:
cd ui/v2.5 && \
files=$$(git diff --name-only --relative --diff-filter d . ../../graphql); \
if [ -n "$$files" ]; then \
npm run prettier -- --write $$files; \
pnpm exec biome format --write $$files; \
fi
# does not run tsc checks, as they are slow
@@ -411,9 +413,9 @@ validate-ui-quick:
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"); \
prettyfiles=$$(git diff --name-only --relative --diff-filter d . ../../graphql); \
if [ -n "$$tsfiles" ]; then npm run eslint -- $$tsfiles; fi && \
if [ -n "$$scssfiles" ]; then npm run stylelint -- $$scssfiles; fi && \
if [ -n "$$prettyfiles" ]; then npm run prettier -- --check $$prettyfiles; fi
if [ -n "$$tsfiles" ]; then pnpm exec biome check $$tsfiles; fi && \
if [ -n "$$scssfiles" ]; then pnpm exec stylelint $$scssfiles; fi && \
if [ -n "$$prettyfiles" ]; then pnpm exec biome format $$prettyfiles; fi
# runs all of the backend PR-acceptance steps
.PHONY: validate-backend
@@ -444,4 +446,14 @@ start-compiler-container:
.PHONY: 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

View File

@@ -1,15 +1,16 @@
# 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)
[![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)
[![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)
[![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 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)
@@ -20,23 +21,24 @@
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/).
> [!important]
>**Windows Users**
> **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.
>
>**macOS 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.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
:---:|:---:|:---:|:---:
@@ -44,7 +46,7 @@ Step-by-step instructions are available at [docs.stashapp.cc/installation](https
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
@@ -57,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.
# 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`.
@@ -67,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:
- 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).
- 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>
# Translation
[![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
## Support & Resources
Need help or want to get involved? Start with the documentation, then reach out to the community if you need further assistance.
### Documentation
- [Official documentation](https://docs.stashapp.cc) - official guides guides and troubleshooting.
- [In-app manual](https://docs.stashapp.cc/in-app-manual) press <kbd>Shift</kbd> + <kbd>?</kbd> in the app or view the manual online.
- [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) - board-style community space.
### Community scrapers & plugins
### Community Scrapers & Plugins
- [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/)
# For Developers
## Architecture
Pull requests are welcome!
You can find an overview of Stash's architecture in the [ARCHITECTURE.md](docs/ARCHITECTURE.md) document.
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.
## 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

@@ -148,7 +148,7 @@ func recoverPanic() {
exitCode = 1
logger.Errorf("panic: %v\n%s", err, debug.Stack())
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
# 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
WORKDIR /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.
ARG CUDA_VERSION=12.8.0
ARG CUDA_VERSION=13.2.1
# Build Frontend
FROM node:20-alpine AS frontend
FROM node:24-alpine AS frontend
RUN apk add --no-cache make git
## cache node_modules separately
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
# 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
WORKDIR /stash
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/

View File

@@ -5,14 +5,14 @@ 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
ARG OSX_SDK_VERSION=11.3
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/phracker/MacOSX-SDKs/releases/download/${OSX_SDK_VERSION}/${OSX_SDK_DOWNLOAD_FILE}
ADD --checksum=sha256:cd4f08a75577145b8f05245a2975f7c81401d75e9535dcffbb879ee1deefcbf4 ${OSX_SDK_DOWNLOAD_URL} /tmp/osxcross/tarballs/${OSX_SDK_DOWNLOAD_FILE}
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}
ENV UNATTENDED=yes \
SDK_VERSION=${OSX_SDK_VERSION} \
OSX_VERSION_MIN=10.10
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
@@ -46,7 +46,7 @@ RUN cd /opt/cross-freebsd/usr/lib && \
ln -s libc++.so libstdc++.so
### BUILDER
FROM golang:1.24.3 AS builder
FROM golang:1.25.9 AS builder
ENV PATH=/opt/osx-ndk-x86/bin:$PATH
# copy in nodejs instead of using nodesource :thumbsup:

View File

@@ -1,7 +1,7 @@
host=ghcr.io
user=stashapp
repo=compiler
version=13
version=14
VERSION_IMAGE = ${host}/${user}/${repo}:${version}
LATEST_IMAGE = ${host}/${user}/${repo}:latest

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

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:
- 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
## AI Usage Policy
The core stash system is not intended for:
- managing downloading of content
- managing content on external websites
- publically sharing content
Please see our [AI Usage Policy](/docs/AI_POLICY.md) for guidelines on the use of AI in contributions to this project.
## Issues
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:
- support as many video and image formats as possible
- interfaces with external systems (for example stash-box) should be made as generic 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.
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.
## 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.**
- 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.

38
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/stashapp/stash
go 1.24.3
go 1.25.0
require (
github.com/99designs/gqlgen v0.17.73
@@ -15,6 +15,9 @@ require (
github.com/disintegration/imaging v1.6.2
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
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/cors v1.2.1
github.com/go-chi/httplog v0.3.1
@@ -30,7 +33,6 @@ require (
github.com/jinzhu/copier v0.4.0
github.com/jmoiron/sqlx v1.4.0
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/knadh/koanf/parsers/yaml v1.1.0
github.com/knadh/koanf/providers/env v1.1.0
@@ -48,7 +50,7 @@ require (
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cast v1.6.0
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/vearutop/statigz v1.4.0
github.com/vektah/dataloaden v0.3.0
@@ -56,12 +58,12 @@ require (
github.com/vektra/mockery/v2 v2.10.0
github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e
github.com/zencoder/go-dash/v3 v3.0.2
golang.org/x/crypto v0.45.0
golang.org/x/image v0.18.0
golang.org/x/net v0.47.0
golang.org/x/sys v0.38.0
golang.org/x/term v0.37.0
golang.org/x/text v0.31.0
golang.org/x/crypto v0.48.0
golang.org/x/image v0.38.0
golang.org/x/net v0.50.0
golang.org/x/sys v0.41.0
golang.org/x/term v0.40.0
golang.org/x/text v0.35.0
golang.org/x/time v0.10.0
gopkg.in/guregu/null.v4 v4.0.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
@@ -70,7 +72,8 @@ require (
require (
github.com/agnivade/levenshtein v1.2.1 // indirect
github.com/antchfx/xpath v1.3.5 // 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-astits v1.8.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
@@ -78,6 +81,10 @@ require (
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/davecgh/go-spew v1.1.1 // 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/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
@@ -92,6 +99,7 @@ require (
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/knadh/koanf/maps v0.1.2 // indirect
github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
@@ -106,6 +114,9 @@ require (
github.com/pelletier/go-toml/v2 v2.1.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
github.com/rs/zerolog v1.30.0 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/sosodev/duration v1.3.1 // indirect
@@ -118,12 +129,13 @@ require (
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.1 // 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
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.3 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/tools v0.38.0 // indirect
golang.org/x/mod v0.33.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/tools v0.42.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

81
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/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/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs=
github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
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/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/antchfx/htmlquery v1.3.5 h1:aYthDDClnG2a2xePf6tys/UyyM/kRcsFRm+ifhFKoU0=
github.com/antchfx/htmlquery v1.3.5/go.mod h1:5oyIPIa3ovYGtLqMPNjBF2Uf25NPCKsMjCnQ8lvjaoA=
github.com/antchfx/xpath v1.3.5 h1:PqbXLC3TkfeZyakF5eeh3NTWEbYl4VHNVeufANzDbKQ=
github.com/antchfx/xpath v1.3.5/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/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=
@@ -173,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/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=
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.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -187,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.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
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/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
@@ -389,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/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/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/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h+h41HNoMySoLVrhVR4=
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/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/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=
@@ -517,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.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
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/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo=
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.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.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
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.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
@@ -589,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.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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
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.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
@@ -614,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/go.mod h1:m/WO2UzWzqgVX3nvqpRQq70I4Z7jbSCRhdmkgtp+Ab4=
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/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/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.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -642,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.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
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/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
go.yaml.in/yaml/v3 v3.0.3 h1:bXOww4E/J3f66rav3pX3m8w6jDE4knZjGOw8b5Y6iNE=
@@ -667,8 +694,8 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
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-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -682,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-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.18.0 h1:jGzIakQa/ZXI1I0Fxvaa9W7yP25TqT6cHIHn+6CqvSQ=
golang.org/x/image v0.18.0/go.mod h1:4yyo5vMFQjVjUcVk4jEQcU9MGy/rulF5WvUILseCM2E=
golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE=
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-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
@@ -714,8 +741,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
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.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
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-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -770,8 +797,8 @@ 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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
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-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -806,8 +833,8 @@ 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.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -894,8 +921,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -905,8 +932,8 @@ 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.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
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.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -923,8 +950,8 @@ 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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
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-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -992,8 +1019,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
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-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

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

View File

@@ -93,6 +93,8 @@ type VideoFile implements BaseFile {
frame_rate: Float!
bit_rate: Int!
scenes: [Scene!]!
created_at: Time!
updated_at: Time!
}
@@ -118,6 +120,8 @@ type ImageFile implements BaseFile {
width: Int!
height: Int!
images: [Image!]!
created_at: Time!
updated_at: Time!
}
@@ -141,6 +145,8 @@ type GalleryFile implements BaseFile {
fingerprint(type: String!): String
fingerprints: [Fingerprint!]!
galleries: [Gallery!]!
created_at: Time!
updated_at: Time!
}

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ import (
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/signedurl"
)
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")
}
// 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 {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -42,6 +83,15 @@ func authenticateHandler() func(http.Handler) http.Handler {
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)
if err != nil {
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)
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)
}
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)
}
@@ -161,7 +161,7 @@ func makeGithubRequest(ctx context.Context, url string, output interface{}) erro
data, err := io.ReadAll(response.Body)
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)
}
@@ -295,10 +295,10 @@ func printLatestVersion(ctx context.Context) {
logger.Errorf("Couldn't retrieve latest version: %v", err)
} else {
_, githash, _ := build.Version()
switch {
case githash == "":
switch githash {
case "":
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)
default:
logger.Infof("New version available: %s (%s)", latestRelease.Version, latestRelease.ShortHash)

View File

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

View File

@@ -12,9 +12,8 @@
//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 FolderRelatedFolderIDsLoader github.com/stashapp/stash/pkg/models.FolderID []github.com/stashapp/stash/pkg/models.FolderID
//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 ImageFileIDsLoader 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 RelatedFileIDsLoader 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 SceneOCountLoader int int
//go:generate go run github.com/vektah/dataloaden ScenePlayCountLoader int int
@@ -44,7 +43,8 @@ const (
type Loaders struct {
SceneByID *SceneLoader
SceneFiles *SceneFileIDsLoader
SceneIDsByFileID *FileIDsRelatedIDsLoader
SceneFiles *RelatedFileIDsLoader
ScenePlayCount *ScenePlayCountLoader
SceneOCount *SceneOCountLoader
ScenePlayHistory *ScenePlayHistoryLoader
@@ -52,12 +52,14 @@ type Loaders struct {
SceneLastPlayed *SceneLastPlayedLoader
SceneCustomFields *CustomFieldsLoader
ImageFiles *ImageFileIDsLoader
GalleryFiles *GalleryFileIDsLoader
ImageFiles *RelatedFileIDsLoader
GalleryFiles *RelatedFileIDsLoader
GalleryByID *GalleryLoader
GalleryIDsByFileID *FileIDsRelatedIDsLoader
GalleryCustomFields *CustomFieldsLoader
ImageByID *ImageLoader
ImageIDsByFileID *FileIDsRelatedIDsLoader
ImageCustomFields *CustomFieldsLoader
PerformerByID *PerformerLoader
@@ -92,11 +94,21 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch,
fetch: m.fetchScenes(ctx),
},
SceneIDsByFileID: &FileIDsRelatedIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchSceneIDsByFileID(ctx),
},
GalleryByID: &GalleryLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchGalleries(ctx),
},
GalleryIDsByFileID: &FileIDsRelatedIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchGalleryIDsByFileID(ctx),
},
GalleryCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
@@ -107,6 +119,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch,
fetch: m.fetchImages(ctx),
},
ImageIDsByFileID: &FileIDsRelatedIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchImageIDsByFileID(ctx),
},
ImageCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
@@ -177,17 +194,17 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch,
fetch: m.fetchFoldersSubFolderIDs(ctx),
},
SceneFiles: &SceneFileIDsLoader{
SceneFiles: &RelatedFileIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchScenesFileIDs(ctx),
},
ImageFiles: &ImageFileIDsLoader{
ImageFiles: &RelatedFileIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchImagesFileIDs(ctx),
},
GalleryFiles: &GalleryFileIDsLoader{
GalleryFiles: &RelatedFileIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchGalleriesFileIDs(ctx),
@@ -247,6 +264,17 @@ 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 {
@@ -271,6 +299,17 @@ 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 {
@@ -295,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) {
return func(keys []int) (ret []*models.Performer, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {

View File

@@ -9,10 +9,10 @@ import (
"github.com/stashapp/stash/pkg/models"
)
// SceneFileIDsLoaderConfig captures the config to create a new SceneFileIDsLoader
type SceneFileIDsLoaderConfig struct {
// FileIDsRelatedIDsLoaderConfig captures the config to create a new FileIDsRelatedIDsLoader
type FileIDsRelatedIDsLoaderConfig struct {
// 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 time.Duration
@@ -21,19 +21,19 @@ type SceneFileIDsLoaderConfig struct {
MaxBatch int
}
// NewSceneFileIDsLoader creates a new SceneFileIDsLoader given a fetch, wait, and maxBatch
func NewSceneFileIDsLoader(config SceneFileIDsLoaderConfig) *SceneFileIDsLoader {
return &SceneFileIDsLoader{
// NewFileIDsRelatedIDsLoader creates a new FileIDsRelatedIDsLoader given a fetch, wait, and maxBatch
func NewFileIDsRelatedIDsLoader(config FileIDsRelatedIDsLoaderConfig) *FileIDsRelatedIDsLoader {
return &FileIDsRelatedIDsLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// SceneFileIDsLoader batches and caches requests
type SceneFileIDsLoader struct {
// FileIDsRelatedIDsLoader batches and caches requests
type FileIDsRelatedIDsLoader struct {
// 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
wait time.Duration
@@ -44,51 +44,51 @@ type SceneFileIDsLoader struct {
// INTERNAL
// 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,
// then everything will be sent to the fetch method and out to the listeners
batch *sceneFileIDsLoaderBatch
batch *fileIDsRelatedIDsLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type sceneFileIDsLoaderBatch struct {
keys []int
data [][]models.FileID
type fileIDsRelatedIDsLoaderBatch struct {
keys []models.FileID
data [][]int
error []error
closing bool
done chan struct{}
}
// Load a FileID by key, batching and caching will be applied automatically
func (l *SceneFileIDsLoader) Load(key int) ([]models.FileID, error) {
// Load a int by key, batching and caching will be applied automatically
func (l *FileIDsRelatedIDsLoader) Load(key models.FileID) ([]int, error) {
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
// different data loaders without blocking until the thunk is called.
func (l *SceneFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) {
func (l *FileIDsRelatedIDsLoader) LoadThunk(key models.FileID) func() ([]int, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() ([]models.FileID, error) {
return func() ([]int, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &sceneFileIDsLoaderBatch{done: make(chan struct{})}
l.batch = &fileIDsRelatedIDsLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() ([]models.FileID, error) {
return func() ([]int, error) {
<-batch.done
var data []models.FileID
var data []int
if pos < len(batch.data) {
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
// sub batches depending on how the loader is configured
func (l *SceneFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) {
results := make([]func() ([]models.FileID, error), len(keys))
func (l *FileIDsRelatedIDsLoader) LoadAll(keys []models.FileID) ([][]int, []error) {
results := make([]func() ([]int, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
fileIDs := make([][]models.FileID, len(keys))
ints := make([][]int, len(keys))
errors := make([]error, len(keys))
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
// different data loaders without blocking until the thunk is called.
func (l *SceneFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) {
results := make([]func() ([]models.FileID, error), len(keys))
func (l *FileIDsRelatedIDsLoader) LoadAllThunk(keys []models.FileID) func() ([][]int, []error) {
results := make([]func() ([]int, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([][]models.FileID, []error) {
fileIDs := make([][]models.FileID, len(keys))
return func() ([][]int, []error) {
ints := make([][]int, len(keys))
errors := make([]error, len(keys))
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
// and false is returned.
// (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 *FileIDsRelatedIDsLoader) Prime(key models.FileID, value []int) bool {
l.mu.Lock()
var found bool
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
// 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)
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
func (l *SceneFileIDsLoader) Clear(key int) {
func (l *FileIDsRelatedIDsLoader) Clear(key models.FileID) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *SceneFileIDsLoader) unsafeSet(key int, value []models.FileID) {
func (l *FileIDsRelatedIDsLoader) unsafeSet(key models.FileID, value []int) {
if l.cache == nil {
l.cache = map[int][]models.FileID{}
l.cache = map[models.FileID][]int{}
}
l.cache[key] = value
}
// keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch
func (b *sceneFileIDsLoaderBatch) keyIndex(l *SceneFileIDsLoader, key int) int {
func (b *fileIDsRelatedIDsLoaderBatch) keyIndex(l *FileIDsRelatedIDsLoader, key models.FileID) int {
for i, existingKey := range b.keys {
if key == existingKey {
return i
@@ -203,7 +203,7 @@ func (b *sceneFileIDsLoaderBatch) keyIndex(l *SceneFileIDsLoader, key int) int {
return pos
}
func (b *sceneFileIDsLoaderBatch) startTimer(l *SceneFileIDsLoader) {
func (b *fileIDsRelatedIDsLoaderBatch) startTimer(l *FileIDsRelatedIDsLoader) {
time.Sleep(l.wait)
l.mu.Lock()
@@ -219,7 +219,7 @@ func (b *sceneFileIDsLoaderBatch) startTimer(l *SceneFileIDsLoader) {
b.end(l)
}
func (b *sceneFileIDsLoaderBatch) end(l *SceneFileIDsLoader) {
func (b *fileIDsRelatedIDsLoaderBatch) end(l *FileIDsRelatedIDsLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -1,225 +0,0 @@
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
package loaders
import (
"sync"
"time"
"github.com/stashapp/stash/pkg/models"
)
// ImageFileIDsLoaderConfig captures the config to create a new ImageFileIDsLoader
type ImageFileIDsLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []int) ([][]models.FileID, []error)
// Wait is how long wait before sending a batch
Wait time.Duration
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
MaxBatch int
}
// NewImageFileIDsLoader creates a new ImageFileIDsLoader given a fetch, wait, and maxBatch
func NewImageFileIDsLoader(config ImageFileIDsLoaderConfig) *ImageFileIDsLoader {
return &ImageFileIDsLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// ImageFileIDsLoader batches and caches requests
type ImageFileIDsLoader struct {
// this method provides the data for the loader
fetch func(keys []int) ([][]models.FileID, []error)
// how long to done before sending a batch
wait time.Duration
// this will limit the maximum number of keys to send in one batch, 0 = no limit
maxBatch int
// INTERNAL
// lazily created cache
cache map[int][]models.FileID
// 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
batch *imageFileIDsLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type imageFileIDsLoaderBatch struct {
keys []int
data [][]models.FileID
error []error
closing bool
done chan struct{}
}
// Load a FileID by key, batching and caching will be applied automatically
func (l *ImageFileIDsLoader) Load(key int) ([]models.FileID, error) {
return l.LoadThunk(key)()
}
// 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
// different data loaders without blocking until the thunk is called.
func (l *ImageFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() ([]models.FileID, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &imageFileIDsLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() ([]models.FileID, error) {
<-batch.done
var data []models.FileID
if pos < len(batch.data) {
data = batch.data[pos]
}
var err error
// its convenient to be able to return a single error for everything
if len(batch.error) == 1 {
err = batch.error[0]
} else if batch.error != nil {
err = batch.error[pos]
}
if err == nil {
l.mu.Lock()
l.unsafeSet(key, data)
l.mu.Unlock()
}
return data, err
}
}
// LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured
func (l *ImageFileIDsLoader) LoadAll(keys []int) ([][]models.FileID, []error) {
results := make([]func() ([]models.FileID, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
fileIDs := make([][]models.FileID, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
fileIDs[i], errors[i] = thunk()
}
return fileIDs, errors
}
// 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
// different data loaders without blocking until the thunk is called.
func (l *ImageFileIDsLoader) LoadAllThunk(keys []int) func() ([][]models.FileID, []error) {
results := make([]func() ([]models.FileID, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([][]models.FileID, []error) {
fileIDs := make([][]models.FileID, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
fileIDs[i], errors[i] = thunk()
}
return fileIDs, errors
}
}
// Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned.
// (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 {
l.mu.Lock()
var found bool
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
// and end up with the whole cache pointing to the same value.
cpy := make([]models.FileID, len(value))
copy(cpy, value)
l.unsafeSet(key, cpy)
}
l.mu.Unlock()
return !found
}
// Clear the value at key from the cache, if it exists
func (l *ImageFileIDsLoader) Clear(key int) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *ImageFileIDsLoader) unsafeSet(key int, value []models.FileID) {
if l.cache == nil {
l.cache = map[int][]models.FileID{}
}
l.cache[key] = value
}
// keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch
func (b *imageFileIDsLoaderBatch) keyIndex(l *ImageFileIDsLoader, key int) int {
for i, existingKey := range b.keys {
if key == existingKey {
return i
}
}
pos := len(b.keys)
b.keys = append(b.keys, key)
if pos == 0 {
go b.startTimer(l)
}
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
if !b.closing {
b.closing = true
l.batch = nil
go b.end(l)
}
}
return pos
}
func (b *imageFileIDsLoaderBatch) startTimer(l *ImageFileIDsLoader) {
time.Sleep(l.wait)
l.mu.Lock()
// we must have hit a batch limit and are already finalizing this batch
if b.closing {
l.mu.Unlock()
return
}
l.batch = nil
l.mu.Unlock()
b.end(l)
}
func (b *imageFileIDsLoaderBatch) end(l *ImageFileIDsLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -9,8 +9,8 @@ import (
"github.com/stashapp/stash/pkg/models"
)
// GalleryFileIDsLoaderConfig captures the config to create a new GalleryFileIDsLoader
type GalleryFileIDsLoaderConfig struct {
// RelatedFileIDsLoaderConfig captures the config to create a new RelatedFileIDsLoader
type RelatedFileIDsLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []int) ([][]models.FileID, []error)
@@ -21,17 +21,17 @@ type GalleryFileIDsLoaderConfig struct {
MaxBatch int
}
// NewGalleryFileIDsLoader creates a new GalleryFileIDsLoader given a fetch, wait, and maxBatch
func NewGalleryFileIDsLoader(config GalleryFileIDsLoaderConfig) *GalleryFileIDsLoader {
return &GalleryFileIDsLoader{
// NewRelatedFileIDsLoader creates a new RelatedFileIDsLoader given a fetch, wait, and maxBatch
func NewRelatedFileIDsLoader(config RelatedFileIDsLoaderConfig) *RelatedFileIDsLoader {
return &RelatedFileIDsLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// GalleryFileIDsLoader batches and caches requests
type GalleryFileIDsLoader struct {
// RelatedFileIDsLoader batches and caches requests
type RelatedFileIDsLoader struct {
// this method provides the data for the loader
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,
// then everything will be sent to the fetch method and out to the listeners
batch *galleryFileIDsLoaderBatch
batch *relatedFileIDsLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type galleryFileIDsLoaderBatch struct {
type relatedFileIDsLoaderBatch struct {
keys []int
data [][]models.FileID
error []error
@@ -63,14 +63,14 @@ type galleryFileIDsLoaderBatch struct {
}
// 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)()
}
// 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
// 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()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
@@ -79,7 +79,7 @@ func (l *GalleryFileIDsLoader) LoadThunk(key int) func() ([]models.FileID, error
}
}
if l.batch == nil {
l.batch = &galleryFileIDsLoaderBatch{done: make(chan struct{})}
l.batch = &relatedFileIDsLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
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
// 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))
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.
// 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.
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))
for i, key := range keys {
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
// and false is returned.
// (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()
var found bool
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
func (l *GalleryFileIDsLoader) Clear(key int) {
func (l *RelatedFileIDsLoader) Clear(key int) {
l.mu.Lock()
delete(l.cache, key)
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 {
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
// 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 {
if key == existingKey {
return i
@@ -203,7 +203,7 @@ func (b *galleryFileIDsLoaderBatch) keyIndex(l *GalleryFileIDsLoader, key int) i
return pos
}
func (b *galleryFileIDsLoaderBatch) startTimer(l *GalleryFileIDsLoader) {
func (b *relatedFileIDsLoaderBatch) startTimer(l *RelatedFileIDsLoader) {
time.Sleep(l.wait)
l.mu.Lock()
@@ -219,7 +219,7 @@ func (b *galleryFileIDsLoaderBatch) startTimer(l *GalleryFileIDsLoader) {
b.end(l)
}
func (b *galleryFileIDsLoaderBatch) end(l *GalleryFileIDsLoader) {
func (b *relatedFileIDsLoaderBatch) end(l *RelatedFileIDsLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

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

View File

@@ -40,10 +40,32 @@ func (r *imageFileResolver) ParentFolder(ctx context.Context, obj *ImageFile) (*
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) {
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) {
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)
}
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) {
return zipFileResolver(ctx, obj.ZipFileID)
}

View File

@@ -9,6 +9,8 @@ import (
"github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/internal/manager"
"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) {
@@ -107,15 +109,38 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*ScenePat
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
config := manager.GetInstance().Config
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()
previewPath := builder.GetStreamPreviewURL()
streamPath := builder.GetStreamURL(config.GetAPIKey()).String()
webpPath := builder.GetStreamPreviewImageURL()
objHash := obj.GetHash(config.GetVideoFileNamingAlgorithm())
vttPath := builder.GetSpriteVTTURL(objHash)
spritePath := builder.GetSpriteURL(objHash)
funscriptPath := builder.GetFunscriptURL()
captionBasePath := builder.GetCaptionURL()
funscriptPath := builder.GetFunscriptURL(config.GetAPIKey()).String()
interactiveHeatmap := builder.GetInteractiveHeatmapURL()
return &ScenePathsType{
@@ -294,9 +319,25 @@ func (r *sceneResolver) SceneStreams(ctx context.Context, obj *models.Scene) ([]
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
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) {

View File

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

View File

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

View File

@@ -654,7 +654,7 @@ func (r *mutationResolver) PerformerMerge(ctx context.Context, input PerformerMe
}
legacyURLs := legacyPerformerURLsFromInput(*input.Values, translator)
if legacyURLs.AnySet() {
return nil, errors.New("Merging legacy performer URLs is not supported")
return nil, errors.New("merging legacy performer URLs is not supported")
}
if input.Values.Image != nil {

View File

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

View File

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

View File

@@ -33,15 +33,26 @@ func (r *queryResolver) FindJob(ctx context.Context, input FindJobInput) (*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{
ID: strconv.Itoa(j.ID),
Status: JobStatus(j.Status),
Description: j.Description,
SubTasks: j.Details,
Description: sanitiseWebsocketString(j.Description),
SubTasks: subTasks,
StartTime: j.StartTime,
EndTime: j.EndTime,
AddTime: j.AddTime,
Error: j.Error,
Error: jobError,
}
if j.Progress != -1 {

View File

@@ -8,6 +8,8 @@ import (
"github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/internal/manager"
"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) {
@@ -39,7 +41,22 @@ func (r *queryResolver) SceneStreams(ctx context.Context, id *string) ([]*manage
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
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

@@ -2,11 +2,19 @@ package api
import (
"context"
"strings"
"github.com/stashapp/stash/internal/log"
"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 {
switch logType {
case "progress":
@@ -33,7 +41,7 @@ func logEntriesFromLogItems(logItems []log.LogItem) []*LogEntry {
ret[i] = &LogEntry{
Time: entry.Time,
Level: getLogLevel(entry.Type),
Message: entry.Message,
Message: sanitiseWebsocketString(entry.Message),
}
}

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 {
return b.BaseURL + "/image/" + b.ImageID + "/image?t=" + b.UpdatedAt
return b.BaseURL + "/image/" + b.ImageID + "/image?t=" + b.cacheBuster()
}
func (b ImageURLBuilder) GetThumbnailURL() string {
return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?t=" + b.UpdatedAt
return b.BaseURL + "/image/" + b.ImageID + "/thumbnail?t=" + b.cacheBuster()
}
func (b ImageURLBuilder) GetPreviewURL() string {
if exists, err := fsutil.FileExists(manager.GetInstance().Paths.Generated.GetClipPreviewPath(b.Checksum, models.DefaultGthumbWidth)); exists && err == nil {
return b.BaseURL + "/image/" + b.ImageID + "/preview?" + b.UpdatedAt
return b.BaseURL + "/image/" + b.ImageID + "/preview?t=" + b.cacheBuster()
} else {
return ""
}

View File

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

View File

@@ -57,12 +57,28 @@ func (b SceneURLBuilder) GetScreenshotURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/screenshot?t=" + b.UpdatedAt
}
func (b SceneURLBuilder) GetFunscriptURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/funscript"
func (b SceneURLBuilder) GetFunscriptURL(apiKey string) *url.URL {
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 {
return b.BaseURL + "/scene/" + b.SceneID + "/caption"
return b.BaseURL + b.GetCaptionPath()
}
func (b SceneURLBuilder) GetInteractiveHeatmapURL() string {

View File

@@ -8,7 +8,7 @@ import (
"os"
"os/exec"
gosxnotifier "github.com/kermieisinthehouse/gosx-notifier"
gosxnotifier "github.com/feederbox826/gosx-notifier"
"github.com/stashapp/stash/pkg/logger"
)

View File

@@ -33,8 +33,10 @@ type MetadataOptions struct {
SetCoverImage *bool `json:"setCoverImage"`
SetOrganized *bool `json:"setOrganized"`
// defaults to true if not provided
// Deprecated: use PerformerGenders instead
IncludeMalePerformers *bool `json:"includeMalePerformers"`
// Filter to only include performers with these genders. If not provided, all genders are included.
PerformerGenders []models.GenderEnum `json:"performerGenders"`
// defaults to true if not provided

View File

@@ -22,7 +22,8 @@ type SceneMissingHashCounter interface {
// will ensure that all oshash values are set on all scenes.
func ValidateVideoFileNamingAlgorithm(ctx context.Context, qb SceneMissingHashCounter, newValue models.HashAlgorithm) error {
// if algorithm is being set to MD5, then all checksums must be present
if newValue == models.HashAlgorithmMd5 {
switch newValue {
case models.HashAlgorithmMd5:
missingMD5, err := qb.CountMissingChecksum(ctx)
if err != nil {
return err
@@ -31,7 +32,7 @@ func ValidateVideoFileNamingAlgorithm(ctx context.Context, qb SceneMissingHashCo
if missingMD5 > 0 {
return errors.New("some checksums are missing on scenes. Run Scan with calculateMD5 set to true")
}
} else if newValue == models.HashAlgorithmOshash {
case models.HashAlgorithmOshash:
missingOSHash, err := qb.CountMissingOSHash(ctx)
if err != nil {
return err

View File

@@ -10,6 +10,7 @@ import (
"runtime"
"strconv"
"strings"
"time"
"sync"
// "github.com/sasha-s/go-deadlock" // if you have deadlock issues
@@ -43,6 +44,9 @@ const (
Password = "password"
MaxSessionAge = "max_session_age"
SignedURLExpiry = "signed_url_expiry"
signedURLExpiryDefault = 60 * 60 * 4 // 4 hours in seconds
// SFWContentMode mode config key
SFWContentMode = "sfw_content_mode"
@@ -1229,6 +1233,21 @@ func (i *Config) GetMaxSessionAge() int {
return ret
}
// GetSignedURLExpiry gets the expiry time for signed URLs, in seconds.
// Defaults to 4 hours to accommodate long video playback sessions.
func (i *Config) GetSignedURLExpiry() time.Duration {
i.RLock()
defer i.RUnlock()
ret := signedURLExpiryDefault * time.Second
v := i.forKey(SignedURLExpiry)
if v.Exists(SignedURLExpiry) {
ret = time.Duration(v.Int(SignedURLExpiry)) * time.Second
}
return ret
}
// GetCustomServedFolders gets the map of custom paths to their applicable
// filesystem locations
func (i *Config) GetCustomServedFolders() utils.URLMap {

View File

@@ -48,6 +48,12 @@ type Action struct {
Speed float64
}
var utf8BOM = []byte{0xEF, 0xBB, 0xBF}
func unmarshalFunscriptData(data []byte, funscript *Script) error {
return json.Unmarshal(bytes.TrimPrefix(data, utf8BOM), funscript)
}
type GradientTable []struct {
Col colorful.Color
Pos float64
@@ -96,7 +102,7 @@ func (g *InteractiveHeatmapSpeedGenerator) LoadFunscriptData(path string, sceneD
}
var funscript Script
err = json.Unmarshal(data, &funscript)
err = unmarshalFunscriptData(data, &funscript)
if err != nil {
return Script{}, err
}
@@ -370,7 +376,7 @@ func LoadFunscriptData(path string) (Script, error) {
}
var funscript Script
err = json.Unmarshal(data, &funscript)
err = unmarshalFunscriptData(data, &funscript)
if err != nil {
return Script{}, err
}
@@ -408,7 +414,7 @@ func ConvertFunscriptToCSV(funscriptPath string) ([]byte, error) {
}
// I don't know whether the csv format requires int or float, so for now we'll use int
buffer.WriteString(fmt.Sprintf("%d,%d\r\n", int(math.Round(action.At)), pos))
fmt.Fprintf(&buffer, "%d,%d\r\n", int(math.Round(action.At)), pos)
}
return buffer.Bytes(), nil
}

View File

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

View File

@@ -76,9 +76,10 @@ func performImport(ctx context.Context, i importer, duplicateBehaviour ImportDup
var id int
if existing != nil {
if duplicateBehaviour == ImportDuplicateEnumFail {
switch duplicateBehaviour {
case ImportDuplicateEnumFail:
return fmt.Errorf("existing object with name '%s'", name)
} else if duplicateBehaviour == ImportDuplicateEnumIgnore {
case ImportDuplicateEnumIgnore:
logger.Infof("Skipping existing object %q", name)
return nil
}

View File

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

View File

@@ -313,9 +313,36 @@ func (j *CleanGeneratedJob) cleanBlobFiles(ctx context.Context, progress *job.Pr
return err
}
// remove empty hash prefix subdirectories
j.removeEmptyDirs(j.Paths.Blobs)
return nil
}
func (j *CleanGeneratedJob) removeEmptyDirs(root string) {
entries, err := os.ReadDir(root)
if err != nil {
return
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
dirPath := filepath.Join(root, entry.Name())
subEntries, err := os.ReadDir(dirPath)
if err != nil {
continue
}
if len(subEntries) == 0 {
j.logDelete("removing empty directory: %s", entry.Name())
j.deleteDir(dirPath)
}
}
}
func (j *CleanGeneratedJob) getScenesWithHash(ctx context.Context, hash string) ([]*models.Scene, error) {
fp := models.Fingerprint{
Fingerprint: hash,
@@ -637,6 +664,8 @@ func (j *CleanGeneratedJob) cleanMarkerFiles(ctx context.Context, progress *job.
return err
}
j.removeEmptyDirs(j.Paths.Generated.Markers)
return nil
}
@@ -730,5 +759,7 @@ func (j *CleanGeneratedJob) cleanThumbnailFiles(ctx context.Context, progress *j
return err
}
j.removeEmptyDirs(j.Paths.Generated.Thumbnails)
return nil
}

View File

@@ -7,6 +7,8 @@ import (
"os"
"path/filepath"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/sqlite"
@@ -29,6 +31,21 @@ type databaseSchemaInfo struct {
StepsRequired uint
}
// PreExecute validates the environment before executing the migration.
// It returns an error if the migration cannot be performed.
func (s *MigrateJob) PreExecute() error {
// ensure backup directory exists and is writable
backupDir := s.Config.GetBackupDirectoryPathOrDefault()
if backupDir != "" {
if err := fsutil.EnsureDir(backupDir); err != nil {
logger.Errorf("error ensuring backup directory exists: %s", err)
logger.Warnf("Backup directory (%s) must be modified to a valid directory or removed from the config file", config.BackupDirectoryPath)
return fmt.Errorf("error creating backup directory: %w", err)
}
}
return nil
}
func (s *MigrateJob) Execute(ctx context.Context, progress *job.Progress) error {
schemaInfo, err := s.required()
if err != nil {

View File

@@ -262,7 +262,9 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
for f := range queue {
if job.IsCancelled(ctx) {
break
// keep draining the queue so the producer goroutine can finish
// and release its read transaction, otherwise the DB stays locked
continue
}
wg.Add()

View File

@@ -25,7 +25,11 @@ func (t *GenerateClipPreviewTask) Start(ctx context.Context) {
}
prevPath := GetInstance().Paths.Generated.GetClipPreviewPath(t.Image.Checksum, models.DefaultGthumbWidth)
filePath := t.Image.Files.Primary().Base().Path
f := t.Image.Files.Primary()
if f == nil {
return
}
filePath := f.Base().Path
clipPreviewOptions := image.ClipPreviewOptions{
InputArgs: GetInstance().Config.GetTranscodeInputArgs(),

View File

@@ -35,6 +35,9 @@ func (t *GenerateImageThumbnailTask) Start(ctx context.Context) {
thumbPath := GetInstance().Paths.Generated.GetThumbnailPath(t.Image.Checksum, models.DefaultGthumbWidth)
f := t.Image.Files.Primary()
if f == nil {
return
}
path := f.Base().Path
logger.Debugf("Generating thumbnail for %s", path)

View File

@@ -45,6 +45,9 @@ func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) {
r := t.repository
if err := r.WithTxn(ctx, func(ctx context.Context) error {
primaryFile := t.Scene.Files.Primary()
if primaryFile == nil {
return nil
}
primaryFile.InteractiveSpeed = &median
if err := r.File.Update(ctx, primaryFile); err != nil {
return fmt.Errorf("updating interactive speed for %s: %w", primaryFile.Path, err)

View File

@@ -162,34 +162,35 @@ func (t *ImportTask) unzipFile() error {
continue
}
if err := os.MkdirAll(filepath.Dir(fn), os.ModePerm); err != nil {
if err := t.unzipFileEntry(f, fn); err != nil {
return err
}
o, err := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
i, err := f.Open()
if err != nil {
o.Close()
return err
}
if _, err := io.Copy(o, i); err != nil {
o.Close()
i.Close()
return err
}
o.Close()
i.Close()
}
return nil
}
func (t *ImportTask) unzipFileEntry(f *zip.File, fn string) error {
if err := os.MkdirAll(filepath.Dir(fn), os.ModePerm); err != nil {
return err
}
o, err := os.OpenFile(fn, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode())
if err != nil {
return err
}
defer o.Close()
i, err := f.Open()
if err != nil {
return err
}
defer i.Close()
_, err = io.Copy(o, i)
return err
}
func (t *ImportTask) ImportPerformers(ctx context.Context) {
logger.Info("[performers] importing")

View File

@@ -35,7 +35,7 @@ func (j *OptimiseDatabaseJob) Execute(ctx context.Context, progress *job.Progres
return nil
}
if err != nil {
return fmt.Errorf("Error analyzing database: %w", err)
return fmt.Errorf("error analyzing database: %w", err)
}
progress.ExecuteTask("Vacuuming database", func() {

View File

@@ -20,12 +20,12 @@ func (s *Manager) RunPluginTask(
pluginProgress := make(chan float64)
task, err := s.PluginCache.CreateTask(ctx, pluginID, taskName, args, pluginProgress)
if err != nil {
return fmt.Errorf("Error creating plugin task: %w", err)
return fmt.Errorf("error creating plugin task: %w", err)
}
err = task.Start()
if err != nil {
return fmt.Errorf("Error running plugin task: %w", err)
return fmt.Errorf("error running plugin task: %w", err)
}
done := make(chan bool)

View File

@@ -283,8 +283,10 @@ func (j *ScanJob) processQueue(ctx context.Context, parallelTasks int, progress
for f := range j.fileQueue {
logger.Tracef("Processing queued file %s", f.Path)
if err := ctx.Err(); err != nil {
return
if ctx.Err() != nil {
// Keep receiving until queueFiles closes the channel; otherwise
// the walker can block on send (full buffer) and never finish.
continue
}
wg.Add()
@@ -379,9 +381,11 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress *
// handle rename should have already handled the contents of the zip file
// so shouldn't need to scan it again.
// Only scan zip contents if the file is new, the fingerprint changed,
// or if a force rescan was requested.
// if a force rescan was requested, or if the handler was required because
// a related object (e.g. a deleted gallery) is missing and needs to be
// recreated from the contents.
if j.scanner.IsZipFile(f.Info.Name()) && (r.New || r.FingerprintChanged || j.scanner.Rescan) {
if j.scanner.IsZipFile(f.Info.Name()) && (r.New || r.FingerprintChanged || j.scanner.Rescan || r.HandlerRequired) {
ff := r.File
f.BaseFile = ff.Base()

View File

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

View File

@@ -33,6 +33,9 @@ func (t *GenerateTranscodeTask) Start(ctx context.Context) {
}
f := t.Scene.Files.Primary()
if f == nil {
return
}
ffprobe := instance.FFProbe
var container ffmpeg.Container

View File

@@ -45,13 +45,13 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
// log if the initialization takes too long
const hwInitLogTimeoutSecondsDefault = 5
hwInitLogTimeoutSeconds := hwInitLogTimeoutSecondsDefault * time.Second
timer := time.NewTimer(hwInitLogTimeoutSeconds)
hwInitLogTimeout := hwInitLogTimeoutSecondsDefault * time.Second
timer := time.NewTimer(hwInitLogTimeout)
go func() {
select {
case <-timer.C:
logger.Warnf("[InitHWSupport] Hardware codec initialization is taking longer than %s...", hwInitLogTimeoutSeconds)
logger.Warnf("[InitHWSupport] Hardware codec initialization is taking longer than %s...", hwInitLogTimeout)
logger.Info("[InitHWSupport] Hardware encoding will not be available until initialization is complete.")
case <-done:
if !timer.Stop() {
@@ -96,16 +96,16 @@ func (f *FFMpeg) initHWSupport(ctx context.Context) {
// #6064 - add timeout to context to prevent hangs
const hwTestTimeoutSecondsDefault = 10
hwTestTimeoutSeconds := hwTestTimeoutSecondsDefault * time.Second
hwTestTimeout := hwTestTimeoutSecondsDefault * time.Second
// allow timeout to be overridden with environment variable
if timeout := os.Getenv("STASH_HW_TEST_TIMEOUT"); timeout != "" {
if seconds, err := strconv.Atoi(timeout); err == nil {
hwTestTimeoutSeconds = time.Duration(seconds) * time.Second
hwTestTimeout = time.Duration(seconds) * time.Second
}
}
testCtx, cancel := context.WithTimeout(ctx, hwTestTimeoutSeconds)
testCtx, cancel := context.WithTimeout(ctx, hwTestTimeout)
defer cancel()
cmd := f.Command(testCtx, args)
@@ -117,7 +117,7 @@ func (f *FFMpeg) initHWSupport(ctx context.Context) {
if err := cmd.Run(); err != nil {
if testCtx.Err() != nil {
logger.Debugf("[InitHWSupport] Codec %s test timed out after %s", codec, hwTestTimeoutSeconds)
logger.Debugf("[InitHWSupport] Codec %s test timed out after %s", codec, hwTestTimeout)
continue
}

View File

@@ -20,6 +20,7 @@ import (
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/signedurl"
"github.com/stashapp/stash/pkg/utils"
"github.com/zencoder/go-dash/v3/mpd"
@@ -433,26 +434,21 @@ func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request,
baseURL := prefix + baseUrl.String()
urlQuery := url.Values{}
apikey := r.URL.Query().Get(apiKeyParamKey)
copyAuthParams(urlQuery, r.URL.Query())
if resolution != "" {
urlQuery.Set(resolutionParamKey, resolution)
}
// TODO - this needs to be handled outside of this package
if apikey != "" {
urlQuery.Set(apiKeyParamKey, apikey)
}
urlQueryString := ""
segQuery := ""
if len(urlQuery) > 0 {
urlQueryString = "?" + urlQuery.Encode()
segQuery = "?" + urlQuery.Encode()
}
var buf bytes.Buffer
fmt.Fprint(&buf, "#EXTM3U\n")
fmt.Fprint(&buf, "#EXT-X-VERSION:3\n")
fmt.Fprint(&buf, "#EXT-X-MEDIA-SEQUENCE:0\n")
fmt.Fprintf(&buf, "#EXT-X-TARGETDURATION:%d\n", segmentLength)
@@ -468,7 +464,7 @@ func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request,
}
fmt.Fprintf(&buf, "#EXTINF:%f,\n", thisLength)
fmt.Fprintf(&buf, "%s/%d.ts%s\n", baseURL, segment, urlQueryString)
fmt.Fprintf(&buf, "%s/%d.ts%s\n", baseURL, segment, segQuery)
leftover -= thisLength
segment++
@@ -480,6 +476,25 @@ func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request,
utils.ServeStaticContent(w, r, buf.Bytes())
}
// Forward auth params to segment URLs. API key takes precedence
// over signed params since it is explicitly configured by the user.
// TODO - this needs to be handled outside of this package
func copyAuthParams(dest url.Values, src url.Values) {
apikey := src.Get(apiKeyParamKey)
if apikey != "" {
dest.Set(apiKeyParamKey, apikey)
} else {
cid := src.Get(signedurl.CIDParam)
expires := src.Get(signedurl.ExpiresParam)
sig := src.Get(signedurl.SigParam)
if cid != "" && expires != "" && sig != "" {
dest.Set(signedurl.CIDParam, cid)
dest.Set(signedurl.ExpiresParam, expires)
dest.Set(signedurl.SigParam, sig)
}
}
}
// serveDASHManifest serves a generated DASH manifest.
func serveDASHManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request, vf *models.VideoFile, resolution string) {
if sm.cacheDir == "" {
@@ -529,11 +544,10 @@ func serveDASHManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request
urlQuery := url.Values{}
// Forward auth params to segment URLs. API key takes precedence
// over signed params since it is explicitly configured by the user.
// TODO - this needs to be handled outside of this package
apikey := r.URL.Query().Get(apiKeyParamKey)
if apikey != "" {
urlQuery.Set(apiKeyParamKey, apikey)
}
copyAuthParams(urlQuery, r.URL.Query())
maxTranscodeSize := sm.config.GetMaxStreamingTranscodeSize().GetMaxResolution()
if resolution != "" {

View File

@@ -19,6 +19,9 @@ type ScreenshotOptions struct {
Verbosity ffmpeg.LogLevel
UseSelectFilter bool
// SlowSeek uses accurate seek by placing -ss after the input.
SlowSeek bool
}
func (o *ScreenshotOptions) setDefaults() {
@@ -60,9 +63,14 @@ func ScreenshotTime(input string, t float64, options ScreenshotOptions) ffmpeg.A
var args ffmpeg.Args
args = args.LogLevel(options.Verbosity)
args = args.Overwrite()
args = args.Seek(t)
if !options.SlowSeek {
args = args.Seek(t)
}
args = args.Input(input)
if options.SlowSeek {
args = args.Seek(t)
}
args = args.VideoFrames(1)
if options.Quality > 0 {

View File

@@ -0,0 +1,51 @@
package transcoder
import (
"reflect"
"testing"
)
func TestScreenshotTimeDefaultUsesFastSeek(t *testing.T) {
options := ScreenshotOptions{
OutputPath: "out.jpg",
OutputType: ScreenshotOutputTypeImage2,
}
got := ScreenshotTime("input.webm", 12.5, options)
want := []string{
"-v", "error",
"-y",
"-ss", "12.5",
"-i", "input.webm",
"-frames:v", "1",
"-f", "image2",
"out.jpg",
}
if !reflect.DeepEqual([]string(got), want) {
t.Fatalf("ScreenshotTime() = %#v, want %#v", []string(got), want)
}
}
func TestScreenshotTimeSlowSeek(t *testing.T) {
options := ScreenshotOptions{
OutputPath: "out.jpg",
OutputType: ScreenshotOutputTypeImage2,
SlowSeek: true,
}
got := ScreenshotTime("input.webm", 12.5, options)
want := []string{
"-v", "error",
"-y",
"-i", "input.webm",
"-ss", "12.5",
"-frames:v", "1",
"-f", "image2",
"out.jpg",
}
if !reflect.DeepEqual([]string(got), want) {
t.Fatalf("ScreenshotTime() = %#v, want %#v", []string(got), want)
}
}

View File

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

View File

@@ -142,6 +142,8 @@ func (f *StashIgnoreFilter) collectIgnoreEntries(dir string, libraryRoot string)
current := dir
for {
// Check if we're still within the library root.
// nolint:staticcheck // QF1006 - we could make this the for condition
// but I don't think it improves readability
if !isPathInOrEqual(libraryRoot, current) {
break
}

View File

@@ -37,11 +37,12 @@ func Generate(encoder *ffmpeg.FFMpeg, videoFile *models.VideoFile) (*uint64, err
return &hashValue, nil
}
func generateSpriteScreenshot(encoder *ffmpeg.FFMpeg, input string, t float64) (image.Image, error) {
func generateSpriteScreenshot(encoder *ffmpeg.FFMpeg, input string, t float64, slowSeek bool) (image.Image, error) {
options := transcoder.ScreenshotOptions{
Width: screenshotSize,
OutputPath: "-",
OutputType: transcoder.ScreenshotOutputTypeBMP,
SlowSeek: slowSeek,
}
args := transcoder.ScreenshotTime(input, t, options)
@@ -84,10 +85,18 @@ func generateSprite(encoder *ffmpeg.FFMpeg, videoFile *models.VideoFile) (image.
offset := 0.05 * videoFile.Duration
stepSize := (0.9 * videoFile.Duration) / float64(chunkCount)
var images []image.Image
slowSeek := false
for i := 0; i < chunkCount; i++ {
time := offset + (float64(i) * stepSize)
img, err := generateSpriteScreenshot(encoder, videoFile.Path, time)
img, err := generateSpriteScreenshot(encoder, videoFile.Path, time, slowSeek)
if err != nil && !slowSeek {
logger.Warnf("[generator] fast phash screenshot seek failed for %s at %.3fs, retrying with accurate seek for remaining phash screenshots: %v", videoFile.Path, time, err)
slowSeek = true
img, err = generateSpriteScreenshot(encoder, videoFile.Path, time, slowSeek)
}
if err != nil {
return nil, fmt.Errorf("generating sprite screenshot: %w", err)
}

View File

@@ -69,19 +69,19 @@ type ScanHandler struct {
func (h *ScanHandler) validate() error {
if h.CreatorUpdater == nil {
return errors.New("CreatorUpdater is required")
return errors.New("internal error: CreatorUpdater is required")
}
if h.ScanGenerator == nil {
return errors.New("ScanGenerator is required")
return errors.New("internal error: ScanGenerator is required")
}
if h.GalleryFinder == nil {
return errors.New("GalleryFinder is required")
return errors.New("internal error: GalleryFinder is required")
}
if h.ScanConfig == nil {
return errors.New("ScanConfig is required")
return errors.New("internal error: ScanConfig is required")
}
if h.Paths == nil {
return errors.New("Paths is required")
return errors.New("internal error: Paths is required")
}
return nil
@@ -375,13 +375,13 @@ func (h *ScanHandler) getOrCreateGallery(ctx context.Context, f models.File) (*m
if _, err := os.Stat(filepath.Join(folderPath, ".forcegallery")); err == nil {
forceGallery = true
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("Could not test Path %s: %w", folderPath, err)
return nil, fmt.Errorf("could not test Path %s: %w", folderPath, err)
}
exemptGallery := false
if _, err := os.Stat(filepath.Join(folderPath, ".nogallery")); err == nil {
exemptGallery = true
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("Could not test Path %s: %w", folderPath, err)
return nil, fmt.Errorf("could not test Path %s: %w", folderPath, err)
}
if forceGallery || (h.ScanConfig.GetCreateGalleriesFromFolders() && !exemptGallery) {

View File

@@ -66,6 +66,23 @@ type Job struct {
cancelFunc context.CancelFunc
}
// statusCopy returns a copy of the Job with only the fields needed for
// status reporting. Internal fields (exec, cancelFunc, outerCtx) are
// excluded so that subscription channels don't retain heavy resources.
func (j *Job) statusCopy() Job {
return Job{
ID: j.ID,
Status: j.Status,
Details: j.Details,
Description: j.Description,
Progress: j.Progress,
StartTime: j.StartTime,
EndTime: j.EndTime,
AddTime: j.AddTime,
Error: j.Error,
}
}
// TimeElapsed returns the total time elapsed for the job.
// If the EndTime is set, then it uses this to calculate the elapsed time, otherwise it uses time.Now.
func (j *Job) TimeElapsed() time.Duration {
@@ -80,9 +97,10 @@ func (j *Job) TimeElapsed() time.Duration {
}
func (j *Job) cancel() {
if j.Status == StatusReady {
switch j.Status {
case StatusReady:
j.Status = StatusCancelled
} else if j.Status == StatusRunning {
case StatusRunning:
j.Status = StatusStopping
}

View File

@@ -105,7 +105,7 @@ func (m *Manager) notifyNewJob(j *Job) {
for _, s := range m.subscriptions {
// don't block if channel is full
select {
case s.newJob <- *j:
case s.newJob <- j.statusCopy():
default:
}
}
@@ -232,7 +232,9 @@ func (m *Manager) removeJob(job *Job) {
return
}
// clear any subtasks
// release the executor and subtask details so they can be GC'd
// while the job remains in the graveyard for status reporting
job.exec = nil
job.Details = nil
m.queue = append(m.queue[:index], m.queue[index+1:]...)
@@ -246,7 +248,7 @@ func (m *Manager) removeJob(job *Job) {
for _, s := range m.subscriptions {
// don't block if channel is full
select {
case s.removedJob <- *job:
case s.removedJob <- job.statusCopy():
default:
}
}
@@ -310,8 +312,7 @@ func (m *Manager) GetJob(id int) *Job {
// get from the queue or graveyard
_, j := m.getJob(append(m.queue, m.graveyard...), id)
if j != nil {
// make a copy of the job and return the pointer
jCopy := *j
jCopy := j.statusCopy()
return &jCopy
}
@@ -326,8 +327,7 @@ func (m *Manager) GetQueue() []Job {
var ret []Job
for _, j := range m.queue {
jCopy := *j
ret = append(ret, jCopy)
ret = append(ret, j.statusCopy())
}
return ret
@@ -372,7 +372,7 @@ func (m *Manager) notifyJobUpdate(j *Job) {
for _, s := range m.subscriptions {
// don't block if channel is full
select {
case s.updatedJob <- *j:
case s.updatedJob <- j.statusCopy():
default:
}
}

View File

@@ -51,7 +51,7 @@ func (tq *TaskQueue) executer(ctx context.Context) {
defer tq.wg.Wait()
for task := range tq.tasks {
if IsCancelled(ctx) {
return
continue // allow channel to continue draining until Close()
}
tt := task

View File

@@ -22,7 +22,7 @@ type GroupNamesFinder interface {
type SceneRelationships struct {
PerformerFinder PerformerFinder
TagFinder models.TagQueryer
TagFinder models.TagNameFinder
StudioFinder StudioFinder
}
@@ -189,7 +189,7 @@ func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, na
}
// ScrapedTagHierarchy executes ScrapedTag for the provided tag and its parent.
func ScrapedTagHierarchy(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {
func ScrapedTagHierarchy(ctx context.Context, qb models.TagNameFinder, s *models.ScrapedTag, stashBoxEndpoint string) error {
if err := ScrapedTag(ctx, qb, s, stashBoxEndpoint); err != nil {
return err
}
@@ -204,7 +204,7 @@ func ScrapedTagHierarchy(ctx context.Context, qb models.TagQueryer, s *models.Sc
// ScrapedTag matches the provided tag with the tags
// in the database and sets the ID field if one is found.
func ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {
func ScrapedTag(ctx context.Context, qb models.TagNameFinder, s *models.ScrapedTag, stashBoxEndpoint string) error {
if s.StoredID != nil {
return nil
}

View File

@@ -524,6 +524,29 @@ func (_m *GalleryReaderWriter) GetManyFileIDs(ctx context.Context, ids []int) ([
return r0, r1
}
// GetManyIDsByFileIDs provides a mock function with given fields: ctx, fileIDs
func (_m *GalleryReaderWriter) GetManyIDsByFileIDs(ctx context.Context, fileIDs []models.FileID) ([][]int, error) {
ret := _m.Called(ctx, fileIDs)
var r0 [][]int
if rf, ok := ret.Get(0).(func(context.Context, []models.FileID) [][]int); ok {
r0 = rf(ctx, fileIDs)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([][]int)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, []models.FileID) error); ok {
r1 = rf(ctx, fileIDs)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetPerformerIDs provides a mock function with given fields: ctx, relatedID
func (_m *GalleryReaderWriter) GetPerformerIDs(ctx context.Context, relatedID int) ([]int, error) {
ret := _m.Called(ctx, relatedID)

View File

@@ -508,6 +508,29 @@ func (_m *ImageReaderWriter) GetManyFileIDs(ctx context.Context, ids []int) ([][
return r0, r1
}
// GetManyIDsByFileIDs provides a mock function with given fields: ctx, fileIDs
func (_m *ImageReaderWriter) GetManyIDsByFileIDs(ctx context.Context, fileIDs []models.FileID) ([][]int, error) {
ret := _m.Called(ctx, fileIDs)
var r0 [][]int
if rf, ok := ret.Get(0).(func(context.Context, []models.FileID) [][]int); ok {
r0 = rf(ctx, fileIDs)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([][]int)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, []models.FileID) error); ok {
r1 = rf(ctx, fileIDs)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetPerformerIDs provides a mock function with given fields: ctx, relatedID
func (_m *ImageReaderWriter) GetPerformerIDs(ctx context.Context, relatedID int) ([]int, error) {
ret := _m.Called(ctx, relatedID)

View File

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

View File

@@ -197,6 +197,29 @@ func (_m *TagReaderWriter) FindAllDescendants(ctx context.Context, tagID int, ex
return r0, r1
}
// FindByAlias provides a mock function with given fields: ctx, alias, nocase
func (_m *TagReaderWriter) FindByAlias(ctx context.Context, alias string, nocase bool) (*models.Tag, error) {
ret := _m.Called(ctx, alias, nocase)
var r0 *models.Tag
if rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Tag); ok {
r0 = rf(ctx, alias, nocase)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.Tag)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
r1 = rf(ctx, alias, nocase)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByChildTagID provides a mock function with given fields: ctx, childID
func (_m *TagReaderWriter) FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error) {
ret := _m.Called(ctx, childID)

View File

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

View File

@@ -92,3 +92,7 @@ type FileReaderWriter interface {
FileReader
FileWriter
}
type IDsFromFileIDsLoader interface {
GetManyIDsByFileIDs(ctx context.Context, fileIDs []FileID) ([][]int, error)
}

View File

@@ -12,6 +12,7 @@ type GalleryGetter interface {
// GalleryFinder provides methods to find galleries.
type GalleryFinder interface {
GalleryGetter
IDsFromFileIDsLoader
FindByFingerprints(ctx context.Context, fp []Fingerprint) ([]*Gallery, error)
FindByChecksum(ctx context.Context, checksum string) ([]*Gallery, error)
FindByChecksums(ctx context.Context, checksums []string) ([]*Gallery, error)

View File

@@ -12,6 +12,7 @@ type ImageGetter interface {
// ImageFinder provides methods to find images.
type ImageFinder interface {
ImageGetter
IDsFromFileIDsLoader
FindByFingerprints(ctx context.Context, fp []Fingerprint) ([]*Image, error)
FindByChecksum(ctx context.Context, checksum string) ([]*Image, error)
FindByFileID(ctx context.Context, fileID FileID) ([]*Image, error)

View File

@@ -18,6 +18,7 @@ type SceneGetter interface {
// SceneFinder provides methods to find scenes.
type SceneFinder interface {
SceneGetter
IDsFromFileIDsLoader
FindByFingerprints(ctx context.Context, fp []Fingerprint) ([]*Scene, error)
FindByChecksum(ctx context.Context, checksum string) ([]*Scene, error)
FindByOSHash(ctx context.Context, oshash string) ([]*Scene, error)
@@ -27,7 +28,7 @@ type SceneFinder interface {
FindByPerformerID(ctx context.Context, performerID int) ([]*Scene, error)
FindByGalleryID(ctx context.Context, performerID int) ([]*Scene, error)
FindByGroupID(ctx context.Context, groupID int) ([]*Scene, error)
FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*Scene, error)
FindDuplicates(ctx context.Context, distance int, durationDiff float64, filter *SceneFilterType) ([][]*Scene, error)
}
// SceneQueryer provides methods to query scenes.

View File

@@ -9,9 +9,16 @@ type TagGetter interface {
Find(ctx context.Context, id int) (*Tag, error)
}
type TagNameFinder interface {
FindByName(ctx context.Context, name string, nocase bool) (*Tag, error)
FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error)
FindByAlias(ctx context.Context, alias string, nocase bool) (*Tag, error)
}
// TagFinder provides methods to find tags.
type TagFinder interface {
TagGetter
TagNameFinder
FindAllAncestors(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error)
FindAllDescendants(ctx context.Context, tagID int, excludeIDs []int) ([]*TagPath, error)
FindByParentTagID(ctx context.Context, parentID int) ([]*Tag, error)
@@ -23,8 +30,6 @@ type TagFinder interface {
FindByGroupID(ctx context.Context, groupID int) ([]*Tag, error)
FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error)
FindByStudioID(ctx context.Context, studioID int) ([]*Tag, error)
FindByName(ctx context.Context, name string, nocase bool) (*Tag, error)
FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error)
FindByStashID(ctx context.Context, stashID StashID) ([]*Tag, error)
FindByStashIDStatus(ctx context.Context, hasStashID bool, stashboxEndpoint string) ([]*Tag, error)
}

View File

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

View File

@@ -169,6 +169,7 @@ func (r *httpRepository) getCachedList(ctx context.Context, u url.URL) ([]Remote
if err != nil {
return nil, fmt.Errorf("failed to get remote file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("failed to get remote file: %s", resp.Status)
@@ -205,6 +206,7 @@ func (r *httpRepository) getFile(ctx context.Context, u url.URL) (io.ReadCloser,
}
if resp.StatusCode >= 400 {
resp.Body.Close()
return nil, nil, fmt.Errorf("failed to get remote file: %s", resp.Status)
}

View File

@@ -456,7 +456,7 @@ type FilenameParserRepository struct {
Performer PerformerNamesFinder
Studio models.StudioQueryer
Group GroupNameFinder
Tag models.TagQueryer
Tag models.TagNameFinder
}
func NewFilenameParserRepository(repo models.Repository) FilenameParserRepository {
@@ -599,7 +599,7 @@ func (p *FilenameParser) queryGroup(ctx context.Context, qb GroupNameFinder, gro
return ret
}
func (p *FilenameParser) queryTag(ctx context.Context, qb models.TagQueryer, tagName string) *models.Tag {
func (p *FilenameParser) queryTag(ctx context.Context, qb models.TagNameFinder, tagName string) *models.Tag {
// massage the tag name
tagName = delimiterRE.ReplaceAllString(tagName, " ")
@@ -638,7 +638,7 @@ func (p *FilenameParser) setPerformers(ctx context.Context, qb PerformerNamesFin
}
}
func (p *FilenameParser) setTags(ctx context.Context, qb models.TagQueryer, h sceneHolder, result *models.SceneParserResult) {
func (p *FilenameParser) setTags(ctx context.Context, qb models.TagNameFinder, h sceneHolder, result *models.SceneParserResult) {
// query for each performer
tagsSet := make(map[int]bool)
for _, tagName := range h.tags {

View File

@@ -232,7 +232,7 @@ func (g Generator) generateConcatFile(chunkFiles []string) (fn string, err error
for _, f := range chunkFiles {
// files in concat file should be relative to concat
relFile := filepath.Base(f)
if _, err := w.WriteString(fmt.Sprintf("file '%s'\n", relFile)); err != nil {
if _, err := fmt.Fprintf(w, "file '%s'\n", relFile); err != nil {
return concatFile.Name(), fmt.Errorf("writing concat file: %w", err)
}
}

View File

@@ -60,7 +60,14 @@ func (g Generator) screenshot(input string, options screenshotOptions) generateF
}
args := transcoder.ScreenshotTime(input, options.Time, ssOptions)
if err := g.generate(lockCtx, args); err != nil {
logger.Warnf("[generator] fast screenshot seek failed for %s at %.3fs, retrying with accurate seek: %v", input, options.Time, err)
return g.generate(lockCtx, args)
ssOptions.SlowSeek = true
args = transcoder.ScreenshotTime(input, options.Time, ssOptions)
return g.generate(lockCtx, args)
}
return nil
}
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/ffmpeg/transcoder"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/utils"
)
@@ -34,8 +35,16 @@ func (g Generator) SpriteScreenshot(ctx context.Context, input string, seconds f
}
args := transcoder.ScreenshotTime(input, seconds, ssOptions)
img, err := g.generateImage(lockCtx, args)
if err != nil {
logger.Warnf("[generator] fast sprite screenshot seek failed for %s at %.3fs, retrying with accurate seek: %v", input, seconds, err)
return g.generateImage(lockCtx, args)
ssOptions.SlowSeek = true
args = transcoder.ScreenshotTime(input, seconds, ssOptions)
return g.generateImage(lockCtx, args)
}
return img, nil
}
func (g Generator) SpriteScreenshotSlow(ctx context.Context, input string, frame int, width int) (image.Image, error) {

View File

@@ -57,19 +57,19 @@ type ScanHandler struct {
func (h *ScanHandler) validate() error {
if h.CreatorUpdater == nil {
return errors.New("CreatorUpdater is required")
return errors.New("internal error: CreatorUpdater is required")
}
if h.ScanGenerator == nil {
return errors.New("ScanGenerator is required")
return errors.New("internal error: ScanGenerator is required")
}
if h.CaptionUpdater == nil {
return errors.New("CaptionUpdater is required")
return errors.New("internal error: CaptionUpdater is required")
}
if !h.FileNamingAlgorithm.IsValid() {
return errors.New("FileNamingAlgorithm is required")
return errors.New("internal error: FileNamingAlgorithm is required")
}
if h.Paths == nil {
return errors.New("Paths is required")
return errors.New("internal error: Paths is required")
}
return nil

View File

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

View File

@@ -70,6 +70,7 @@ type StudioFinder interface {
type TagFinder interface {
models.TagGetter
models.TagNameFinder
models.TagAutoTagQueryer
}

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