Compare commits

...

147 Commits

Author SHA1 Message Date
DogmaDragon
7c8590eb7b Merge pull request #6789 from stashapp/releases/0.31.0 2026-04-02 09:24:03 +03:00
WithoutPants
2da8074316 Codeberg weblate translation update (#6767)
* Translated using Weblate (French)

Currently translated at 100.0% (1341 of 1341 strings)

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

* Translated using Weblate (Turkish)

Currently translated at 75.3% (1010 of 1341 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1341 of 1341 strings)

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

* Translated using Weblate (French)

Currently translated at 99.9% (1345 of 1346 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1346 of 1346 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1346 of 1346 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1346 of 1346 strings)

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

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

Currently translated at 100.0% (1346 of 1346 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 67.3% (906 of 1346 strings)

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

* Update translation files

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

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

* Translated using Weblate (French)

Currently translated at 100.0% (1348 of 1348 strings)

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

* Translated using Weblate (Czech)

Currently translated at 100.0% (1351 of 1351 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1351 of 1351 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1351 of 1351 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1351 of 1351 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1351 of 1351 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1351 of 1351 strings)

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

* Translated using Weblate (Arabic)

Currently translated at 56.9% (769 of 1351 strings)

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

* Translated using Weblate (Polish)

Currently translated at 80.1% (1083 of 1351 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1351 of 1351 strings)

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

* Translated using Weblate (Arabic)

Currently translated at 58.0% (784 of 1351 strings)

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

---------

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

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

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

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

Fixes URL list in studio list styling

* Add stash id pill to studio and tag modals

* Fix create parent check box

* Allow excluding parent studio

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

* Don't render modal on every studio

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

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

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

* [compiler] use new image instead of placeholder

removes .gitignore, update README

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

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

Uses same styling as performer list table

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

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

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

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

Fixes #3998

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

* Remove unused isLoading destructure in SelectComponent

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

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

* Apply Prettier formatting to FilterSelect.tsx

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

Currently translated at 100.0% (1251 of 1251 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1251 of 1251 strings)

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

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

Currently translated at 100.0% (1251 of 1251 strings)

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

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1251 of 1251 strings)

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

* Translated using Weblate (Japanese)

Currently translated at 82.2% (1029 of 1251 strings)

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

* Translated using Weblate (Finnish)

Currently translated at 79.3% (993 of 1251 strings)

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

* Translated using Weblate (Japanese)

Currently translated at 83.2% (1042 of 1251 strings)

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

* Translated using Weblate (Polish)

Currently translated at 95.2% (1192 of 1251 strings)

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

* Translated using Weblate (Danish)

Currently translated at 86.1% (1078 of 1251 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.6% (871 of 1251 strings)

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

* Translated using Weblate (Danish)

Currently translated at 86.2% (1079 of 1251 strings)

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

* Translated using Weblate (Danish)

Currently translated at 86.1% (1080 of 1253 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1253 of 1253 strings)

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

* Update translation files

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

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

* Translated using Weblate (French)

Currently translated at 100.0% (1250 of 1250 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1250 of 1250 strings)

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

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1250 of 1250 strings)

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

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

Currently translated at 100.0% (1250 of 1250 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.9% (874 of 1250 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1251 of 1251 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1251 of 1251 strings)

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

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1251 of 1251 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (1251 of 1251 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1253 of 1253 strings)

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

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1253 of 1253 strings)

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

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

Currently translated at 100.0% (1253 of 1253 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (1253 of 1253 strings)

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

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1253 of 1253 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1253 of 1253 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1253 of 1253 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1253 of 1253 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1253 of 1253 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 70.0% (878 of 1253 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1271 of 1271 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1271 of 1271 strings)

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

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

Currently translated at 100.0% (1271 of 1271 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1271 of 1271 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1271 of 1271 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.0% (877 of 1271 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1271 of 1271 strings)

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

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1271 of 1271 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 76.7% (975 of 1271 strings)

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

* Translated using Weblate (Czech)

Currently translated at 100.0% (1271 of 1271 strings)

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

* Translated using Weblate (Japanese)

Currently translated at 86.7% (1102 of 1271 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1273 of 1273 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1273 of 1273 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1273 of 1273 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1273 of 1273 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1273 of 1273 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1273 of 1273 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1273 of 1273 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 91.0% (1159 of 1273 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1273 of 1273 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1278 of 1278 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1278 of 1278 strings)

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

* Translated using Weblate (German)

Currently translated at 86.5% (1106 of 1278 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 88.1% (1127 of 1278 strings)

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

* Translated using Weblate (Italian)

Currently translated at 65.9% (843 of 1278 strings)

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

* Translated using Weblate (Russian)

Currently translated at 82.7% (1057 of 1278 strings)

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

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

Currently translated at 100.0% (1278 of 1278 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1280 of 1280 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1280 of 1280 strings)

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

* Update translation files

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

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

* Translated using Weblate (French)

Currently translated at 100.0% (1299 of 1299 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1299 of 1299 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1300 of 1300 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1300 of 1300 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1300 of 1300 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1300 of 1300 strings)

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

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

Currently translated at 100.0% (1300 of 1300 strings)

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

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

Currently translated at 100.0% (1300 of 1300 strings)

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

* Update translation files

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

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

* Update translation files

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

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

* Translated using Weblate (Estonian)

Currently translated at 85.0% (1122 of 1320 strings)

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

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1320 of 1320 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1320 of 1320 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 98.0% (1294 of 1320 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1322 of 1322 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1322 of 1322 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1322 of 1322 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1322 of 1322 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1322 of 1322 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 97.4% (1288 of 1322 strings)

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

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1322 of 1322 strings)

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

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

Currently translated at 100.0% (1322 of 1322 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1322 of 1322 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1323 of 1323 strings)

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

* Translated using Weblate (German)

Currently translated at 85.1% (1130 of 1327 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1327 of 1327 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1327 of 1327 strings)

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

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1332 of 1332 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1332 of 1332 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1332 of 1332 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1332 of 1332 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1332 of 1332 strings)

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

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

Currently translated at 100.0% (1332 of 1332 strings)

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

* Added translation using Weblate (Arabic)

* Translated using Weblate (Arabic)

Currently translated at 29.8% (397 of 1332 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1334 of 1334 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1334 of 1334 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1338 of 1338 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1338 of 1338 strings)

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

* Translated using Weblate (Estonian)

Currently translated at 99.7% (1335 of 1338 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1338 of 1338 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1338 of 1338 strings)

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

* Translated using Weblate (Arabic)

Currently translated at 40.1% (537 of 1338 strings)

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

* Add arabic language option

---------

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

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

Ref: stashapp/stash#5530

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

* feat: add scenes_size sort option for studios

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

Ref: stashapp/stash#5530

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

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

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

Ref: stashapp/stash#5530

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

* feat: extend scenes_size sort to tags

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

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

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

* fix: rename CircumisedEnum to CircumcisedEnum across codebase

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

* fix: gofmt performer model files after enum rename

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

---------

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

We'll collect other bulk inputs here

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

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

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

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

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

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

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

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

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

Fixes #6518

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

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

Closes #6512

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

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

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

Closes #6624

* Render modal once above sidebar conditional

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

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

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

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

* Create full folder hierarchy when scanning a new folder

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

* Create folder hierarchy on new file scan

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

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

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

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

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

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

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

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

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

MergeHierarchy is removed as it is no longer needed

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

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

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

Refactor ImageCard component to use PatchComponent wrapper.

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

* Add documentation
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-01-27 16:10:49 +11:00
DogmaDragon
09044b92bf docs: add missing patchable components and library (#6517) 2026-01-27 16:06:27 +11:00
Gykes
2c8e7d709f FR: Add Interfaces to Destroy File Database Entries (#6437) 2026-01-27 16:02:47 +11:00
Gykes
bef4e3fbd5 Feature: Add "Troubleshooting Mode" (#6343)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
Co-authored-by: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com>
2026-01-27 14:26:26 +11:00
DogmaDragon
91d9bda278 Merge pull request #6453 from stashapp/releases/0.30.1 2025-12-23 23:54:11 +02:00
655 changed files with 33827 additions and 11485 deletions

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

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

View File

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

View File

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

View File

@@ -50,7 +50,7 @@ export CGO_ENABLED := 1
# define COMPILER_IMAGE for cross-compilation docker container # define COMPILER_IMAGE for cross-compilation docker container
ifndef COMPILER_IMAGE ifndef COMPILER_IMAGE
COMPILER_IMAGE := stashapp/compiler:latest COMPILER_IMAGE := ghcr.io/stashapp/compiler:latest
endif endif
.PHONY: release .PHONY: release
@@ -129,7 +129,7 @@ phasher: build-flags
# builds dynamically-linked debug binaries # builds dynamically-linked debug binaries
.PHONY: build .PHONY: build
build: stash phasher build: stash
# builds dynamically-linked PIE release binaries # builds dynamically-linked PIE release binaries
.PHONY: build-release .PHONY: build-release
@@ -187,8 +187,6 @@ build-cc-macos:
# Combine into universal binaries # Combine into universal binaries
lipo -create -output dist/stash-macos dist/stash-macos-intel dist/stash-macos-arm lipo -create -output dist/stash-macos dist/stash-macos-intel dist/stash-macos-arm
rm dist/stash-macos-intel dist/stash-macos-arm rm dist/stash-macos-intel dist/stash-macos-arm
lipo -create -output dist/phasher-macos dist/phasher-macos-intel dist/phasher-macos-arm
rm dist/phasher-macos-intel dist/phasher-macos-arm
# Place into bundle and zip up # Place into bundle and zip up
rm -rf dist/Stash.app rm -rf dist/Stash.app
@@ -198,6 +196,16 @@ build-cc-macos:
cd dist && rm -f Stash.app.zip && zip -r Stash.app.zip Stash.app cd dist && rm -f Stash.app.zip && zip -r Stash.app.zip Stash.app
rm -rf dist/Stash.app rm -rf dist/Stash.app
.PHONY: build-cc-macos-phasher
build-cc-macos-phasher:
make build-cc-macos-arm
make build-cc-macos-intel
# Combine into universal binaries
lipo -create -output dist/phasher-macos dist/phasher-macos-intel dist/phasher-macos-arm
rm dist/phasher-macos-intel dist/phasher-macos-arm
# do not bundle phasher
.PHONY: build-cc-freebsd .PHONY: build-cc-freebsd
build-cc-freebsd: export GOOS := freebsd build-cc-freebsd: export GOOS := freebsd
build-cc-freebsd: export GOARCH := amd64 build-cc-freebsd: export GOARCH := amd64

View File

@@ -13,10 +13,10 @@
![Screenshot of Stash web application interface](docs/readme_assets/demo_image.png) ![Screenshot of Stash web application interface](docs/readme_assets/demo_image.png)
* Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites. - Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites.
* Stash supports a wide variety of both video and image formats. - Stash supports a wide variety of both video and image formats.
* You can tag videos and find them later. - You can tag videos and find them later.
* Stash provides statistics about performers, tags, studios and more. - Stash provides statistics about performers, tags, studios and more.
You can [watch a SFW demo video](https://vimeo.com/545323354) to see it in action. You can [watch a SFW demo video](https://vimeo.com/545323354) to see it in action.
@@ -24,17 +24,19 @@ For further information you can consult the [documentation](https://docs.stashap
# Installing Stash # Installing Stash
> [!tip]
Step-by-step instructions are available at [docs.stashapp.cc/installation](https://docs.stashapp.cc/installation/). Step-by-step instructions are available at [docs.stashapp.cc/installation](https://docs.stashapp.cc/installation/).
#### Windows Users: > [!important]
>**Windows Users**
As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._ >
At least Windows 10 or Server 2016 is required. >As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._
>At least Windows 10 or Server 2016 is required.
#### Mac Users: >
>**macOS Users**
As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later. >
Stash can still be run through docker on older versions of macOS. > As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later.
> Stash can still be run through docker on older versions of macOS.
<img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS | <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker <img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS | <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker
:---:|:---:|:---:|:---: :---:|:---:|:---:|:---:
@@ -85,23 +87,23 @@ The badge below shows the current translation status of Stash across all support
Need help or want to get involved? Start with the documentation, then reach out to the community if you need further assistance. Need help or want to get involved? Start with the documentation, then reach out to the community if you need further assistance.
- Documentation ### Documentation
- Official docs: https://docs.stashapp.cc - official guides guides and troubleshooting. - [Official documentation](https://docs.stashapp.cc) - official guides guides and troubleshooting.
- In-app manual: press <kbd>Shift</kbd> + <kbd>?</kbd> in the app or view the manual online: https://docs.stashapp.cc/in-app-manual. - [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. - [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 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. - [Community forum](https://discourse.stashapp.cc) - community support, feature requests and discussions.
- Discord: https://discord.gg/2TsNFKt - real-time chat and community support. - [Discord](https://discord.gg/2TsNFKt) - real-time chat and community support.
- GitHub discussions: https://github.com/stashapp/stash/discussions - community support and feature discussions. - [GitHub discussions](https://github.com/stashapp/stash/discussions) - community support and feature discussions.
- Lemmy community: https://discuss.online/c/stashapp - Reddit-style community space. - [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/ - [Metadata sources](https://docs.stashapp.cc/metadata-sources/)
- Plugins: https://docs.stashapp.cc/plugins/ - [Plugins](https://docs.stashapp.cc/plugins/)
- Themes: https://docs.stashapp.cc/themes/ - [Themes](https://docs.stashapp.cc/themes/)
- Other projects: https://docs.stashapp.cc/other-projects/ - [Other projects](https://docs.stashapp.cc/other-projects/)
# For Developers # For Developers

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

1
go.mod
View File

@@ -44,6 +44,7 @@ require (
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/remeh/sizedwaitgroup v1.0.0 github.com/remeh/sizedwaitgroup v1.0.0
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06
github.com/sirupsen/logrus v1.9.3 github.com/sirupsen/logrus v1.9.3
github.com/spf13/cast v1.6.0 github.com/spf13/cast v1.6.0
github.com/spf13/pflag v1.0.6 github.com/spf13/pflag v1.0.6

2
go.sum
View File

@@ -537,6 +537,8 @@ github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd h1:CmH9+J6ZSsIjUK
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk= github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8= github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06 h1:OkMGxebDjyw0ULyrTYWeN0UNCCkmCWfjPnIA2W6oviI=
github.com/sabhiram/go-gitignore v0.0.0-20210923224102-525f6e181f06/go.mod h1:+ePHsJ1keEjQtpvf9HHw0f4ZeJ0TLRsxhunSI2hYJSs=
github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig= github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM= github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=

View File

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

View File

@@ -422,8 +422,14 @@ type Mutation {
""" """
moveFiles(input: MoveFilesInput!): Boolean! moveFiles(input: MoveFilesInput!): Boolean!
deleteFiles(ids: [ID!]!): Boolean! deleteFiles(ids: [ID!]!): Boolean!
"Deletes file entries from the database without deleting the files from the filesystem"
destroyFiles(ids: [ID!]!): Boolean!
fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean! fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean!
"Reveal the file in the system file manager"
revealFileInFileManager(id: ID!): Boolean!
"Reveal the folder in the system file manager"
revealFolderInFileManager(id: ID!): Boolean!
# Saved filters # Saved filters
saveFilter(input: SaveFilterInput!): SavedFilter! saveFilter(input: SaveFilterInput!): SavedFilter!
@@ -577,6 +583,8 @@ type Mutation {
stashBoxBatchPerformerTag(input: StashBoxBatchTagInput!): String! stashBoxBatchPerformerTag(input: StashBoxBatchTagInput!): String!
"Run batch studio tag task. Returns the job ID." "Run batch studio tag task. Returns the job ID."
stashBoxBatchStudioTag(input: StashBoxBatchTagInput!): String! stashBoxBatchStudioTag(input: StashBoxBatchTagInput!): String!
"Run batch tag tag task. Returns the job ID."
stashBoxBatchTagTag(input: StashBoxBatchTagInput!): String!
"Enables DLNA for an optional duration. Has no effect if DLNA is enabled by default" "Enables DLNA for an optional duration. Has no effect if DLNA is enabled by default"
enableDLNA(input: EnableDLNAInput!): Boolean! enableDLNA(input: EnableDLNAInput!): Boolean!

View File

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

View File

@@ -6,13 +6,19 @@ type Fingerprint {
type Folder { type Folder {
id: ID! id: ID!
path: String! path: String!
basename: String!
parent_folder_id: ID @deprecated(reason: "Use parent_folder instead") parent_folder_id: ID @deprecated(reason: "Use parent_folder instead")
zip_file_id: ID @deprecated(reason: "Use zip_file instead") zip_file_id: ID @deprecated(reason: "Use zip_file instead")
parent_folder: Folder parent_folder: Folder
"Returns all parent folders in order from immediate parent to top-level"
parent_folders: [Folder!]!
zip_file: BasicFile zip_file: BasicFile
"Returns direct sub-folders"
sub_folders: [Folder!]!
mod_time: Time! mod_time: Time!
created_at: Time! created_at: Time!
@@ -153,7 +159,7 @@ input MoveFilesInput {
input SetFingerprintsInput { input SetFingerprintsInput {
type: String! type: String!
"an null value will remove the fingerprint" "a null value will remove the fingerprint"
value: String value: String
} }

View File

@@ -75,10 +75,26 @@ input OrientationCriterionInput {
value: [OrientationEnum!]! value: [OrientationEnum!]!
} }
input PHashDuplicationCriterionInput { input DuplicationCriterionInput {
duplicated: Boolean duplicated: Boolean @deprecated(reason: "Use phash field instead")
"Currently unimplemented" "Currently unimplemented. Intended for phash distance matching."
distance: Int distance: Int
"Filter by phash duplication"
phash: Boolean
"Filter by URL duplication"
url: Boolean
"Filter by Stash ID duplication"
stash_id: Boolean
"Filter by title duplication"
title: Boolean
}
input FileDuplicationCriterionInput {
duplicated: Boolean @deprecated(reason: "Use phash field instead")
"Currently unimplemented. Intended for phash distance matching."
distance: Int
"Filter by phash duplication"
phash: Boolean
} }
input StashIDCriterionInput { input StashIDCriterionInput {
@@ -136,10 +152,15 @@ input PerformerFilterType {
fake_tits: StringCriterionInput fake_tits: StringCriterionInput
"Filter by penis length value" "Filter by penis length value"
penis_length: FloatCriterionInput penis_length: FloatCriterionInput
"Filter by ciricumcision" "Filter by circumcision"
circumcised: CircumcisionCriterionInput circumcised: CircumcisionCriterionInput
"Filter by career length" "Deprecated: use career_start and career_end. This filter is non-functional."
career_length: StringCriterionInput career_length: StringCriterionInput
@deprecated(reason: "Use career_start and career_end")
"Filter by career start"
career_start: DateCriterionInput
"Filter by career end"
career_end: DateCriterionInput
"Filter by tattoos" "Filter by tattoos"
tattoos: StringCriterionInput tattoos: StringCriterionInput
"Filter by piercings" "Filter by piercings"
@@ -156,6 +177,8 @@ input PerformerFilterType {
tag_count: IntCriterionInput tag_count: IntCriterionInput
"Filter by scene count" "Filter by scene count"
scene_count: IntCriterionInput scene_count: IntCriterionInput
"Filter by marker count (via scene)"
marker_count: IntCriterionInput
"Filter by image count" "Filter by image count"
image_count: IntCriterionInput image_count: IntCriterionInput
"Filter by gallery count" "Filter by gallery count"
@@ -199,6 +222,8 @@ input PerformerFilterType {
galleries_filter: GalleryFilterType galleries_filter: GalleryFilterType
"Filter by related tags that meet this criteria" "Filter by related tags that meet this criteria"
tags_filter: TagFilterType tags_filter: TagFilterType
"Filter by related scene markers (via scene) that meet this criteria"
markers_filter: SceneMarkerFilterType
"Filter by creation time" "Filter by creation time"
created_at: TimestampCriterionInput created_at: TimestampCriterionInput
"Filter by last update time" "Filter by last update time"
@@ -224,9 +249,9 @@ input SceneMarkerFilterType {
updated_at: TimestampCriterionInput updated_at: TimestampCriterionInput
"Filter by scene date" "Filter by scene date"
scene_date: DateCriterionInput scene_date: DateCriterionInput
"Filter by cscene reation time" "Filter by scene creation time"
scene_created_at: TimestampCriterionInput scene_created_at: TimestampCriterionInput
"Filter by lscene ast update time" "Filter by scene last update time"
scene_updated_at: TimestampCriterionInput scene_updated_at: TimestampCriterionInput
"Filter by related scenes that meet this criteria" "Filter by related scenes that meet this criteria"
scene_filter: SceneFilterType scene_filter: SceneFilterType
@@ -261,8 +286,8 @@ input SceneFilterType {
organized: Boolean organized: Boolean
"Filter by o-counter" "Filter by o-counter"
o_counter: IntCriterionInput o_counter: IntCriterionInput
"Filter Scenes that have an exact phash match available" "Filter Scenes by duplication criteria"
duplicated: PHashDuplicationCriterionInput duplicated: DuplicationCriterionInput
"Filter by resolution" "Filter by resolution"
resolution: ResolutionCriterionInput resolution: ResolutionCriterionInput
"Filter by orientation" "Filter by orientation"
@@ -308,6 +333,8 @@ input SceneFilterType {
@deprecated(reason: "use stash_ids_endpoint instead") @deprecated(reason: "use stash_ids_endpoint instead")
"Filter by StashIDs" "Filter by StashIDs"
stash_ids_endpoint: StashIDsCriterionInput stash_ids_endpoint: StashIDsCriterionInput
"Filter by StashID count"
stash_id_count: IntCriterionInput
"Filter by url" "Filter by url"
url: StringCriterionInput url: StringCriterionInput
"Filter by interactive" "Filter by interactive"
@@ -348,6 +375,8 @@ input SceneFilterType {
markers_filter: SceneMarkerFilterType markers_filter: SceneMarkerFilterType
"Filter by related files that meet this criteria" "Filter by related files that meet this criteria"
files_filter: FileFilterType files_filter: FileFilterType
custom_fields: [CustomFieldCriterionInput!]
} }
input MovieFilterType { input MovieFilterType {
@@ -430,11 +459,16 @@ input GroupFilterType {
containing_group_count: IntCriterionInput containing_group_count: IntCriterionInput
"Filter by number of sub-groups the group has" "Filter by number of sub-groups the group has"
sub_group_count: IntCriterionInput sub_group_count: IntCriterionInput
"Filter by number of scenes the group has"
scene_count: IntCriterionInput
"Filter by related scenes that meet this criteria" "Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType scenes_filter: SceneFilterType
"Filter by related studios that meet this criteria" "Filter by related studios that meet this criteria"
studios_filter: StudioFilterType studios_filter: StudioFilterType
"Filter by custom fields"
custom_fields: [CustomFieldCriterionInput!]
} }
input StudioFilterType { input StudioFilterType {
@@ -465,6 +499,8 @@ input StudioFilterType {
image_count: IntCriterionInput image_count: IntCriterionInput
"Filter by gallery count" "Filter by gallery count"
gallery_count: IntCriterionInput gallery_count: IntCriterionInput
"Filter by group count"
group_count: IntCriterionInput
"Filter by tag count" "Filter by tag count"
tag_count: IntCriterionInput tag_count: IntCriterionInput
"Filter by url" "Filter by url"
@@ -475,16 +511,22 @@ input StudioFilterType {
child_count: IntCriterionInput child_count: IntCriterionInput
"Filter by autotag ignore value" "Filter by autotag ignore value"
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
"Filter by organized"
organized: Boolean
"Filter by related scenes that meet this criteria" "Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType scenes_filter: SceneFilterType
"Filter by related images that meet this criteria" "Filter by related images that meet this criteria"
images_filter: ImageFilterType images_filter: ImageFilterType
"Filter by related galleries that meet this criteria" "Filter by related galleries that meet this criteria"
galleries_filter: GalleryFilterType galleries_filter: GalleryFilterType
"Filter by related groups that meet this criteria"
groups_filter: GroupFilterType
"Filter by creation time" "Filter by creation time"
created_at: TimestampCriterionInput created_at: TimestampCriterionInput
"Filter by last update time" "Filter by last update time"
updated_at: TimestampCriterionInput updated_at: TimestampCriterionInput
custom_fields: [CustomFieldCriterionInput!]
} }
input GalleryFilterType { input GalleryFilterType {
@@ -561,6 +603,10 @@ input GalleryFilterType {
files_filter: FileFilterType files_filter: FileFilterType
"Filter by related folders that meet this criteria" "Filter by related folders that meet this criteria"
folders_filter: FolderFilterType folders_filter: FolderFilterType
"Filter by parent folder of the zip or folder the gallery is in"
parent_folder: HierarchicalMultiCriterionInput
custom_fields: [CustomFieldCriterionInput!]
} }
input TagFilterType { input TagFilterType {
@@ -619,7 +665,7 @@ input TagFilterType {
"Filter by number of parent tags the tag has" "Filter by number of parent tags the tag has"
parent_count: IntCriterionInput parent_count: IntCriterionInput
"Filter by number f child tags the tag has" "Filter by number of child tags the tag has"
child_count: IntCriterionInput child_count: IntCriterionInput
"Filter by autotag ignore value" "Filter by autotag ignore value"
@@ -638,12 +684,22 @@ input TagFilterType {
images_filter: ImageFilterType images_filter: ImageFilterType
"Filter by related galleries that meet this criteria" "Filter by related galleries that meet this criteria"
galleries_filter: GalleryFilterType galleries_filter: GalleryFilterType
"Filter by related groups that meet this criteria"
groups_filter: GroupFilterType
"Filter by related performers that meet this criteria"
performers_filter: PerformerFilterType
"Filter by related studios that meet this criteria"
studios_filter: StudioFilterType
"Filter by related scene markers that meet this criteria"
markers_filter: SceneMarkerFilterType
"Filter by creation time" "Filter by creation time"
created_at: TimestampCriterionInput created_at: TimestampCriterionInput
"Filter by last update time" "Filter by last update time"
updated_at: TimestampCriterionInput updated_at: TimestampCriterionInput
custom_fields: [CustomFieldCriterionInput!]
} }
input ImageFilterType { input ImageFilterType {
@@ -658,6 +714,8 @@ input ImageFilterType {
id: IntCriterionInput id: IntCriterionInput
"Filter by file checksum" "Filter by file checksum"
checksum: StringCriterionInput checksum: StringCriterionInput
"Filter by file phash distance"
phash_distance: PhashDistanceCriterionInput
"Filter by path" "Filter by path"
path: StringCriterionInput path: StringCriterionInput
"Filter by file count" "Filter by file count"
@@ -715,6 +773,8 @@ input ImageFilterType {
tags_filter: TagFilterType tags_filter: TagFilterType
"Filter by related files that meet this criteria" "Filter by related files that meet this criteria"
files_filter: FileFilterType files_filter: FileFilterType
"Filter by custom fields"
custom_fields: [CustomFieldCriterionInput!]
} }
input FileFilterType { input FileFilterType {
@@ -732,8 +792,8 @@ input FileFilterType {
"Filter by modification time" "Filter by modification time"
mod_time: TimestampCriterionInput mod_time: TimestampCriterionInput
"Filter files that have an exact match available" "Filter files by duplication criteria (only phash applies to files)"
duplicated: PHashDuplicationCriterionInput duplicated: FileDuplicationCriterionInput
"find files based on hash" "find files based on hash"
hashes: [FingerprintFilterInput!] hashes: [FingerprintFilterInput!]
@@ -764,6 +824,7 @@ input FolderFilterType {
NOT: FolderFilterType NOT: FolderFilterType
path: StringCriterionInput path: StringCriterionInput
basename: StringCriterionInput
parent_folder: HierarchicalMultiCriterionInput parent_folder: HierarchicalMultiCriterionInput
zip_file: MultiCriterionInput zip_file: MultiCriterionInput
@@ -872,7 +933,7 @@ input GenderCriterionInput {
} }
input CircumcisionCriterionInput { input CircumcisionCriterionInput {
value: [CircumisedEnum!] value: [CircumcisedEnum!]
modifier: CriterionModifier! modifier: CriterionModifier!
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -71,6 +71,9 @@ type ScrapedTag {
"Set if tag matched" "Set if tag matched"
stored_id: ID stored_id: ID
name: String! name: String!
description: String
alias_list: [String!]
parent: ScrapedTag
"Remote site ID, if applicable" "Remote site ID, if applicable"
remote_site_id: String remote_site_id: String
} }

View File

@@ -8,6 +8,7 @@ type Studio {
aliases: [String!]! aliases: [String!]!
tags: [Tag!]! tags: [Tag!]!
ignore_auto_tag: Boolean! ignore_auto_tag: Boolean!
organized: Boolean!
image_path: String # Resolver image_path: String # Resolver
scene_count(depth: Int): Int! # Resolver scene_count(depth: Int): Int! # Resolver
@@ -26,6 +27,8 @@ type Studio {
groups: [Group!]! groups: [Group!]!
movies: [Movie!]! @deprecated(reason: "use groups instead") movies: [Movie!]! @deprecated(reason: "use groups instead")
o_counter: Int o_counter: Int
custom_fields: Map!
} }
input StudioCreateInput { input StudioCreateInput {
@@ -40,9 +43,13 @@ input StudioCreateInput {
rating100: Int rating100: Int
favorite: Boolean favorite: Boolean
details: String details: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
aliases: [String!] aliases: [String!]
tag_ids: [ID!] tag_ids: [ID!]
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
organized: Boolean
custom_fields: Map
} }
input StudioUpdateInput { input StudioUpdateInput {
@@ -58,9 +65,13 @@ input StudioUpdateInput {
rating100: Int rating100: Int
favorite: Boolean favorite: Boolean
details: String details: String
"Duplicate aliases and those equal to name will be ignored (case-insensitive)"
aliases: [String!] aliases: [String!]
tag_ids: [ID!] tag_ids: [ID!]
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
organized: Boolean
custom_fields: CustomFieldsInput
} }
input BulkStudioUpdateInput { input BulkStudioUpdateInput {
@@ -74,6 +85,7 @@ input BulkStudioUpdateInput {
details: String details: String
tag_ids: BulkUpdateIds tag_ids: BulkUpdateIds
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
organized: Boolean
} }
input StudioDestroyInput { input StudioDestroyInput {

View File

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

View File

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

View File

@@ -40,6 +40,8 @@ func authenticateHandler() func(http.Handler) http.Handler {
return return
} }
r = session.SetLocalRequest(r)
userID, err := manager.GetInstance().SessionStore.Authenticate(w, r) userID, err := manager.GetInstance().SessionStore.Authenticate(w, r)
if err != nil { if err != nil {
if !errors.Is(err, session.ErrUnauthorized) { if !errors.Is(err, session.ErrUnauthorized) {

View File

@@ -11,6 +11,7 @@
//go:generate go run github.com/vektah/dataloaden GroupLoader int *github.com/stashapp/stash/pkg/models.Group //go:generate go run github.com/vektah/dataloaden GroupLoader int *github.com/stashapp/stash/pkg/models.Group
//go:generate go run github.com/vektah/dataloaden FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File //go:generate go run github.com/vektah/dataloaden FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File
//go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder //go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder
//go:generate go run github.com/vektah/dataloaden 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 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 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 GalleryFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
@@ -42,28 +43,40 @@ const (
) )
type Loaders struct { type Loaders struct {
SceneByID *SceneLoader SceneByID *SceneLoader
SceneFiles *SceneFileIDsLoader SceneFiles *SceneFileIDsLoader
ScenePlayCount *ScenePlayCountLoader ScenePlayCount *ScenePlayCountLoader
SceneOCount *SceneOCountLoader SceneOCount *SceneOCountLoader
ScenePlayHistory *ScenePlayHistoryLoader ScenePlayHistory *ScenePlayHistoryLoader
SceneOHistory *SceneOHistoryLoader SceneOHistory *SceneOHistoryLoader
SceneLastPlayed *SceneLastPlayedLoader SceneLastPlayed *SceneLastPlayedLoader
SceneCustomFields *CustomFieldsLoader
ImageFiles *ImageFileIDsLoader ImageFiles *ImageFileIDsLoader
GalleryFiles *GalleryFileIDsLoader GalleryFiles *GalleryFileIDsLoader
GalleryByID *GalleryLoader GalleryByID *GalleryLoader
ImageByID *ImageLoader GalleryCustomFields *CustomFieldsLoader
ImageByID *ImageLoader
ImageCustomFields *CustomFieldsLoader
PerformerByID *PerformerLoader PerformerByID *PerformerLoader
PerformerCustomFields *CustomFieldsLoader PerformerCustomFields *CustomFieldsLoader
StudioByID *StudioLoader StudioByID *StudioLoader
TagByID *TagLoader StudioCustomFields *CustomFieldsLoader
GroupByID *GroupLoader
FileByID *FileLoader TagByID *TagLoader
FolderByID *FolderLoader TagCustomFields *CustomFieldsLoader
GroupByID *GroupLoader
GroupCustomFields *CustomFieldsLoader
FileByID *FileLoader
FolderByID *FolderLoader
FolderParentFolderIDs *FolderRelatedFolderIDsLoader
FolderSubFolderIDs *FolderRelatedFolderIDsLoader
} }
type Middleware struct { type Middleware struct {
@@ -84,11 +97,21 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch, maxBatch: maxBatch,
fetch: m.fetchGalleries(ctx), fetch: m.fetchGalleries(ctx),
}, },
GalleryCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchGalleryCustomFields(ctx),
},
ImageByID: &ImageLoader{ ImageByID: &ImageLoader{
wait: wait, wait: wait,
maxBatch: maxBatch, maxBatch: maxBatch,
fetch: m.fetchImages(ctx), fetch: m.fetchImages(ctx),
}, },
ImageCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchImageCustomFields(ctx),
},
PerformerByID: &PerformerLoader{ PerformerByID: &PerformerLoader{
wait: wait, wait: wait,
maxBatch: maxBatch, maxBatch: maxBatch,
@@ -99,6 +122,16 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch, maxBatch: maxBatch,
fetch: m.fetchPerformerCustomFields(ctx), fetch: m.fetchPerformerCustomFields(ctx),
}, },
StudioCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchStudioCustomFields(ctx),
},
SceneCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchSceneCustomFields(ctx),
},
StudioByID: &StudioLoader{ StudioByID: &StudioLoader{
wait: wait, wait: wait,
maxBatch: maxBatch, maxBatch: maxBatch,
@@ -109,11 +142,21 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch, maxBatch: maxBatch,
fetch: m.fetchTags(ctx), fetch: m.fetchTags(ctx),
}, },
TagCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchTagCustomFields(ctx),
},
GroupByID: &GroupLoader{ GroupByID: &GroupLoader{
wait: wait, wait: wait,
maxBatch: maxBatch, maxBatch: maxBatch,
fetch: m.fetchGroups(ctx), fetch: m.fetchGroups(ctx),
}, },
GroupCustomFields: &CustomFieldsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchGroupCustomFields(ctx),
},
FileByID: &FileLoader{ FileByID: &FileLoader{
wait: wait, wait: wait,
maxBatch: maxBatch, maxBatch: maxBatch,
@@ -124,6 +167,16 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch, maxBatch: maxBatch,
fetch: m.fetchFolders(ctx), fetch: m.fetchFolders(ctx),
}, },
FolderParentFolderIDs: &FolderRelatedFolderIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchFoldersParentFolderIDs(ctx),
},
FolderSubFolderIDs: &FolderRelatedFolderIDsLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchFoldersSubFolderIDs(ctx),
},
SceneFiles: &SceneFileIDsLoader{ SceneFiles: &SceneFileIDsLoader{
wait: wait, wait: wait,
maxBatch: maxBatch, maxBatch: maxBatch,
@@ -194,6 +247,18 @@ func (m Middleware) fetchScenes(ctx context.Context) func(keys []int) ([]*models
} }
} }
func (m Middleware) fetchSceneCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Scene.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchImages(ctx context.Context) func(keys []int) ([]*models.Image, []error) { func (m Middleware) fetchImages(ctx context.Context) func(keys []int) ([]*models.Image, []error) {
return func(keys []int) (ret []*models.Image, errs []error) { return func(keys []int) (ret []*models.Image, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error { err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
@@ -206,6 +271,18 @@ func (m Middleware) fetchImages(ctx context.Context) func(keys []int) ([]*models
} }
} }
func (m Middleware) fetchImageCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Image.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchGalleries(ctx context.Context) func(keys []int) ([]*models.Gallery, []error) { func (m Middleware) fetchGalleries(ctx context.Context) func(keys []int) ([]*models.Gallery, []error) {
return func(keys []int) (ret []*models.Gallery, errs []error) { return func(keys []int) (ret []*models.Gallery, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error { err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
@@ -253,6 +330,18 @@ func (m Middleware) fetchStudios(ctx context.Context) func(keys []int) ([]*model
} }
} }
func (m Middleware) fetchStudioCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Studio.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchTags(ctx context.Context) func(keys []int) ([]*models.Tag, []error) { func (m Middleware) fetchTags(ctx context.Context) func(keys []int) ([]*models.Tag, []error) {
return func(keys []int) (ret []*models.Tag, errs []error) { return func(keys []int) (ret []*models.Tag, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error { err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
@@ -264,6 +353,42 @@ func (m Middleware) fetchTags(ctx context.Context) func(keys []int) ([]*models.T
} }
} }
func (m Middleware) fetchTagCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Tag.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchGroupCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Group.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchGalleryCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) {
return func(keys []int) (ret []models.CustomFieldMap, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Gallery.GetCustomFieldsBulk(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchGroups(ctx context.Context) func(keys []int) ([]*models.Group, []error) { func (m Middleware) fetchGroups(ctx context.Context) func(keys []int) ([]*models.Group, []error) {
return func(keys []int) (ret []*models.Group, errs []error) { return func(keys []int) (ret []*models.Group, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error { err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
@@ -297,6 +422,28 @@ func (m Middleware) fetchFolders(ctx context.Context) func(keys []models.FolderI
} }
} }
func (m Middleware) fetchFoldersParentFolderIDs(ctx context.Context) func(keys []models.FolderID) ([][]models.FolderID, []error) {
return func(keys []models.FolderID) (ret [][]models.FolderID, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Folder.GetManyParentFolderIDs(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchFoldersSubFolderIDs(ctx context.Context) func(keys []models.FolderID) ([][]models.FolderID, []error) {
return func(keys []models.FolderID) (ret [][]models.FolderID, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Folder.GetManySubFolderIDs(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) { func (m Middleware) fetchScenesFileIDs(ctx context.Context) func(keys []int) ([][]models.FileID, []error) {
return func(keys []int) (ret [][]models.FileID, errs []error) { return func(keys []int) (ret [][]models.FileID, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error { err := m.Repository.WithDB(ctx, func(ctx context.Context) error {

View File

@@ -0,0 +1,225 @@
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
package loaders
import (
"sync"
"time"
"github.com/stashapp/stash/pkg/models"
)
// FolderParentFolderIDsLoaderConfig captures the config to create a new FolderParentFolderIDsLoader
type FolderParentFolderIDsLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []models.FolderID) ([][]models.FolderID, []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
}
// NewFolderParentFolderIDsLoader creates a new FolderParentFolderIDsLoader given a fetch, wait, and maxBatch
func NewFolderParentFolderIDsLoader(config FolderParentFolderIDsLoaderConfig) *FolderRelatedFolderIDsLoader {
return &FolderRelatedFolderIDsLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// FolderRelatedFolderIDsLoader batches and caches requests
type FolderRelatedFolderIDsLoader struct {
// this method provides the data for the loader
fetch func(keys []models.FolderID) ([][]models.FolderID, []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[models.FolderID][]models.FolderID
// 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 *folderParentFolderIDsLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type folderParentFolderIDsLoaderBatch struct {
keys []models.FolderID
data [][]models.FolderID
error []error
closing bool
done chan struct{}
}
// Load a FolderID by key, batching and caching will be applied automatically
func (l *FolderRelatedFolderIDsLoader) Load(key models.FolderID) ([]models.FolderID, error) {
return l.LoadThunk(key)()
}
// LoadThunk returns a function that when called will block waiting for a FolderID.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *FolderRelatedFolderIDsLoader) LoadThunk(key models.FolderID) func() ([]models.FolderID, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() ([]models.FolderID, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &folderParentFolderIDsLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() ([]models.FolderID, error) {
<-batch.done
var data []models.FolderID
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 *FolderRelatedFolderIDsLoader) LoadAll(keys []models.FolderID) ([][]models.FolderID, []error) {
results := make([]func() ([]models.FolderID, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
folderIDs := make([][]models.FolderID, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
folderIDs[i], errors[i] = thunk()
}
return folderIDs, errors
}
// LoadAllThunk returns a function that when called will block waiting for a FolderIDs.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *FolderRelatedFolderIDsLoader) LoadAllThunk(keys []models.FolderID) func() ([][]models.FolderID, []error) {
results := make([]func() ([]models.FolderID, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([][]models.FolderID, []error) {
folderIDs := make([][]models.FolderID, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
folderIDs[i], errors[i] = thunk()
}
return folderIDs, 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 *FolderRelatedFolderIDsLoader) Prime(key models.FolderID, value []models.FolderID) 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.FolderID, 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 *FolderRelatedFolderIDsLoader) Clear(key models.FolderID) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *FolderRelatedFolderIDsLoader) unsafeSet(key models.FolderID, value []models.FolderID) {
if l.cache == nil {
l.cache = map[models.FolderID][]models.FolderID{}
}
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 *folderParentFolderIDsLoaderBatch) keyIndex(l *FolderRelatedFolderIDsLoader, key models.FolderID) 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 *folderParentFolderIDsLoaderBatch) startTimer(l *FolderRelatedFolderIDsLoader) {
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 *folderParentFolderIDsLoaderBatch) end(l *FolderRelatedFolderIDsLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -410,3 +410,16 @@ func (r *sceneResolver) OHistory(ctx context.Context, obj *models.Scene) ([]*tim
return ptrRet, nil return ptrRet, nil
} }
func (r *sceneResolver) CustomFields(ctx context.Context, obj *models.Scene) (map[string]interface{}, error) {
m, err := loaders.From(ctx).SceneCustomFields.Load(obj.ID)
if err != nil {
return nil, err
}
if m == nil {
return make(map[string]interface{}), nil
}
return m, nil
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -43,7 +43,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
newPerformer.Name = strings.TrimSpace(input.Name) newPerformer.Name = strings.TrimSpace(input.Name)
newPerformer.Disambiguation = translator.string(input.Disambiguation) newPerformer.Disambiguation = translator.string(input.Disambiguation)
newPerformer.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.AliasList)) newPerformer.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.AliasList), newPerformer.Name))
newPerformer.Gender = input.Gender newPerformer.Gender = input.Gender
newPerformer.Ethnicity = translator.string(input.Ethnicity) newPerformer.Ethnicity = translator.string(input.Ethnicity)
newPerformer.Country = translator.string(input.Country) newPerformer.Country = translator.string(input.Country)
@@ -52,7 +52,6 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
newPerformer.FakeTits = translator.string(input.FakeTits) newPerformer.FakeTits = translator.string(input.FakeTits)
newPerformer.PenisLength = input.PenisLength newPerformer.PenisLength = input.PenisLength
newPerformer.Circumcised = input.Circumcised newPerformer.Circumcised = input.Circumcised
newPerformer.CareerLength = translator.string(input.CareerLength)
newPerformer.Tattoos = translator.string(input.Tattoos) newPerformer.Tattoos = translator.string(input.Tattoos)
newPerformer.Piercings = translator.string(input.Piercings) newPerformer.Piercings = translator.string(input.Piercings)
newPerformer.Favorite = translator.bool(input.Favorite) newPerformer.Favorite = translator.bool(input.Favorite)
@@ -90,6 +89,25 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
return nil, fmt.Errorf("converting death date: %w", err) return nil, fmt.Errorf("converting death date: %w", err)
} }
newPerformer.CareerStart, err = translator.datePtr(input.CareerStart)
if err != nil {
return nil, fmt.Errorf("converting career start: %w", err)
}
newPerformer.CareerEnd, err = translator.datePtr(input.CareerEnd)
if err != nil {
return nil, fmt.Errorf("converting career end: %w", err)
}
// if career_start/career_end not provided, parse deprecated career_length
if newPerformer.CareerStart == nil && newPerformer.CareerEnd == nil && input.CareerLength != nil {
start, end, err := models.ParseYearRangeString(*input.CareerLength)
if err != nil {
return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err)
}
newPerformer.CareerStart = start
newPerformer.CareerEnd = end
}
newPerformer.TagIDs, err = translator.relatedIds(input.TagIds) newPerformer.TagIDs, err = translator.relatedIds(input.TagIds)
if err != nil { if err != nil {
return nil, fmt.Errorf("converting tag ids: %w", err) return nil, fmt.Errorf("converting tag ids: %w", err)
@@ -261,7 +279,29 @@ func performerPartialFromInput(input models.PerformerUpdateInput, translator cha
updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits")
updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length")
updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised") updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised")
updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") // prefer career_start/career_end over deprecated career_length
if translator.hasField("career_start") || translator.hasField("career_end") {
var err error
updatedPerformer.CareerStart, err = translator.optionalDate(input.CareerStart, "career_start")
if err != nil {
return nil, fmt.Errorf("converting career start: %w", err)
}
updatedPerformer.CareerEnd, err = translator.optionalDate(input.CareerEnd, "career_end")
if err != nil {
return nil, fmt.Errorf("converting career end: %w", err)
}
} else if translator.hasField("career_length") && input.CareerLength != nil {
start, end, err := models.ParseYearRangeString(*input.CareerLength)
if err != nil {
return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err)
}
if start != nil {
updatedPerformer.CareerStart = models.NewOptionalDate(*start)
}
if end != nil {
updatedPerformer.CareerEnd = models.NewOptionalDate(*end)
}
}
updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos")
updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings")
updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedPerformer.Favorite = translator.optionalBool(input.Favorite, "favorite")
@@ -348,6 +388,27 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
} }
} }
if updatedPerformer.Aliases != nil {
p, err := qb.Find(ctx, performerID)
if err != nil {
return err
}
if p != nil {
if err := p.LoadAliases(ctx, qb); err != nil {
return err
}
effectiveAliases := updatedPerformer.Aliases.Apply(p.Aliases.List())
name := p.Name
if updatedPerformer.Name.Set {
name = updatedPerformer.Name.Value
}
sanitized := stringslice.UniqueExcludeFold(effectiveAliases, name)
updatedPerformer.Aliases.Values = sanitized
updatedPerformer.Aliases.Mode = models.RelationshipUpdateModeSet
}
}
if err := performer.ValidateUpdate(ctx, performerID, *updatedPerformer, qb); err != nil { if err := performer.ValidateUpdate(ctx, performerID, *updatedPerformer, qb); err != nil {
return err return err
} }
@@ -396,7 +457,28 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits") updatedPerformer.FakeTits = translator.optionalString(input.FakeTits, "fake_tits")
updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length") updatedPerformer.PenisLength = translator.optionalFloat64(input.PenisLength, "penis_length")
updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised") updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised")
updatedPerformer.CareerLength = translator.optionalString(input.CareerLength, "career_length") // prefer career_start/career_end over deprecated career_length
if translator.hasField("career_start") || translator.hasField("career_end") {
updatedPerformer.CareerStart, err = translator.optionalDate(input.CareerStart, "career_start")
if err != nil {
return nil, fmt.Errorf("converting career start: %w", err)
}
updatedPerformer.CareerEnd, err = translator.optionalDate(input.CareerEnd, "career_end")
if err != nil {
return nil, fmt.Errorf("converting career end: %w", err)
}
} else if translator.hasField("career_length") && input.CareerLength != nil {
start, end, err := models.ParseYearRangeString(*input.CareerLength)
if err != nil {
return nil, fmt.Errorf("could not parse career_length %q: %w", *input.CareerLength, err)
}
if start != nil {
updatedPerformer.CareerStart = models.NewOptionalDate(*start)
}
if end != nil {
updatedPerformer.CareerEnd = models.NewOptionalDate(*end)
}
}
updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos") updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos")
updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings") updatedPerformer.Piercings = translator.optionalString(input.Piercings, "piercings")

View File

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

View File

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

View File

@@ -31,14 +31,15 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
} }
// Populate a new studio from the input // Populate a new studio from the input
newStudio := models.NewStudio() newStudio := models.NewCreateStudioInput()
newStudio.Name = strings.TrimSpace(input.Name) newStudio.Name = strings.TrimSpace(input.Name)
newStudio.Rating = input.Rating100 newStudio.Rating = input.Rating100
newStudio.Favorite = translator.bool(input.Favorite) newStudio.Favorite = translator.bool(input.Favorite)
newStudio.Details = translator.string(input.Details) newStudio.Details = translator.string(input.Details)
newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
newStudio.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.Aliases)) newStudio.Organized = translator.bool(input.Organized)
newStudio.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newStudio.Name))
newStudio.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs()) newStudio.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs())
var err error var err error
@@ -61,6 +62,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
if err != nil { if err != nil {
return nil, fmt.Errorf("converting tag ids: %w", err) return nil, fmt.Errorf("converting tag ids: %w", err)
} }
newStudio.CustomFields = convertMapJSONNumbers(input.CustomFields)
// Process the base 64 encoded image string // Process the base 64 encoded image string
var imageData []byte var imageData []byte
@@ -119,6 +121,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
updatedStudio.Rating = translator.optionalInt(input.Rating100, "rating100") updatedStudio.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedStudio.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedStudio.Favorite = translator.optionalBool(input.Favorite, "favorite")
updatedStudio.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") updatedStudio.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
updatedStudio.Organized = translator.optionalBool(input.Organized, "organized")
updatedStudio.Aliases = translator.updateStrings(input.Aliases, "aliases") updatedStudio.Aliases = translator.updateStrings(input.Aliases, "aliases")
updatedStudio.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids") updatedStudio.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids")
@@ -152,6 +155,11 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
} }
} }
updatedStudio.CustomFields = input.CustomFields
// convert json.Numbers to int/float
updatedStudio.CustomFields.Full = convertMapJSONNumbers(updatedStudio.CustomFields.Full)
updatedStudio.CustomFields.Partial = convertMapJSONNumbers(updatedStudio.CustomFields.Partial)
// Process the base 64 encoded image string // Process the base 64 encoded image string
var imageData []byte var imageData []byte
imageIncluded := translator.hasField("image") imageIncluded := translator.hasField("image")
@@ -167,6 +175,28 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
if err := r.withTxn(ctx, func(ctx context.Context) error { if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Studio qb := r.repository.Studio
if updatedStudio.Aliases != nil {
s, err := qb.Find(ctx, studioID)
if err != nil {
return err
}
if s != nil {
if err := s.LoadAliases(ctx, qb); err != nil {
return err
}
effectiveAliases := updatedStudio.Aliases.Apply(s.Aliases.List())
name := s.Name
if updatedStudio.Name.Set {
name = updatedStudio.Name.Value
}
sanitized := stringslice.UniqueExcludeFold(effectiveAliases, name)
updatedStudio.Aliases.Values = sanitized
updatedStudio.Aliases.Mode = models.RelationshipUpdateModeSet
}
}
if err := studio.ValidateModify(ctx, updatedStudio, qb); err != nil { if err := studio.ValidateModify(ctx, updatedStudio, qb); err != nil {
return err return err
} }
@@ -233,6 +263,7 @@ func (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudi
partial.Rating = translator.optionalInt(input.Rating100, "rating100") partial.Rating = translator.optionalInt(input.Rating100, "rating100")
partial.Details = translator.optionalString(input.Details, "details") partial.Details = translator.optionalString(input.Details, "details")
partial.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") partial.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
partial.Organized = translator.optionalBool(input.Organized, "organized")
partial.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") partial.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids")
if err != nil { if err != nil {

View File

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

View File

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

View File

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

View File

@@ -450,7 +450,7 @@ func cssHandler(c *config.Config) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var paths []string var paths []string
if c.GetCSSEnabled() { if c.GetCSSEnabled() && !c.GetDisableCustomizations() {
// search for custom.css in current directory, then $HOME/.stash // search for custom.css in current directory, then $HOME/.stash
fn := c.GetCSSPath() fn := c.GetCSSPath()
exists, _ := fsutil.FileExists(fn) exists, _ := fsutil.FileExists(fn)
@@ -468,7 +468,7 @@ func javascriptHandler(c *config.Config) func(w http.ResponseWriter, r *http.Req
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
var paths []string var paths []string
if c.GetJavascriptEnabled() { if c.GetJavascriptEnabled() && !c.GetDisableCustomizations() {
// search for custom.js in current directory, then $HOME/.stash // search for custom.js in current directory, then $HOME/.stash
fn := c.GetJavascriptPath() fn := c.GetJavascriptPath()
exists, _ := fsutil.FileExists(fn) exists, _ := fsutil.FileExists(fn)
@@ -486,7 +486,7 @@ func customLocalesHandler(c *config.Config) func(w http.ResponseWriter, r *http.
return func(w http.ResponseWriter, r *http.Request) { return func(w http.ResponseWriter, r *http.Request) {
buffer := bytes.Buffer{} buffer := bytes.Buffer{}
if c.GetCustomLocalesEnabled() { if c.GetCustomLocalesEnabled() && !c.GetDisableCustomizations() {
// search for custom-locales.json in current directory, then $HOME/.stash // search for custom-locales.json in current directory, then $HOME/.stash
path := c.GetCustomLocalesPath() path := c.GetCustomLocalesPath()
exists, _ := fsutil.FileExists(path) exists, _ := fsutil.FileExists(path)

View File

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

View File

@@ -2,6 +2,7 @@
package desktop package desktop
import ( import (
"fmt"
"os" "os"
"path" "path"
"path/filepath" "path/filepath"
@@ -155,15 +156,17 @@ func getIconPath() string {
return path.Join(config.GetInstance().GetConfigPath(), "icon.png") return path.Join(config.GetInstance().GetConfigPath(), "icon.png")
} }
func RevealInFileManager(path string) { func RevealInFileManager(path string) error {
exists, err := fsutil.FileExists(path) info, err := os.Stat(path)
if err != nil { if err != nil {
logger.Errorf("Error checking file: %s", err) return fmt.Errorf("error checking path: %w", err)
return
} }
if exists && IsDesktop() {
revealInFileManager(path) absPath, err := filepath.Abs(path)
if err != nil {
return fmt.Errorf("error getting absolute path: %w", err)
} }
return revealInFileManager(absPath, info)
} }
func getServerURL(path string) string { func getServerURL(path string) string {

View File

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

View File

@@ -4,8 +4,10 @@
package desktop package desktop
import ( import (
"fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"strings" "strings"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
@@ -33,8 +35,15 @@ func sendNotification(notificationTitle string, notificationText string) {
} }
} }
func revealInFileManager(path string) { func revealInFileManager(path string, info os.FileInfo) error {
dir := path
if !info.IsDir() {
dir = filepath.Dir(path)
}
if err := exec.Command("xdg-open", dir).Run(); err != nil {
return fmt.Errorf("error opening directory in file manager: %w", err)
}
return nil
} }
func isDoubleClickLaunched() bool { func isDoubleClickLaunched() bool {

View File

@@ -4,6 +4,7 @@
package desktop package desktop
import ( import (
"os"
"os/exec" "os/exec"
"syscall" "syscall"
"unsafe" "unsafe"
@@ -83,6 +84,10 @@ func sendNotification(notificationTitle string, notificationText string) {
} }
} }
func revealInFileManager(path string) { func revealInFileManager(path string, _ os.FileInfo) error {
exec.Command(`explorer`, `\select`, path) c := exec.Command(`explorer`, `/select,`, path)
logger.Debugf("Running: %s", c.String())
// explorer seems to return an error code even when it works, so ignore the error
_ = c.Run()
return nil
} }

View File

@@ -147,6 +147,9 @@ func (t *SceneIdentifier) getOptions(source ScraperSource) MetadataOptions {
if source.Options.IncludeMalePerformers != nil { if source.Options.IncludeMalePerformers != nil {
options.IncludeMalePerformers = source.Options.IncludeMalePerformers options.IncludeMalePerformers = source.Options.IncludeMalePerformers
} }
if source.Options.PerformerGenders != nil {
options.PerformerGenders = source.Options.PerformerGenders
}
if source.Options.SkipMultipleMatches != nil { if source.Options.SkipMultipleMatches != nil {
options.SkipMultipleMatches = source.Options.SkipMultipleMatches options.SkipMultipleMatches = source.Options.SkipMultipleMatches
} }
@@ -204,13 +207,23 @@ func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene,
ret.Partial.StudioID = models.NewOptionalInt(*studioID) ret.Partial.StudioID = models.NewOptionalInt(*studioID)
} }
includeMalePerformers := true // Determine allowed genders for performer filtering
if options.IncludeMalePerformers != nil { var allowedGenders []models.GenderEnum
includeMalePerformers = *options.IncludeMalePerformers if options.PerformerGenders != nil {
// New field takes precedence
allowedGenders = options.PerformerGenders
} else if options.IncludeMalePerformers != nil && !*options.IncludeMalePerformers {
// Legacy: if includeMalePerformers is false, include all genders except male
for _, g := range models.AllGenderEnum {
if g != models.GenderEnumMale {
allowedGenders = append(allowedGenders, g)
}
}
} }
// nil allowedGenders means include all performers
addSkipSingleNamePerformerTag := false addSkipSingleNamePerformerTag := false
performerIDs, err := rel.performers(ctx, !includeMalePerformers) performerIDs, err := rel.performers(ctx, allowedGenders)
if err != nil { if err != nil {
if errors.Is(err, ErrSkipSingleNamePerformer) { if errors.Is(err, ErrSkipSingleNamePerformer) {
addSkipSingleNamePerformerTag = true addSkipSingleNamePerformerTag = true

View File

@@ -60,9 +60,15 @@ func TestSceneIdentifier_Identify(t *testing.T) {
) )
defaultOptions := &MetadataOptions{ defaultOptions := &MetadataOptions{
SetOrganized: &boolFalse, SetOrganized: &boolFalse,
SetCoverImage: &boolFalse, SetCoverImage: &boolFalse,
IncludeMalePerformers: &boolFalse, PerformerGenders: []models.GenderEnum{
models.GenderEnumFemale,
models.GenderEnumTransgenderFemale,
models.GenderEnumTransgenderMale,
models.GenderEnumIntersex,
models.GenderEnumNonBinary,
},
SkipSingleNamePerformers: &boolFalse, SkipSingleNamePerformers: &boolFalse,
} }
sources := []ScraperSource{ sources := []ScraperSource{
@@ -216,9 +222,15 @@ func TestSceneIdentifier_modifyScene(t *testing.T) {
boolFalse := false boolFalse := false
defaultOptions := &MetadataOptions{ defaultOptions := &MetadataOptions{
SetOrganized: &boolFalse, SetOrganized: &boolFalse,
SetCoverImage: &boolFalse, SetCoverImage: &boolFalse,
IncludeMalePerformers: &boolFalse, PerformerGenders: []models.GenderEnum{
models.GenderEnumFemale,
models.GenderEnumTransgenderFemale,
models.GenderEnumTransgenderMale,
models.GenderEnumIntersex,
models.GenderEnumNonBinary,
},
SkipSingleNamePerformers: &boolFalse, SkipSingleNamePerformers: &boolFalse,
} }
tr := &SceneIdentifier{ tr := &SceneIdentifier{

View File

@@ -5,6 +5,7 @@ import (
"io" "io"
"strconv" "strconv"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper" "github.com/stashapp/stash/pkg/scraper"
) )
@@ -32,7 +33,10 @@ type MetadataOptions struct {
SetCoverImage *bool `json:"setCoverImage"` SetCoverImage *bool `json:"setCoverImage"`
SetOrganized *bool `json:"setOrganized"` SetOrganized *bool `json:"setOrganized"`
// defaults to true if not provided // defaults to true if not provided
// Deprecated: use PerformerGenders instead
IncludeMalePerformers *bool `json:"includeMalePerformers"` 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 // defaults to true if not provided
SkipMultipleMatches *bool `json:"skipMultipleMatches"` SkipMultipleMatches *bool `json:"skipMultipleMatches"`
// ID of tag to tag skipped multiple matches with // ID of tag to tag skipped multiple matches with

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -69,7 +70,7 @@ func (g sceneRelationships) studio(ctx context.Context) (*int, error) {
return nil, nil return nil, nil
} }
func (g sceneRelationships) performers(ctx context.Context, ignoreMale bool) ([]int, error) { func (g sceneRelationships) performers(ctx context.Context, allowedGenders []models.GenderEnum) ([]int, error) {
fieldStrategy := g.fieldOptions["performers"] fieldStrategy := g.fieldOptions["performers"]
scraped := g.result.result.Performers scraped := g.result.result.Performers
@@ -97,8 +98,11 @@ func (g sceneRelationships) performers(ctx context.Context, ignoreMale bool) ([]
singleNamePerformerSkipped := false singleNamePerformerSkipped := false
for _, p := range scraped { for _, p := range scraped {
if ignoreMale && p.Gender != nil && strings.EqualFold(*p.Gender, models.GenderEnumMale.String()) { if allowedGenders != nil && p.Gender != nil {
continue gender := models.GenderEnum(strings.ToUpper(*p.Gender))
if !slices.Contains(allowedGenders, gender) {
continue
}
} }
performerID, err := getPerformerID(ctx, endpoint, g.performerCreator, p, createMissing, g.skipSingleNamePerformers) performerID, err := getPerformerID(ctx, endpoint, g.performerCreator, p, createMissing, g.skipSingleNamePerformers)
@@ -167,7 +171,9 @@ func (g sceneRelationships) tags(ctx context.Context) ([]int, error) {
} else if createMissing { } else if createMissing {
newTag := t.ToTag(endpoint, nil) newTag := t.ToTag(endpoint, nil)
err := g.tagCreator.Create(ctx, newTag) err := g.tagCreator.Create(ctx, &models.CreateTagInput{
Tag: newTag,
})
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating tag: %w", err) return nil, fmt.Errorf("error creating tag: %w", err)
} }

View File

@@ -27,7 +27,7 @@ func Test_sceneRelationships_studio(t *testing.T) {
db := mocks.NewDatabase() db := mocks.NewDatabase()
db.Studio.On("Create", testCtx, mock.Anything).Run(func(args mock.Arguments) { db.Studio.On("Create", testCtx, mock.Anything).Run(func(args mock.Arguments) {
s := args.Get(1).(*models.Studio) s := args.Get(1).(*models.CreateStudioInput)
s.ID = validStoredIDInt s.ID = validStoredIDInt
}).Return(nil) }).Return(nil)
@@ -183,13 +183,13 @@ func Test_sceneRelationships_performers(t *testing.T) {
} }
tests := []struct { tests := []struct {
name string name string
scene *models.Scene scene *models.Scene
fieldOptions *FieldOptions fieldOptions *FieldOptions
scraped []*models.ScrapedPerformer scraped []*models.ScrapedPerformer
ignoreMale bool allowedGenders []models.GenderEnum
want []int want []int
wantErr bool wantErr bool
}{ }{
{ {
"ignore", "ignore",
@@ -202,7 +202,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
StoredID: &validStoredID, StoredID: &validStoredID,
}, },
}, },
false, nil,
nil, nil,
false, false,
}, },
@@ -211,7 +211,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
emptyScene, emptyScene,
defaultOptions, defaultOptions,
[]*models.ScrapedPerformer{}, []*models.ScrapedPerformer{},
false, nil,
nil, nil,
false, false,
}, },
@@ -225,7 +225,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
StoredID: &existingPerformerStr, StoredID: &existingPerformerStr,
}, },
}, },
false, nil,
nil, nil,
false, false,
}, },
@@ -239,7 +239,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
StoredID: &validStoredID, StoredID: &validStoredID,
}, },
}, },
false, nil,
[]int{existingPerformerID, validStoredIDInt}, []int{existingPerformerID, validStoredIDInt},
false, false,
}, },
@@ -254,7 +254,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
Gender: &male, Gender: &male,
}, },
}, },
true, []models.GenderEnum{models.GenderEnumFemale, models.GenderEnumTransgenderMale, models.GenderEnumTransgenderFemale, models.GenderEnumIntersex, models.GenderEnumNonBinary},
nil, nil,
false, false,
}, },
@@ -270,7 +270,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
StoredID: &validStoredID, StoredID: &validStoredID,
}, },
}, },
false, nil,
[]int{validStoredIDInt}, []int{validStoredIDInt},
false, false,
}, },
@@ -287,7 +287,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
Gender: &female, Gender: &female,
}, },
}, },
true, []models.GenderEnum{models.GenderEnumFemale, models.GenderEnumTransgenderMale, models.GenderEnumTransgenderFemale, models.GenderEnumIntersex, models.GenderEnumNonBinary},
[]int{validStoredIDInt}, []int{validStoredIDInt},
false, false,
}, },
@@ -304,7 +304,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
StoredID: &invalidStoredID, StoredID: &invalidStoredID,
}, },
}, },
false, nil,
nil, nil,
true, true,
}, },
@@ -319,7 +319,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
}, },
} }
got, err := tr.performers(testCtx, tt.ignoreMale) got, err := tr.performers(testCtx, tt.allowedGenders)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {
t.Errorf("sceneRelationships.performers() error = %v, wantErr %v", err, tt.wantErr) t.Errorf("sceneRelationships.performers() error = %v, wantErr %v", err, tt.wantErr)
return return
@@ -368,14 +368,14 @@ func Test_sceneRelationships_tags(t *testing.T) {
db := mocks.NewDatabase() db := mocks.NewDatabase()
db.Tag.On("Create", testCtx, mock.MatchedBy(func(p *models.Tag) bool { db.Tag.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateTagInput) bool {
return p.Name == validName return p.Tag.Name == validName
})).Run(func(args mock.Arguments) { })).Run(func(args mock.Arguments) {
t := args.Get(1).(*models.Tag) t := args.Get(1).(*models.CreateTagInput)
t.ID = validStoredIDInt t.Tag.ID = validStoredIDInt
}).Return(nil) }).Return(nil)
db.Tag.On("Create", testCtx, mock.MatchedBy(func(p *models.Tag) bool { db.Tag.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateTagInput) bool {
return p.Name == invalidName return p.Tag.Name == invalidName
})).Return(errors.New("error creating tag")) })).Return(errors.New("error creating tag"))
tr := sceneRelationships{ tr := sceneRelationships{

View File

@@ -21,13 +21,13 @@ func Test_createMissingStudio(t *testing.T) {
db := mocks.NewDatabase() db := mocks.NewDatabase()
db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.Studio) bool { db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateStudioInput) bool {
return p.Name == validName return p.Name == validName
})).Run(func(args mock.Arguments) { })).Run(func(args mock.Arguments) {
s := args.Get(1).(*models.Studio) s := args.Get(1).(*models.CreateStudioInput)
s.ID = createdID s.ID = createdID
}).Return(nil) }).Return(nil)
db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.Studio) bool { db.Studio.On("Create", testCtx, mock.MatchedBy(func(p *models.CreateStudioInput) bool {
return p.Name == invalidName return p.Name == invalidName
})).Return(errors.New("error creating studio")) })).Return(errors.New("error creating studio"))

185
internal/manager/backup.go Normal file
View File

@@ -0,0 +1,185 @@
package manager
import (
"archive/zip"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
)
type databaseBackupZip struct {
*zip.Writer
}
func (z *databaseBackupZip) zipFileRename(fn, outDir, outFn string) error {
p := filepath.Join(outDir, outFn)
p = filepath.ToSlash(p)
f, err := z.Create(p)
if err != nil {
return fmt.Errorf("error creating zip entry for %s: %v", fn, err)
}
i, err := os.Open(fn)
if err != nil {
return fmt.Errorf("error opening %s: %v", fn, err)
}
defer i.Close()
if _, err := io.Copy(f, i); err != nil {
return fmt.Errorf("error writing %s to zip: %v", fn, err)
}
return nil
}
func (z *databaseBackupZip) zipFile(fn, outDir string) error {
return z.zipFileRename(fn, outDir, filepath.Base(fn))
}
func (s *Manager) BackupDatabase(download bool, includeBlobs bool) (string, string, error) {
var backupPath string
var backupName string
// if we include blobs, then the output is a zip file
// if not, using the same backup logic as before, which creates a sqlite file
if !includeBlobs || s.Config.GetBlobsStorage() != config.BlobStorageTypeFilesystem {
return s.backupDatabaseOnly(download)
}
// use tmp directory for the backup
backupDir := s.Paths.Generated.Tmp
if err := fsutil.EnsureDir(backupDir); err != nil {
return "", "", fmt.Errorf("could not create backup directory %v: %w", backupDir, err)
}
f, err := os.CreateTemp(backupDir, "backup*.sqlite")
if err != nil {
return "", "", err
}
backupPath = f.Name()
backupName = s.Database.DatabaseBackupPath("")
f.Close()
// delete the temp file so that the backup operation can create it
if err := os.Remove(backupPath); err != nil {
return "", "", fmt.Errorf("could not remove temporary backup file %v: %w", backupPath, err)
}
if err := s.Database.Backup(backupPath); err != nil {
return "", "", err
}
// create a zip file
zipFileDir := s.Paths.Generated.Downloads
if !download {
zipFileDir = s.Config.GetBackupDirectoryPathOrDefault()
if zipFileDir != "" {
if err := fsutil.EnsureDir(zipFileDir); err != nil {
return "", "", fmt.Errorf("could not create backup directory %v: %w", zipFileDir, err)
}
}
}
zipFileName := backupName + ".zip"
zipFilePath := filepath.Join(zipFileDir, zipFileName)
logger.Debugf("Preparing zip file for database backup at %v", zipFilePath)
zf, err := os.Create(zipFilePath)
if err != nil {
return "", "", fmt.Errorf("could not create zip file %v: %w", zipFilePath, err)
}
defer zf.Close()
z := databaseBackupZip{
Writer: zip.NewWriter(zf),
}
defer z.Close()
// move the database file into the zip
dbFn := filepath.Base(s.Config.GetDatabasePath())
if err := z.zipFileRename(backupPath, "", dbFn); err != nil {
return "", "", fmt.Errorf("could not add database backup to zip file: %w", err)
}
if err := os.Remove(backupPath); err != nil {
return "", "", fmt.Errorf("could not remove temporary backup file %v: %w", backupPath, err)
}
// walk the blobs directory and add files to the zip
blobsDir := s.Config.GetBlobsPath()
err = filepath.WalkDir(blobsDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
// calculate out dir by removing the blobsDir prefix from the path
outDir := filepath.Join("blobs", strings.TrimPrefix(filepath.Dir(path), blobsDir))
if err := z.zipFile(path, outDir); err != nil {
return fmt.Errorf("could not add blob %v to zip file: %w", path, err)
}
return nil
})
if err != nil {
return "", "", fmt.Errorf("error walking blobs directory: %w", err)
}
return zipFilePath, zipFileName, nil
}
func (s *Manager) backupDatabaseOnly(download bool) (string, string, error) {
var backupPath string
var backupName string
if download {
backupDir := s.Paths.Generated.Downloads
if err := fsutil.EnsureDir(backupDir); err != nil {
return "", "", fmt.Errorf("could not create backup directory %v: %w", backupDir, err)
}
f, err := os.CreateTemp(backupDir, "backup*.sqlite")
if err != nil {
return "", "", err
}
backupPath = f.Name()
backupName = s.Database.DatabaseBackupPath("")
f.Close()
// delete the temp file so that the backup operation can create it
if err := os.Remove(backupPath); err != nil {
return "", "", fmt.Errorf("could not remove temporary backup file %v: %w", backupPath, err)
}
} else {
backupDir := s.Config.GetBackupDirectoryPathOrDefault()
if backupDir != "" {
if err := fsutil.EnsureDir(backupDir); err != nil {
return "", "", fmt.Errorf("could not create backup directory %v: %w", backupDir, err)
}
}
backupPath = s.Database.DatabaseBackupPath(backupDir)
backupName = filepath.Base(backupPath)
}
err := s.Database.Backup(backupPath)
if err != nil {
return "", "", err
}
return backupPath, backupName, nil
}

View File

@@ -83,6 +83,21 @@ const (
ParallelTasks = "parallel_tasks" ParallelTasks = "parallel_tasks"
parallelTasksDefault = 1 parallelTasksDefault = 1
UseCustomSpriteInterval = "use_custom_sprite_interval"
UseCustomSpriteIntervalDefault = false
SpriteInterval = "sprite_interval"
SpriteIntervalDefault = 30
MinimumSprites = "minimum_sprites"
MinimumSpritesDefault = 10
MaximumSprites = "maximum_sprites"
MaximumSpritesDefault = 500
SpriteScreenshotSize = "sprite_screenshot_width"
spriteScreenshotSizeDefault = 160
PreviewPreset = "preview_preset" PreviewPreset = "preview_preset"
TranscodeHardwareAcceleration = "ffmpeg.hardware_acceleration" TranscodeHardwareAcceleration = "ffmpeg.hardware_acceleration"
@@ -194,6 +209,7 @@ const (
CSSEnabled = "cssenabled" CSSEnabled = "cssenabled"
JavascriptEnabled = "javascriptenabled" JavascriptEnabled = "javascriptenabled"
CustomLocalesEnabled = "customlocalesenabled" CustomLocalesEnabled = "customlocalesenabled"
DisableCustomizations = "disable_customizations"
ShowScrubber = "show_scrubber" ShowScrubber = "show_scrubber"
showScrubberDefault = true showScrubberDefault = true
@@ -974,6 +990,50 @@ func (i *Config) GetParallelTasksWithAutoDetection() int {
return parallelTasks return parallelTasks
} }
// GetUseCustomSpriteInterval returns true if the sprite minimum, maximum, and interval settings
// should be used instead of the default
func (i *Config) GetUseCustomSpriteInterval() bool {
value := i.getBool(UseCustomSpriteInterval)
return value
}
// GetSpriteInterval returns the time (in seconds) to be between each scrubber sprite
// A value of 0 indicates that the sprite interval should be automatically determined
// based on the minimum sprite setting.
func (i *Config) GetSpriteInterval() float64 {
value := i.getFloat64(SpriteInterval)
return value
}
// GetMinimumSprites returns the minimum number of sprites that have to be generated
// A value of 0 will be overridden with the default of 10.
func (i *Config) GetMinimumSprites() int {
value := i.getInt(MinimumSprites)
if value <= 0 {
return MinimumSpritesDefault
}
return value
}
// GetMaximumSprites returns the maximum number of sprites that can be generated
// A value of 0 indicates no maximum.
func (i *Config) GetMaximumSprites() int {
value := i.getInt(MaximumSprites)
return value
}
// GetSpriteScreenshotSize returns the required size of the screenshots to be taken
// during sprite generation in pixels. This will be the width for landscape scenes
// and the height for portrait scenes, with the other dimension being scaled to maintain
// the aspect ratio. If the value is less than or equal to 0, the default will be used.
func (i *Config) GetSpriteScreenshotSize() int {
value := i.getInt(SpriteScreenshotSize)
if value <= 0 {
return spriteScreenshotSizeDefault
}
return value
}
func (i *Config) GetPreviewAudio() bool { func (i *Config) GetPreviewAudio() bool {
return i.getBool(PreviewAudio) return i.getBool(PreviewAudio)
} }
@@ -1479,6 +1539,13 @@ func (i *Config) GetCustomLocalesEnabled() bool {
return i.getBool(CustomLocalesEnabled) return i.getBool(CustomLocalesEnabled)
} }
// GetDisableCustomizations returns true if all customizations (plugins, custom CSS,
// custom JavaScript, and custom locales) should be disabled. This is useful for
// troubleshooting issues without permanently disabling individual customizations.
func (i *Config) GetDisableCustomizations() bool {
return i.getBool(DisableCustomizations)
}
func (i *Config) GetHandyKey() string { func (i *Config) GetHandyKey() string {
return i.getString(HandyKey) return i.getString(HandyKey)
} }
@@ -1853,6 +1920,12 @@ func (i *Config) setDefaultValues() {
i.setDefault(PreviewAudio, previewAudioDefault) i.setDefault(PreviewAudio, previewAudioDefault)
i.setDefault(SoundOnPreview, false) i.setDefault(SoundOnPreview, false)
i.setDefault(UseCustomSpriteInterval, UseCustomSpriteIntervalDefault)
i.setDefault(SpriteInterval, SpriteIntervalDefault)
i.setDefault(MinimumSprites, MinimumSpritesDefault)
i.setDefault(MaximumSprites, MaximumSpritesDefault)
i.setDefault(SpriteScreenshotSize, spriteScreenshotSizeDefault)
i.setDefault(ThemeColor, DefaultThemeColor) i.setDefault(ThemeColor, DefaultThemeColor)
i.setDefault(WriteImageThumbnails, writeImageThumbnailsDefault) i.setDefault(WriteImageThumbnails, writeImageThumbnailsDefault)

View File

@@ -38,3 +38,12 @@ func (s StashConfigs) GetStashFromDirPath(dirPath string) *StashConfig {
} }
return nil return nil
} }
func (s StashConfigs) Paths() []string {
paths := make([]string, len(s))
for i, c := range s {
// #6618 - clean the path to ensure comparison works correctly
paths[i] = filepath.Clean(c.Path)
}
return paths
}

View File

@@ -11,8 +11,10 @@ type ScanMetadataOptions struct {
ScanGenerateImagePreviews bool `json:"scanGenerateImagePreviews"` ScanGenerateImagePreviews bool `json:"scanGenerateImagePreviews"`
// Generate sprites during scan // Generate sprites during scan
ScanGenerateSprites bool `json:"scanGenerateSprites"` ScanGenerateSprites bool `json:"scanGenerateSprites"`
// Generate phashes during scan // Generate video phashes during scan
ScanGeneratePhashes bool `json:"scanGeneratePhashes"` ScanGeneratePhashes bool `json:"scanGeneratePhashes"`
// Generate image phashes during scan
ScanGenerateImagePhashes bool `json:"scanGenerateImagePhashes"`
// Generate image thumbnails during scan // Generate image thumbnails during scan
ScanGenerateThumbnails bool `json:"scanGenerateThumbnails"` ScanGenerateThumbnails bool `json:"scanGenerateThumbnails"`
// Generate image thumbnails during scan // Generate image thumbnails during scan

View File

@@ -21,8 +21,7 @@ type SpriteGenerator struct {
VideoChecksum string VideoChecksum string
ImageOutputPath string ImageOutputPath string
VTTOutputPath string VTTOutputPath string
Rows int Config SpriteGeneratorConfig
Columns int
SlowSeek bool // use alternate seek function, very slow! SlowSeek bool // use alternate seek function, very slow!
Overwrite bool Overwrite bool
@@ -30,13 +29,81 @@ type SpriteGenerator struct {
g *generate.Generator g *generate.Generator
} }
func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageOutputPath string, vttOutputPath string, rows int, cols int) (*SpriteGenerator, error) { // SpriteGeneratorConfig holds configuration for the SpriteGenerator
type SpriteGeneratorConfig struct {
// MinimumSprites is the minimum number of sprites to generate, even if the video duration is short
// SpriteInterval will be adjusted accordingly to ensure at least this many sprites are generated.
// A value of 0 means no minimum, and the generator will use the provided SpriteInterval or
// calculate it based on the video duration and MaximumSprites
MinimumSprites int
// MaximumSprites is the maximum number of sprites to generate, even if the video duration is long
// SpriteInterval will be adjusted accordingly to ensure no more than this many sprites are generated
// A value of 0 means no maximum, and the generator will use the provided SpriteInterval or
// calculate it based on the video duration and MinimumSprites
MaximumSprites int
// SpriteInterval is the default interval in seconds between each sprite.
// If MinimumSprites or MaximumSprites are set, this value will be adjusted accordingly
// to ensure the desired number of sprites are generated
// A value of 0 means the generator will calculate the interval based on the video duration and
// the provided MinimumSprites and MaximumSprites
SpriteInterval float64
// SpriteSize is the size in pixels of the longest dimension of each sprite image.
// The other dimension will be automatically calculated to maintain the aspect ratio of the video
SpriteSize int
}
const (
// DefaultSpriteAmount is the default number of sprites to generate if no configuration is provided
// This corresponds to the legacy behavior of the generator, which generates 81 sprites at equal
// intervals across the video duration
DefaultSpriteAmount = 81
// DefaultSpriteSize is the default size in pixels of the longest dimension of each sprite image
// if no configuration is provided. This corresponds to the legacy behavior of the generator.
DefaultSpriteSize = 160
)
var DefaultSpriteGeneratorConfig = SpriteGeneratorConfig{
MinimumSprites: DefaultSpriteAmount,
MaximumSprites: DefaultSpriteAmount,
SpriteInterval: 0,
SpriteSize: DefaultSpriteSize,
}
// NewSpriteGenerator creates a new SpriteGenerator for the given video file and configuration
// It calculates the appropriate sprite interval and count based on the video duration and the provided configuration
func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageOutputPath string, vttOutputPath string, config SpriteGeneratorConfig) (*SpriteGenerator, error) {
exists, err := fsutil.FileExists(videoFile.Path) exists, err := fsutil.FileExists(videoFile.Path)
if !exists { if !exists {
return nil, err return nil, err
} }
if videoFile.VideoStreamDuration <= 0 {
s := fmt.Sprintf("video %s: duration(%.3f)/frame count(%d) invalid, skipping sprite creation", videoFile.Path, videoFile.VideoStreamDuration, videoFile.FrameCount)
return nil, errors.New(s)
}
config.SpriteInterval = calculateSpriteInterval(videoFile, config)
chunkCount := int(math.Ceil(videoFile.VideoStreamDuration / config.SpriteInterval))
// adjust the chunk count to the next highest perfect square, to ensure the sprite image
// is completely filled (no empty space in the grid) and the grid is as square as possible (minimizing the number of rows/columns)
gridSize := generate.GetSpriteGridSize(chunkCount)
newChunkCount := gridSize * gridSize
if newChunkCount != chunkCount {
logger.Debugf("[generator] adjusting chunk count from %d to %d to fit a %dx%d grid", chunkCount, newChunkCount, gridSize, gridSize)
chunkCount = newChunkCount
}
if config.SpriteSize <= 0 {
config.SpriteSize = DefaultSpriteSize
}
slowSeek := false slowSeek := false
chunkCount := rows * cols
// For files with small duration / low frame count try to seek using frame number intead of seconds // For files with small duration / low frame count try to seek using frame number intead of seconds
if videoFile.VideoStreamDuration < 5 || (0 < videoFile.FrameCount && videoFile.FrameCount <= int64(chunkCount)) { // some files can have FrameCount == 0, only use SlowSeek if duration < 5 if videoFile.VideoStreamDuration < 5 || (0 < videoFile.FrameCount && videoFile.FrameCount <= int64(chunkCount)) { // some files can have FrameCount == 0, only use SlowSeek if duration < 5
@@ -71,9 +138,8 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO
VideoChecksum: videoChecksum, VideoChecksum: videoChecksum,
ImageOutputPath: imageOutputPath, ImageOutputPath: imageOutputPath,
VTTOutputPath: vttOutputPath, VTTOutputPath: vttOutputPath,
Rows: rows, Config: config,
SlowSeek: slowSeek, SlowSeek: slowSeek,
Columns: cols,
g: &generate.Generator{ g: &generate.Generator{
Encoder: instance.FFMpeg, Encoder: instance.FFMpeg,
FFMpegConfig: instance.Config, FFMpegConfig: instance.Config,
@@ -83,6 +149,40 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO
}, nil }, nil
} }
func calculateSpriteInterval(videoFile ffmpeg.VideoFile, config SpriteGeneratorConfig) float64 {
// If a custom sprite interval is provided, start with that
spriteInterval := config.SpriteInterval
// If no custom interval is provided, calculate the interval based on the
// video duration and minimum sprite count
if spriteInterval <= 0 {
minSprites := config.MinimumSprites
if minSprites <= 0 {
panic("invalid configuration: MinimumSprites must be greater than 0 if SpriteInterval is not set")
}
logger.Debugf("[generator] calculating sprite interval for video duration %.3fs with minimum sprites %d", videoFile.VideoStreamDuration, minSprites)
return videoFile.VideoStreamDuration / float64(minSprites)
}
// Calculate the number of sprites that would be generated with the provided interval
spriteCount := int(math.Ceil(videoFile.VideoStreamDuration / spriteInterval))
// If the calculated sprite count is greater than the maximum, adjust the interval to meet the maximum
if config.MaximumSprites > 0 && spriteCount > int(config.MaximumSprites) {
spriteInterval = videoFile.VideoStreamDuration / float64(config.MaximumSprites)
logger.Debugf("[generator] provided sprite interval %.1fs results in %d sprites, which exceeds the maximum of %d, adjusting interval to %.1fs", config.SpriteInterval, spriteCount, config.MaximumSprites, spriteInterval)
}
// If the calculated sprite count is less than the minimum, adjust the interval to meet the minimum
if config.MinimumSprites > 0 && spriteCount < int(config.MinimumSprites) {
spriteInterval = videoFile.VideoStreamDuration / float64(config.MinimumSprites)
logger.Debugf("[generator] provided sprite interval %.1fs results in %d sprites, which is less than the minimum of %d, adjusting interval to %.1fs", config.SpriteInterval, spriteCount, config.MinimumSprites, spriteInterval)
}
return spriteInterval
}
func (g *SpriteGenerator) Generate() error { func (g *SpriteGenerator) Generate() error {
if err := g.generateSpriteImage(); err != nil { if err := g.generateSpriteImage(); err != nil {
return err return err
@@ -100,6 +200,8 @@ func (g *SpriteGenerator) generateSpriteImage() error {
var images []image.Image var images []image.Image
isPortrait := g.Info.VideoFile.Height > g.Info.VideoFile.Width
if !g.SlowSeek { if !g.SlowSeek {
logger.Infof("[generator] generating sprite image for %s", g.Info.VideoFile.Path) logger.Infof("[generator] generating sprite image for %s", g.Info.VideoFile.Path)
// generate `ChunkCount` thumbnails // generate `ChunkCount` thumbnails
@@ -107,8 +209,7 @@ func (g *SpriteGenerator) generateSpriteImage() error {
for i := 0; i < g.Info.ChunkCount; i++ { for i := 0; i < g.Info.ChunkCount; i++ {
time := float64(i) * stepSize time := float64(i) * stepSize
img, err := g.g.SpriteScreenshot(context.TODO(), g.Info.VideoFile.Path, time, g.Config.SpriteSize, isPortrait)
img, err := g.g.SpriteScreenshot(context.TODO(), g.Info.VideoFile.Path, time)
if err != nil { if err != nil {
return err return err
} }
@@ -126,7 +227,7 @@ func (g *SpriteGenerator) generateSpriteImage() error {
return errors.New("invalid frame number conversion") return errors.New("invalid frame number conversion")
} }
img, err := g.g.SpriteScreenshotSlow(context.TODO(), g.Info.VideoFile.Path, int(frame)) img, err := g.g.SpriteScreenshotSlow(context.TODO(), g.Info.VideoFile.Path, int(frame), g.Config.SpriteSize)
if err != nil { if err != nil {
return err return err
} }
@@ -158,7 +259,7 @@ func (g *SpriteGenerator) generateSpriteVTT() error {
stepSize /= g.Info.FrameRate stepSize /= g.Info.FrameRate
} }
return g.g.SpriteVTT(context.TODO(), g.VTTOutputPath, g.ImageOutputPath, stepSize) return g.g.SpriteVTT(context.TODO(), g.VTTOutputPath, g.ImageOutputPath, stepSize, g.Info.ChunkCount)
} }
func (g *SpriteGenerator) imageExists() bool { func (g *SpriteGenerator) imageExists() bool {

View File

@@ -313,46 +313,6 @@ func (s *Manager) validateFFmpeg() error {
return nil return nil
} }
func (s *Manager) BackupDatabase(download bool) (string, string, error) {
var backupPath string
var backupName string
if download {
backupDir := s.Paths.Generated.Downloads
if err := fsutil.EnsureDir(backupDir); err != nil {
return "", "", fmt.Errorf("could not create backup directory %v: %w", backupDir, err)
}
f, err := os.CreateTemp(backupDir, "backup*.sqlite")
if err != nil {
return "", "", err
}
backupPath = f.Name()
backupName = s.Database.DatabaseBackupPath("")
f.Close()
// delete the temp file so that the backup operation can create it
if err := os.Remove(backupPath); err != nil {
return "", "", fmt.Errorf("could not remove temporary backup file %v: %w", backupPath, err)
}
} else {
backupDir := s.Config.GetBackupDirectoryPathOrDefault()
if backupDir != "" {
if err := fsutil.EnsureDir(backupDir); err != nil {
return "", "", fmt.Errorf("could not create backup directory %v: %w", backupDir, err)
}
}
backupPath = s.Database.DatabaseBackupPath(backupDir)
backupName = filepath.Base(backupPath)
}
err := s.Database.Backup(backupPath)
if err != nil {
return "", "", err
}
return backupPath, backupName, nil
}
func (s *Manager) AnonymiseDatabase(download bool) (string, string, error) { func (s *Manager) AnonymiseDatabase(download bool) (string, string, error) {
var outPath string var outPath string
var outName string var outName string

View File

@@ -74,6 +74,28 @@ func getScanPaths(inputPaths []string) []*config.StashConfig {
return ret return ret
} }
// Filters the input array for paths that are within the paths managed by stash
func filterStashPaths(inputPaths []string) []string {
if len(inputPaths) == 0 {
return inputPaths
}
stashPaths := config.GetInstance().GetStashPaths()
var ret []string
for _, p := range inputPaths {
s := stashPaths.GetStashFromDirPath(p)
if s == nil {
logger.Warnf("%s is not in the configured stash paths", p)
continue
}
ret = append(ret, p)
}
return ret
}
// ScanSubscribe subscribes to a notification that is triggered when a // ScanSubscribe subscribes to a notification that is triggered when a
// scan or clean is complete. // scan or clean is complete.
func (s *Manager) ScanSubscribe(ctx context.Context) <-chan bool { func (s *Manager) ScanSubscribe(ctx context.Context) <-chan bool {
@@ -100,6 +122,8 @@ func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error
return 0, err return 0, err
} }
cfg := config.GetInstance()
scanner := &file.Scanner{ scanner := &file.Scanner{
Repository: file.NewRepository(s.Repository), Repository: file.NewRepository(s.Repository),
FileDecorators: []file.Decorator{ FileDecorators: []file.Decorator{
@@ -118,6 +142,11 @@ func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error
}, },
FingerprintCalculator: &fingerprintCalculator{s.Config}, FingerprintCalculator: &fingerprintCalculator{s.Config},
FS: &file.OsFS{}, FS: &file.OsFS{},
ZipFileExtensions: cfg.GetGalleryExtensions(),
// ScanFilters is set in ScanJob.Execute
// HandlerRequiredFilters is set in ScanJob.Execute
RootPaths: cfg.GetStashPaths().Paths(),
Rescan: input.Rescan,
} }
scanJob := ScanJob{ scanJob := ScanJob{
@@ -285,6 +314,8 @@ type CleanMetadataInput struct {
Paths []string `json:"paths"` Paths []string `json:"paths"`
// Do a dry run. Don't delete any files // Do a dry run. Don't delete any files
DryRun bool `json:"dryRun"` DryRun bool `json:"dryRun"`
IgnoreZipFileContents bool `json:"ignoreZipFileContents"`
} }
func (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int { func (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int {
@@ -402,7 +433,7 @@ type StashBoxBatchTagInput struct {
ExcludeFields []string `json:"exclude_fields"` ExcludeFields []string `json:"exclude_fields"`
// Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false // Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false
Refresh bool `json:"refresh"` Refresh bool `json:"refresh"`
// If batch adding studios, should their parent studios also be created? // If batch adding studios or tags, should their parent entities also be created?
CreateParent bool `json:"createParent"` CreateParent bool `json:"createParent"`
// IDs in stash of the items to update. // IDs in stash of the items to update.
// If set, names and stash_ids fields will be ignored. // If set, names and stash_ids fields will be ignored.
@@ -698,3 +729,137 @@ func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, box *models.StashB
return s.JobManager.Add(ctx, "Batch stash-box studio tag...", j) return s.JobManager.Add(ctx, "Batch stash-box studio tag...", j)
} }
func (s *Manager) batchTagTagsByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
var tasks []Task
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
tagQuery := s.Repository.Tag
for _, tagID := range input.Ids {
if id, err := strconv.Atoi(tagID); err == nil {
t, err := tagQuery.Find(ctx, id)
if err != nil {
return err
}
if err := t.LoadStashIDs(ctx, tagQuery); err != nil {
return fmt.Errorf("loading tag stash ids: %w", err)
}
hasStashID := t.StashIDs.ForEndpoint(box.Endpoint) != nil
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
tasks = append(tasks, &stashBoxBatchTagTagTask{
tag: t,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
}
return nil
})
return tasks, err
}
func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task {
var tasks []Task
for i := range input.StashIDs {
stashID := input.StashIDs[i]
if len(stashID) > 0 {
tasks = append(tasks, &stashBoxBatchTagTagTask{
stashID: &stashID,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
for i := range input.Names {
name := input.Names[i]
if len(name) > 0 {
tasks = append(tasks, &stashBoxBatchTagTagTask{
name: &name,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
return tasks
}
func (s *Manager) batchTagAllTags(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
var tasks []Task
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
tagQuery := s.Repository.Tag
var tags []*models.Tag
var err error
tags, err = tagQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint)
if err != nil {
return fmt.Errorf("error querying tags: %v", err)
}
for _, t := range tags {
tasks = append(tasks, &stashBoxBatchTagTagTask{
tag: t,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
}
return nil
})
return tasks, err
}
func (s *Manager) StashBoxBatchTagTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {
logger.Infof("Initiating stash-box batch tag tag")
var tasks []Task
var err error
switch input.getBatchTagType(false) {
case batchTagByIds:
tasks, err = s.batchTagTagsByIds(ctx, input, box)
case batchTagByNamesOrStashIds:
tasks = s.batchTagTagsByNamesOrStashIds(input, box)
case batchTagAll:
tasks, err = s.batchTagAllTags(ctx, input, box)
}
if err != nil {
return err
}
if len(tasks) == 0 {
return nil
}
progress.SetTotal(len(tasks))
logger.Infof("Starting stash-box batch operation for %d tags", len(tasks))
for _, task := range tasks {
progress.ExecuteTask(task.GetDescription(), func() {
task.Start(ctx)
})
progress.Increment()
}
return nil
})
return s.JobManager.Add(ctx, "Batch stash-box tag tag...", j)
}

View File

@@ -10,17 +10,17 @@ import (
) )
type SceneService interface { type SceneService interface {
Create(ctx context.Context, input *models.Scene, fileIDs []models.FileID, coverImage []byte) (*models.Scene, error) Create(ctx context.Context, input models.CreateSceneInput) (*models.Scene, error)
AssignFile(ctx context.Context, sceneID int, fileID models.FileID) error AssignFile(ctx context.Context, sceneID int, fileID models.FileID) error
Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *scene.FileDeleter, options scene.MergeOptions) error Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *scene.FileDeleter, options scene.MergeOptions) error
Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile bool) error Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error
FindByIDs(ctx context.Context, ids []int, load ...scene.LoadRelationshipOption) ([]*models.Scene, error) FindByIDs(ctx context.Context, ids []int, load ...scene.LoadRelationshipOption) ([]*models.Scene, error)
sceneFingerprintGetter sceneFingerprintGetter
} }
type ImageService interface { type ImageService interface {
Destroy(ctx context.Context, image *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) error Destroy(ctx context.Context, image *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error
DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error) DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error)
} }
@@ -31,7 +31,7 @@ type GalleryService interface {
SetCover(ctx context.Context, g *models.Gallery, coverImageId int) error SetCover(ctx context.Context, g *models.Gallery, coverImageId int) error
ResetCover(ctx context.Context, g *models.Gallery) error ResetCover(ctx context.Context, g *models.Gallery) error
Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error)
ValidateImageGalleryChange(ctx context.Context, i *models.Image, updateIDs models.UpdateIDs) error ValidateImageGalleryChange(ctx context.Context, i *models.Image, updateIDs models.UpdateIDs) error
@@ -39,7 +39,7 @@ type GalleryService interface {
} }
type GroupService interface { type GroupService interface {
Create(ctx context.Context, group *models.Group, frontimageData []byte, backimageData []byte) error Create(ctx context.Context, input *models.CreateGroupInput) error
UpdatePartial(ctx context.Context, id int, updatedGroup models.GroupPartial, frontImage group.ImageInput, backImage group.ImageInput) (*models.Group, error) UpdatePartial(ctx context.Context, id int, updatedGroup models.GroupPartial, frontImage group.ImageInput, backImage group.ImageInput) (*models.Group, error)
AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error AddSubGroups(ctx context.Context, groupID int, subGroups []models.GroupIDDescription, insertIndex *int) error

View File

@@ -0,0 +1,268 @@
//go:build integration
// +build integration
package manager
import (
"context"
"io/fs"
"os"
"path/filepath"
"testing"
"github.com/stashapp/stash/pkg/file"
// Necessary to register custom migrations.
_ "github.com/stashapp/stash/pkg/sqlite/migrations"
)
// stashIgnorePathFilter wraps StashIgnoreFilter to implement PathFilter for testing.
// It provides a fixed library root for the filter.
type stashIgnorePathFilter struct {
filter *file.StashIgnoreFilter
libraryRoot string
}
func (f *stashIgnorePathFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool {
return f.filter.Accept(ctx, path, info, f.libraryRoot, zipFilePath)
}
// createTestFileOnDisk creates a file with some content.
func createTestFileOnDisk(t *testing.T, dir, name string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
t.Fatalf("failed to create directory for %s: %v", path, err)
}
// Write some content so the file has a non-zero size.
if err := os.WriteFile(path, []byte("test content for "+name), 0644); err != nil {
t.Fatalf("failed to create file %s: %v", path, err)
}
return path
}
// createStashIgnoreFile creates a .stashignore file with the given content.
func createStashIgnoreFile(t *testing.T, dir, content string) {
t.Helper()
path := filepath.Join(dir, ".stashignore")
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("failed to create .stashignore: %v", err)
}
}
func TestScannerWithStashIgnore(t *testing.T) {
// Create temp directory structure.
tmpDir := t.TempDir()
// Create test files.
createTestFileOnDisk(t, tmpDir, "video1.mp4")
createTestFileOnDisk(t, tmpDir, "video2.mp4")
createTestFileOnDisk(t, tmpDir, "ignore_me.mp4")
createTestFileOnDisk(t, tmpDir, "subdir/video3.mp4")
createTestFileOnDisk(t, tmpDir, "subdir/skip_this.mp4")
createTestFileOnDisk(t, tmpDir, "excluded_dir/video4.mp4")
createTestFileOnDisk(t, tmpDir, "temp/processing.mp4")
// Create .stashignore file.
stashignore := `# Ignore specific files
ignore_me.mp4
subdir/skip_this.mp4
# Ignore directories
excluded_dir/
temp/
`
createStashIgnoreFile(t, tmpDir, stashignore)
// Create stashignore filter with library root.
stashIgnoreFilter := &stashIgnorePathFilter{
filter: file.NewStashIgnoreFilter(),
libraryRoot: tmpDir,
}
// Create scanner.
scanner := &file.Scanner{
ScanFilters: []file.PathFilter{stashIgnoreFilter},
}
testScenarios := []struct {
path string
accepted bool
}{
{filepath.Join(tmpDir, "video1.mp4"), true},
{filepath.Join(tmpDir, "video2.mp4"), true},
{filepath.Join(tmpDir, "ignore_me.mp4"), false},
{filepath.Join(tmpDir, "subdir/video3.mp4"), true},
{filepath.Join(tmpDir, "subdir/skip_this.mp4"), false},
{filepath.Join(tmpDir, "excluded_dir/video4.mp4"), false},
{filepath.Join(tmpDir, "temp/processing.mp4"), false},
}
ctx := context.Background()
for _, scenario := range testScenarios {
info, err := os.Stat(scenario.path)
if err != nil {
t.Fatalf("failed to stat file %s: %v", scenario.path, err)
}
accepted := scanner.AcceptEntry(ctx, scenario.path, info, "")
if accepted != scenario.accepted {
t.Errorf("unexpected accept result for %s: expected %v, got %v",
scenario.path, scenario.accepted, accepted)
}
}
}
func TestScannerWithNestedStashIgnore(t *testing.T) {
// Create temp directory structure.
tmpDir := t.TempDir()
// Create test files.
createTestFileOnDisk(t, tmpDir, "root.mp4")
createTestFileOnDisk(t, tmpDir, "root.tmp")
createTestFileOnDisk(t, tmpDir, "subdir/sub.mp4")
createTestFileOnDisk(t, tmpDir, "subdir/sub.log")
createTestFileOnDisk(t, tmpDir, "subdir/sub.tmp")
// Root .stashignore excludes *.tmp.
createStashIgnoreFile(t, tmpDir, "*.tmp\n")
// Subdir .stashignore excludes *.log.
createStashIgnoreFile(t, filepath.Join(tmpDir, "subdir"), "*.log\n")
// Create stashignore filter with library root.
stashIgnoreFilter := &stashIgnorePathFilter{
filter: file.NewStashIgnoreFilter(),
libraryRoot: tmpDir,
}
// Create scanner.
scanner := &file.Scanner{
ScanFilters: []file.PathFilter{stashIgnoreFilter},
}
testScenarios := []struct {
path string
accepted bool
}{
{filepath.Join(tmpDir, "root.mp4"), true},
{filepath.Join(tmpDir, "root.tmp"), false},
{filepath.Join(tmpDir, "subdir/sub.mp4"), true},
{filepath.Join(tmpDir, "subdir/sub.log"), false},
{filepath.Join(tmpDir, "subdir/sub.tmp"), false},
}
ctx := context.Background()
for _, scenario := range testScenarios {
info, err := os.Stat(scenario.path)
if err != nil {
t.Fatalf("failed to stat file %s: %v", scenario.path, err)
}
accepted := scanner.AcceptEntry(ctx, scenario.path, info, "")
if accepted != scenario.accepted {
t.Errorf("unexpected accept result for %s: expected %v, got %v",
scenario.path, scenario.accepted, accepted)
}
}
}
func TestScannerWithoutStashIgnore(t *testing.T) {
// Create temp directory structure (no .stashignore).
tmpDir := t.TempDir()
// Create test files.
createTestFileOnDisk(t, tmpDir, "video1.mp4")
createTestFileOnDisk(t, tmpDir, "video2.mp4")
createTestFileOnDisk(t, tmpDir, "subdir/video3.mp4")
// Create stashignore filter with library root (but no .stashignore file exists).
stashIgnoreFilter := &stashIgnorePathFilter{
filter: file.NewStashIgnoreFilter(),
libraryRoot: tmpDir,
}
// Create scanner.
scanner := &file.Scanner{
ScanFilters: []file.PathFilter{stashIgnoreFilter},
}
testScenarios := []struct {
path string
accepted bool
}{
{filepath.Join(tmpDir, "video1.mp4"), true},
{filepath.Join(tmpDir, "video2.mp4"), true},
{filepath.Join(tmpDir, "subdir/video3.mp4"), true},
}
ctx := context.Background()
for _, scenario := range testScenarios {
info, err := os.Stat(scenario.path)
if err != nil {
t.Fatalf("failed to stat file %s: %v", scenario.path, err)
}
accepted := scanner.AcceptEntry(ctx, scenario.path, info, "")
if accepted != scenario.accepted {
t.Errorf("unexpected accept result for %s: expected %v, got %v",
scenario.path, scenario.accepted, accepted)
}
}
}
func TestScannerWithNegationPattern(t *testing.T) {
// Create temp directory structure.
tmpDir := t.TempDir()
// Create test files.
createTestFileOnDisk(t, tmpDir, "file1.tmp")
createTestFileOnDisk(t, tmpDir, "file2.tmp")
createTestFileOnDisk(t, tmpDir, "keep_this.tmp")
createTestFileOnDisk(t, tmpDir, "video.mp4")
// Create .stashignore with negation.
stashignore := `*.tmp
!keep_this.tmp
`
createStashIgnoreFile(t, tmpDir, stashignore)
// Create stashignore filter with library root.
stashIgnoreFilter := &stashIgnorePathFilter{
filter: file.NewStashIgnoreFilter(),
libraryRoot: tmpDir,
}
// Create scanner.
scanner := &file.Scanner{
ScanFilters: []file.PathFilter{stashIgnoreFilter},
}
testScenarios := []struct {
path string
accepted bool
}{
{filepath.Join(tmpDir, "file1.tmp"), false},
{filepath.Join(tmpDir, "file2.tmp"), false},
{filepath.Join(tmpDir, "keep_this.tmp"), true},
{filepath.Join(tmpDir, "video.mp4"), true},
}
ctx := context.Background()
for _, scenario := range testScenarios {
info, err := os.Stat(scenario.path)
if err != nil {
t.Fatalf("failed to stat file %s: %v", scenario.path, err)
}
accepted := scanner.AcceptEntry(ctx, scenario.path, info, "")
if accepted != scenario.accepted {
t.Errorf("unexpected accept result for %s: expected %v, got %v",
scenario.path, scenario.accepted, accepted)
}
}
}

View File

@@ -565,6 +565,7 @@ func (j *CleanGeneratedJob) cleanMarkerFiles(ctx context.Context, progress *job.
j.setProgressFromFilename(sceneHash[0:2], progress) j.setProgressFromFilename(sceneHash[0:2], progress)
// check if the scene exists // check if the scene exists
var walkErr error
if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error { if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error {
var err error var err error
scenes, err = j.getScenesWithHash(ctx, sceneHash) scenes, err = j.getScenesWithHash(ctx, sceneHash)
@@ -575,15 +576,18 @@ func (j *CleanGeneratedJob) cleanMarkerFiles(ctx context.Context, progress *job.
if len(scenes) == 0 { if len(scenes) == 0 {
j.logDelete("deleting unused marker directory: %s", sceneHash) j.logDelete("deleting unused marker directory: %s", sceneHash)
j.deleteDir(path) j.deleteDir(path)
} else { // #5911 - we've just deleted the directory, so skip it in the walk to avoid errors
// get the markers now walkErr = fs.SkipDir
for _, scene := range scenes { return nil
thisMarkers, err := j.Repository.SceneMarker.FindBySceneID(ctx, scene.ID) }
if err != nil {
return fmt.Errorf("error getting markers for scene: %v", err) // get the markers now
} for _, scene := range scenes {
markers = append(markers, thisMarkers...) thisMarkers, err := j.Repository.SceneMarker.FindBySceneID(ctx, scene.ID)
if err != nil {
return fmt.Errorf("error getting markers for scene: %v", err)
} }
markers = append(markers, thisMarkers...)
} }
return nil return nil
@@ -591,7 +595,7 @@ func (j *CleanGeneratedJob) cleanMarkerFiles(ctx context.Context, progress *job.
logger.Error(err.Error()) logger.Error(err.Error())
} }
return nil return walkErr
} }
filename := info.Name() filename := info.Name()

View File

@@ -40,9 +40,10 @@ func (j *cleanJob) Execute(ctx context.Context, progress *job.Progress) error {
} }
j.cleaner.Clean(ctx, file.CleanOptions{ j.cleaner.Clean(ctx, file.CleanOptions{
Paths: j.input.Paths, Paths: j.input.Paths,
DryRun: j.input.DryRun, DryRun: j.input.DryRun,
PathFilter: newCleanFilter(instance.Config), IgnoreZipFileContents: j.input.IgnoreZipFileContents,
PathFilter: newCleanFilter(instance.Config),
}, progress) }, progress)
if job.IsCancelled(ctx) { if job.IsCancelled(ctx) {
@@ -154,11 +155,12 @@ func newCleanFilter(c *config.Config) *cleanFilter {
generatedPath: c.GetGeneratedPath(), generatedPath: c.GetGeneratedPath(),
videoExcludeRegex: generateRegexps(c.GetExcludes()), videoExcludeRegex: generateRegexps(c.GetExcludes()),
imageExcludeRegex: generateRegexps(c.GetImageExcludes()), imageExcludeRegex: generateRegexps(c.GetImageExcludes()),
stashIgnoreFilter: file.NewStashIgnoreFilter(),
}, },
} }
} }
func (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) bool { func (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool {
// #1102 - clean anything in generated path // #1102 - clean anything in generated path
generatedPath := f.generatedPath generatedPath := f.generatedPath
@@ -173,12 +175,18 @@ func (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo)
} }
if stash == nil { if stash == nil {
logger.Infof("%s not in any stash library directories. Marking to clean: \"%s\"", fileOrFolder, path) logger.Infof("%s not in any stash library directories. Marking to clean: %q", fileOrFolder, path)
return false return false
} }
if fsutil.IsPathInDir(generatedPath, path) { if fsutil.IsPathInDir(generatedPath, path) {
logger.Infof("%s is in generated path. Marking to clean: \"%s\"", fileOrFolder, path) logger.Infof("%s is in generated path. Marking to clean: %q", fileOrFolder, path)
return false
}
// Check .stashignore files, bounded to the library root.
if !f.stashIgnoreFilter.Accept(ctx, path, info, stash.Path, zipFilePath) {
logger.Infof("%s is excluded due to .stashignore. Marking to clean: %q", fileOrFolder, path)
return false return false
} }
@@ -300,7 +308,10 @@ func (h *cleanHandler) handleRelatedScenes(ctx context.Context, fileDeleter *fil
// only delete if the scene has no other files // only delete if the scene has no other files
if len(scene.Files.List()) <= 1 { if len(scene.Files.List()) <= 1 {
logger.Infof("Deleting scene %q since it has no other related files", scene.DisplayName()) logger.Infof("Deleting scene %q since it has no other related files", scene.DisplayName())
if err := mgr.SceneService.Destroy(ctx, scene, sceneFileDeleter, true, false); err != nil { const deleteGenerated = true
const deleteFile = false
const destroyFileEntry = false
if err := mgr.SceneService.Destroy(ctx, scene, sceneFileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
return err return err
} }
@@ -421,7 +432,10 @@ func (h *cleanHandler) handleRelatedImages(ctx context.Context, fileDeleter *fil
if len(i.Files.List()) <= 1 { if len(i.Files.List()) <= 1 {
logger.Infof("Deleting image %q since it has no other related files", i.DisplayName()) logger.Infof("Deleting image %q since it has no other related files", i.DisplayName())
if err := mgr.ImageService.Destroy(ctx, i, imageFileDeleter, true, false); err != nil { const deleteGenerated = true
const deleteFile = false
const destroyFileEntry = false
if err := mgr.ImageService.Destroy(ctx, i, imageFileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
return err return err
} }

View File

@@ -651,6 +651,7 @@ func (t *ExportTask) exportImage(ctx context.Context, wg *sync.WaitGroup, jobCha
galleryReader := r.Gallery galleryReader := r.Gallery
performerReader := r.Performer performerReader := r.Performer
tagReader := r.Tag tagReader := r.Tag
imageReader := r.Image
for s := range jobChan { for s := range jobChan {
imageHash := s.Checksum imageHash := s.Checksum
@@ -665,14 +666,17 @@ func (t *ExportTask) exportImage(ctx context.Context, wg *sync.WaitGroup, jobCha
continue continue
} }
newImageJSON := image.ToBasicJSON(s) newImageJSON, err := image.ToBasicJSON(ctx, imageReader, s)
if err != nil {
logger.Errorf("[images] <%s> error converting image to JSON: %v", imageHash, err)
continue
}
// export files // export files
for _, f := range s.Files.List() { for _, f := range s.Files.List() {
t.exportFile(f) t.exportFile(f)
} }
var err error
newImageJSON.Studio, err = image.GetStudioName(ctx, studioReader, s) newImageJSON.Studio, err = image.GetStudioName(ctx, studioReader, s)
if err != nil { if err != nil {
logger.Errorf("[images] <%s> error getting image studio name: %v", imageHash, err) logger.Errorf("[images] <%s> error getting image studio name: %v", imageHash, err)
@@ -779,6 +783,7 @@ func (t *ExportTask) exportGallery(ctx context.Context, wg *sync.WaitGroup, jobC
studioReader := r.Studio studioReader := r.Studio
performerReader := r.Performer performerReader := r.Performer
tagReader := r.Tag tagReader := r.Tag
galleryReader := r.Gallery
galleryChapterReader := r.GalleryChapter galleryChapterReader := r.GalleryChapter
for g := range jobChan { for g := range jobChan {
@@ -847,6 +852,12 @@ func (t *ExportTask) exportGallery(ctx context.Context, wg *sync.WaitGroup, jobC
newGalleryJSON.Tags = tag.GetNames(tags) newGalleryJSON.Tags = tag.GetNames(tags)
newGalleryJSON.CustomFields, err = galleryReader.GetCustomFields(ctx, g.ID)
if err != nil {
logger.Errorf("[galleries] <%s> error getting gallery custom fields: %v", g.DisplayName(), err)
continue
}
if t.includeDependencies { if t.includeDependencies {
if g.StudioID != nil { if g.StudioID != nil {
t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *g.StudioID) t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *g.StudioID)

View File

@@ -29,6 +29,7 @@ type GenerateMetadataInput struct {
// Generate transcodes even if not required // Generate transcodes even if not required
ForceTranscodes bool `json:"forceTranscodes"` ForceTranscodes bool `json:"forceTranscodes"`
Phashes bool `json:"phashes"` Phashes bool `json:"phashes"`
ImagePhashes bool `json:"imagePhashes"`
InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"` InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"`
ClipPreviews bool `json:"clipPreviews"` ClipPreviews bool `json:"clipPreviews"`
ImageThumbnails bool `json:"imageThumbnails"` ImageThumbnails bool `json:"imageThumbnails"`
@@ -36,8 +37,14 @@ type GenerateMetadataInput struct {
SceneIDs []string `json:"sceneIDs"` SceneIDs []string `json:"sceneIDs"`
// marker ids to generate for // marker ids to generate for
MarkerIDs []string `json:"markerIDs"` MarkerIDs []string `json:"markerIDs"`
// image ids to generate for
ImageIDs []string `json:"imageIDs"`
// gallery ids to generate for
GalleryIDs []string `json:"galleryIDs"`
// overwrite existing media // overwrite existing media
Overwrite bool `json:"overwrite"` Overwrite bool `json:"overwrite"`
// paths to run generate on, in addition to the other ID lists
Paths []string `json:"paths"`
} }
type GeneratePreviewOptionsInput struct { type GeneratePreviewOptionsInput struct {
@@ -73,6 +80,7 @@ type totalsGenerate struct {
markers int64 markers int64
transcodes int64 transcodes int64
phashes int64 phashes int64
imagePhashes int64
interactiveHeatmapSpeeds int64 interactiveHeatmapSpeeds int64
clipPreviews int64 clipPreviews int64
imageThumbnails int64 imageThumbnails int64
@@ -82,8 +90,9 @@ type totalsGenerate struct {
func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error { func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error {
var scenes []*models.Scene var scenes []*models.Scene
var err error
var markers []*models.SceneMarker var markers []*models.SceneMarker
var images []*models.Image
var err error
j.overwrite = j.input.Overwrite j.overwrite = j.input.Overwrite
j.fileNamingAlgo = config.GetInstance().GetVideoFileNamingAlgorithm() j.fileNamingAlgo = config.GetInstance().GetVideoFileNamingAlgorithm()
@@ -105,6 +114,14 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
if err != nil { if err != nil {
logger.Error(err.Error()) logger.Error(err.Error())
} }
imageIDs, err := stringslice.StringSliceToIntSlice(j.input.ImageIDs)
if err != nil {
logger.Error(err.Error())
}
galleryIDs, err := stringslice.StringSliceToIntSlice(j.input.GalleryIDs)
if err != nil {
logger.Error(err.Error())
}
g := &generate.Generator{ g := &generate.Generator{
Encoder: instance.FFMpeg, Encoder: instance.FFMpeg,
@@ -118,8 +135,13 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
r := j.repository r := j.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error { if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
qb := r.Scene qb := r.Scene
if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 { if len(j.input.SceneIDs) == 0 &&
j.queueTasks(ctx, g, queue) len(j.input.MarkerIDs) == 0 &&
len(j.input.ImageIDs) == 0 &&
len(j.input.GalleryIDs) == 0 &&
len(j.input.Paths) == 0 {
j.queueTasks(ctx, g, nil, queue)
} else { } else {
if len(j.input.SceneIDs) > 0 { if len(j.input.SceneIDs) > 0 {
scenes, err = qb.FindMany(ctx, sceneIDs) scenes, err = qb.FindMany(ctx, sceneIDs)
@@ -141,6 +163,38 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
j.queueMarkerJob(g, m, queue) j.queueMarkerJob(g, m, queue)
} }
} }
if len(j.input.ImageIDs) > 0 {
images, err = r.Image.FindMany(ctx, imageIDs)
for _, i := range images {
if err := i.LoadFiles(ctx, r.Image); err != nil {
return err
}
j.queueImageJob(g, i, queue)
}
}
if len(j.input.GalleryIDs) > 0 {
for _, galleryID := range galleryIDs {
imgs, err := r.Image.FindByGalleryID(ctx, galleryID)
if err != nil {
return err
}
for _, img := range imgs {
if err := img.LoadFiles(ctx, r.Image); err != nil {
return err
}
j.queueImageJob(g, img, queue)
}
}
}
if len(j.input.Paths) > 0 {
paths := filterStashPaths(j.input.Paths)
j.queueTasks(ctx, g, paths, queue)
}
} }
return nil return nil
@@ -172,14 +226,17 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
if j.input.Phashes { if j.input.Phashes {
logMsg += fmt.Sprintf(" %d phashes", totals.phashes) logMsg += fmt.Sprintf(" %d phashes", totals.phashes)
} }
if j.input.ImagePhashes {
logMsg += fmt.Sprintf(" %d image phashes", totals.imagePhashes)
}
if j.input.InteractiveHeatmapsSpeeds { if j.input.InteractiveHeatmapsSpeeds {
logMsg += fmt.Sprintf(" %d heatmaps & speeds", totals.interactiveHeatmapSpeeds) logMsg += fmt.Sprintf(" %d heatmaps & speeds", totals.interactiveHeatmapSpeeds)
} }
if j.input.ClipPreviews { if j.input.ClipPreviews {
logMsg += fmt.Sprintf(" %d Image Clip Previews", totals.clipPreviews) logMsg += fmt.Sprintf(" %d image clip previews", totals.clipPreviews)
} }
if j.input.ImageThumbnails { if j.input.ImageThumbnails {
logMsg += fmt.Sprintf(" %d Image Thumbnails", totals.imageThumbnails) logMsg += fmt.Sprintf(" %d image thumbnails", totals.imageThumbnails)
} }
if logMsg == "Generating" { if logMsg == "Generating" {
logMsg = "Nothing selected to generate" logMsg = "Nothing selected to generate"
@@ -231,17 +288,18 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
return nil return nil
} }
func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) { func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) {
j.totals = totalsGenerate{} j.totals = totalsGenerate{}
j.queueScenesTasks(ctx, g, queue) j.queueScenesTasks(ctx, g, paths, queue)
j.queueImagesTasks(ctx, g, queue) j.queueImagesTasks(ctx, g, paths, queue)
} }
func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) { func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) {
const batchSize = 1000 const batchSize = 1000
findFilter := models.BatchFindFilter(batchSize) findFilter := models.BatchFindFilter(batchSize)
sceneFilter := scene.FilterFromPaths(paths)
r := j.repository r := j.repository
@@ -250,7 +308,7 @@ func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generato
return return
} }
scenes, err := scene.Query(ctx, r.Scene, nil, findFilter) scenes, err := scene.Query(ctx, r.Scene, sceneFilter, findFilter)
if err != nil { if err != nil {
logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
return return
@@ -277,19 +335,20 @@ func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generato
} }
} }
func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) { func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generator, paths []string, queue chan<- Task) {
const batchSize = 1000 const batchSize = 1000
findFilter := models.BatchFindFilter(batchSize) findFilter := models.BatchFindFilter(batchSize)
imageFilter := image.FilterFromPaths(paths)
r := j.repository r := j.repository
for more := j.input.ClipPreviews || j.input.ImageThumbnails; more; { for more := j.input.ClipPreviews || j.input.ImageThumbnails || j.input.ImagePhashes; more; {
if job.IsCancelled(ctx) { if job.IsCancelled(ctx) {
return return
} }
images, err := image.Query(ctx, r.Image, nil, findFilter) images, err := image.Query(ctx, r.Image, imageFilter, findFilter)
if err != nil { if err != nil {
logger.Errorf("Error encountered queuing files to scan: %s", err.Error()) logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
return return
@@ -525,4 +584,23 @@ func (j *GenerateJob) queueImageJob(g *generate.Generator, image *models.Image,
queue <- task queue <- task
} }
} }
if j.input.ImagePhashes {
// generate for all files in image
for _, f := range image.Files.List() {
if imageFile, ok := f.(*models.ImageFile); ok {
task := &GenerateImagePhashTask{
repository: j.repository,
File: imageFile,
Overwrite: j.overwrite,
}
if task.required() {
j.totals.imagePhashes++
j.totals.tasks++
queue <- task
}
}
}
}
} }

View File

@@ -0,0 +1,103 @@
package manager
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/hash/imagephash"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
type GenerateImagePhashTask struct {
repository models.Repository
File *models.ImageFile
Overwrite bool
}
func (t *GenerateImagePhashTask) GetDescription() string {
return fmt.Sprintf("Generating phash for %s", t.File.Path)
}
func (t *GenerateImagePhashTask) Start(ctx context.Context) {
if !t.required() {
return
}
var hash int64
set := false
// #4393 - if there is a file with the same md5, we can use the same phash
// only use this if we're not overwriting
if !t.Overwrite {
existing, err := t.findExistingPhash(ctx)
if err != nil {
logger.Warnf("Error finding existing phash: %v", err)
} else if existing != nil {
logger.Infof("Using existing phash for %s", t.File.Path)
hash = existing.(int64)
set = true
}
}
if !set {
generated, err := imagephash.Generate(instance.FFMpeg, t.File)
if err != nil {
logger.Errorf("Error generating phash for %q: %v", t.File.Path, err)
logErrorOutput(err)
return
}
hash = int64(*generated)
}
r := t.repository
if err := r.WithTxn(ctx, func(ctx context.Context) error {
t.File.Fingerprints = t.File.Fingerprints.AppendUnique(models.Fingerprint{
Type: models.FingerprintTypePhash,
Fingerprint: hash,
})
return r.File.Update(ctx, t.File)
}); err != nil && ctx.Err() == nil {
logger.Errorf("Error setting phash: %v", err)
}
}
func (t *GenerateImagePhashTask) findExistingPhash(ctx context.Context) (interface{}, error) {
r := t.repository
var ret interface{}
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
md5 := t.File.Fingerprints.Get(models.FingerprintTypeMD5)
// find other files with the same md5
files, err := r.File.FindByFingerprint(ctx, models.Fingerprint{
Type: models.FingerprintTypeMD5,
Fingerprint: md5,
})
if err != nil {
return fmt.Errorf("finding files by md5: %w", err)
}
// find the first file with a phash
for _, file := range files {
if phash := file.Base().Fingerprints.Get(models.FingerprintTypePhash); phash != nil {
ret = phash
return nil
}
}
return nil
}); err != nil {
return nil, err
}
return ret, nil
}
func (t *GenerateImagePhashTask) required() bool {
if t.Overwrite {
return true
}
return t.File.Fingerprints.Get(models.FingerprintTypePhash) == nil
}

View File

@@ -44,7 +44,7 @@ func (t *GeneratePhashTask) Start(ctx context.Context) {
if !set { if !set {
generated, err := videophash.Generate(instance.FFMpeg, t.File) generated, err := videophash.Generate(instance.FFMpeg, t.File)
if err != nil { if err != nil {
logger.Errorf("Error generating phash: %v", err) logger.Errorf("Error generating phash for %q: %v", t.File.Path, err)
logErrorOutput(err) logErrorOutput(err)
return return
} }

View File

@@ -34,7 +34,17 @@ func (t *GenerateSpriteTask) Start(ctx context.Context) {
sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm) sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)
imagePath := instance.Paths.Scene.GetSpriteImageFilePath(sceneHash) imagePath := instance.Paths.Scene.GetSpriteImageFilePath(sceneHash)
vttPath := instance.Paths.Scene.GetSpriteVttFilePath(sceneHash) vttPath := instance.Paths.Scene.GetSpriteVttFilePath(sceneHash)
generator, err := NewSpriteGenerator(*videoFile, sceneHash, imagePath, vttPath, 9, 9)
cfg := DefaultSpriteGeneratorConfig
cfg.SpriteSize = instance.Config.GetSpriteScreenshotSize()
if instance.Config.GetUseCustomSpriteInterval() {
cfg.MinimumSprites = instance.Config.GetMinimumSprites()
cfg.MaximumSprites = instance.Config.GetMaximumSprites()
cfg.SpriteInterval = instance.Config.GetSpriteInterval()
}
generator, err := NewSpriteGenerator(*videoFile, sceneHash, imagePath, vttPath, cfg)
if err != nil { if err != nil {
logger.Errorf("error creating sprite generator: %s", err.Error()) logger.Errorf("error creating sprite generator: %s", err.Error())

View File

@@ -2,13 +2,17 @@ package manager
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io/fs" "io/fs"
"path/filepath" "path/filepath"
"regexp" "regexp"
"runtime/debug"
"sync"
"time" "time"
"github.com/99designs/gqlgen/graphql/handler/lru" "github.com/99designs/gqlgen/graphql/handler/lru"
"github.com/remeh/sizedwaitgroup"
"github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/file/video" "github.com/stashapp/stash/pkg/file/video"
@@ -22,16 +26,18 @@ import (
"github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/scene/generate" "github.com/stashapp/stash/pkg/scene/generate"
"github.com/stashapp/stash/pkg/txn" "github.com/stashapp/stash/pkg/txn"
"github.com/stashapp/stash/pkg/utils"
) )
type scanner interface {
Scan(ctx context.Context, handlers []file.Handler, options file.ScanOptions, progressReporter file.ProgressReporter)
}
type ScanJob struct { type ScanJob struct {
scanner scanner scanner *file.Scanner
input ScanMetadataInput input ScanMetadataInput
subscriptions *subscriptionManager subscriptions *subscriptionManager
fileQueue chan file.ScannedFile
count int
unmatchedCaptionFiles utils.MutexField[[]string]
} }
func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error { func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {
@@ -55,22 +61,24 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {
start := time.Now() start := time.Now()
nTasks := cfg.GetParallelTasksWithAutoDetection()
const taskQueueSize = 200000 const taskQueueSize = 200000
taskQueue := job.NewTaskQueue(ctx, progress, taskQueueSize, cfg.GetParallelTasksWithAutoDetection()) taskQueue := job.NewTaskQueue(ctx, progress, taskQueueSize, nTasks)
var minModTime time.Time var minModTime time.Time
if j.input.Filter != nil && j.input.Filter.MinModTime != nil { if j.input.Filter != nil && j.input.Filter.MinModTime != nil {
minModTime = *j.input.Filter.MinModTime minModTime = *j.input.Filter.MinModTime
} }
j.scanner.Scan(ctx, getScanHandlers(j.input, taskQueue, progress), file.ScanOptions{ // HACK - these should really be set in the scanner initialization
Paths: paths, j.scanner.FileHandlers = getScanHandlers(j.input, taskQueue, progress)
ScanFilters: []file.PathFilter{newScanFilter(c, repo, minModTime)}, j.scanner.ScanFilters = []file.PathFilter{newScanFilter(c, repo, minModTime)}
ZipFileExtensions: cfg.GetGalleryExtensions(), j.scanner.HandlerRequiredFilters = []file.Filter{newHandlerRequiredFilter(cfg, repo)}
ParallelTasks: cfg.GetParallelTasksWithAutoDetection(),
HandlerRequiredFilters: []file.Filter{newHandlerRequiredFilter(cfg, repo)}, logger.Infof("Starting scan of %d paths with %d parallel tasks", len(paths), nTasks)
Rescan: j.input.Rescan,
}, progress) j.runJob(ctx, paths, nTasks, progress)
taskQueue.Close() taskQueue.Close()
@@ -80,12 +88,336 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {
} }
elapsed := time.Since(start) elapsed := time.Since(start)
logger.Info(fmt.Sprintf("Scan finished (%s)", elapsed)) logger.Infof("Scan finished (%s)", elapsed)
j.subscriptions.notify() j.subscriptions.notify()
return nil return nil
} }
func (j *ScanJob) runJob(ctx context.Context, paths []string, nTasks int, progress *job.Progress) {
var wg sync.WaitGroup
wg.Add(1)
j.fileQueue = make(chan file.ScannedFile, scanQueueSize)
go func() {
defer func() {
wg.Done()
// handle panics in goroutine
if p := recover(); p != nil {
logger.Errorf("panic while queuing files for scan: %v", p)
logger.Errorf(string(debug.Stack()))
}
}()
if err := j.queueFiles(ctx, paths, progress); err != nil {
if errors.Is(err, context.Canceled) {
return
}
logger.Errorf("error queuing files for scan: %v", err)
return
}
logger.Infof("Finished adding files to queue. %d files queued", j.count)
}()
defer wg.Wait()
j.processQueue(ctx, nTasks, progress)
}
const scanQueueSize = 200000
func (j *ScanJob) queueFiles(ctx context.Context, paths []string, progress *job.Progress) error {
fs := &file.OsFS{}
defer func() {
close(j.fileQueue)
progress.AddTotal(j.count)
progress.Definite()
}()
var err error
progress.ExecuteTask("Walking directory tree", func() {
for _, p := range paths {
err = file.SymWalk(fs, p, j.queueFileFunc(ctx, fs, nil, progress))
if err != nil {
return
}
}
})
return err
}
func (j *ScanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *file.ScannedFile, progress *job.Progress) fs.WalkDirFunc {
return func(path string, d fs.DirEntry, err error) error {
if err != nil {
// don't let errors prevent scanning
logger.Errorf("error scanning %s: %v", path, err)
return nil
}
if err = ctx.Err(); err != nil {
return err
}
info, err := d.Info()
if err != nil {
logger.Errorf("reading info for %q: %v", path, err)
return nil
}
zipFilePath := ""
if zipFile != nil {
zipFilePath = zipFile.Path
}
if !j.scanner.AcceptEntry(ctx, path, info, zipFilePath) {
if info.IsDir() {
logger.Debugf("Skipping directory %s", path)
return fs.SkipDir
}
// we don't include caption files in the file scan, but we do need
// to handle them
if fsutil.MatchExtension(path, video.CaptionExts) {
fileRepo := j.scanner.Repository.File
matched := video.AssociateCaptions(ctx, path, j.scanner.Repository.TxnManager, fileRepo, fileRepo)
if !matched {
logger.Debugf("No matching video file found for caption file %s", path)
j.unmatchedCaptionFiles.SetFunc(func(files []string) []string {
return append(files, path)
})
}
return nil
}
logger.Debugf("Skipping file %s", path)
return nil
}
size, err := file.GetFileSize(f, path, info)
if err != nil {
return err
}
ff := file.ScannedFile{
BaseFile: &models.BaseFile{
DirEntry: models.DirEntry{
ModTime: file.ModTime(info),
},
Path: path,
Basename: filepath.Base(path),
Size: size,
},
FS: f,
Info: info,
}
if zipFile != nil {
ff.ZipFileID = &zipFile.ID
ff.ZipFile = zipFile
}
if info.IsDir() {
// handle folders immediately
if err := j.handleFolder(ctx, ff, progress); err != nil {
if !errors.Is(err, context.Canceled) {
logger.Errorf("error processing %q: %v", path, err)
}
// skip the directory since we won't be able to process the files anyway
return fs.SkipDir
}
return nil
}
// if zip file is present, we handle immediately
if zipFile != nil {
progress.ExecuteTask("Scanning "+path, func() {
// don't increment progress in zip files
if err := j.handleFile(ctx, ff, nil); err != nil {
if !errors.Is(err, context.Canceled) {
logger.Errorf("error processing %q: %v", path, err)
}
// don't return an error, just skip the file
}
})
return nil
}
logger.Tracef("Queueing file %s for scanning", path)
j.fileQueue <- ff
j.count++
return nil
}
}
func (j *ScanJob) processQueue(ctx context.Context, parallelTasks int, progress *job.Progress) {
if parallelTasks < 1 {
parallelTasks = 1
}
wg := sizedwaitgroup.New(parallelTasks)
func() {
defer func() {
wg.Wait()
// handle panics in goroutine
if p := recover(); p != nil {
logger.Errorf("panic while scanning files: %v", p)
logger.Errorf(string(debug.Stack()))
}
}()
for f := range j.fileQueue {
logger.Tracef("Processing queued file %s", f.Path)
if err := ctx.Err(); err != nil {
return
}
wg.Add()
ff := f
go func() {
defer wg.Done()
j.processQueueItem(ctx, ff, progress)
}()
}
}()
}
func (j *ScanJob) processQueueItem(ctx context.Context, f file.ScannedFile, progress *job.Progress) {
progress.ExecuteTask("Scanning "+f.Path, func() {
var err error
if f.Info.IsDir() {
err = j.handleFolder(ctx, f, progress)
} else {
err = j.handleFile(ctx, f, progress)
}
if err != nil && !errors.Is(err, context.Canceled) {
logger.Errorf("error processing %q: %v", f.Path, err)
}
})
}
func (j *ScanJob) handleFolder(ctx context.Context, f file.ScannedFile, progress *job.Progress) error {
if progress != nil {
defer progress.Increment()
}
_, err := j.scanner.ScanFolder(ctx, f)
if err != nil {
return err
}
return nil
}
func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress *job.Progress) error {
if progress != nil {
defer progress.Increment()
}
r, err := j.scanner.ScanFile(ctx, f)
if err != nil {
return err
}
// if this is a new video file, match it with any unmatched caption files
if r.New && len(j.unmatchedCaptionFiles.Get()) > 0 {
videoFile, _ := r.File.(*models.VideoFile)
if videoFile != nil {
// try to match any unmatched caption files to this video file
for _, captionPath := range j.unmatchedCaptionFiles.Get() {
if video.MatchesCaption(videoFile.Path, captionPath) {
video.AssociateCaptions(ctx, captionPath, j.scanner.Repository.TxnManager, j.scanner.Repository.File, j.scanner.Repository.File)
// remove from the unmatched list
j.unmatchedCaptionFiles.SetFunc(func(files []string) []string {
newFiles := make([]string, 0, len(files)-1)
for _, f := range files {
if f != captionPath {
newFiles = append(newFiles, f)
}
}
return newFiles
})
}
}
}
}
// clean captions - scene handler handles this as well, but
// unchanged files aren't processed by the scene handler
if r.IsUnchanged() {
videoFile, _ := r.File.(*models.VideoFile)
if videoFile != nil {
txnMgr := j.scanner.Repository.TxnManager
fileRepo := j.scanner.Repository.File
if err := txn.WithDatabase(ctx, txnMgr, func(ctx context.Context) error {
return video.CleanCaptions(ctx, videoFile, txnMgr, fileRepo)
}); err != nil {
logger.Errorf("Error cleaning captions: %v", err)
}
}
}
// 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 j.scanner.IsZipFile(f.Info.Name()) && (r.New || r.FingerprintChanged || j.scanner.Rescan) {
ff := r.File
f.BaseFile = ff.Base()
// scan zip files with a different context that is not cancellable
// cancelling while scanning zip file contents results in the scan
// contents being partially completed
zipCtx := context.WithoutCancel(ctx)
if err := j.scanZipFile(zipCtx, f, progress); err != nil {
logger.Errorf("Error scanning zip file %q: %v", f.Path, err)
}
} else if r.Updated && j.scanner.IsZipFile(f.Info.Name()) {
logger.Debugf("Skipping zip file scan for %q: fingerprint unchanged", f.Path)
}
return nil
}
func (j *ScanJob) scanZipFile(ctx context.Context, f file.ScannedFile, progress *job.Progress) error {
zipFS, err := f.FS.OpenZip(f.Path, f.Size)
if err != nil {
if errors.Is(err, file.ErrNotReaderAt) {
// can't walk the zip file
// just return
logger.Debugf("Skipping zip file %q as it cannot be opened for walking", f.Path)
return nil
}
return err
}
defer zipFS.Close()
return file.SymWalk(zipFS, f.Path, j.queueFileFunc(ctx, zipFS, &f, progress))
}
type extensionConfig struct { type extensionConfig struct {
vidExt []string vidExt []string
imgExt []string imgExt []string
@@ -117,11 +449,10 @@ type sceneFinder interface {
// handlerRequiredFilter returns true if a File's handler needs to be executed despite the file not being updated. // handlerRequiredFilter returns true if a File's handler needs to be executed despite the file not being updated.
type handlerRequiredFilter struct { type handlerRequiredFilter struct {
extensionConfig extensionConfig
txnManager txn.Manager txnManager txn.Manager
SceneFinder sceneFinder SceneFinder sceneFinder
ImageFinder fileCounter ImageFinder fileCounter
GalleryFinder galleryFinder GalleryFinder galleryFinder
CaptionUpdater video.CaptionUpdater
FolderCache *lru.LRU[bool] FolderCache *lru.LRU[bool]
@@ -137,7 +468,6 @@ func newHandlerRequiredFilter(c *config.Config, repo models.Repository) *handler
SceneFinder: repo.Scene, SceneFinder: repo.Scene,
ImageFinder: repo.Image, ImageFinder: repo.Image,
GalleryFinder: repo.Gallery, GalleryFinder: repo.Gallery,
CaptionUpdater: repo.File,
FolderCache: lru.New[bool](processes * 2), FolderCache: lru.New[bool](processes * 2),
videoFileNamingAlgorithm: c.GetVideoFileNamingAlgorithm(), videoFileNamingAlgorithm: c.GetVideoFileNamingAlgorithm(),
} }
@@ -212,65 +542,35 @@ func (f *handlerRequiredFilter) Accept(ctx context.Context, ff models.File) bool
} }
} }
if isVideoFile {
// TODO - check if the cover exists
// hash := scene.GetHash(ff, f.videoFileNamingAlgorithm)
// ssPath := instance.Paths.Scene.GetScreenshotPath(hash)
// if exists, _ := fsutil.FileExists(ssPath); !exists {
// // if not, check if the file is a primary file for a scene
// scenes, err := f.SceneFinder.FindByPrimaryFileID(ctx, ff.Base().ID)
// if err != nil {
// // just ignore
// return false
// }
// if len(scenes) > 0 {
// // if it is, then it needs to be re-generated
// return true
// }
// }
// clean captions - scene handler handles this as well, but
// unchanged files aren't processed by the scene handler
videoFile, _ := ff.(*models.VideoFile)
if videoFile != nil {
if err := video.CleanCaptions(ctx, videoFile, f.txnManager, f.CaptionUpdater); err != nil {
logger.Errorf("Error cleaning captions: %v", err)
}
}
}
return false return false
} }
type scanFilter struct { type scanFilter struct {
extensionConfig extensionConfig
txnManager txn.Manager txnManager txn.Manager
FileFinder models.FileFinder
CaptionUpdater video.CaptionUpdater
stashPaths config.StashConfigs stashPaths config.StashConfigs
generatedPath string generatedPath string
videoExcludeRegex []*regexp.Regexp videoExcludeRegex []*regexp.Regexp
imageExcludeRegex []*regexp.Regexp imageExcludeRegex []*regexp.Regexp
minModTime time.Time minModTime time.Time
stashIgnoreFilter *file.StashIgnoreFilter
} }
func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Time) *scanFilter { func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Time) *scanFilter {
return &scanFilter{ return &scanFilter{
extensionConfig: newExtensionConfig(c), extensionConfig: newExtensionConfig(c),
txnManager: repo.TxnManager, txnManager: repo.TxnManager,
FileFinder: repo.File,
CaptionUpdater: repo.File,
stashPaths: c.GetStashPaths(), stashPaths: c.GetStashPaths(),
generatedPath: c.GetGeneratedPath(), generatedPath: c.GetGeneratedPath(),
videoExcludeRegex: generateRegexps(c.GetExcludes()), videoExcludeRegex: generateRegexps(c.GetExcludes()),
imageExcludeRegex: generateRegexps(c.GetImageExcludes()), imageExcludeRegex: generateRegexps(c.GetImageExcludes()),
minModTime: minModTime, minModTime: minModTime,
stashIgnoreFilter: file.NewStashIgnoreFilter(),
} }
} }
func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo) bool { func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool {
if fsutil.IsPathInDir(f.generatedPath, path) { if fsutil.IsPathInDir(f.generatedPath, path) {
logger.Warnf("Skipping %q as it overlaps with the generated folder", path) logger.Warnf("Skipping %q as it overlaps with the generated folder", path)
return false return false
@@ -287,19 +587,16 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo)
return false return false
} }
// Check .stashignore files, bounded to the library root.
if !f.stashIgnoreFilter.Accept(ctx, path, info, s.Path, zipFilePath) {
logger.Debugf("Skipping %s due to .stashignore", path)
return false
}
isVideoFile := useAsVideo(path) isVideoFile := useAsVideo(path)
isImageFile := useAsImage(path) isImageFile := useAsImage(path)
isZipFile := fsutil.MatchExtension(path, f.zipExt) isZipFile := fsutil.MatchExtension(path, f.zipExt)
// handle caption files
if fsutil.MatchExtension(path, video.CaptionExts) {
// we don't include caption files in the file scan, but we do need
// to handle them
video.AssociateCaptions(ctx, path, f.txnManager, f.FileFinder, f.CaptionUpdater)
return false
}
if !info.IsDir() && !isVideoFile && !isImageFile && !isZipFile { if !info.IsDir() && !isVideoFile && !isImageFile && !isZipFile {
logger.Debugf("Skipping %s as it does not match any known file extensions", path) logger.Debugf("Skipping %s as it does not match any known file extensions", path)
return false return false
@@ -363,8 +660,9 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre
&file.FilteredHandler{ &file.FilteredHandler{
Filter: file.FilterFunc(imageFileFilter), Filter: file.FilterFunc(imageFileFilter),
Handler: &image.ScanHandler{ Handler: &image.ScanHandler{
CreatorUpdater: r.Image, CreatorUpdater: r.Image,
GalleryFinder: r.Gallery, GalleryFinder: r.Gallery,
SceneFinderUpdater: r.Scene,
ScanGenerator: &imageGenerators{ ScanGenerator: &imageGenerators{
input: options, input: options,
taskQueue: taskQueue, taskQueue: taskQueue,
@@ -393,9 +691,10 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre
&file.FilteredHandler{ &file.FilteredHandler{
Filter: file.FilterFunc(videoFileFilter), Filter: file.FilterFunc(videoFileFilter),
Handler: &scene.ScanHandler{ Handler: &scene.ScanHandler{
CreatorUpdater: r.Scene, CreatorUpdater: r.Scene,
CaptionUpdater: r.File, GalleryFinderUpdater: r.Gallery,
PluginCache: pluginCache, CaptionUpdater: r.File,
PluginCache: pluginCache,
ScanGenerator: &sceneGenerators{ ScanGenerator: &sceneGenerators{
input: options, input: options,
taskQueue: taskQueue, taskQueue: taskQueue,
@@ -463,6 +762,29 @@ func (g *imageGenerators) Generate(ctx context.Context, i *models.Image, f model
} }
} }
if t.ScanGenerateImagePhashes {
progress.AddTotal(1)
phashFn := func(ctx context.Context) {
mgr := GetInstance()
// Only generate phash for image files, not video files
if imageFile, ok := f.(*models.ImageFile); ok {
taskPhash := GenerateImagePhashTask{
repository: mgr.Repository,
File: imageFile,
Overwrite: overwrite,
}
taskPhash.Start(ctx)
}
progress.Increment()
}
if g.sequentialScanning {
phashFn(ctx)
} else {
g.taskQueue.Add(fmt.Sprintf("Generating phash for %s", path), phashFn)
}
}
return nil return nil
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"fmt" "fmt"
"strconv" "strconv"
"strings"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/match" "github.com/stashapp/stash/pkg/match"
@@ -12,6 +13,7 @@ import (
"github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/stashbox" "github.com/stashapp/stash/pkg/stashbox"
"github.com/stashapp/stash/pkg/studio" "github.com/stashapp/stash/pkg/studio"
"github.com/stashapp/stash/pkg/tag"
) )
// stashBoxBatchPerformerTagTask is used to tag or create performers from stash-box. // stashBoxBatchPerformerTagTask is used to tag or create performers from stash-box.
@@ -275,6 +277,12 @@ func (t *stashBoxBatchStudioTagTask) getName() string {
} }
func (t *stashBoxBatchStudioTagTask) Start(ctx context.Context) { func (t *stashBoxBatchStudioTagTask) Start(ctx context.Context) {
// Skip organized studios
if t.studio != nil && t.studio.Organized {
logger.Infof("Skipping organized studio %s", t.studio.Name)
return
}
studio, err := t.findStashBoxStudio(ctx) studio, err := t.findStashBoxStudio(ctx)
if err != nil { if err != nil {
logger.Errorf("Error fetching studio data from stash-box: %v", err) logger.Errorf("Error fetching studio data from stash-box: %v", err)
@@ -523,3 +531,235 @@ func (t *stashBoxBatchStudioTagTask) processParentStudio(ctx context.Context, pa
return err return err
} }
} }
// stashBoxBatchTagTagTask is used to tag or create tags from stash-box.
//
// Two modes of operation:
// - Update existing tag: set tag to update from stash-box data
// - Create new tag: set name or stashID to search stash-box and create locally
type stashBoxBatchTagTagTask struct {
box *models.StashBox
name *string
stashID *string
tag *models.Tag
createParent bool
excludedFields []string
}
func (t *stashBoxBatchTagTagTask) getName() string {
switch {
case t.name != nil:
return *t.name
case t.stashID != nil:
return *t.stashID
case t.tag != nil:
return t.tag.Name
default:
return ""
}
}
func (t *stashBoxBatchTagTagTask) Start(ctx context.Context) {
scrapedTag, err := t.findStashBoxTag(ctx)
if err != nil {
logger.Errorf("Error fetching tag data from stash-box: %v", err)
return
}
excluded := map[string]bool{}
for _, field := range t.excludedFields {
excluded[field] = true
}
if scrapedTag != nil {
t.processMatchedTag(ctx, scrapedTag, excluded)
} else {
logger.Infof("No match found for %s", t.getName())
}
}
func (t *stashBoxBatchTagTagTask) GetDescription() string {
return fmt.Sprintf("Tagging tag %s from stash-box", t.getName())
}
func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.ScrapedTag, error) {
var results []*models.ScrapedTag
var err error
r := instance.Repository
client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))
nameQuery := ""
switch {
case t.name != nil:
nameQuery = *t.name
results, err = client.QueryTag(ctx, *t.name)
case t.stashID != nil:
results, err = client.QueryTag(ctx, *t.stashID)
case t.tag != nil:
var remoteID string
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
if !t.tag.StashIDs.Loaded() {
err = t.tag.LoadStashIDs(ctx, r.Tag)
if err != nil {
return err
}
}
for _, id := range t.tag.StashIDs.List() {
if id.Endpoint == t.box.Endpoint {
remoteID = id.StashID
}
}
return nil
}); err != nil {
return nil, err
}
if remoteID != "" {
results, err = client.QueryTag(ctx, remoteID)
} else {
nameQuery = t.tag.Name
results, err = client.QueryTag(ctx, t.tag.Name)
}
}
if err != nil {
return nil, err
}
if len(results) == 0 {
return nil, nil
}
var result *models.ScrapedTag
// QueryTag returns tags that partially match the name, so find the exact match if searching by name
if nameQuery != "" {
for _, r := range results {
if strings.EqualFold(r.Name, nameQuery) {
result = r
break
}
}
} else {
result = results[0]
}
if result == nil {
return nil, nil
}
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
return match.ScrapedTagHierarchy(ctx, r.Tag, result, t.box.Endpoint)
}); err != nil {
return nil, err
}
return result, nil
}
func (t *stashBoxBatchTagTagTask) processParentTag(ctx context.Context, parent *models.ScrapedTag, excluded map[string]bool) error {
if parent.StoredID == nil {
// Create new parent tag
newParentTag := parent.ToTag(t.box.Endpoint, excluded)
r := instance.Repository
err := r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Tag
if err := tag.ValidateCreate(ctx, *newParentTag, qb); err != nil {
return err
}
if err := qb.Create(ctx, &models.CreateTagInput{Tag: newParentTag}); err != nil {
return err
}
storedID := strconv.Itoa(newParentTag.ID)
parent.StoredID = &storedID
return nil
})
if err != nil {
logger.Errorf("Failed to create parent tag %s: %v", parent.Name, err)
} else {
logger.Infof("Created parent tag %s", parent.Name)
}
return err
}
// Parent already exists — nothing to update for categories
return nil
}
func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *models.ScrapedTag, excluded map[string]bool) {
// Determine the tag ID to update — either from the task's tag or from the
// StoredID set by match.ScrapedTag (when batch adding by name and the tag
// already exists locally).
tagID := 0
if t.tag != nil {
tagID = t.tag.ID
} else if s.StoredID != nil {
tagID, _ = strconv.Atoi(*s.StoredID)
}
if s.Parent != nil && t.createParent {
if err := t.processParentTag(ctx, s.Parent, excluded); err != nil {
return
}
}
if tagID > 0 {
r := instance.Repository
err := r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Tag
existingStashIDs, err := qb.GetStashIDs(ctx, tagID)
if err != nil {
return err
}
storedID := strconv.Itoa(tagID)
partial := s.ToPartial(storedID, t.box.Endpoint, excluded, existingStashIDs)
if err := tag.ValidateUpdate(ctx, tagID, partial, qb); err != nil {
return err
}
if _, err := qb.UpdatePartial(ctx, tagID, partial); err != nil {
return err
}
return nil
})
if err != nil {
logger.Errorf("Failed to update tag %s: %v", s.Name, err)
} else {
logger.Infof("Updated tag %s", s.Name)
}
} else if s.Name != "" {
// no existing tag, create a new one
newTag := s.ToTag(t.box.Endpoint, excluded)
r := instance.Repository
err := r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Tag
if err := tag.ValidateCreate(ctx, *newTag, qb); err != nil {
return err
}
if err := qb.Create(ctx, &models.CreateTagInput{Tag: newTag}); err != nil {
return err
}
return nil
})
if err != nil {
logger.Errorf("Failed to create tag %s: %v", s.Name, err)
} else {
logger.Infof("Created tag %s", s.Name)
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 850 1250"><path fill="#fff" fill-rule="evenodd" d="M480.72 36.58c-10.56.01-21.69 1.97-34.35 9.32-28.85 23.98-32.38 59.58-34.6 98.83 2.47 24.57 6.03 47.44-4.17 73.49-11.7 1.42-24.56 5.66-37.6 3.92-13.16-1.77-28.8.54-35.24 7.21-20.4 27.97-29.09 64.17-40.61 100.3-11.53 36.13-18.01 65.15-18.22 70.6-2.83 15.39-2.51 33.25 2.95 50.89 11.33 29.32 21.89 56.36 33.98 87.95.79 1.81-3.34 3.11-2.83 5.1s1.53 4.44 1.83 6.54c.31 2.09 1.54 4.43 1.7 6.54.16 2.12 3.58 4.21 3.68 6.28.2 4.13.72 8.09 1.09 11.57.35 3.46.94 6.44 2.03 8.64l10.12 14.39c1.57 2.4 7.54-.99 8.58.93 2.17 4.07 8.13 2.75 10.29.62 1.53-1.52 3.99 2.03 6.07 1.8 2.09-.21 4.15-.6 6.13-1.25-7.69 22.24-20 44.47-23.07 66.71-8.78 29.38-21.69 44.27-26.36 88.13-1.3 17.3-.07 34.6 0 51.89l-9.88 32.94L280 901.69c-19.57 58.62-20.55 113.74-24.72 174.61l-6.59 12.36v18.93c-3.64 30.03-16.39 58.76-20.53 88.73.83 2.15 1.18 4.75 3.06 6.7 3.02 3.11 8.17 5.46 11.77 8.47 4.47 3.09 9.69 2.36 14.92 1.66 54.03-16.43 20.05-34.21 27.03-52.83.98-17.36 1.37-34.58 13.18-51.07 2.31-9.33-1.25-18.67-3.3-28.01 5.88-43.68 48.11-124.8 50.24-134.25l15.66-65.07c16.14-20.59 23.3-52.18 32.94-80.72l43.65-86.48 21.31-51.73c9.84-.07 19-10.61 23.16-16.63l9.88.82c-.86 10.43.07 20.87 10.71 31.29l27.19 132.61 16.47 37.88 11.54 52.71 15.65 104.61 1.64 42-4.11 14.83.82 30.48-4.94 38.72-11.1 31.68c-1.36 4.76-.24 11.37 2.86 14.2 20.97 20.41 52.34-.35 55.72-11.59.63-18.29 1.25-32.22 4.12-50.52l-.54-35.66-2.47-4.94v-12.36l-3.3-18.12c4.08-41.75 10.1-82.84 9.89-126.02-.54-35.83-6.63-70.28-16.48-103.77-2.39-22.61-4.97-45.14-6.92-67.98-1.4-16.4-2.48-32.94-2.95-49.8 1.15-11.25 2.29-21.55 3.17-31.35 2.86-32.14 2.9-58.75-8.16-94.67l-2.43-6.59 4.95-3.29c3.65-.82 3.05 4.44 11.53-3.3 1.52 1.27 4.12.91 8.72-2.47 3.65-9.09 11.8-16.92 9.84-28.1-6.39-4.95-2.79-3.45-21.03-13.08-2.21-9.68.25-18.2.82-27.17.17-15.34-1.29-29.93-5.11-44.61-5.09-19.73-8.47-39.44-13-59.18-5.76-20.28-4.09-56.63-11.97-89.87-9.42-15.61-19.5-15.96-21.42-17.38l-11.08-31.12c3.56 6.11 6.41 12.23 10.71 18.33l-5.77-19.15c5.23 9.53 16.02 18.6 12.92 28.83 3.22-8.21-1.64-14.95-5.61-21.85 3.91 6.17 10.05 11.8 14.92 17.73-7.23-10.16-14.41-20.32-14.83-30.48l-4.94-24.71c3.06 7.81 2.43 15.63 9.65 23.44-4.6-8.91-1.76-17.82-2.24-26.75 1.76 9.07 3.58 18.13 9.89 27.19-6.47-12.36-6.92-24.71-9.06-37.06-2.43-8.59-5.1-17.18-6.13-25.77-1.33-11.18-.67-22.37-1.29-33.54l.83-42.84c-2.94-12.9-5.05-25.81-16.48-38.72-7.84-10.63-17.89-16.1-28.01-21.41-8-.74-15.89-1.91-24.11-1.91Zm69.42 210.28c.48 1.07 1.02 2.13 1.6 3.19-.6-1.05-1.15-2.11-1.6-3.19Zm-208.4 107.77c1.56 22.71.19 45.21 3.62 67.92 5.74 22.85 11.94 41.69 16.71 66.52.16 12.4 1.33 22.33-6.98 55.89l-4.83-13.46c-1.32-2.05-2.21-4.2-6.7-5.73-4.36-16.84-11.3-31.44-13.26-48.73-2.25-19.97-6.79-34.65-11.92-51.22 1.11-16.12 7.62-28.97 12.69-46.1l10.66-25.09Zm205.09 80.48 7.85 29.71c2.19 15.92 5.13 31.36 15.73 41.92l-6.7 17.77c.05-5.06.11-10.09-.59-15.14-3.01-7.45-6-12.83-9-17.16l-19.23-27.39c-1.85-2.04-4.76-4.67-8.16-7l-.87-7.26-1.76-7.29c10.68-.94 17.45-4.12 22.73-8.16Zm38.42 99.9 1.17 8.13-4.07 11.07-6.13-12.51 7-3.5 2.03-3.19Z"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 850 1250"><path fill="#fff" d="M376.15 24.84c-3.58 1.02-10.08 2.26-14.39 2.85-9.64 1.31-12.93 2.19-18.26 4.82-14.17 7.01-23.67 19.21-29.66 37.99-3.36 10.52-4.53 19.14-5.26 37.62-.37 9.64-1.1 18.99-1.75 21.99-3.43 16.14-10.3 24.47-23.3 28.12-5.33 1.53-7.52 1.75-20.09 1.75-7.67 0-14.03.07-14.03.15 0 .15 1.46.58 3.29 1.02s3.29 1.02 3.29 1.31-3.07.95-6.72 1.46c-12.2 1.75-22.72 5.41-27.98 9.86-1.68 1.46-1.97 1.97-1.17 2.56.73.44.37 1.1-1.68 2.7-3.36 2.7-6.72 7.38-8.18 11.4-1.31 3.8-1.46 11.25-.22 13.95s2.12 2.48 2.12-.58c0-7.38 8.04-12.2 16.44-9.86 1.83.51 3.29 1.02 3.29 1.17 0 .22-1.39 2.05-3.14 4.16-1.75 2.19-3.87 5.33-4.75 7.16-1.83 3.94-2.92 9.13-1.68 8.4 1.17-.73 1.1-.44-1.39 4.82-2.63 5.62-4.38 13.22-4.38 19.21 0 5.11 1.02 10.08 2.12 10.45.44.15.8 1.53.8 3.07 0 3.87 2.48 11.4 5.04 15.41 2.19 3.58 5.84 6.72 8.69 7.6l1.68.51-1.53-2.48c-.8-1.31-1.46-3.29-1.46-4.38 0-2.48 2.78-10.08 3.73-10.08.37 0 .66 1.75.66 3.8.07 2.12.51 4.68 1.1 5.7 1.02 1.75 1.1 1.75.66-1.17-.51-3.29.88-11.25 1.97-11.25.58 0 .22 5.55-.51 7.89-.58 2.05 1.31 10.67 3.36 15.19 2.19 4.82 3.36 6.06 2.78 2.85-.58-3.07.66-2.05 1.53 1.31.95 3.14 5.04 10.45 5.62 9.86.15-.22 0-2.7-.37-5.55-.73-5.84 0-13.88 1.83-19.87 1.24-4.09 5.84-13.81 7.82-16.44s2.12-.58.22 3.14c-3.36 6.72-5.62 15.92-6.06 24.69-.44 8.62.15 14.9 1.31 14.9.37 0 .66-.44.58-.95-.22-2.05.15-4.89.66-4.89.29 0 .44 2.12.29 4.68-.73 11.47 4.46 26.44 10.23 29.51 3.8 2.05 3.94 2.7 3.43 25.86-.51 24.4-1.24 31.63-8.33 81.3-5.92 41.57-6.57 46.9-8.4 66.26-2.19 24.11-2.12 23.74-12.35 43.25-2.19 4.16-3 6.57-3 8.91q0 3.21 4.24 5.26l4.16 1.97.22 6.65c.22 7.52.22 7.6 6.57 7.6h3.14l-.44 5.26c-1.1 12.71-2.78 24.18-4.53 32-3 13.22-2.85 14.76 2.41 25.93 4.09 8.84 13.37 25.71 18.85 34.33 2.05 3.07 3 3.87 5.99 4.82 3.43 1.02 3.58 1.24 4.09 4.31.29 1.83 1.1 19.72 1.68 39.81.58 20.09 1.75 45.07 2.56 55.52 3.43 43.83 3.36 54.64-.73 100.08-1.17 13.22-3.29 43.03-4.75 66.11-1.39 23.08-4.02 65.16-5.84 93.5-3.65 58.07-6.65 108.33-7.38 124.33l-.51 10.74 1.75.37c.95.22 4.53 1.1 8.04 1.83 8.99 2.12 14.83 4.46 21.33 8.55 5.33 3.36 5.62 3.73 5.26 5.77-1.9 11.18-.58 19.87 3.87 24.62 7.52 7.96 25.06 8.55 34.84 1.17 5.92-4.46 8.4-11.4 8.4-23.52v-6.79l7.74-3.43c4.31-1.97 9.42-4.09 11.54-4.82l3.73-1.24-.22-3.21c-.07-1.75-.95-19.65-1.97-39.74-3.8-75.83-4.68-90.58-9.13-148.29-4.46-57.71-5.19-71.44-5.62-104.83-.58-41.71.44-58.37 7.38-121.7 4.82-43.83 8.33-79.26 9.2-93.8.95-14.61 2.26-23.96 4.38-30.68 1.97-6.5 2.63-7.38 3.51-5.11.44 1.02 4.38 11.03 8.77 22.28 4.38 11.25 14.46 36.96 22.35 57.2 7.96 20.16 20.75 52.23 28.49 71.22 32.14 78.75 37.55 96.06 49.02 159.03 2.26 12.27 5.41 29.37 7.01 37.99 6.72 36.6 12.56 72.32 27.03 165.38 6.28 40.1 6.94 43.76 8.33 44.19.8.29 10.88 3.8 22.35 7.74l20.89 7.16.51 6.28c1.46 19.07 7.89 26.3 24.03 26.96 6.5.29 7.38.15 11.1-1.61 4.46-2.26 9.72-7.6 12.05-12.49 1.97-4.09 2.19-12.49.44-19.07-.66-2.48-1.17-4.68-1.17-4.89 0-.15 2.19-1.61 4.82-3.14 5.7-3.29 9.57-7.52 10.23-11.03.73-3.94-.88-11.91-7.38-36.31-7.67-28.78-33.24-118.05-39.45-137.77-2.63-8.25-10.52-32.07-17.6-52.96-17.82-52.81-19.21-57.93-34.63-124.91-8.99-39.3-18.7-80.79-30.68-130.76-5.55-23.3-11.18-47.77-12.42-54.42-2.63-14.03-5.84-24.11-13.15-41.27-2.92-6.87-6.65-16.07-8.25-20.45l-2.92-8.04-.22-18.63c-.07-10.23-.51-22.06-.95-26.22s-.58-7.89-.37-8.25c.29-.44 1.46-1.17 2.78-1.68 2.19-.95 2.34-.88 5.99 2.56 7.16 6.79 15.49 7.38 27.03 1.9 3.07-1.46 6.43-3.29 7.6-4.09l2.05-1.46 4.09 5.55 4.16 5.48 4.53-3.58c6.36-5.04 11.25-7.96 15.41-9.06 3-.8 4.02-1.61 6.79-5.11 4.53-5.99 8.99-14.54 13.73-26.88 2.26-5.84 6.28-15.19 8.91-20.82 18.34-39.23 23.08-55.96 19.58-69.32-.66-2.41-4.31-10.74-8.11-18.63-17.17-34.99-29.37-64.87-43.03-105.26-8.18-24.25-11.4-31.78-15.71-37.04-1.97-2.34-4.97-6.43-6.57-9.2-3.87-6.43-4.97-7.09-11.69-7.01-3.87.07-8.77-.73-16.8-2.56-7.6-1.83-14.83-2.92-21.99-3.51l-10.74-.88 1.31-3.8c11.18-32.43 14.03-69.18 7.38-96.79-6.43-27.25-27.9-59.46-47.92-71.95-3.07-1.9-8.4-4.46-11.91-5.62-5.7-2.05-7.38-2.26-16.58-2.48-9.06-.22-10.96 0-16.8 1.61Zm125.64 306.44c7.3 11.25 14.76 27.76 25.2 55.74 9.72 26.15 10.96 32.51 8.33 41.42-1.31 4.31-13.37 28.85-15.85 32.29-.95 1.31-3.14 2.63-5.84 3.65-5.55 1.97-8.55 4.82-8.55 7.89 0 1.83.73 3.07 3.29 5.55 1.83 1.75 3.29 3.58 3.29 4.09 0 .58-.95 1.53-2.05 2.19-1.17.73-3.51 2.78-5.19 4.6-2.92 3.21-3.07 3.51-1.9 4.75 1.1 1.24 1.1 1.39-.15 1.68-5.62 1.61-5.26 1.75-13.44-6.21-4.24-4.16-8.91-8.18-10.37-8.91-1.39-.8-2.78-2.12-3-2.92-.22-.88-.8-6.57-1.31-12.78-1.61-20.97 1.97-48.36 10.81-82.25 1.75-6.87 4.82-18.7 6.79-26.3 1.97-7.67 4.16-17.39 4.89-21.77.66-4.31 1.39-7.82 1.53-7.82s1.75 2.26 3.51 5.11ZM283.01 578.92c0 9.72-.66 11.61-2.19 6.79-.95-2.7-2.19-14.76-1.68-15.78.15-.29 1.1-.51 2.12-.51h1.75v9.5Zm-.73 60.92c.58.07 1.31.88 1.53 1.9.73 2.78.73 10.23.07 10.23-1.53 0-7.45-10.3-7.45-12.93 0-.22 1.1-.07 2.41.22 1.24.29 2.85.51 3.43.58Z" vector-effect="non-scaling-stroke"/></svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 850 1250"><path fill="#fff" d="M472.98 38.67c-13.15.96-26.67 7.37-39.17 14.9-15.4 4.08-26.21 21.1-32.37 33.99-5.93 21.36-10.44 43.16-12.62 65.39-8.56 18.78 2.69 49.48-20.08 58.28-26.46 3.29-45.91 24.89-57.3 47.58-7.59 25.25-21.44 47.64-34.64 70.25-8.14 19.94-32.81 48.39-10.36 66.7 26.26 16.49 58.12 9.34 86.76 5.18 19.26 6.03 13.11 40.3 13.27 57.62.75 24.57-7.78 47.72-11.98 71.55-5.73 30.16 7.91 58.68 9.71 88.38 5.03 13.95-6.07 56.76 17.49 46.62 13.87 33.68 36.67 62.85 51.48 96.15 8.34 20.57 26.8 43.12 16.18 66.04-13.64 21.93-14.66 48.16-13.27 73.17-.62 26.72 8.46 52.33 10.04 78.64.81 38.12-21.73 71.17-25.9 108.47-1.34 21.76-26.4 39.99-12.31 61.54.39 2.59 6.27 16.37 9.72 29.42 2.95 11.19 7.33 18.65 11.32 25.9 7.35 14.5 20.87 3.11 23.96-3.83 8.76-19.79 14.33-29.42 13.27-5.18-1.06 24.24 47.37-4.77 52.13-24.35.58-2.38-.94-3.52 1.62-11.29 2.44-19.89-13.36-38.12-11.98-60.81-1.27-32.43-9.15-64.23-10.69-96.55-2.57-23.93 7.7-45.17 15.87-66.99 19.16-49.34 5.91-103.12 15.54-154.1 10.93-42.36 20.98-85.33 38.52-125.61-.89-18.39 20.38-20.42 25.58-30.76-17.12-45.49-12.18-96.36-31.73-141.15-11.79-19.53-23.53-41.51-34.95-59.57 10.41 8.04 27.29 13.76 37.22-2.59 2.46-11.07 14.4-21 9.72-32.7-8.61-21.13-4.68-43.27-8.42-65.72-10.33-20.34 26.7-1.53 19.42-11.33 21.04-14.01-21.31 8.01-11.98-12.3-4.57-23.82-10.07-47.05-19.75-69.29-7.05-48.59-12.56-98.26-29.46-144.71-7.04-10.12-4.91-16.98-13.6-28.49-10.4-14.73-23.1-19.4-36.26-18.45ZM343.16 329.72c5.59.8 1.72 20.91-.96 29.46-.23 6.92-4.89 5.55-9.4 4.21-23.94.47-6.9-16.97 1.29-26.87 4.43-5.2 7.2-7.07 9.06-6.8Z"/></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 850 1250"><path fill="#fff" d="M390.27 1215.71c-26.85-.4-39.98-26.54-37.66-50.52-5.05-22.51-3.56-45.85-4.53-68.85 2.06-23.14 21.76-41.17 15.57-66.3-3.11-37.23-4.87-74.88-.58-112.07 4.2-24.94 13.46-48.45 25.58-70.54.29-20.04-3.9-39.59-12.25-57.87-14.58-37.71-27.84-75.94-45.89-112.18-8.06-20.45-4.48-48.36-34.09-48.93-17.01-1.88-35.19-2.94-30.5-26.06-1.18-31.57 3.95-62.99 2.66-94.64 4.55-28.15-22.55-45.52-37.4-65.53-15.7-21.87-32.9-42.98-45.36-67-4.35-29.81 14.01-56.22 24.4-82.85 12.15-30.72 29.69-58.93 42.97-89.06 10.76-17.8 24.8-40.58 48.45-39.99 22.64-.45 46.86.54 65.3-14.85 17.26-15.49 22.96-38.33 32.33-58.42 12.24-28.4 35.57-54.65 67.74-58.99 24.33-3.89 50.81 5.21 68.9 21.61 3.9 22.95 19.71 38.48 25.56 60.65 2.49 21.53-2.29 45.5 24.99 52.18 24.74 16.83 50.28 32.79 73.42 51.79 14.52 18.65-5.63 46.05-27.33 47.31-28.5 13.29-59.15 21.79-90.55 24.61-27.88-4.29-18.63 31.66-1.72 39.88 23.56 21.16-22.88-12.64-23.18-12.36-.26 12.99-3.78 24.58-8.09 5.3-11.15-22.15 3.96 31.49-5.43 19.37-1.78-11.09-13.78-34.88-13.28-9.82 1.3 8.03 14.21 33.96-.67 15.64-6.99-15.67-3.58-33.86-5.09-50.74-26.39-2.77-11.88 22.66-7.91 36.5 5.97 25-25.13 39.55-22.68 61.96 12.94 16.43 31.57 29.5 42.84 49.13 24.41 33.32 29.99 74.92 40.63 113.67-1.49 14.89 32.93 29.94 3.81 30.29-16.72 11.07-10.87 39.91-21.52 57.74-7.71 38.93-19.29 77-31.3 114.78-8.06 27.2-18.27 54.63-17.23 83.57-.36 40.67-6.09 81.1-16.62 120.35-5.81 25.47-15.31 50.35-14.89 77.06-2.19 24.53 2.41 48.98 11.71 71.6 7.37 20.12-4.17 48.71-29.66 40.63-24.51-5.69-10.97 27.47 2.91 33.26 14.53 16.75 1.15 34.28-19.32 29.02-5.02 0-10.06.1-15.06-.33Zm-121.8-810.49c.16-22.09 10.41-42.25 5.24-64.23-2.08-9.98 4.9-41.86-5.44-38.66-6.72 22.85-29.25 40.55-28.37 65.34 8.51 16.52 21.61 30.41 27.22 48.67 1.8-3.45.92-7.45 1.35-11.12Zm252.68-105.3c.93-13.95-20.6-7.37-6.22 2.41 3.97 6.03 8.46 5.04 6.22-2.41Zm59.8-74.98c-11.22-15.73-26.65-27.69-40.46-40.91-11.99-1.74-12.72 24.78-21.55 33.89 1.23 17.12 35.65 4.3 49.7 7.82 4.1-.25 8.26-.03 12.31-.8Zm-20.67-81.28c4.51-20.53-17.82 12.62-1.15 4.06l1.15-4.06Zm-5.14-8.5c8.84-11.18-12.11-43.92-7.45-15.82 1.26 3.6-.62 29.43 7.45 15.82Z"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

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