Compare commits

..

93 Commits

Author SHA1 Message Date
DogmaDragon
3c06df402b Document changes from https://github.com/stashapp/stash/pull/6673 2026-03-30 15:33:19 +03:00
DogmaDragon
e6e87d64d6 Add troubleshooting mode confirmation to bug report
Added a checkbox to confirm troubleshooting mode is enabled before filing a bug report.
2026-03-30 11:53:30 +03:00
WithoutPants
2da8074316 Codeberg weblate translation update (#6767)
* Translated using Weblate (French)

Currently translated at 100.0% (1341 of 1341 strings)

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

* Translated using Weblate (Turkish)

Currently translated at 75.3% (1010 of 1341 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1341 of 1341 strings)

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

* Translated using Weblate (French)

Currently translated at 99.9% (1345 of 1346 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1346 of 1346 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1346 of 1346 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1346 of 1346 strings)

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

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

Currently translated at 100.0% (1346 of 1346 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 67.3% (906 of 1346 strings)

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

* Update translation files

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

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

* Translated using Weblate (French)

Currently translated at 100.0% (1348 of 1348 strings)

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

* Translated using Weblate (Czech)

Currently translated at 100.0% (1351 of 1351 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1351 of 1351 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1351 of 1351 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1351 of 1351 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1351 of 1351 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1351 of 1351 strings)

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

* Translated using Weblate (Arabic)

Currently translated at 56.9% (769 of 1351 strings)

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

* Translated using Weblate (Polish)

Currently translated at 80.1% (1083 of 1351 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1351 of 1351 strings)

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

* Translated using Weblate (Arabic)

Currently translated at 58.0% (784 of 1351 strings)

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

---------

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

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

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

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

Fixes URL list in studio list styling

* Add stash id pill to studio and tag modals

* Fix create parent check box

* Allow excluding parent studio

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

* Don't render modal on every studio

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

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

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

* [compiler] use new image instead of placeholder

removes .gitignore, update README

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

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

Uses same styling as performer list table

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

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

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

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

Fixes #3998

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

* Remove unused isLoading destructure in SelectComponent

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

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

* Apply Prettier formatting to FilterSelect.tsx

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

Currently translated at 100.0% (1251 of 1251 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1251 of 1251 strings)

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

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

Currently translated at 100.0% (1251 of 1251 strings)

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

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1251 of 1251 strings)

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

* Translated using Weblate (Japanese)

Currently translated at 82.2% (1029 of 1251 strings)

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

* Translated using Weblate (Finnish)

Currently translated at 79.3% (993 of 1251 strings)

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

* Translated using Weblate (Japanese)

Currently translated at 83.2% (1042 of 1251 strings)

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

* Translated using Weblate (Polish)

Currently translated at 95.2% (1192 of 1251 strings)

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

* Translated using Weblate (Danish)

Currently translated at 86.1% (1078 of 1251 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.6% (871 of 1251 strings)

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

* Translated using Weblate (Danish)

Currently translated at 86.2% (1079 of 1251 strings)

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

* Translated using Weblate (Danish)

Currently translated at 86.1% (1080 of 1253 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1253 of 1253 strings)

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

* Update translation files

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

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

* Translated using Weblate (French)

Currently translated at 100.0% (1250 of 1250 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1250 of 1250 strings)

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

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1250 of 1250 strings)

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

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

Currently translated at 100.0% (1250 of 1250 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.9% (874 of 1250 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1251 of 1251 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1251 of 1251 strings)

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

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1251 of 1251 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (1251 of 1251 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1253 of 1253 strings)

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

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1253 of 1253 strings)

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

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

Currently translated at 100.0% (1253 of 1253 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (1253 of 1253 strings)

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

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1253 of 1253 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1253 of 1253 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1253 of 1253 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1253 of 1253 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1253 of 1253 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 70.0% (878 of 1253 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1271 of 1271 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1271 of 1271 strings)

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

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

Currently translated at 100.0% (1271 of 1271 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1271 of 1271 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1271 of 1271 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.0% (877 of 1271 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1271 of 1271 strings)

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

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1271 of 1271 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 76.7% (975 of 1271 strings)

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

* Translated using Weblate (Czech)

Currently translated at 100.0% (1271 of 1271 strings)

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

* Translated using Weblate (Japanese)

Currently translated at 86.7% (1102 of 1271 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1273 of 1273 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1273 of 1273 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1273 of 1273 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1273 of 1273 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1273 of 1273 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1273 of 1273 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1273 of 1273 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 91.0% (1159 of 1273 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1273 of 1273 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1278 of 1278 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1278 of 1278 strings)

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

* Translated using Weblate (German)

Currently translated at 86.5% (1106 of 1278 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 88.1% (1127 of 1278 strings)

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

* Translated using Weblate (Italian)

Currently translated at 65.9% (843 of 1278 strings)

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

* Translated using Weblate (Russian)

Currently translated at 82.7% (1057 of 1278 strings)

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

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

Currently translated at 100.0% (1278 of 1278 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1280 of 1280 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1280 of 1280 strings)

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

* Update translation files

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

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

* Translated using Weblate (French)

Currently translated at 100.0% (1299 of 1299 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1299 of 1299 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1300 of 1300 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1300 of 1300 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1300 of 1300 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1300 of 1300 strings)

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

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

Currently translated at 100.0% (1300 of 1300 strings)

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

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

Currently translated at 100.0% (1300 of 1300 strings)

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

* Update translation files

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

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

* Update translation files

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

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

* Translated using Weblate (Estonian)

Currently translated at 85.0% (1122 of 1320 strings)

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

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1320 of 1320 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1320 of 1320 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 98.0% (1294 of 1320 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1322 of 1322 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1322 of 1322 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1322 of 1322 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1322 of 1322 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1322 of 1322 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 97.4% (1288 of 1322 strings)

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

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1322 of 1322 strings)

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

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

Currently translated at 100.0% (1322 of 1322 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1322 of 1322 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1323 of 1323 strings)

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

* Translated using Weblate (German)

Currently translated at 85.1% (1130 of 1327 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1327 of 1327 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1327 of 1327 strings)

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

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1332 of 1332 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1332 of 1332 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1332 of 1332 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1332 of 1332 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1332 of 1332 strings)

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

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

Currently translated at 100.0% (1332 of 1332 strings)

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

* Added translation using Weblate (Arabic)

* Translated using Weblate (Arabic)

Currently translated at 29.8% (397 of 1332 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1334 of 1334 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1334 of 1334 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1338 of 1338 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1338 of 1338 strings)

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

* Translated using Weblate (Estonian)

Currently translated at 99.7% (1335 of 1338 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1338 of 1338 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 100.0% (1338 of 1338 strings)

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

* Translated using Weblate (Arabic)

Currently translated at 40.1% (537 of 1338 strings)

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

* Add arabic language option

---------

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

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

Ref: stashapp/stash#5530

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

* feat: add scenes_size sort option for studios

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

Ref: stashapp/stash#5530

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

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

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

Ref: stashapp/stash#5530

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

* feat: extend scenes_size sort to tags

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

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

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

* fix: rename CircumisedEnum to CircumcisedEnum across codebase

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

* fix: gofmt performer model files after enum rename

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

---------

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

We'll collect other bulk inputs here

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

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

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

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

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

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

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

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

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

Fixes #6518

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

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

Closes #6512

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

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

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

Closes #6624

* Render modal once above sidebar conditional

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

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

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

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

* Create full folder hierarchy when scanning a new folder

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

* Create folder hierarchy on new file scan

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

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

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

* Generalise filtered recommendation rows. Include warning popover for unsupported criteria
2026-02-26 07:55:26 +11:00
WithoutPants
5734ee43ff Add sidebar to scene markers list (#6603)
* Add tag markers filter
* Add marker count and markers filter to performer filter
* Add sidebar to marker list
2026-02-26 07:54:40 +11:00
DogmaDragon
c9f0dba62f Fix capitalization in custom localisation heading [skip-ci] (#6606) 2026-02-26 07:54:12 +11:00
312 changed files with 16555 additions and 6950 deletions

View File

@@ -6,6 +6,15 @@ body:
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: checkboxes
id: confirm-troubleshooting
attributes:
label: Have you enabled troubleshooting mode?
description: |
To ensure the bug is not caused by custom modifications or plugins make sure to enable troubleshooting mode before filing the report. In Stash go to Settings and click **Troubleshooting mode** and then retest to see if the bug still occurs.
options:
- label: I confirm that the troubleshooting mode is enabled.
required: true
- type: textarea
id: description
attributes:
@@ -61,4 +70,4 @@ body:
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output from Settings > Logs. This will be automatically formatted into code, so no need for backticks.
render: shell
render: shell

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:
push:
branches:
branches:
- develop
- master
- 'releases/**'
@@ -15,50 +15,163 @@ concurrency:
cancel-in-progress: true
env:
COMPILER_IMAGE: stashapp/compiler:12
COMPILER_IMAGE: ghcr.io/stashapp/compiler:13
jobs:
build:
runs-on: ubuntu-22.04
# Job 1: Generate code and build UI
# 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:
- 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
run: git fetch --prune --unshallow --tags
# pnpm version is read from the packageManager field in package.json
# 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
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: 'go.mod'
- name: Pull compiler image
run: docker pull $COMPILER_IMAGE
- name: Cache node modules
uses: actions/cache@v3
env:
cache-name: cache-node_modules
# Places generated Go files + UI build into the working tree so the build compiles
- name: Download generated artifacts
uses: actions/download-artifact@v8
with:
path: ui/v2.5/node_modules
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/pnpm-lock.yaml') }}
name: generated
- name: Cache UI build
uses: actions/cache@v3
id: cache-ui
env:
cache-name: cache-ui
- name: Test Backend
run: make it
# Job 3: Cross-compile for all platforms
# Each platform gets its own runner and Docker container (ghcr.io/stashapp/compiler:13).
# Each build-cc-* make target is self-contained (sets its own GOOS/GOARCH/CC),
# so running them in separate containers is functionally identical to one container.
# Runs in parallel with the test job.
build:
needs: generate
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- platform: windows
make-target: build-cc-windows
artifact-paths: |
dist/stash-win.exe
tag: win
- platform: macos
make-target: build-cc-macos
artifact-paths: |
dist/stash-macos
dist/Stash.app.zip
tag: osx
- platform: linux
make-target: build-cc-linux
artifact-paths: |
dist/stash-linux
tag: linux
- platform: linux-arm64v8
make-target: build-cc-linux-arm64v8
artifact-paths: |
dist/stash-linux-arm64v8
tag: arm
- platform: linux-arm32v7
make-target: build-cc-linux-arm32v7
artifact-paths: |
dist/stash-linux-arm32v7
tag: arm
- platform: linux-arm32v6
make-target: build-cc-linux-arm32v6
artifact-paths: |
dist/stash-linux-arm32v6
tag: arm
- platform: freebsd
make-target: build-cc-freebsd
artifact-paths: |
dist/stash-freebsd
tag: freebsd
steps:
- uses: actions/checkout@v6
with:
path: ui/v2.5/build
key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('ui/v2.5/pnpm-lock.yaml', 'ui/v2.5/public/**', 'ui/v2.5/src/**', 'graphql/**/*.graphql') }}
fetch-depth: 0
fetch-tags: true
- name: Cache go build
uses: actions/cache@v3
env:
# increment the number suffix to bump the cache
cache-name: cache-go-cache-1
- name: Download generated artifacts
uses: actions/download-artifact@v8
with:
name: generated
- name: Cache Go build
uses: actions/cache@v5
with:
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
env:
@@ -67,45 +180,50 @@ jobs:
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
- name: Pre-install
run: docker exec -t build /bin/bash -c "make CI=1 pre-ui"
- 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: Build (${{ matrix.platform }})
run: docker exec -t build /bin/bash -c "make ${{ matrix.make-target }}"
- name: Cleanup build container
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
run: |
git describe --tags --exclude latest_develop | tee CHECKSUMS_SHA1
@@ -116,7 +234,7 @@ jobs:
- name: Upload Windows binary
# 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@v4
uses: actions/upload-artifact@v7
with:
name: stash-win.exe
path: dist/stash-win.exe
@@ -124,15 +242,23 @@ jobs:
- name: Upload macOS binary
# 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@v4
uses: actions/upload-artifact@v7
with:
name: 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
# 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@v4
uses: actions/upload-artifact@v7
with:
name: stash-linux
path: dist/stash-linux
@@ -140,14 +266,14 @@ jobs:
- name: Upload UI
# only upload 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@v4
uses: actions/upload-artifact@v7
with:
name: stash-ui.zip
path: dist/stash-ui.zip
- name: Update latest_develop tag
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
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
@@ -197,7 +323,7 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: |
docker run --rm --privileged docker/binfmt:a7996909642ee92942dcd6cff44b9b95f08dad64
docker run --rm --privileged tonistiigi/binfmt
docker info
docker buildx create --name builder --use
docker buildx inspect --bootstrap
@@ -213,7 +339,7 @@ jobs:
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
run: |
docker run --rm --privileged docker/binfmt:a7996909642ee92942dcd6cff44b9b95f08dad64
docker run --rm --privileged tonistiigi/binfmt
docker info
docker buildx create --name builder --use
docker buildx inspect --bootstrap

View File

@@ -9,65 +9,20 @@ on:
- 'releases/**'
pull_request:
env:
COMPILER_IMAGE: stashapp/compiler:12
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Checkout
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
# no tags or depth needed for lint
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
# generate-backend runs natively (just go generate + touch-ui) — no Docker needed
- 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
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
uses: golangci/golangci-lint-action@v6

View File

@@ -50,7 +50,7 @@ export CGO_ENABLED := 1
# define COMPILER_IMAGE for cross-compilation docker container
ifndef COMPILER_IMAGE
COMPILER_IMAGE := stashapp/compiler:latest
COMPILER_IMAGE := ghcr.io/stashapp/compiler:latest
endif
.PHONY: release
@@ -129,7 +129,7 @@ phasher: build-flags
# builds dynamically-linked debug binaries
.PHONY: build
build: stash phasher
build: stash
# builds dynamically-linked PIE release binaries
.PHONY: build-release
@@ -187,8 +187,6 @@ build-cc-macos:
# Combine into universal binaries
lipo -create -output dist/stash-macos 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
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
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
build-cc-freebsd: export GOOS := freebsd
build-cc-freebsd: export GOARCH := amd64

View File

@@ -13,10 +13,10 @@
![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 supports a wide variety of both video and image formats.
* You can tag videos and find them later.
* Stash provides statistics about performers, tags, studios and more.
- 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.
- You can tag videos and find them later.
- 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.
@@ -24,17 +24,19 @@ For further information you can consult the [documentation](https://docs.stashap
# Installing Stash
> [!tip]
Step-by-step instructions are available at [docs.stashapp.cc/installation](https://docs.stashapp.cc/installation/).
#### 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.
#### Mac 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.
> [!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.
>
>**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.
<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.
- Documentation
- Official docs: https://docs.stashapp.cc - official guides guides and troubleshooting.
- In-app manual: press <kbd>Shift</kbd> + <kbd>?</kbd> in the app or view the manual online: https://docs.stashapp.cc/in-app-manual.
- 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.
### Documentation
- [Official documentation](https://docs.stashapp.cc) - official guides guides and troubleshooting.
- [In-app manual](https://docs.stashapp.cc/in-app-manual) press <kbd>Shift</kbd> + <kbd>?</kbd> in the app or view the manual online.
- [FAQ](https://discourse.stashapp.cc/c/support/faq/28) - common questions and answers.
- [Community wiki](https://discourse.stashapp.cc/tags/c/community-wiki/22/stash) - guides, how-tos and tips.
- Community & discussion
- Community forum: https://discourse.stashapp.cc - community support, feature requests and discussions.
- Discord: https://discord.gg/2TsNFKt - real-time chat and community support.
- GitHub discussions: https://github.com/stashapp/stash/discussions - community support and feature discussions.
- Lemmy community: https://discuss.online/c/stashapp - Reddit-style community space.
### Community & discussion
- [Community forum](https://discourse.stashapp.cc) - community support, feature requests and discussions.
- [Discord](https://discord.gg/2TsNFKt) - real-time chat and community support.
- [GitHub discussions](https://github.com/stashapp/stash/discussions) - community support and feature discussions.
- [Lemmy community](https://discuss.online/c/stashapp) - board-style community space.
- Community scrapers & plugins
- Metadata sources: https://docs.stashapp.cc/metadata-sources/
- Plugins: https://docs.stashapp.cc/plugins/
- Themes: https://docs.stashapp.cc/themes/
- Other projects: https://docs.stashapp.cc/other-projects/
### Community scrapers & plugins
- [Metadata sources](https://docs.stashapp.cc/metadata-sources/)
- [Plugins](https://docs.stashapp.cc/plugins/)
- [Themes](https://docs.stashapp.cc/themes/)
- [Other projects](https://docs.stashapp.cc/other-projects/)
# For Developers

View File

@@ -12,7 +12,7 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \
FROM --platform=$TARGETPLATFORM alpine:latest AS app
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
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
RUN cat nodesource.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg && rm nodesource.gpg.key
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
WORKDIR /opt/cross-freebsd
RUN apk add --no-cache tar xz
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 && \
apt-get install -y --no-install-recommends \
git make tar bash nodejs zip \
clang llvm-dev cmake patch libxml2-dev uuid-dev libssl-dev xz-utils \
bzip2 gzip sed cpio libbz2-dev zlib1g-dev \
gcc-mingw-w64 \
gcc-arm-linux-gnueabi libc-dev-armel-cross linux-libc-dev-armel-cross \
gcc-aarch64-linux-gnu libc-dev-arm64-cross && \
rm -rf /var/lib/apt/lists/*;
### BUILDER
FROM golang:1.24.3 AS builder
ENV PATH=/opt/osx-ndk-x86/bin:$PATH
# copy in nodejs instead of using nodesource :thumbsup:
COPY --from=docker.io/library/node:24-bookworm /usr/local /usr/local
# copy in osxcross
COPY --from=osxcross /tmp/osxcross/target/lib /usr/lib
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
RUN npm install -g pnpm
# FreeBSD cross-compilation setup
# https://github.com/smartmontools/docker-build/blob/6b8c92560d17d325310ba02d9f5a4b250cb0764a/Dockerfile#L66
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
# git for getting hash
# make and bash for building
RUN cd /tmp && \
curl -o base.txz $FREEBSD_DOWNLOAD_URL && \
echo "$FREEBSD_SHA base.txz" | sha256sum -c - && \
mkdir -p /opt/cross-freebsd && \
cd /opt/cross-freebsd && \
tar -xf /tmp/base.txz ./lib/ ./usr/lib/ ./usr/include/ && \
rm -f /tmp/base.txz && \
cd /opt/cross-freebsd/usr/lib && \
find . -xtype l | xargs ls -l | grep ' /lib/' | awk '{print "ln -sf /opt/cross-freebsd"$11 " " $9}' | /bin/sh && \
ln -s libc++.a libstdc++.a && \
ln -s libc++.so libstdc++.so
# macOS cross-compilation setup
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
# clang for macos
# zip for stashapp.zip
# gcc-extensions for cross-arch build
# we still target arm soft float?
RUN apt-get update && \
apt-get install -y --no-install-recommends \
git make bash \
clang zip \
gcc-mingw-w64 \
gcc-arm-linux-gnueabi \
libc-dev-armel-cross linux-libc-dev-armel-cross \
gcc-aarch64-linux-gnu libc-dev-arm64-cross && \
rm -rf /var/lib/apt/lists/*;
RUN git config --global safe.directory '*'
# To test locally:
# make generate
# make ui
# cd docker/compiler
# make build
# docker run --rm -v /PATH_TO_STASH:/stash -w /stash -i -t stashapp/compiler:latest make build-cc-all
# # binaries will show up in /dist
# docker build . -t ghcr.io/stashapp/compiler:latest
# 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

View File

@@ -1,16 +1,22 @@
host=ghcr.io
user=stashapp
repo=compiler
version=12
version=13
VERSION_IMAGE = ${host}/${user}/${repo}:${version}
LATEST_IMAGE = ${host}/${user}/${repo}:latest
latest:
docker build -t ${user}/${repo}:latest .
docker build -t ${LATEST_IMAGE} .
build:
docker build -t ${user}/${repo}:${version} -t ${user}/${repo}:latest .
docker build -t ${VERSION_IMAGE} -t ${LATEST_IMAGE} .
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
docker push ${user}/${repo}:${version}
docker push ${user}/${repo}:latest
# requires docker login ghcr.io
# echo $CR_PAT | docker login ghcr.io -u USERNAME --password-stdin
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
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:
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`
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.
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 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).
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/remeh/sizedwaitgroup v1.0.0
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/spf13/cast v1.6.0
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/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/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.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM=
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=

View File

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

View File

@@ -152,15 +152,15 @@ input PerformerFilterType {
fake_tits: StringCriterionInput
"Filter by penis length value"
penis_length: FloatCriterionInput
"Filter by ciricumcision"
"Filter by circumcision"
circumcised: CircumcisionCriterionInput
"Deprecated: use career_start and career_end. This filter is non-functional."
career_length: StringCriterionInput
@deprecated(reason: "Use career_start and career_end")
"Filter by career start year"
career_start: IntCriterionInput
"Filter by career end year"
career_end: IntCriterionInput
"Filter by career start"
career_start: DateCriterionInput
"Filter by career end"
career_end: DateCriterionInput
"Filter by tattoos"
tattoos: StringCriterionInput
"Filter by piercings"
@@ -177,6 +177,8 @@ input PerformerFilterType {
tag_count: IntCriterionInput
"Filter by scene count"
scene_count: IntCriterionInput
"Filter by marker count (via scene)"
marker_count: IntCriterionInput
"Filter by image count"
image_count: IntCriterionInput
"Filter by gallery count"
@@ -220,6 +222,8 @@ input PerformerFilterType {
galleries_filter: GalleryFilterType
"Filter by related tags that meet this criteria"
tags_filter: TagFilterType
"Filter by related scene markers (via scene) that meet this criteria"
markers_filter: SceneMarkerFilterType
"Filter by creation time"
created_at: TimestampCriterionInput
"Filter by last update time"
@@ -245,9 +249,9 @@ input SceneMarkerFilterType {
updated_at: TimestampCriterionInput
"Filter by scene date"
scene_date: DateCriterionInput
"Filter by cscene reation time"
"Filter by scene creation time"
scene_created_at: TimestampCriterionInput
"Filter by lscene ast update time"
"Filter by scene last update time"
scene_updated_at: TimestampCriterionInput
"Filter by related scenes that meet this criteria"
scene_filter: SceneFilterType
@@ -599,6 +603,8 @@ input GalleryFilterType {
files_filter: FileFilterType
"Filter by related folders that meet this criteria"
folders_filter: FolderFilterType
"Filter by parent folder of the zip or folder the gallery is in"
parent_folder: HierarchicalMultiCriterionInput
custom_fields: [CustomFieldCriterionInput!]
}
@@ -659,7 +665,7 @@ input TagFilterType {
"Filter by number of parent tags the tag has"
parent_count: IntCriterionInput
"Filter by number f child tags the tag has"
"Filter by number of child tags the tag has"
child_count: IntCriterionInput
"Filter by autotag ignore value"
@@ -684,6 +690,8 @@ input TagFilterType {
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"
created_at: TimestampCriterionInput
@@ -816,6 +824,7 @@ input FolderFilterType {
NOT: FolderFilterType
path: StringCriterionInput
basename: StringCriterionInput
parent_folder: HierarchicalMultiCriterionInput
zip_file: MultiCriterionInput
@@ -924,7 +933,7 @@ input GenderCriterionInput {
}
input CircumcisionCriterionInput {
value: [CircumisedEnum!]
value: [CircumcisedEnum!]
modifier: CriterionModifier!
}

View File

@@ -99,6 +99,8 @@ input BulkGroupUpdateInput {
ids: [ID!]
# rating expressed as 1-100
rating100: Int
date: String
synopsis: String
studio_id: ID
director: String
urls: BulkUpdateStrings

View File

@@ -26,6 +26,8 @@ input GenerateMetadataInput {
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: Boolean
@@ -129,6 +131,14 @@ type ScanMetadataOptions {
input CleanMetadataInput {
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"
dryRun: Boolean!
}

View File

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

View File

@@ -19,8 +19,8 @@ type ScrapedPerformer {
penis_length: String
circumcised: String
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: Int
career_end: Int
career_start: String
career_end: String
tattoos: String
piercings: String
# aliases must be comma-delimited to be parsed correctly
@@ -57,8 +57,8 @@ input ScrapedPerformerInput {
penis_length: String
circumcised: String
career_length: String @deprecated(reason: "Use career_start and career_end")
career_start: Int
career_end: Int
career_start: String
career_end: String
tattoos: String
piercings: String
aliases: String

View File

@@ -73,6 +73,7 @@ type ScrapedTag {
name: String!
description: String
alias_list: [String!]
parent: ScrapedTag
"Remote site ID, if applicable"
remote_site_id: String
}

View File

@@ -31,6 +31,11 @@ fragment TagFragment on Tag {
id
description
aliases
category {
id
name
description
}
}
fragment MeasurementsFragment on Measurements {

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 FileLoader github.com/stashapp/stash/pkg/models.FileID github.com/stashapp/stash/pkg/models.File
//go:generate go run github.com/vektah/dataloaden FolderLoader github.com/stashapp/stash/pkg/models.FolderID *github.com/stashapp/stash/pkg/models.Folder
//go:generate go run github.com/vektah/dataloaden FolderRelatedFolderIDsLoader github.com/stashapp/stash/pkg/models.FolderID []github.com/stashapp/stash/pkg/models.FolderID
//go:generate go run github.com/vektah/dataloaden SceneFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
//go:generate go run github.com/vektah/dataloaden ImageFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
//go:generate go run github.com/vektah/dataloaden GalleryFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
@@ -65,12 +66,17 @@ type Loaders struct {
StudioByID *StudioLoader
StudioCustomFields *CustomFieldsLoader
TagByID *TagLoader
TagCustomFields *CustomFieldsLoader
TagByID *TagLoader
TagCustomFields *CustomFieldsLoader
GroupByID *GroupLoader
GroupCustomFields *CustomFieldsLoader
FileByID *FileLoader
FolderByID *FolderLoader
FileByID *FileLoader
FolderByID *FolderLoader
FolderParentFolderIDs *FolderRelatedFolderIDsLoader
FolderSubFolderIDs *FolderRelatedFolderIDsLoader
}
type Middleware struct {
@@ -161,6 +167,16 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch,
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{
wait: wait,
maxBatch: maxBatch,
@@ -406,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) {
return func(keys []int) (ret [][]models.FileID, errs []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"
"strconv"
"github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/internal/build"
"github.com/stashapp/stash/internal/manager"
"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)
}
// 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) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.SceneMarker.Wall(ctx, q)

View File

@@ -2,19 +2,77 @@ package api
import (
"context"
"path/filepath"
"github.com/stashapp/stash/internal/api/loaders"
"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) {
if obj.ParentFolderID == nil {
return nil, nil
}
if r.idOnly(ctx) {
return &models.Folder{ID: *obj.ParentFolderID}, nil
}
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) {
// 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)
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/utils"
)
func (r *performerResolver) AliasList(ctx context.Context, obj *models.Performer) ([]string, error) {
@@ -110,12 +109,28 @@ func (r *performerResolver) HeightCm(ctx context.Context, obj *models.Performer)
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 := utils.FormatYearRange(obj.CareerStart, obj.CareerEnd)
ret := models.FormatYearRange(obj.CareerStart, obj.CareerEnd)
return &ret, nil
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"path/filepath"
"regexp"
"strconv"
@@ -85,6 +86,8 @@ func (r *mutationResolver) setConfigFloat(key string, value *float64) {
func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGeneralInput) (*ConfigGeneralResult, error) {
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()
if input.Stashes != nil {
for _, s := range input.Stashes {
@@ -97,8 +100,12 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
}
}
if isNew {
s.Path = filepath.Clean(s.Path)
// if it exists, it must be directory
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
}
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/stashapp/stash/internal/desktop"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
@@ -19,7 +20,7 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput)
if err := r.withTxn(ctx, func(ctx context.Context) error {
fileStore := r.repository.File
folderStore := r.repository.Folder
mover := file.NewMover(fileStore, folderStore)
mover := file.NewMover(fileStore, folderStore, manager.GetInstance().Config.GetStashPaths().Paths())
mover.RegisterHooks(ctx)
var (
@@ -57,13 +58,14 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput)
folderPath := *input.DestinationFolder
// 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
}
// get or create folder hierarchy
var err error
folder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath)
folder, err = file.GetOrCreateFolderHierarchy(ctx, folderStore, folderPath, stashPaths.Paths())
if err != nil {
return fmt.Errorf("getting or creating folder hierarchy: %w", err)
}
@@ -112,8 +114,7 @@ func (r *mutationResolver) MoveFiles(ctx context.Context, input MoveFilesInput)
return true, nil
}
func (r *mutationResolver) validateFolderPath(folderPath string) error {
paths := manager.GetInstance().Config.GetStashPaths()
func (r *mutationResolver) validateFolderPath(paths config.StashConfigs, folderPath string) error {
if l := paths.GetStashFromDirPath(folderPath); l == nil {
return fmt.Errorf("folder path %s must be within a stash library path", folderPath)
}

View File

@@ -227,6 +227,12 @@ func (r *mutationResolver) GroupUpdate(ctx context.Context, input GroupUpdateInp
func groupPartialFromBulkGroupUpdateInput(translator changesetTranslator, input BulkGroupUpdateInput) (ret models.GroupPartial, err error) {
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.Director = translator.optionalString(input.Director, "director")

View File

@@ -52,17 +52,6 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
newPerformer.FakeTits = translator.string(input.FakeTits)
newPerformer.PenisLength = input.PenisLength
newPerformer.Circumcised = input.Circumcised
newPerformer.CareerStart = input.CareerStart
newPerformer.CareerEnd = input.CareerEnd
// if career_start/career_end not provided, parse deprecated career_length
if newPerformer.CareerStart == nil && newPerformer.CareerEnd == nil && input.CareerLength != nil {
start, end, err := utils.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.Tattoos = translator.string(input.Tattoos)
newPerformer.Piercings = translator.string(input.Piercings)
newPerformer.Favorite = translator.bool(input.Favorite)
@@ -100,6 +89,25 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
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)
if err != nil {
return nil, fmt.Errorf("converting tag ids: %w", err)
@@ -273,18 +281,25 @@ func performerPartialFromInput(input models.PerformerUpdateInput, translator cha
updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised")
// prefer career_start/career_end over deprecated career_length
if translator.hasField("career_start") || translator.hasField("career_end") {
updatedPerformer.CareerStart = translator.optionalInt(input.CareerStart, "career_start")
updatedPerformer.CareerEnd = translator.optionalInt(input.CareerEnd, "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 := utils.ParseYearRangeString(*input.CareerLength)
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.NewOptionalInt(*start)
updatedPerformer.CareerStart = models.NewOptionalDate(*start)
}
if end != nil {
updatedPerformer.CareerEnd = models.NewOptionalInt(*end)
updatedPerformer.CareerEnd = models.NewOptionalDate(*end)
}
}
updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos")
@@ -444,18 +459,24 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
updatedPerformer.Circumcised = translator.optionalString((*string)(input.Circumcised), "circumcised")
// prefer career_start/career_end over deprecated career_length
if translator.hasField("career_start") || translator.hasField("career_end") {
updatedPerformer.CareerStart = translator.optionalInt(input.CareerStart, "career_start")
updatedPerformer.CareerEnd = translator.optionalInt(input.CareerEnd, "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 := utils.ParseYearRangeString(*input.CareerLength)
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.NewOptionalInt(*start)
updatedPerformer.CareerStart = models.NewOptionalDate(*start)
}
if end != nil {
updatedPerformer.CareerEnd = models.NewOptionalInt(*end)
updatedPerformer.CareerEnd = models.NewOptionalDate(*end)
}
}
updatedPerformer.Tattoos = translator.optionalString(input.Tattoos, "tattoos")

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"slices"
"strconv"
"strings"
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
@@ -363,7 +364,8 @@ func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Sour
client := r.newStashBoxClient(*b)
var ret []*models.ScrapedTag
out, err := client.QueryTag(ctx, *input.Query)
query := *input.Query
out, err := client.QueryTag(ctx, query)
if err != nil {
return nil, err
@@ -383,6 +385,22 @@ func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Sour
}); err != nil {
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
}

View File

@@ -38,3 +38,12 @@ func (s StashConfigs) GetStashFromDirPath(dirPath string) *StashConfig {
}
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

@@ -74,6 +74,28 @@ func getScanPaths(inputPaths []string) []*config.StashConfig {
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
// scan or clean is complete.
func (s *Manager) ScanSubscribe(ctx context.Context) <-chan bool {
@@ -123,7 +145,8 @@ func (s *Manager) Scan(ctx context.Context, input ScanMetadataInput) (int, error
ZipFileExtensions: cfg.GetGalleryExtensions(),
// ScanFilters is set in ScanJob.Execute
// HandlerRequiredFilters is set in ScanJob.Execute
Rescan: input.Rescan,
RootPaths: cfg.GetStashPaths().Paths(),
Rescan: input.Rescan,
}
scanJob := ScanJob{
@@ -291,6 +314,8 @@ type CleanMetadataInput struct {
Paths []string `json:"paths"`
// Do a dry run. Don't delete any files
DryRun bool `json:"dryRun"`
IgnoreZipFileContents bool `json:"ignoreZipFileContents"`
}
func (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int {
@@ -408,7 +433,7 @@ type StashBoxBatchTagInput struct {
ExcludeFields []string `json:"exclude_fields"`
// Refresh items already tagged by StashBox if true. Only tag items with no StashBox tagging if false
Refresh bool `json:"refresh"`
// If batch adding studios, should their parent studios also be created?
// If batch adding studios or tags, should their parent entities also be created?
CreateParent bool `json:"createParent"`
// IDs in stash of the items to update.
// If set, names and stash_ids fields will be ignored.
@@ -726,6 +751,7 @@ func (s *Manager) batchTagTagsByIds(ctx context.Context, input StashBoxBatchTagI
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
tasks = append(tasks, &stashBoxBatchTagTagTask{
tag: t,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
@@ -746,6 +772,7 @@ func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box
if len(stashID) > 0 {
tasks = append(tasks, &stashBoxBatchTagTagTask{
stashID: &stashID,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
@@ -757,6 +784,7 @@ func (s *Manager) batchTagTagsByNamesOrStashIds(input StashBoxBatchTagInput, box
if len(name) > 0 {
tasks = append(tasks, &stashBoxBatchTagTagTask{
name: &name,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
@@ -783,6 +811,7 @@ func (s *Manager) batchTagAllTags(ctx context.Context, input StashBoxBatchTagInp
for _, t := range tags {
tasks = append(tasks, &stashBoxBatchTagTagTask{
tag: t,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})

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

@@ -40,9 +40,10 @@ func (j *cleanJob) Execute(ctx context.Context, progress *job.Progress) error {
}
j.cleaner.Clean(ctx, file.CleanOptions{
Paths: j.input.Paths,
DryRun: j.input.DryRun,
PathFilter: newCleanFilter(instance.Config),
Paths: j.input.Paths,
DryRun: j.input.DryRun,
IgnoreZipFileContents: j.input.IgnoreZipFileContents,
PathFilter: newCleanFilter(instance.Config),
}, progress)
if job.IsCancelled(ctx) {
@@ -154,11 +155,12 @@ func newCleanFilter(c *config.Config) *cleanFilter {
generatedPath: c.GetGeneratedPath(),
videoExcludeRegex: generateRegexps(c.GetExcludes()),
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
generatedPath := f.generatedPath
@@ -173,12 +175,18 @@ func (f *cleanFilter) Accept(ctx context.Context, path string, info fs.FileInfo)
}
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
}
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
}

View File

@@ -43,6 +43,8 @@ type GenerateMetadataInput struct {
GalleryIDs []string `json:"galleryIDs"`
// overwrite existing media
Overwrite bool `json:"overwrite"`
// paths to run generate on, in addition to the other ID lists
Paths []string `json:"paths"`
}
type GeneratePreviewOptionsInput struct {
@@ -133,8 +135,13 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
r := j.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
qb := r.Scene
if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 && len(j.input.ImageIDs) == 0 && len(j.input.GalleryIDs) == 0 {
j.queueTasks(ctx, g, queue)
if len(j.input.SceneIDs) == 0 &&
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 {
if len(j.input.SceneIDs) > 0 {
scenes, err = qb.FindMany(ctx, sceneIDs)
@@ -183,6 +190,11 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
}
}
}
if len(j.input.Paths) > 0 {
paths := filterStashPaths(j.input.Paths)
j.queueTasks(ctx, g, paths, queue)
}
}
return nil
@@ -276,17 +288,18 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error
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.queueScenesTasks(ctx, g, queue)
j.queueImagesTasks(ctx, g, queue)
j.queueScenesTasks(ctx, g, paths, 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
findFilter := models.BatchFindFilter(batchSize)
sceneFilter := scene.FilterFromPaths(paths)
r := j.repository
@@ -295,7 +308,7 @@ func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generato
return
}
scenes, err := scene.Query(ctx, r.Scene, nil, findFilter)
scenes, err := scene.Query(ctx, r.Scene, sceneFilter, findFilter)
if err != nil {
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
return
@@ -322,10 +335,11 @@ 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
findFilter := models.BatchFindFilter(batchSize)
imageFilter := image.FilterFromPaths(paths)
r := j.repository
@@ -334,7 +348,7 @@ func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generato
return
}
images, err := image.Query(ctx, r.Image, nil, findFilter)
images, err := image.Query(ctx, r.Image, imageFilter, findFilter)
if err != nil {
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
return

View File

@@ -26,6 +26,7 @@ import (
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/scene/generate"
"github.com/stashapp/stash/pkg/txn"
"github.com/stashapp/stash/pkg/utils"
)
type ScanJob struct {
@@ -35,6 +36,8 @@ type ScanJob struct {
fileQueue chan file.ScannedFile
count int
unmatchedCaptionFiles utils.MutexField[[]string]
}
func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {
@@ -73,6 +76,8 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {
j.scanner.ScanFilters = []file.PathFilter{newScanFilter(c, repo, minModTime)}
j.scanner.HandlerRequiredFilters = []file.Filter{newHandlerRequiredFilter(cfg, repo)}
logger.Infof("Starting scan of %d paths with %d parallel tasks", len(paths), nTasks)
j.runJob(ctx, paths, nTasks, progress)
taskQueue.Close()
@@ -83,7 +88,7 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {
}
elapsed := time.Since(start)
logger.Info(fmt.Sprintf("Scan finished (%s)", elapsed))
logger.Infof("Scan finished (%s)", elapsed)
j.subscriptions.notify()
return nil
@@ -166,12 +171,33 @@ func (j *ScanJob) queueFileFunc(ctx context.Context, f models.FS, zipFile *file.
return nil
}
if !j.scanner.AcceptEntry(ctx, path, info) {
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
}
@@ -309,10 +335,53 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress *
return err
}
// handle rename should have already handled the contents of the zip file
// so shouldn't need to scan it again
// 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 (r.New || r.Updated) && j.scanner.IsZipFile(f.Info.Name()) {
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()
@@ -324,6 +393,8 @@ func (j *ScanJob) handleFile(ctx context.Context, f file.ScannedFile, progress *
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
@@ -378,11 +449,10 @@ type sceneFinder interface {
// handlerRequiredFilter returns true if a File's handler needs to be executed despite the file not being updated.
type handlerRequiredFilter struct {
extensionConfig
txnManager txn.Manager
SceneFinder sceneFinder
ImageFinder fileCounter
GalleryFinder galleryFinder
CaptionUpdater video.CaptionUpdater
txnManager txn.Manager
SceneFinder sceneFinder
ImageFinder fileCounter
GalleryFinder galleryFinder
FolderCache *lru.LRU[bool]
@@ -398,7 +468,6 @@ func newHandlerRequiredFilter(c *config.Config, repo models.Repository) *handler
SceneFinder: repo.Scene,
ImageFinder: repo.Image,
GalleryFinder: repo.Gallery,
CaptionUpdater: repo.File,
FolderCache: lru.New[bool](processes * 2),
videoFileNamingAlgorithm: c.GetVideoFileNamingAlgorithm(),
}
@@ -473,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
}
type scanFilter struct {
extensionConfig
txnManager txn.Manager
FileFinder models.FileFinder
CaptionUpdater video.CaptionUpdater
txnManager txn.Manager
stashPaths config.StashConfigs
generatedPath string
videoExcludeRegex []*regexp.Regexp
imageExcludeRegex []*regexp.Regexp
minModTime time.Time
stashIgnoreFilter *file.StashIgnoreFilter
}
func newScanFilter(c *config.Config, repo models.Repository, minModTime time.Time) *scanFilter {
return &scanFilter{
extensionConfig: newExtensionConfig(c),
txnManager: repo.TxnManager,
FileFinder: repo.File,
CaptionUpdater: repo.File,
stashPaths: c.GetStashPaths(),
generatedPath: c.GetGeneratedPath(),
videoExcludeRegex: generateRegexps(c.GetExcludes()),
imageExcludeRegex: generateRegexps(c.GetImageExcludes()),
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) {
logger.Warnf("Skipping %q as it overlaps with the generated folder", path)
return false
@@ -548,19 +587,16 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo)
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)
isImageFile := useAsImage(path)
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 {
logger.Debugf("Skipping %s as it does not match any known file extensions", path)
return false
@@ -624,8 +660,9 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre
&file.FilteredHandler{
Filter: file.FilterFunc(imageFileFilter),
Handler: &image.ScanHandler{
CreatorUpdater: r.Image,
GalleryFinder: r.Gallery,
CreatorUpdater: r.Image,
GalleryFinder: r.Gallery,
SceneFinderUpdater: r.Scene,
ScanGenerator: &imageGenerators{
input: options,
taskQueue: taskQueue,
@@ -654,9 +691,10 @@ func getScanHandlers(options ScanMetadataInput, taskQueue *job.TaskQueue, progre
&file.FilteredHandler{
Filter: file.FilterFunc(videoFileFilter),
Handler: &scene.ScanHandler{
CreatorUpdater: r.Scene,
CaptionUpdater: r.File,
PluginCache: pluginCache,
CreatorUpdater: r.Scene,
GalleryFinderUpdater: r.Gallery,
CaptionUpdater: r.File,
PluginCache: pluginCache,
ScanGenerator: &sceneGenerators{
input: options,
taskQueue: taskQueue,

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strconv"
"strings"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/match"
@@ -541,6 +542,7 @@ type stashBoxBatchTagTagTask struct {
name *string
stashID *string
tag *models.Tag
createParent bool
excludedFields []string
}
@@ -588,8 +590,11 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.
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)
@@ -615,6 +620,7 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.
if remoteID != "" {
results, err = client.QueryTag(ctx, remoteID)
} else {
nameQuery = t.tag.Name
results, err = client.QueryTag(ctx, t.tag.Name)
}
}
@@ -627,10 +633,26 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.
return nil, nil
}
result := results[0]
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.ScrapedTag(ctx, r.Tag, result, t.box.Endpoint)
return match.ScrapedTagHierarchy(ctx, r.Tag, result, t.box.Endpoint)
}); err != nil {
return nil, err
}
@@ -638,6 +660,39 @@ func (t *stashBoxBatchTagTagTask) findStashBoxTag(ctx context.Context) (*models.
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
@@ -649,6 +704,12 @@ func (t *stashBoxBatchTagTagTask) processMatchedTag(ctx context.Context, s *mode
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 {

View File

@@ -185,6 +185,12 @@ func (f *FFMpeg) hwCanFullHWTranscode(ctx context.Context, codec VideoCodec, vf
// Prepend input for hardware encoding only
func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args {
// check for custom /dev/dri device #6435
driDevice := os.Getenv("STASH_HW_DRI_DEVICE")
if driDevice == "" {
driDevice = "/dev/dri/renderD128"
}
switch toCodec {
case VideoCodecN264,
VideoCodecN264H:
@@ -201,7 +207,7 @@ func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args {
case VideoCodecV264,
VideoCodecVVP9:
args = append(args, "-vaapi_device")
args = append(args, "/dev/dri/renderD128")
args = append(args, driDevice)
if fullhw {
args = append(args, "-hwaccel")
args = append(args, "vaapi")

View File

@@ -33,6 +33,11 @@ type cleanJob struct {
type CleanOptions struct {
Paths []string
// IgnoreZipFileContents will skip checking the contents of zip files 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.
IgnoreZipFileContents bool
// Do a dry run. Don't delete any files
DryRun bool
@@ -174,13 +179,16 @@ func (j *cleanJob) assessFiles(ctx context.Context, toDelete *deleteSet) error {
more := true
r := j.Repository
includeZipContents := !j.options.IgnoreZipFileContents
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
for more {
if job.IsCancelled(ctx) {
return nil
}
files, err := r.File.FindAllInPaths(ctx, j.options.Paths, batchSize, offset)
files, err := r.File.FindAllInPaths(ctx, j.options.Paths, includeZipContents, batchSize, offset)
if err != nil {
return fmt.Errorf("error querying for files: %w", err)
}
@@ -258,6 +266,8 @@ func (j *cleanJob) assessFolders(ctx context.Context, toDelete *deleteSet) error
offset := 0
progress := j.progress
includeZipContents := !j.options.IgnoreZipFileContents
more := true
r := j.Repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
@@ -266,7 +276,7 @@ func (j *cleanJob) assessFolders(ctx context.Context, toDelete *deleteSet) error
return nil
}
folders, err := r.Folder.FindAllInPaths(ctx, j.options.Paths, batchSize, offset)
folders, err := r.Folder.FindAllInPaths(ctx, j.options.Paths, includeZipContents, batchSize, offset)
if err != nil {
return fmt.Errorf("error querying for folders: %w", err)
}
@@ -348,8 +358,14 @@ func (j *cleanJob) shouldClean(ctx context.Context, f models.File) bool {
// run through path filter, if returns false then the file should be cleaned
filter := j.options.PathFilter
// need to get the zip file path if present
zipFilePath := ""
if f.Base().ZipFile != nil {
zipFilePath = f.Base().ZipFile.Base().Path
}
// don't log anything - assume filter will have logged the reason
return !filter.Accept(ctx, path, info)
return !filter.Accept(ctx, path, info, zipFilePath)
}
func (j *cleanJob) shouldCleanFolder(ctx context.Context, f *models.Folder) bool {
@@ -387,8 +403,14 @@ func (j *cleanJob) shouldCleanFolder(ctx context.Context, f *models.Folder) bool
// run through path filter, if returns false then the file should be cleaned
filter := j.options.PathFilter
// need to get the zip file path if present
zipFilePath := ""
if f.ZipFile != nil {
zipFilePath = f.ZipFile.Base().Path
}
// don't log anything - assume filter will have logged the reason
return !filter.Accept(ctx, path, info)
return !filter.Accept(ctx, path, info, zipFilePath)
}
func (j *cleanJob) deleteFile(ctx context.Context, fileID models.FileID, fn string) {

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"path/filepath"
"slices"
"strings"
"time"
@@ -12,8 +13,9 @@ import (
)
// GetOrCreateFolderHierarchy gets the folder for the given path, or creates a folder hierarchy for the given path if one if no existing folder is found.
// Does not create any folders in the file system
func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreator, path string) (*models.Folder, error) {
// Creates folder entries for each level of the hierarchy that doesn't already exist, up to the provided root paths.
// Does not create any folders in the file system.
func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreator, path string, rootPaths []string) (*models.Folder, error) {
// get or create folder hierarchy
// assume case sensitive when searching for the folder
const caseSensitive = true
@@ -23,17 +25,33 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat
}
if folder == nil {
parentPath := filepath.Dir(path)
parent, err := GetOrCreateFolderHierarchy(ctx, fc, parentPath)
if err != nil {
return nil, err
var parentID *models.FolderID
if !slices.Contains(rootPaths, path) {
parentPath := filepath.Dir(path)
// safety check - don't allow parent path to be the same as the current path,
// otherwise we could end up in an infinite loop
if parentPath == path {
// #6618 - log a warning and return nil for the parent ID,
// which will cause the folder to be created with no parent
logger.Warnf("parent path is the same as the current path: %s", path)
return nil, nil
}
parent, err := GetOrCreateFolderHierarchy(ctx, fc, parentPath, rootPaths)
if err != nil {
return nil, err
}
parentID = &parent.ID
}
now := time.Now()
folder = &models.Folder{
Path: path,
ParentFolderID: &parent.ID,
ParentFolderID: parentID,
DirEntry: models.DirEntry{
// leave mod time empty for now - it will be updated when the folder is scanned
},
@@ -41,6 +59,8 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat
UpdatedAt: now,
}
logger.Infof("%s doesn't exist. Creating new folder entry...", path)
if err = fc.Create(ctx, folder); err != nil {
return nil, fmt.Errorf("creating folder %s: %w", path, err)
}
@@ -49,12 +69,18 @@ func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreat
return folder, nil
}
func transferZipHierarchy(ctx context.Context, folderStore models.FolderReaderWriter, files models.FileFinderUpdater, zipFileID models.FileID, oldPath string, newPath string) error {
if err := transferZipFolderHierarchy(ctx, folderStore, zipFileID, oldPath, newPath); err != nil {
type zipHierarchyMover struct {
folderStore models.FolderReaderWriter
files models.FileFinderUpdater
rootPaths []string
}
func (m zipHierarchyMover) transferZipHierarchy(ctx context.Context, zipFileID models.FileID, oldPath string, newPath string) error {
if err := m.transferZipFolderHierarchy(ctx, zipFileID, oldPath, newPath); err != nil {
return fmt.Errorf("moving folder hierarchy for file %s: %w", oldPath, err)
}
if err := transferZipFileEntries(ctx, folderStore, files, zipFileID, oldPath, newPath); err != nil {
if err := m.transferZipFileEntries(ctx, zipFileID, oldPath, newPath); err != nil {
return fmt.Errorf("moving zip file contents for file %s: %w", oldPath, err)
}
@@ -63,8 +89,8 @@ func transferZipHierarchy(ctx context.Context, folderStore models.FolderReaderWr
// transferZipFolderHierarchy creates the folder hierarchy for zipFileID under newPath, and removes
// ZipFileID from folders under oldPath.
func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderReaderWriter, zipFileID models.FileID, oldPath string, newPath string) error {
zipFolders, err := folderStore.FindByZipFileID(ctx, zipFileID)
func (m zipHierarchyMover) transferZipFolderHierarchy(ctx context.Context, zipFileID models.FileID, oldPath string, newPath string) error {
zipFolders, err := m.folderStore.FindByZipFileID(ctx, zipFileID)
if err != nil {
return err
}
@@ -83,7 +109,7 @@ func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe
}
newZfPath := filepath.Join(newPath, relZfPath)
newFolder, err := GetOrCreateFolderHierarchy(ctx, folderStore, newZfPath)
newFolder, err := GetOrCreateFolderHierarchy(ctx, m.folderStore, newZfPath, m.rootPaths)
if err != nil {
return err
}
@@ -91,14 +117,14 @@ func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe
// add ZipFileID to new folder
logger.Debugf("adding zip file %s to folder %s", zipFileID, newFolder.Path)
newFolder.ZipFileID = &zipFileID
if err = folderStore.Update(ctx, newFolder); err != nil {
if err = m.folderStore.Update(ctx, newFolder); err != nil {
return err
}
// remove ZipFileID from old folder
logger.Debugf("removing zip file %s from folder %s", zipFileID, oldFolder.Path)
oldFolder.ZipFileID = nil
if err = folderStore.Update(ctx, oldFolder); err != nil {
if err = m.folderStore.Update(ctx, oldFolder); err != nil {
return err
}
}
@@ -106,9 +132,9 @@ func transferZipFolderHierarchy(ctx context.Context, folderStore models.FolderRe
return nil
}
func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCreator, files models.FileFinderUpdater, zipFileID models.FileID, oldPath, newPath string) error {
func (m zipHierarchyMover) transferZipFileEntries(ctx context.Context, zipFileID models.FileID, oldPath, newPath string) error {
// move contained files if file is a zip file
zipFiles, err := files.FindByZipFileID(ctx, zipFileID)
zipFiles, err := m.files.FindByZipFileID(ctx, zipFileID)
if err != nil {
return fmt.Errorf("finding contained files in file %s: %w", oldPath, err)
}
@@ -129,7 +155,7 @@ func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCrea
newZfDir := filepath.Join(newPath, relZfDir)
// folder should have been created by transferZipFolderHierarchy
newZfFolder, err := GetOrCreateFolderHierarchy(ctx, folders, newZfDir)
newZfFolder, err := GetOrCreateFolderHierarchy(ctx, m.folderStore, newZfDir, m.rootPaths)
if err != nil {
return fmt.Errorf("getting or creating folder hierarchy: %w", err)
}
@@ -137,7 +163,7 @@ func transferZipFileEntries(ctx context.Context, folders models.FolderFinderCrea
// update file parent folder
zfBase.ParentFolderID = newZfFolder.ID
logger.Debugf("moving %s to folder %s", zfBase.Path, newZfFolder.Path)
if err := files.Update(ctx, zf); err != nil {
if err := m.files.Update(ctx, zf); err != nil {
return fmt.Errorf("updating file %s: %w", oldZfPath, err)
}
}

View File

@@ -2,7 +2,6 @@ package file
import (
"context"
"errors"
"fmt"
"io/fs"
@@ -88,6 +87,11 @@ func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*mode
r := s.Repository
zipFilePath := ""
if file.ZipFile != nil {
zipFilePath = file.ZipFile.Base().Path
}
if err := SymWalk(file.FS, file.Path, func(path string, d fs.DirEntry, err error) error {
if err != nil {
// don't let errors prevent scanning
@@ -111,7 +115,7 @@ func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*mode
return nil
}
if !s.AcceptEntry(ctx, path, info) {
if !s.AcceptEntry(ctx, path, info, zipFilePath) {
return nil
}
@@ -161,9 +165,7 @@ func (s *Scanner) detectFolderMove(ctx context.Context, file ScannedFile) (*mode
continue
}
if !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("checking for parent folder %q: %w", pf.Path, err)
}
// treat any error as missing folder
// parent folder is missing, possible candidate
// count the total number of files in the existing folder

View File

@@ -9,7 +9,7 @@ import (
// PathFilter provides a filter function for paths.
type PathFilter interface {
Accept(ctx context.Context, path string, info fs.FileInfo) bool
Accept(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool
}
type PathFilterFunc func(path string) bool

View File

@@ -45,9 +45,12 @@ type Mover struct {
moved map[string]string
foldersCreated []string
// needed for creating folder hierarchy when moving zip file entries
rootPaths []string
}
func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReaderWriter) *Mover {
func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReaderWriter, rootPaths []string) *Mover {
return &Mover{
Files: fileStore,
Folders: folderStore,
@@ -55,6 +58,7 @@ func NewMover(fileStore models.FileFinderUpdater, folderStore models.FolderReade
renamerRemoverImpl: newRenamerRemoverImpl(),
mkDirFn: os.Mkdir,
},
rootPaths: rootPaths,
}
}
@@ -87,7 +91,13 @@ func (m *Mover) Move(ctx context.Context, f models.File, folder *models.Folder,
return fmt.Errorf("file %s already exists", newPath)
}
if err := transferZipHierarchy(ctx, m.Folders, m.Files, fBase.ID, oldPath, newPath); err != nil {
zipMover := zipHierarchyMover{
folderStore: m.Folders,
files: m.Files,
rootPaths: m.rootPaths,
}
if err := zipMover.transferZipHierarchy(ctx, fBase.ID, oldPath, newPath); err != nil {
return fmt.Errorf("moving folder hierarchy for file %s: %w", fBase.Path, err)
}
@@ -195,6 +205,25 @@ func correctSubFolderHierarchy(ctx context.Context, rw models.FolderReaderWriter
logger.Debugf("updating folder %s to %s", oldPath, correctPath)
// #6427 - ensure folder entry with new path doesn't already exist
const caseSensitive = true
existing, err := rw.FindByPath(ctx, correctPath, caseSensitive)
if err != nil {
return fmt.Errorf("finding folder by path %s: %w", correctPath, err)
}
if existing != nil {
// this should no longer be possible, but if it does happen, log a warning
// and skip updating this folder and its subfolders
logger.Warnf("folder with path %s already exists, setting parent_folder_id of %s to NULL and skipping", correctPath, oldPath)
f.ParentFolderID = nil
if err := rw.Update(ctx, f); err != nil {
return fmt.Errorf("updating folder parent id to NULL for folder %s: %w", oldPath, err)
}
continue
}
f.Path = correctPath
if err := rw.Update(ctx, f); err != nil {
return fmt.Errorf("updating folder path %s -> %s: %w", oldPath, f.Path, err)

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"io/fs"
"path/filepath"
"slices"
"strings"
"sync"
"time"
@@ -60,6 +61,10 @@ type Scanner struct {
// handlers are called after a file has been scanned.
FileHandlers []Handler
// RootPaths form the top-level paths for the library.
// Used to determine the root of the folder hierarchy when creating folders.
RootPaths []string
// Rescan indicates whether files should be rescanned even if they haven't changed.
Rescan bool
@@ -106,12 +111,12 @@ type ScannedFile struct {
}
// AcceptEntry determines if the file entry should be accepted for scanning
func (s *Scanner) AcceptEntry(ctx context.Context, path string, info fs.FileInfo) bool {
func (s *Scanner) AcceptEntry(ctx context.Context, path string, info fs.FileInfo, zipFilePath string) bool {
// always accept if there's no filters
accept := len(s.ScanFilters) == 0
for _, filter := range s.ScanFilters {
// accept if any filter accepts the file
if filter.Accept(ctx, path, info) {
if filter.Accept(ctx, path, info, zipFilePath) {
accept = true
break
}
@@ -193,6 +198,10 @@ func (s *Scanner) ScanFolder(ctx context.Context, file ScannedFile) (*models.Fol
return f, err
}
func (s *Scanner) isRootPath(path string) bool {
return path == "." || slices.Contains(s.RootPaths, path)
}
func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Folder, error) {
renamed, err := s.handleFolderRename(ctx, file)
if err != nil {
@@ -212,18 +221,16 @@ func (s *Scanner) onNewFolder(ctx context.Context, file ScannedFile) (*models.Fo
UpdatedAt: now,
}
dir := filepath.Dir(file.Path)
if dir != "." {
parentFolderID, err := s.getFolderID(ctx, dir)
if !s.isRootPath(file.Path) {
dir := filepath.Dir(file.Path)
// create full folder hierarchy if parent folder doesn't exist, and set parent folder ID
parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, dir, s.RootPaths)
if err != nil {
return nil, fmt.Errorf("getting parent folder %q: %w", dir, err)
}
// if parent folder doesn't exist, assume it's a top-level folder
// this may not be true if we're using multiple goroutines
if parentFolderID != nil {
toCreate.ParentFolderID = parentFolderID
}
toCreate.ParentFolderID = &parentFolder.ID
}
txn.AddPostCommitHook(ctx, func(ctx context.Context) {
@@ -312,6 +319,19 @@ func (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing
}
}
// handle case where parent folder was not previously set
if existing.ParentFolderID == nil && !s.isRootPath(existing.Path) {
logger.Infof("Existing folder entry %q has no parent folder. Creating folder hierarchy and setting parent ID...", existing.Path)
// create full folder hierarchy if parent folder doesn't exist, and set parent folder ID
parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, filepath.Dir(f.Path), s.RootPaths)
if err != nil {
return nil, fmt.Errorf("getting parent folder for %q: %w", f.Path, err)
}
existing.ParentFolderID = &parentFolder.ID
update = true
}
if update {
var err error
if err = s.Repository.Folder.Update(ctx, existing); err != nil {
@@ -323,10 +343,15 @@ func (s *Scanner) onExistingFolder(ctx context.Context, f ScannedFile, existing
}
type ScanFileResult struct {
File models.File
New bool
Renamed bool
Updated bool
File models.File
New bool
Renamed bool
Updated bool
FingerprintChanged bool
}
func (r ScanFileResult) IsUnchanged() bool {
return !r.New && !r.Renamed && !r.Updated
}
// ScanFile scans the provided file into the database, returning the scan result.
@@ -393,13 +418,31 @@ func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult
baseFile.UpdatedAt = now
// find the parent folder
parentFolderID, err := s.getFolderID(ctx, filepath.Dir(path))
folderPath := filepath.Dir(path)
parentFolderID, err := s.getFolderID(ctx, folderPath)
if err != nil {
return nil, fmt.Errorf("getting parent folder for %q: %w", path, err)
}
if parentFolderID == nil {
return nil, fmt.Errorf("parent folder for %q doesn't exist", path)
// parent folders should have been created before scanning this file in a recursive scan
// assume that we are scanning specifically and only this file,
// so we should create the parent folder hierarchy if it doesn't exist
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
parentFolder, err := GetOrCreateFolderHierarchy(ctx, s.Repository.Folder, folderPath, s.RootPaths)
if err != nil {
return fmt.Errorf("getting parent folder for %q: %w", f.Path, err)
}
parentFolderID = &parentFolder.ID
return nil
}); err != nil {
return nil, err
}
}
if parentFolderID == nil {
// shouldn't happen
return nil, fmt.Errorf("parent folder ID is nil for %q", path)
}
baseFile.ParentFolderID = *parentFolderID
@@ -419,7 +462,11 @@ func (s *Scanner) onNewFile(ctx context.Context, f ScannedFile) (*ScanFileResult
// determine if the file is renamed from an existing file in the store
// do this after decoration so that missing fields can be populated
renamed, err := s.handleRename(ctx, file, fp)
zipFilePath := ""
if f.ZipFile != nil {
zipFilePath = f.ZipFile.Base().Path
}
renamed, err := s.handleRename(ctx, file, fp, zipFilePath)
if err != nil {
return nil, err
}
@@ -529,7 +576,7 @@ func (s *Scanner) getFileFS(f *models.BaseFile) (models.FS, error) {
return fs.OpenZip(zipPath, zipSize)
}
func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint) (models.File, error) {
func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.Fingerprint, zipFilePath string) (models.File, error) {
var others []models.File
for _, tfp := range fp {
@@ -571,7 +618,7 @@ func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.F
// treat as a move
missing = append(missing, other)
}
case !s.AcceptEntry(ctx, other.Base().Path, info):
case !s.AcceptEntry(ctx, other.Base().Path, info, zipFilePath):
// #4393 - if the file is no longer in the configured library paths, treat it as a move
logger.Debugf("File %q no longer in library paths. Treating as a move.", other.Base().Path)
missing = append(missing, other)
@@ -604,13 +651,19 @@ func (s *Scanner) handleRename(ctx context.Context, f models.File, fp []models.F
fBaseCopy.Fingerprints = updatedBase.Fingerprints
*updatedBase = fBaseCopy
zipMover := zipHierarchyMover{
folderStore: s.Repository.Folder,
files: s.Repository.File,
rootPaths: s.RootPaths,
}
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
if err := s.Repository.File.Update(ctx, updated); err != nil {
return fmt.Errorf("updating file for rename %q: %w", newPath, err)
}
if s.IsZipFile(updatedBase.Basename) {
if err := transferZipHierarchy(ctx, s.Repository.Folder, s.Repository.File, updatedBase.ID, oldPath, newPath); err != nil {
if err := zipMover.transferZipHierarchy(ctx, updatedBase.ID, oldPath, newPath); err != nil {
return fmt.Errorf("moving zip hierarchy for renamed zip file %q: %w", newPath, err)
}
}
@@ -743,6 +796,9 @@ func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing mo
return nil, err
}
oldFingerprints := existing.Base().Fingerprints
fingerprintChanged := fp.ContentsChanged(oldFingerprints)
s.removeOutdatedFingerprints(existing, fp)
existing.SetFingerprints(fp)
@@ -766,8 +822,9 @@ func (s *Scanner) onExistingFile(ctx context.Context, f ScannedFile, existing mo
return nil, err
}
return &ScanFileResult{
File: existing,
Updated: true,
File: existing,
Updated: true,
FingerprintChanged: fingerprintChanged,
}, nil
}

262
pkg/file/stashignore.go Normal file
View File

@@ -0,0 +1,262 @@
package file
import (
"context"
"io/fs"
"os"
"path/filepath"
"strings"
"sync"
lru "github.com/hashicorp/golang-lru/v2"
ignore "github.com/sabhiram/go-gitignore"
"github.com/stashapp/stash/pkg/logger"
)
const stashIgnoreFilename = ".stashignore"
// entriesCacheSize is the size of the LRU cache for collected ignore entries.
// This cache stores the computed list of ignore entries per directory, avoiding
// repeated directory tree walks for files in the same directory.
const entriesCacheSize = 500
// StashIgnoreFilter implements PathFilter to exclude files/directories
// based on .stashignore files with gitignore-style patterns.
type StashIgnoreFilter struct {
// cache stores compiled ignore patterns per directory.
cache sync.Map // map[string]*ignoreEntry
// entriesCache stores collected ignore entries per (dir, libraryRoot) pair.
// This avoids recomputing the entry list for every file in the same directory.
entriesCache *lru.Cache[string, []*ignoreEntry]
}
// ignoreEntry holds the compiled ignore patterns for a directory.
type ignoreEntry struct {
// patterns is the compiled gitignore matcher for this directory.
patterns *ignore.GitIgnore
// dir is the directory this entry applies to.
dir string
}
// NewStashIgnoreFilter creates a new StashIgnoreFilter.
func NewStashIgnoreFilter() *StashIgnoreFilter {
// Create the LRU cache for collected entries.
// Ignore error as it only fails if size <= 0.
entriesCache, _ := lru.New[string, []*ignoreEntry](entriesCacheSize)
return &StashIgnoreFilter{
entriesCache: entriesCache,
}
}
// Accept returns true if the path should be included in the scan.
// It checks for .stashignore files in the directory hierarchy and
// applies gitignore-style pattern matching.
// The libraryRoot parameter bounds the search for .stashignore files -
// only directories within the library root are checked.
// zipFilepath is the path of the zip file if the file is inside a zip.
// .stashignore files will not be read within zip files.
func (f *StashIgnoreFilter) Accept(ctx context.Context, path string, info fs.FileInfo, libraryRoot string, zipFilePath string) bool {
// If no library root provided, accept the file (safety fallback).
if libraryRoot == "" {
return true
}
// Get the directory containing this path.
dir := filepath.Dir(path)
// If the file is inside a zip, use the zip file's directory as the base for .stashignore lookup.
if zipFilePath != "" {
dir = filepath.Dir(zipFilePath)
}
// Collect all applicable ignore entries from library root to this directory.
entries := f.collectIgnoreEntries(dir, libraryRoot)
// If no .stashignore files found, accept the file.
if len(entries) == 0 {
return true
}
// Check each ignore entry in order (from root to most specific).
// Later entries can override earlier ones with negation patterns.
ignored := false
for _, entry := range entries {
// Get path relative to the ignore file's directory.
entryRelPath, err := filepath.Rel(entry.dir, path)
if err != nil {
continue
}
entryRelPath = filepath.ToSlash(entryRelPath)
if info.IsDir() {
entryRelPath += "/"
}
if entry.patterns.MatchesPath(entryRelPath) {
ignored = true
}
}
return !ignored
}
// collectIgnoreEntries gathers all ignore entries from library root to the given directory.
// It walks up the directory tree from dir to libraryRoot and returns entries in order
// from root to most specific. Results are cached to avoid repeated computation for
// files in the same directory.
func (f *StashIgnoreFilter) collectIgnoreEntries(dir string, libraryRoot string) []*ignoreEntry {
// Clean paths for consistent comparison and cache key generation.
dir = filepath.Clean(dir)
libraryRoot = filepath.Clean(libraryRoot)
// Build cache key from dir and libraryRoot.
cacheKey := dir + "\x00" + libraryRoot
// Check the entries cache first.
if cached, ok := f.entriesCache.Get(cacheKey); ok {
return cached
}
// Try subdirectory shortcut: if parent's entries are cached, extend them.
if dir != libraryRoot {
parent := filepath.Dir(dir)
if isPathInOrEqual(libraryRoot, parent) {
parentKey := parent + "\x00" + libraryRoot
if parentEntries, ok := f.entriesCache.Get(parentKey); ok {
// Parent is cached - just check if current dir has a .stashignore.
entries := parentEntries
if entry := f.getOrLoadIgnoreEntry(dir); entry != nil {
// Copy parent slice and append to avoid mutating cached slice.
entries = make([]*ignoreEntry, len(parentEntries), len(parentEntries)+1)
copy(entries, parentEntries)
entries = append(entries, entry)
}
f.entriesCache.Add(cacheKey, entries)
return entries
}
}
}
// No cache hit - compute from scratch.
// Walk up from dir to library root, collecting directories.
var dirs []string
current := dir
for {
// Check if we're still within the library root.
if !isPathInOrEqual(libraryRoot, current) {
break
}
dirs = append(dirs, current)
// Stop if we've reached the library root.
if current == libraryRoot {
break
}
parent := filepath.Dir(current)
if parent == current {
// Reached filesystem root without finding library root.
break
}
current = parent
}
// Reverse to get root-to-leaf order.
for i, j := 0, len(dirs)-1; i < j; i, j = i+1, j-1 {
dirs[i], dirs[j] = dirs[j], dirs[i]
}
// Check each directory for .stashignore files.
var entries []*ignoreEntry
for _, d := range dirs {
if entry := f.getOrLoadIgnoreEntry(d); entry != nil {
entries = append(entries, entry)
}
}
// Cache the result.
f.entriesCache.Add(cacheKey, entries)
return entries
}
// isPathInOrEqual checks if path is equal to or inside root.
func isPathInOrEqual(root, path string) bool {
if path == root {
return true
}
// Check if path starts with root + separator.
return strings.HasPrefix(path, root+string(filepath.Separator))
}
// getOrLoadIgnoreEntry returns the cached ignore entry for a directory, or loads it.
func (f *StashIgnoreFilter) getOrLoadIgnoreEntry(dir string) *ignoreEntry {
// Check cache first.
if cached, ok := f.cache.Load(dir); ok {
entry := cached.(*ignoreEntry)
if entry.patterns == nil {
return nil // Cached negative result.
}
return entry
}
// Try to load .stashignore from this directory.
stashIgnorePath := filepath.Join(dir, stashIgnoreFilename)
patterns, err := f.loadIgnoreFile(stashIgnorePath)
if err != nil {
if !os.IsNotExist(err) {
logger.Warnf("Failed to load .stashignore from %s: %v", dir, err)
}
f.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir})
return nil
}
if patterns == nil {
// File exists but has no patterns (empty or only comments).
f.cache.Store(dir, &ignoreEntry{patterns: nil, dir: dir})
return nil
}
logger.Debugf("Loaded .stashignore from %s", dir)
entry := &ignoreEntry{
patterns: patterns,
dir: dir,
}
f.cache.Store(dir, entry)
return entry
}
// loadIgnoreFile loads and compiles a .stashignore file.
func (f *StashIgnoreFilter) loadIgnoreFile(path string) (*ignore.GitIgnore, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, err
}
lines := strings.Split(string(data), "\n")
var patterns []string
for _, line := range lines {
// Trim trailing whitespace (but preserve leading for patterns).
line = strings.TrimRight(line, " \t\r")
// Skip empty lines.
if line == "" {
continue
}
// Skip comments (but not escaped #).
if strings.HasPrefix(line, "#") && !strings.HasPrefix(line, "\\#") {
continue
}
patterns = append(patterns, line)
}
if len(patterns) == 0 {
// File exists but has no patterns (e.g., only comments).
return nil, nil
}
return ignore.CompileIgnoreLines(patterns...), nil
}

View File

@@ -0,0 +1,523 @@
package file
import (
"context"
"io/fs"
"os"
"path/filepath"
"sort"
"testing"
)
// Helper to create an empty file.
func createTestFile(t *testing.T, dir, name 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)
}
if err := os.WriteFile(path, []byte{}, 0644); err != nil {
t.Fatalf("failed to create file %s: %v", path, err)
}
}
// Helper to create a file with content.
func createTestFileWithContent(t *testing.T, dir, name, content 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)
}
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
t.Fatalf("failed to create file %s: %v", path, err)
}
}
// Helper to create a directory.
func createTestDir(t *testing.T, dir, name string) {
t.Helper()
path := filepath.Join(dir, name)
if err := os.MkdirAll(path, 0755); err != nil {
t.Fatalf("failed to create directory %s: %v", path, err)
}
}
// walkAndFilter walks the directory tree and returns paths accepted by the filter.
// Returns paths relative to root for easier assertion.
func walkAndFilter(t *testing.T, root string, filter *StashIgnoreFilter) []string {
t.Helper()
var accepted []string
ctx := context.Background()
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
// Skip the root directory itself.
if path == root {
return nil
}
info, err := d.Info()
if err != nil {
return err
}
if filter.Accept(ctx, path, info, root, "") {
relPath, _ := filepath.Rel(root, path)
accepted = append(accepted, relPath)
} else if info.IsDir() {
// If directory is rejected, skip it.
return filepath.SkipDir
}
return nil
})
if err != nil {
t.Fatalf("walk failed: %v", err)
}
sort.Strings(accepted)
return accepted
}
// assertPathsEqual checks that the accepted paths match expected.
func assertPathsEqual(t *testing.T, expected, actual []string) {
t.Helper()
sort.Strings(expected)
if len(expected) != len(actual) {
t.Errorf("path count mismatch:\nexpected %d: %v\nactual %d: %v", len(expected), expected, len(actual), actual)
return
}
for i := range expected {
if expected[i] != actual[i] {
t.Errorf("path mismatch at index %d:\nexpected: %s\nactual: %s", i, expected[i], actual[i])
}
}
}
func TestStashIgnore_ExactFilename(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestFile(t, tmpDir, "video2.mp4")
createTestFile(t, tmpDir, "ignore_me.mp4")
// Create .stashignore that excludes exact filename.
createTestFileWithContent(t, tmpDir, ".stashignore", "ignore_me.mp4\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"video1.mp4",
"video2.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_WildcardPattern(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestFile(t, tmpDir, "video2.mp4")
createTestFile(t, tmpDir, "temp1.tmp")
createTestFile(t, tmpDir, "temp2.tmp")
createTestFile(t, tmpDir, "notes.log")
// Create .stashignore that excludes by extension.
createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n*.log\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"video1.mp4",
"video2.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_DirectoryExclusion(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestDir(t, tmpDir, "excluded_dir")
createTestFile(t, tmpDir, "excluded_dir/video2.mp4")
createTestFile(t, tmpDir, "excluded_dir/video3.mp4")
createTestDir(t, tmpDir, "included_dir")
createTestFile(t, tmpDir, "included_dir/video4.mp4")
// Create .stashignore that excludes a directory.
createTestFileWithContent(t, tmpDir, ".stashignore", "excluded_dir/\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"included_dir",
"included_dir/video4.mp4",
"video1.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_NegationPattern(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "file1.tmp")
createTestFile(t, tmpDir, "file2.tmp")
createTestFile(t, tmpDir, "keep_this.tmp")
// Create .stashignore that excludes *.tmp but keeps one.
createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n!keep_this.tmp\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"keep_this.tmp",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_CommentsAndEmptyLines(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestFile(t, tmpDir, "ignore_me.mp4")
// Create .stashignore with comments and empty lines.
stashignore := `# This is a comment
ignore_me.mp4
# Another comment
`
createTestFileWithContent(t, tmpDir, ".stashignore", stashignore)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"video1.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_NestedStashIgnoreFiles(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "root_video.mp4")
createTestFile(t, tmpDir, "root_ignore.tmp")
createTestDir(t, tmpDir, "subdir")
createTestFile(t, tmpDir, "subdir/sub_video.mp4")
createTestFile(t, tmpDir, "subdir/sub_ignore.log")
createTestFile(t, tmpDir, "subdir/also_tmp.tmp")
// Root .stashignore excludes *.tmp.
createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n")
// Subdir .stashignore excludes *.log.
createTestFileWithContent(t, tmpDir, "subdir/.stashignore", "*.log\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
// *.tmp from root should apply everywhere.
// *.log from subdir should only apply in subdir.
expected := []string{
".stashignore",
"root_video.mp4",
"subdir",
"subdir/.stashignore",
"subdir/sub_video.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_PathPattern(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestDir(t, tmpDir, "subdir")
createTestFile(t, tmpDir, "subdir/video2.mp4")
createTestFile(t, tmpDir, "subdir/skip_this.mp4")
// Create .stashignore that excludes a specific path.
createTestFileWithContent(t, tmpDir, ".stashignore", "subdir/skip_this.mp4\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"subdir",
"subdir/video2.mp4",
"video1.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_DoubleStarPattern(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestDir(t, tmpDir, "a")
createTestFile(t, tmpDir, "a/video2.mp4")
createTestDir(t, tmpDir, "a/temp")
createTestFile(t, tmpDir, "a/temp/video3.mp4")
createTestDir(t, tmpDir, "a/b")
createTestDir(t, tmpDir, "a/b/temp")
createTestFile(t, tmpDir, "a/b/temp/video4.mp4")
// Create .stashignore that excludes temp directories at any level.
createTestFileWithContent(t, tmpDir, ".stashignore", "**/temp/\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"a",
"a/b",
"a/video2.mp4",
"video1.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_LeadingSlashPattern(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "ignore.mp4")
createTestDir(t, tmpDir, "subdir")
createTestFile(t, tmpDir, "subdir/ignore.mp4")
// Create .stashignore that excludes only at root level.
createTestFileWithContent(t, tmpDir, ".stashignore", "/ignore.mp4\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
// Only root ignore.mp4 should be excluded.
expected := []string{
".stashignore",
"subdir",
"subdir/ignore.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_NoStashIgnoreFile(t *testing.T) {
tmpDir := t.TempDir()
// Create test files without any .stashignore.
createTestFile(t, tmpDir, "video1.mp4")
createTestFile(t, tmpDir, "video2.mp4")
createTestDir(t, tmpDir, "subdir")
createTestFile(t, tmpDir, "subdir/video3.mp4")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
// All files should be accepted.
expected := []string{
"subdir",
"subdir/video3.mp4",
"video1.mp4",
"video2.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_HiddenDirectories(t *testing.T) {
tmpDir := t.TempDir()
// Create test files including hidden directory.
createTestFile(t, tmpDir, "video1.mp4")
createTestDir(t, tmpDir, ".hidden")
createTestFile(t, tmpDir, ".hidden/video2.mp4")
// Create .stashignore that excludes hidden directories.
createTestFileWithContent(t, tmpDir, ".stashignore", ".*\n!.stashignore\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"video1.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_MultiplePatternsSameLine(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestFile(t, tmpDir, "file.tmp")
createTestFile(t, tmpDir, "file.log")
createTestFile(t, tmpDir, "file.bak")
// Each pattern should be on its own line.
createTestFileWithContent(t, tmpDir, ".stashignore", "*.tmp\n*.log\n*.bak\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"video1.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_TrailingSpaces(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestFile(t, tmpDir, "ignore_me.mp4")
// Pattern with trailing spaces (should be trimmed).
createTestFileWithContent(t, tmpDir, ".stashignore", "ignore_me.mp4 \n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"video1.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_EscapedHash(t *testing.T) {
tmpDir := t.TempDir()
// Create test files.
createTestFile(t, tmpDir, "video1.mp4")
createTestFile(t, tmpDir, "#filename.mp4")
// Escaped hash should match literal # character.
createTestFileWithContent(t, tmpDir, ".stashignore", "\\#filename.mp4\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"video1.mp4",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_CaseSensitiveMatching(t *testing.T) {
tmpDir := t.TempDir()
// Create test files - use distinct names that work on all filesystems.
createTestFile(t, tmpDir, "video_lower.mp4")
createTestFile(t, tmpDir, "VIDEO_UPPER.mp4")
createTestFile(t, tmpDir, "other.avi")
// Pattern should match exactly (case-sensitive).
createTestFileWithContent(t, tmpDir, ".stashignore", "video_lower.mp4\n")
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
// Only exact match is excluded.
expected := []string{
".stashignore",
"VIDEO_UPPER.mp4",
"other.avi",
}
assertPathsEqual(t, expected, accepted)
}
func TestStashIgnore_ComplexScenario(t *testing.T) {
tmpDir := t.TempDir()
// Create a complex directory structure.
createTestFile(t, tmpDir, "video1.mp4")
createTestFile(t, tmpDir, "video2.avi")
createTestFile(t, tmpDir, "thumbnail.jpg")
createTestFile(t, tmpDir, "metadata.nfo")
createTestDir(t, tmpDir, "movies")
createTestFile(t, tmpDir, "movies/movie1.mp4")
createTestFile(t, tmpDir, "movies/movie1.nfo")
createTestDir(t, tmpDir, "movies/.thumbnails")
createTestFile(t, tmpDir, "movies/.thumbnails/thumb1.jpg")
createTestDir(t, tmpDir, "temp")
createTestFile(t, tmpDir, "temp/processing.mp4")
createTestDir(t, tmpDir, "backup")
createTestFile(t, tmpDir, "backup/video1.mp4.bak")
// Complex .stashignore.
stashignore := `# Ignore metadata files
*.nfo
# Ignore hidden directories
.*
!.stashignore
# Ignore temp and backup directories
temp/
backup/
# But keep thumbnails in specific location
!movies/.thumbnails/
`
createTestFileWithContent(t, tmpDir, ".stashignore", stashignore)
filter := NewStashIgnoreFilter()
accepted := walkAndFilter(t, tmpDir, filter)
expected := []string{
".stashignore",
"movies",
"movies/.thumbnails",
"movies/.thumbnails/thumb1.jpg",
"movies/movie1.mp4",
"thumbnail.jpg",
"video1.mp4",
"video2.avi",
}
assertPathsEqual(t, expected, accepted)
}

View File

@@ -90,11 +90,20 @@ type CaptionUpdater interface {
UpdateCaptions(ctx context.Context, fileID models.FileID, captions []*models.VideoCaption) error
}
// MatchesCaption returns true if the caption file matches the video file based on the filename
func MatchesCaption(videoPath, captionPath string) bool {
captionPrefix := getCaptionPrefix(captionPath)
videoPrefix := strings.TrimSuffix(videoPath, filepath.Ext(videoPath)) + "."
return captionPrefix == videoPrefix
}
// associates captions to scene/s with the same basename
func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manager, fqb models.FileFinder, w CaptionUpdater) {
// returns true if the caption file was matched to a video file and processed, false otherwise
func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manager, fqb models.FileFinder, w CaptionUpdater) bool {
captionLang := getCaptionsLangFromPath(captionPath)
captionPrefix := getCaptionPrefix(captionPath)
matched := false
if err := txn.WithTxn(ctx, txnMgr, func(ctx context.Context) error {
var err error
files, er := fqb.FindAllByPath(ctx, captionPrefix+"*", true)
@@ -117,28 +126,36 @@ func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manag
path := f.Base().Path
logger.Debugf("Matched captions to file %s", path)
matched = true
captions, er := w.GetCaptions(ctx, fileID)
if er == nil {
fileExt := filepath.Ext(captionPath)
ext := fileExt[1:]
if !IsLangInCaptions(captionLang, ext, captions) { // only update captions if language code is not present
newCaption := &models.VideoCaption{
LanguageCode: captionLang,
Filename: filepath.Base(captionPath),
CaptionType: ext,
}
captions = append(captions, newCaption)
er = w.UpdateCaptions(ctx, fileID, captions)
if er == nil {
logger.Debugf("Updated captions for file %s. Added %s", path, captionLang)
}
if er != nil {
return fmt.Errorf("getting captions for file %s: %w", path, er)
}
fileExt := filepath.Ext(captionPath)
ext := fileExt[1:]
if !IsLangInCaptions(captionLang, ext, captions) { // only update captions if language code is not present
newCaption := &models.VideoCaption{
LanguageCode: captionLang,
Filename: filepath.Base(captionPath),
CaptionType: ext,
}
captions = append(captions, newCaption)
er = w.UpdateCaptions(ctx, fileID, captions)
if er != nil {
return fmt.Errorf("updating captions for file %s: %w", path, er)
}
logger.Debugf("Updated captions for file %s. Added %s", path, captionLang)
}
}
return err
}); err != nil {
logger.Error(err.Error())
}
return matched
}
// CleanCaptions removes non existent/accessible language codes from captions

View File

@@ -99,7 +99,9 @@ func (f *zipFS) rel(name string) (string, error) {
relName, err := filepath.Rel(f.zipPath, name)
if err != nil {
return "", fmt.Errorf("internal error getting relative path: %w", err)
// if the path is not relative to the zip path, then it's not found in the zip file,
// so treat this as a file not found
return "", fs.ErrNotExist
}
// convert relName to use slash, since zip files do so regardless

View File

@@ -148,7 +148,7 @@ func Touch(path string) error {
var (
replaceCharsRE = regexp.MustCompile(`[&=\\/:*"?_ ]`)
removeCharsRE = regexp.MustCompile(`[^[:alnum:]-.]`)
removeCharsRE = regexp.MustCompile(`[^\p{L}\p{N}\-.]`)
multiHyphenRE = regexp.MustCompile(`\-+`)
)

View File

@@ -15,6 +15,9 @@ func TestSanitiseBasename(t *testing.T) {
{"multi-hyphen", `hyphened--name`, "hyphened-name-2da2a58f"},
{"replaced characters", `a&b=c\d/:e*"f?_ g`, "a-b-c-d-e-f-g-ffca6fb0"},
{"removed characters", `foo!!bar@@and, more`, "foobarand-more-7cee02ab"},
{"unicode cjk", `テスト`, "テスト-63b560db"},
{"unicode korean", `시험`, "시험-3fcc7beb"},
{"mixed unicode", `Test テスト`, "Test-テスト-366aff1e"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@@ -5,7 +5,6 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
)
@@ -27,18 +26,10 @@ func IsFsPathCaseSensitive(path string) (bool, error) {
if err != nil { // cannot be case flipped
return false, err
}
i := strings.LastIndex(path, base)
if i < 0 { // shouldn't happen
return false, fmt.Errorf("could not case flip path %s", path)
}
flipped := []rune(path)
for _, c := range fBase { // replace base of path with the flipped one ( we need to flip the base or last dir part )
flipped[i] = c
i++
}
flippedPath := filepath.Join(filepath.Dir(path), fBase)
fiCase, err := os.Stat(string(flipped))
fiCase, err := os.Stat(flippedPath)
if err != nil { // cannot stat the case flipped path
return true, nil // fs of path should be case sensitive
}

View File

@@ -41,4 +41,15 @@ func TestIsFsPathCaseSensitive_UnicodeByteLength(t *testing.T) {
}
// assert.True(t, r, "expected fs to be case sensitive")
// Ensure that subfolders of a folder with multi-byte chars is not causing a panic
path3 := filepath.Join(dir, "NoPanic ❤️")
makeDir(path3)
path4 := filepath.Join(path3, "Test")
makeDir(path4)
_, err = IsFsPathCaseSensitive(path4)
if err != nil {
t.Fatal(err)
}
}

View File

@@ -24,7 +24,6 @@ type ScanCreatorUpdater interface {
type ScanSceneFinderUpdater interface {
FindByPath(ctx context.Context, p string) ([]*models.Scene, error)
Update(ctx context.Context, updatedScene *models.Scene) error
AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error
}
@@ -135,13 +134,14 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.
if err := h.CreatorUpdater.AddFileID(ctx, i.ID, f.Base().ID); err != nil {
return fmt.Errorf("adding file to gallery: %w", err)
}
// update updated_at time
if _, err := h.CreatorUpdater.UpdatePartial(ctx, i.ID, models.NewGalleryPartial()); err != nil {
return fmt.Errorf("updating gallery: %w", err)
}
}
if !found || updateExisting {
// update updated_at time when file association or content changes
if _, err := h.CreatorUpdater.UpdatePartial(ctx, i.ID, models.NewGalleryPartial()); err != nil {
return fmt.Errorf("updating gallery: %w", err)
}
h.PluginCache.RegisterPostHooks(ctx, i.ID, hook.GalleryUpdatePost, nil, nil)
}
}

108
pkg/gallery/scan_test.go Normal file
View File

@@ -0,0 +1,108 @@
package gallery
import (
"context"
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) {
const (
testGalleryID = 1
testFileID = 100
)
existingFile := &models.BaseFile{ID: models.FileID(testFileID), Path: "test.zip"}
makeGallery := func() *models.Gallery {
return &models.Gallery{
ID: testGalleryID,
Files: models.NewRelatedFiles([]models.File{existingFile}),
}
}
tests := []struct {
name string
updateExisting bool
expectUpdate bool
}{
{
name: "calls UpdatePartial when file content changed",
updateExisting: true,
expectUpdate: true,
},
{
name: "skips UpdatePartial when file unchanged and already associated",
updateExisting: false,
expectUpdate: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := mocks.NewDatabase()
db.Gallery.On("GetFiles", mock.Anything, testGalleryID).Return([]models.File{existingFile}, nil)
if tt.expectUpdate {
db.Gallery.On("UpdatePartial", mock.Anything, testGalleryID, mock.Anything).
Return(&models.Gallery{ID: testGalleryID}, nil)
}
h := &ScanHandler{
CreatorUpdater: db.Gallery,
PluginCache: &plugin.Cache{},
}
db.WithTxnCtx(func(ctx context.Context) {
err := h.associateExisting(ctx, []*models.Gallery{makeGallery()}, existingFile, tt.updateExisting)
assert.NoError(t, err)
})
if tt.expectUpdate {
db.Gallery.AssertCalled(t, "UpdatePartial", mock.Anything, testGalleryID, mock.Anything)
} else {
db.Gallery.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything)
}
})
}
}
func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) {
const (
testGalleryID = 1
existFileID = 100
newFileID = 200
)
existingFile := &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.zip"}
newFile := &models.BaseFile{ID: models.FileID(newFileID), Path: "new.zip"}
gallery := &models.Gallery{
ID: testGalleryID,
Files: models.NewRelatedFiles([]models.File{existingFile}),
}
db := mocks.NewDatabase()
db.Gallery.On("GetFiles", mock.Anything, testGalleryID).Return([]models.File{existingFile}, nil)
db.Gallery.On("AddFileID", mock.Anything, testGalleryID, models.FileID(newFileID)).Return(nil)
db.Gallery.On("UpdatePartial", mock.Anything, testGalleryID, mock.Anything).
Return(&models.Gallery{ID: testGalleryID}, nil)
h := &ScanHandler{
CreatorUpdater: db.Gallery,
PluginCache: &plugin.Cache{},
}
db.WithTxnCtx(func(ctx context.Context) {
err := h.associateExisting(ctx, []*models.Gallery{gallery}, newFile, false)
assert.NoError(t, err)
})
db.Gallery.AssertCalled(t, "AddFileID", mock.Anything, testGalleryID, models.FileID(newFileID))
db.Gallery.AssertCalled(t, "UpdatePartial", mock.Anything, testGalleryID, mock.Anything)
}

View File

@@ -3,10 +3,9 @@ package imagephash
import (
"bytes"
"context"
"errors"
"fmt"
"image"
"path/filepath"
"strings"
"github.com/corona10/goimagehash"
"github.com/stashapp/stash/pkg/ffmpeg"
@@ -32,17 +31,9 @@ func Generate(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (*uint64, err
}
// loadImage loads an image from disk and decodes it.
// For AVIF files, ffmpeg is used to convert to BMP first since Go has no built-in AVIF decoder.
// Where Go has no built-in decoder for a specific format, ffmpeg is used to convert to BMP first.
func loadImage(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (image.Image, error) {
ext := strings.ToLower(filepath.Ext(imageFile.Path))
if ext == ".avif" {
// AVIF in zip files is not supported - ffmpeg cannot read files inside zips
if imageFile.Base().ZipFileID != nil {
return nil, fmt.Errorf("AVIF images in zip files are not supported for phash generation")
}
return loadImageFFmpeg(encoder, imageFile.Path)
}
// try to load with Go's built-in decoders first for better performance
reader, err := imageFile.Open(&file.OsFS{})
if err != nil {
return nil, err
@@ -55,6 +46,15 @@ func loadImage(encoder *ffmpeg.FFMpeg, imageFile *models.ImageFile) (image.Image
}
img, _, err := image.Decode(buf)
if errors.Is(err, image.ErrFormat) {
// try ffmpeg as a fallback for unsupported formats
// ffmpeg cannot read files inside zips
if imageFile.Base().ZipFileID != nil {
return nil, fmt.Errorf("ffmpeg fallback unsupported for images in zip files")
}
return loadImageFFmpeg(encoder, imageFile.Path)
}
if err != nil {
return nil, fmt.Errorf("decoding image: %w", err)
}

View File

@@ -2,7 +2,9 @@ package image
import (
"context"
"path/filepath"
"strconv"
"strings"
"github.com/stashapp/stash/pkg/models"
)
@@ -46,6 +48,35 @@ func Query(ctx context.Context, qb Queryer, imageFilter *models.ImageFilterType,
return images, nil
}
// FilterFromPaths creates a ImageFilterType that filters using the provided
// paths.
func FilterFromPaths(paths []string) *models.ImageFilterType {
ret := &models.ImageFilterType{}
or := ret
sep := string(filepath.Separator)
for _, p := range paths {
if !strings.HasSuffix(p, sep) {
p += sep
}
if ret.Path == nil {
or = ret
} else {
newOr := &models.ImageFilterType{}
or.Or = newOr
or = newOr
}
or.Path = &models.StringCriterionInput{
Modifier: models.CriterionModifierEquals,
Value: p + "%",
}
}
return ret
}
func CountByPerformerID(ctx context.Context, r QueryCounter, id int) (int, error) {
filter := &models.ImageFilterType{
Performers: &models.MultiCriterionInput{

View File

@@ -7,6 +7,7 @@ import (
"os"
"path/filepath"
"slices"
"strings"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
@@ -39,6 +40,11 @@ type GalleryFinderCreator interface {
UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error)
}
type ScanSceneFinderUpdater interface {
FindByPath(ctx context.Context, p string) ([]*models.Scene, error)
AddGalleryIDs(ctx context.Context, sceneID int, galleryIDs []int) error
}
type ScanConfig interface {
GetCreateGalleriesFromFolders() bool
}
@@ -48,8 +54,9 @@ type ScanGenerator interface {
}
type ScanHandler struct {
CreatorUpdater ScanCreatorUpdater
GalleryFinder GalleryFinderCreator
CreatorUpdater ScanCreatorUpdater
GalleryFinder GalleryFinderCreator
SceneFinderUpdater ScanSceneFinderUpdater
ScanGenerator ScanGenerator
@@ -210,8 +217,8 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.
changed = true
}
if changed {
// always update updated_at time
if changed || updateExisting {
// update updated_at time when file association or content changes
imagePartial := models.NewImagePartial()
imagePartial.GalleryIDs = galleryIDs
@@ -229,9 +236,7 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.
return fmt.Errorf("updating gallery updated at timestamp: %w", err)
}
}
}
if changed || updateExisting {
h.PluginCache.RegisterPostHooks(ctx, i.ID, hook.ImageUpdatePost, nil, nil)
}
}
@@ -324,11 +329,39 @@ func (h *ScanHandler) getOrCreateZipBasedGallery(ctx context.Context, zipFile mo
return nil, fmt.Errorf("creating zip-based gallery: %w", err)
}
// try to associate with scene
if err := h.associateScene(ctx, &newGallery, zipFile); err != nil {
return nil, fmt.Errorf("associating scene: %w", err)
}
h.PluginCache.RegisterPostHooks(ctx, newGallery.ID, hook.GalleryCreatePost, nil, nil)
return &newGallery, nil
}
func (h *ScanHandler) associateScene(ctx context.Context, existing *models.Gallery, zipFile models.File) error {
galleryIDs := []int{existing.ID}
path := zipFile.Base().Path
withoutExt := strings.TrimSuffix(path, filepath.Ext(path)) + ".*"
// find scenes with a file that matches
scenes, err := h.SceneFinderUpdater.FindByPath(ctx, withoutExt)
if err != nil {
return err
}
for _, scene := range scenes {
// found related Scene
logger.Infof("associate: Gallery %s is related to scene: %d", path, scene.ID)
if err := h.SceneFinderUpdater.AddGalleryIDs(ctx, scene.ID, galleryIDs); err != nil {
return err
}
}
return nil
}
func (h *ScanHandler) getOrCreateGallery(ctx context.Context, f models.File) (*models.Gallery, error) {
// don't create folder-based galleries for files in zip file
if f.Base().ZipFile != nil {

120
pkg/image/scan_test.go Normal file
View File

@@ -0,0 +1,120 @@
package image
import (
"context"
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
type mockScanConfig struct{}
func (m *mockScanConfig) GetCreateGalleriesFromFolders() bool { return false }
func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) {
const (
testImageID = 1
testFileID = 100
)
existingFile := &models.BaseFile{ID: models.FileID(testFileID), Path: "/images/test.jpg"}
makeImage := func() *models.Image {
return &models.Image{
ID: testImageID,
Files: models.NewRelatedFiles([]models.File{existingFile}),
GalleryIDs: models.NewRelatedIDs([]int{}),
}
}
tests := []struct {
name string
updateExisting bool
expectUpdate bool
}{
{
name: "calls UpdatePartial when file content changed",
updateExisting: true,
expectUpdate: true,
},
{
name: "skips UpdatePartial when file unchanged and already associated",
updateExisting: false,
expectUpdate: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := mocks.NewDatabase()
db.Image.On("GetFiles", mock.Anything, testImageID).Return([]models.File{existingFile}, nil)
db.Image.On("GetGalleryIDs", mock.Anything, testImageID).Return([]int{}, nil)
if tt.expectUpdate {
db.Image.On("UpdatePartial", mock.Anything, testImageID, mock.Anything).
Return(&models.Image{ID: testImageID}, nil)
}
h := &ScanHandler{
CreatorUpdater: db.Image,
GalleryFinder: db.Gallery,
ScanConfig: &mockScanConfig{},
PluginCache: &plugin.Cache{},
}
db.WithTxnCtx(func(ctx context.Context) {
err := h.associateExisting(ctx, []*models.Image{makeImage()}, existingFile, tt.updateExisting)
assert.NoError(t, err)
})
if tt.expectUpdate {
db.Image.AssertCalled(t, "UpdatePartial", mock.Anything, testImageID, mock.Anything)
} else {
db.Image.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything)
}
})
}
}
func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) {
const (
testImageID = 1
existFileID = 100
newFileID = 200
)
existingFile := &models.BaseFile{ID: models.FileID(existFileID), Path: "/images/existing.jpg"}
newFile := &models.BaseFile{ID: models.FileID(newFileID), Path: "/images/new.jpg"}
image := &models.Image{
ID: testImageID,
Files: models.NewRelatedFiles([]models.File{existingFile}),
GalleryIDs: models.NewRelatedIDs([]int{}),
}
db := mocks.NewDatabase()
db.Image.On("GetFiles", mock.Anything, testImageID).Return([]models.File{existingFile}, nil)
db.Image.On("GetGalleryIDs", mock.Anything, testImageID).Return([]int{}, nil)
db.Image.On("AddFileID", mock.Anything, testImageID, models.FileID(newFileID)).Return(nil)
db.Image.On("UpdatePartial", mock.Anything, testImageID, mock.Anything).
Return(&models.Image{ID: testImageID}, nil)
h := &ScanHandler{
CreatorUpdater: db.Image,
GalleryFinder: db.Gallery,
ScanConfig: &mockScanConfig{},
PluginCache: &plugin.Cache{},
}
db.WithTxnCtx(func(ctx context.Context) {
err := h.associateExisting(ctx, []*models.Image{image}, newFile, false)
assert.NoError(t, err)
})
db.Image.AssertCalled(t, "AddFileID", mock.Anything, testImageID, models.FileID(newFileID))
db.Image.AssertCalled(t, "UpdatePartial", mock.Anything, testImageID, mock.Anything)
}

View File

@@ -188,6 +188,20 @@ func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, na
return
}
// ScrapedTagHierarchy executes ScrapedTag for the provided tag and its parent.
func ScrapedTagHierarchy(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {
if err := ScrapedTag(ctx, qb, s, stashBoxEndpoint); err != nil {
return err
}
if s.Parent == nil {
return nil
}
// Match parent by name only (categories don't have StashDB tag IDs)
return ScrapedTag(ctx, qb, s.Parent, "")
}
// ScrapedTag matches the provided tag with the tags
// in the database and sets the ID field if one is found.
func ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error {

View File

@@ -2,6 +2,7 @@ package models
import (
"fmt"
"strings"
"time"
"github.com/stashapp/stash/pkg/utils"
@@ -61,3 +62,114 @@ func ParseDate(s string) (Date, error) {
return Date{}, fmt.Errorf("failed to parse date %q: %v", s, errs)
}
func DateFromYear(year int) Date {
return Date{
Time: time.Date(year, 1, 1, 0, 0, 0, 0, time.UTC),
Precision: DatePrecisionYear,
}
}
func FormatYearRange(start *Date, end *Date) string {
var (
startStr, endStr string
)
if start != nil {
startStr = start.Format(dateFormatPrecision[DatePrecisionYear])
}
if end != nil {
endStr = end.Format(dateFormatPrecision[DatePrecisionYear])
}
switch {
case startStr == "" && endStr == "":
return ""
case endStr == "":
return fmt.Sprintf("%s -", startStr)
case startStr == "":
return fmt.Sprintf("- %s", endStr)
default:
return fmt.Sprintf("%s - %s", startStr, endStr)
}
}
func FormatYearRangeString(start *string, end *string) string {
switch {
case start == nil && end == nil:
return ""
case end == nil:
return fmt.Sprintf("%s -", *start)
case start == nil:
return fmt.Sprintf("- %s", *end)
default:
return fmt.Sprintf("%s - %s", *start, *end)
}
}
// ParseYearRangeString parses a year range string into start and end year integers.
// Supported formats: "YYYY", "YYYY - YYYY", "YYYY-YYYY", "YYYY -", "- YYYY", "YYYY-present".
// Returns nil for start/end if not present in the string.
func ParseYearRangeString(s string) (start *Date, end *Date, err error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, nil, fmt.Errorf("empty year range string")
}
// normalize "present" to empty end
lower := strings.ToLower(s)
lower = strings.ReplaceAll(lower, "present", "")
// split on "-" if it contains one
var parts []string
if strings.Contains(lower, "-") {
parts = strings.SplitN(lower, "-", 2)
} else {
// single value, treat as start year
year, err := parseYear(lower)
if err != nil {
return nil, nil, fmt.Errorf("invalid year range %q: %w", s, err)
}
return year, nil, nil
}
startStr := strings.TrimSpace(parts[0])
endStr := strings.TrimSpace(parts[1])
if startStr != "" {
y, err := parseYear(startStr)
if err != nil {
return nil, nil, fmt.Errorf("invalid start year in %q: %w", s, err)
}
start = y
}
if endStr != "" {
y, err := parseYear(endStr)
if err != nil {
return nil, nil, fmt.Errorf("invalid end year in %q: %w", s, err)
}
end = y
}
if start == nil && end == nil {
return nil, nil, fmt.Errorf("could not parse year range %q", s)
}
return start, end, nil
}
func parseYear(s string) (*Date, error) {
ret, err := ParseDate(s)
if err != nil {
return nil, fmt.Errorf("parsing year %q: %w", s, err)
}
year := ret.Time.Year()
if year < 1900 || year > 2200 {
return nil, fmt.Errorf("year %d out of reasonable range", year)
}
return &ret, nil
}

View File

@@ -3,6 +3,8 @@ package models
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestParseDateStringAsTime(t *testing.T) {
@@ -48,3 +50,102 @@ func TestParseDateStringAsTime(t *testing.T) {
})
}
}
func TestFormatYearRange(t *testing.T) {
datePtr := func(v int) *Date {
date := DateFromYear(v)
return &date
}
tests := []struct {
name string
start *Date
end *Date
want string
}{
{"both nil", nil, nil, ""},
{"only start", datePtr(2005), nil, "2005 -"},
{"only end", nil, datePtr(2010), "- 2010"},
{"start and end", datePtr(2005), datePtr(2010), "2005 - 2010"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatYearRange(tt.start, tt.end)
assert.Equal(t, tt.want, got)
})
}
}
func TestFormatYearRangeString(t *testing.T) {
stringPtr := func(v string) *string { return &v }
tests := []struct {
name string
start *string
end *string
want string
}{
{"both nil", nil, nil, ""},
{"only start", stringPtr("2005"), nil, "2005 -"},
{"only end", nil, stringPtr("2010"), "- 2010"},
{"start and end", stringPtr("2005"), stringPtr("2010"), "2005 - 2010"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatYearRangeString(tt.start, tt.end)
assert.Equal(t, tt.want, got)
})
}
}
func TestParseYearRangeString(t *testing.T) {
intPtr := func(v int) *int { return &v }
tests := []struct {
name string
input string
wantStart *int
wantEnd *int
wantErr bool
}{
{"single year", "2005", intPtr(2005), nil, false},
{"year range with spaces", "2005 - 2010", intPtr(2005), intPtr(2010), false},
{"year range no spaces", "2005-2010", intPtr(2005), intPtr(2010), false},
{"year dash open", "2005 -", intPtr(2005), nil, false},
{"year dash open no space", "2005-", intPtr(2005), nil, false},
{"dash year", "- 2010", nil, intPtr(2010), false},
{"year present", "2005-present", intPtr(2005), nil, false},
{"year Present caps", "2005 - Present", intPtr(2005), nil, false},
{"whitespace padding", " 2005 - 2010 ", intPtr(2005), intPtr(2010), false},
{"empty string", "", nil, nil, true},
{"garbage", "not a year", nil, nil, true},
{"partial garbage start", "abc - 2010", nil, nil, true},
{"partial garbage end", "2005 - abc", nil, nil, true},
{"year out of range", "1800", nil, nil, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
start, end, err := ParseYearRangeString(tt.input)
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
if tt.wantStart != nil {
assert.NotNil(t, start)
assert.Equal(t, *tt.wantStart, start.Time.Year())
} else {
assert.Nil(t, start)
}
if tt.wantEnd != nil {
assert.NotNil(t, end)
assert.Equal(t, *tt.wantEnd, end.Time.Year())
} else {
assert.Nil(t, end)
}
})
}
}

View File

@@ -18,10 +18,8 @@ type FolderQueryOptions struct {
type FolderFilterType struct {
OperatorFilter[FolderFilterType]
Path *StringCriterionInput `json:"path,omitempty"`
Basename *StringCriterionInput `json:"basename,omitempty"`
// Filter by parent directory path
Dir *StringCriterionInput `json:"dir,omitempty"`
Path *StringCriterionInput `json:"path,omitempty"`
Basename *StringCriterionInput `json:"basename,omitempty"`
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"`
ZipFile *MultiCriterionInput `json:"zip_file,omitempty"`
// Filter by modification time

View File

@@ -11,6 +11,8 @@ type GalleryFilterType struct {
Checksum *StringCriterionInput `json:"checksum"`
// Filter by path
Path *StringCriterionInput `json:"path"`
// Filter by parent folder
ParentFolder *HierarchicalMultiCriterionInput `json:"parent_folder,omitempty"`
// Filter by zip file count
FileCount *IntCriterionInput `json:"file_count"`
// Filter to only include galleries missing this property

View File

@@ -49,8 +49,8 @@ type Performer struct {
PenisLength float64 `json:"penis_length,omitempty"`
Circumcised string `json:"circumcised,omitempty"`
CareerLength string `json:"career_length,omitempty"` // deprecated - for import only
CareerStart *int `json:"career_start,omitempty"`
CareerEnd *int `json:"career_end,omitempty"`
CareerStart string `json:"career_start,omitempty"`
CareerEnd string `json:"career_end,omitempty"`
Tattoos string `json:"tattoos,omitempty"`
Piercings string `json:"piercings,omitempty"`
Aliases StringOrStringList `json:"aliases,omitempty"`

View File

@@ -153,13 +153,13 @@ func (_m *FileReaderWriter) FindAllByPath(ctx context.Context, path string, case
return r0, r1
}
// FindAllInPaths provides a mock function with given fields: ctx, p, limit, offset
func (_m *FileReaderWriter) FindAllInPaths(ctx context.Context, p []string, limit int, offset int) ([]models.File, error) {
ret := _m.Called(ctx, p, limit, offset)
// FindAllInPaths provides a mock function with given fields: ctx, p, includeZipContents, limit, offset
func (_m *FileReaderWriter) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit int, offset int) ([]models.File, error) {
ret := _m.Called(ctx, p, includeZipContents, limit, offset)
var r0 []models.File
if rf, ok := ret.Get(0).(func(context.Context, []string, int, int) []models.File); ok {
r0 = rf(ctx, p, limit, offset)
if rf, ok := ret.Get(0).(func(context.Context, []string, bool, int, int) []models.File); ok {
r0 = rf(ctx, p, includeZipContents, limit, offset)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]models.File)
@@ -167,8 +167,8 @@ func (_m *FileReaderWriter) FindAllInPaths(ctx context.Context, p []string, limi
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, []string, int, int) error); ok {
r1 = rf(ctx, p, limit, offset)
if rf, ok := ret.Get(1).(func(context.Context, []string, bool, int, int) error); ok {
r1 = rf(ctx, p, includeZipContents, limit, offset)
} else {
r1 = ret.Error(1)
}

View File

@@ -86,13 +86,13 @@ func (_m *FolderReaderWriter) Find(ctx context.Context, id models.FolderID) (*mo
return r0, r1
}
// FindAllInPaths provides a mock function with given fields: ctx, p, limit, offset
func (_m *FolderReaderWriter) FindAllInPaths(ctx context.Context, p []string, limit int, offset int) ([]*models.Folder, error) {
ret := _m.Called(ctx, p, limit, offset)
// FindAllInPaths provides a mock function with given fields: ctx, p, includeZipContents, limit, offset
func (_m *FolderReaderWriter) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit int, offset int) ([]*models.Folder, error) {
ret := _m.Called(ctx, p, includeZipContents, limit, offset)
var r0 []*models.Folder
if rf, ok := ret.Get(0).(func(context.Context, []string, int, int) []*models.Folder); ok {
r0 = rf(ctx, p, limit, offset)
if rf, ok := ret.Get(0).(func(context.Context, []string, bool, int, int) []*models.Folder); ok {
r0 = rf(ctx, p, includeZipContents, limit, offset)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Folder)
@@ -100,8 +100,8 @@ func (_m *FolderReaderWriter) FindAllInPaths(ctx context.Context, p []string, li
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, []string, int, int) error); ok {
r1 = rf(ctx, p, limit, offset)
if rf, ok := ret.Get(1).(func(context.Context, []string, bool, int, int) error); ok {
r1 = rf(ctx, p, includeZipContents, limit, offset)
} else {
r1 = ret.Error(1)
}
@@ -201,6 +201,52 @@ func (_m *FolderReaderWriter) FindMany(ctx context.Context, id []models.FolderID
return r0, r1
}
// GetManyParentFolderIDs provides a mock function with given fields: ctx, folderIDs
func (_m *FolderReaderWriter) GetManyParentFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) {
ret := _m.Called(ctx, folderIDs)
var r0 [][]models.FolderID
if rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) [][]models.FolderID); ok {
r0 = rf(ctx, folderIDs)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([][]models.FolderID)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok {
r1 = rf(ctx, folderIDs)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetManySubFolderIDs provides a mock function with given fields: ctx, folderIDs
func (_m *FolderReaderWriter) GetManySubFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) {
ret := _m.Called(ctx, folderIDs)
var r0 [][]models.FolderID
if rf, ok := ret.Get(0).(func(context.Context, []models.FolderID) [][]models.FolderID); ok {
r0 = rf(ctx, folderIDs)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([][]models.FolderID)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, []models.FolderID) error); ok {
r1 = rf(ctx, folderIDs)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Query provides a mock function with given fields: ctx, options
func (_m *FolderReaderWriter) Query(ctx context.Context, options models.FolderQueryOptions) (*models.FolderQueryResult, error) {
ret := _m.Called(ctx, options)

View File

@@ -49,6 +49,20 @@ func (_m *GalleryReaderWriter) AddImages(ctx context.Context, galleryID int, ima
return r0
}
// AddSceneIDs provides a mock function with given fields: ctx, galleryID, sceneIDs
func (_m *GalleryReaderWriter) AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error {
ret := _m.Called(ctx, galleryID, sceneIDs)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int, []int) error); ok {
r0 = rf(ctx, galleryID, sceneIDs)
} else {
r0 = ret.Error(0)
}
return r0
}
// All provides a mock function with given fields: ctx
func (_m *GalleryReaderWriter) All(ctx context.Context) ([]*models.Gallery, error) {
ret := _m.Called(ctx)

View File

@@ -3,6 +3,7 @@ package mocks
import (
"context"
"errors"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/txn"
@@ -89,6 +90,16 @@ func (db *Database) AssertExpectations(t mock.TestingT) {
db.SavedFilter.AssertExpectations(t)
}
// WithTxnCtx runs fn with a context that has a transaction hook manager registered,
// so code that calls txn.AddPostCommitHook (e.g. plugin cache) won't nil-panic.
// Always rolls back to avoid executing the registered hooks.
func (db *Database) WithTxnCtx(fn func(ctx context.Context)) {
_ = txn.WithTxn(context.Background(), db, func(ctx context.Context) error {
fn(ctx)
return errors.New("rollback")
})
}
func (db *Database) Repository() models.Repository {
return models.Repository{
TxnManager: db,

View File

@@ -6,26 +6,26 @@ import (
)
type Performer struct {
ID int `json:"id"`
Name string `json:"name"`
Disambiguation string `json:"disambiguation"`
Gender *GenderEnum `json:"gender"`
Birthdate *Date `json:"birthdate"`
Ethnicity string `json:"ethnicity"`
Country string `json:"country"`
EyeColor string `json:"eye_color"`
Height *int `json:"height"`
Measurements string `json:"measurements"`
FakeTits string `json:"fake_tits"`
PenisLength *float64 `json:"penis_length"`
Circumcised *CircumisedEnum `json:"circumcised"`
CareerStart *int `json:"career_start"`
CareerEnd *int `json:"career_end"`
Tattoos string `json:"tattoos"`
Piercings string `json:"piercings"`
Favorite bool `json:"favorite"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID int `json:"id"`
Name string `json:"name"`
Disambiguation string `json:"disambiguation"`
Gender *GenderEnum `json:"gender"`
Birthdate *Date `json:"birthdate"`
Ethnicity string `json:"ethnicity"`
Country string `json:"country"`
EyeColor string `json:"eye_color"`
Height *int `json:"height"`
Measurements string `json:"measurements"`
FakeTits string `json:"fake_tits"`
PenisLength *float64 `json:"penis_length"`
Circumcised *CircumcisedEnum `json:"circumcised"`
CareerStart *Date `json:"career_start"`
CareerEnd *Date `json:"career_end"`
Tattoos string `json:"tattoos"`
Piercings string `json:"piercings"`
Favorite bool `json:"favorite"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Rating expressed in 1-100 scale
Rating *int `json:"rating"`
Details string `json:"details"`
@@ -76,8 +76,8 @@ type PerformerPartial struct {
FakeTits OptionalString
PenisLength OptionalFloat64
Circumcised OptionalString
CareerStart OptionalInt
CareerEnd OptionalInt
CareerStart OptionalDate
CareerEnd OptionalDate
Tattoos OptionalString
Piercings OptionalString
Favorite OptionalBool

View File

@@ -177,8 +177,8 @@ type ScrapedPerformer struct {
PenisLength *string `json:"penis_length"`
Circumcised *string `json:"circumcised"`
CareerLength *string `json:"career_length"` // deprecated: use CareerStart/CareerEnd
CareerStart *int `json:"career_start"`
CareerEnd *int `json:"career_end"`
CareerStart *string `json:"career_start"`
CareerEnd *string `json:"career_end"`
Tattoos *string `json:"tattoos"`
Piercings *string `json:"piercings"`
Aliases *string `json:"aliases"`
@@ -225,12 +225,16 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool
// assume that career length is _not_ populated in favour of start/end
if p.CareerStart != nil && !excluded["career_start"] {
cs := *p.CareerStart
ret.CareerStart = &cs
date, err := ParseDate(*p.CareerStart)
if err == nil {
ret.CareerStart = &date
}
}
if p.CareerEnd != nil && !excluded["career_end"] {
ce := *p.CareerEnd
ret.CareerEnd = &ce
date, err := ParseDate(*p.CareerEnd)
if err == nil {
ret.CareerEnd = &date
}
}
if p.Country != nil && !excluded["country"] {
ret.Country = *p.Country
@@ -288,7 +292,7 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool
}
}
if p.Circumcised != nil && !excluded["circumcised"] {
v := CircumisedEnum(*p.Circumcised)
v := CircumcisedEnum(*p.Circumcised)
if v.IsValid() {
ret.Circumcised = &v
}
@@ -367,13 +371,13 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
}
if p.CareerLength != nil && !excluded["career_length"] {
// parse career_length into career_start/career_end
start, end, err := utils.ParseYearRangeString(*p.CareerLength)
start, end, err := ParseYearRangeString(*p.CareerLength)
if err == nil {
if start != nil {
ret.CareerStart = NewOptionalInt(*start)
ret.CareerStart = NewOptionalDate(*start)
}
if end != nil {
ret.CareerEnd = NewOptionalInt(*end)
ret.CareerEnd = NewOptionalDate(*end)
}
}
}
@@ -471,11 +475,12 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
type ScrapedTag struct {
// Set if tag matched
StoredID *string `json:"stored_id"`
Name string `json:"name"`
Description *string `json:"description"`
AliasList []string `json:"alias_list"`
RemoteSiteID *string `json:"remote_site_id"`
StoredID *string `json:"stored_id"`
Name string `json:"name"`
Description *string `json:"description"`
AliasList []string `json:"alias_list"`
RemoteSiteID *string `json:"remote_site_id"`
Parent *ScrapedTag `json:"parent"`
}
func (ScrapedTag) IsScrapedContent() {}
@@ -496,6 +501,13 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag {
ret.Aliases = NewRelatedStrings(t.AliasList)
}
if t.Parent != nil && t.Parent.StoredID != nil {
parentID, err := strconv.Atoi(*t.Parent.StoredID)
if err == nil && parentID > 0 {
ret.ParentIDs = NewRelatedIDs([]int{parentID})
}
}
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
ret.StashIDs = NewRelatedStashIDs([]StashID{
{
@@ -527,6 +539,16 @@ func (t *ScrapedTag) ToPartial(storedID string, endpoint string, excluded map[st
}
}
if t.Parent != nil && t.Parent.StoredID != nil {
parentID, err := strconv.Atoi(*t.Parent.StoredID)
if err == nil && parentID > 0 {
ret.ParentIDs = &UpdateIDs{
IDs: []int{parentID},
Mode: RelationshipUpdateModeAdd,
}
}
}
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
ret.StashIDs = &UpdateStashIDs{
StashIDs: existingStashIDs,

View File

@@ -8,8 +8,6 @@ import (
"github.com/stretchr/testify/assert"
)
func intPtr(i int) *int { return &i }
func Test_scrapedToStudioInput(t *testing.T) {
const name = "name"
url := "url"
@@ -186,8 +184,8 @@ func Test_scrapedToPerformerInput(t *testing.T) {
Weight: nextVal(),
Measurements: nextVal(),
FakeTits: nextVal(),
CareerStart: intPtr(2005),
CareerEnd: intPtr(2015),
CareerStart: dateStrFromInt(2005),
CareerEnd: dateStrFromInt(2015),
Tattoos: nextVal(),
Piercings: nextVal(),
Aliases: nextVal(),
@@ -212,8 +210,8 @@ func Test_scrapedToPerformerInput(t *testing.T) {
Weight: nextIntVal(),
Measurements: *nextVal(),
FakeTits: *nextVal(),
CareerStart: intPtr(2005),
CareerEnd: intPtr(2015),
CareerStart: dateFromInt(2005),
CareerEnd: dateFromInt(2015),
Tattoos: *nextVal(), // skip CareerLength counter slot
Piercings: *nextVal(),
Aliases: NewRelatedStrings([]string{*nextVal()}),

View File

@@ -61,49 +61,49 @@ type GenderCriterionInput struct {
Modifier CriterionModifier `json:"modifier"`
}
type CircumisedEnum string
type CircumcisedEnum string
const (
CircumisedEnumCut CircumisedEnum = "CUT"
CircumisedEnumUncut CircumisedEnum = "UNCUT"
CircumcisedEnumCut CircumcisedEnum = "CUT"
CircumcisedEnumUncut CircumcisedEnum = "UNCUT"
)
var AllCircumcisionEnum = []CircumisedEnum{
CircumisedEnumCut,
CircumisedEnumUncut,
var AllCircumcisionEnum = []CircumcisedEnum{
CircumcisedEnumCut,
CircumcisedEnumUncut,
}
func (e CircumisedEnum) IsValid() bool {
func (e CircumcisedEnum) IsValid() bool {
switch e {
case CircumisedEnumCut, CircumisedEnumUncut:
case CircumcisedEnumCut, CircumcisedEnumUncut:
return true
}
return false
}
func (e CircumisedEnum) String() string {
func (e CircumcisedEnum) String() string {
return string(e)
}
func (e *CircumisedEnum) UnmarshalGQL(v interface{}) error {
func (e *CircumcisedEnum) UnmarshalGQL(v interface{}) error {
str, ok := v.(string)
if !ok {
return fmt.Errorf("enums must be strings")
}
*e = CircumisedEnum(str)
*e = CircumcisedEnum(str)
if !e.IsValid() {
return fmt.Errorf("%s is not a valid CircumisedEnum", str)
return fmt.Errorf("%s is not a valid CircumcisedEnum", str)
}
return nil
}
func (e CircumisedEnum) MarshalGQL(w io.Writer) {
func (e CircumcisedEnum) MarshalGQL(w io.Writer) {
fmt.Fprint(w, strconv.Quote(e.String()))
}
type CircumcisionCriterionInput struct {
Value []CircumisedEnum `json:"value"`
Value []CircumcisedEnum `json:"value"`
Modifier CriterionModifier `json:"modifier"`
}
@@ -139,9 +139,9 @@ type PerformerFilterType struct {
// Filter by career length
CareerLength *StringCriterionInput `json:"career_length"` // deprecated
// Filter by career start year
CareerStart *IntCriterionInput `json:"career_start"`
CareerStart *DateCriterionInput `json:"career_start"`
// Filter by career end year
CareerEnd *IntCriterionInput `json:"career_end"`
CareerEnd *DateCriterionInput `json:"career_end"`
// Filter by tattoos
Tattoos *StringCriterionInput `json:"tattoos"`
// Filter by piercings
@@ -158,6 +158,8 @@ type PerformerFilterType struct {
TagCount *IntCriterionInput `json:"tag_count"`
// Filter by scene count
SceneCount *IntCriterionInput `json:"scene_count"`
// Filter by scene marker count (via scene)
MarkerCount *IntCriterionInput `json:"marker_count"`
// Filter by image count
ImageCount *IntCriterionInput `json:"image_count"`
// Filter by gallery count
@@ -202,6 +204,8 @@ type PerformerFilterType struct {
GalleriesFilter *GalleryFilterType `json:"galleries_filter"`
// Filter by related tags that meet this criteria
TagsFilter *TagFilterType `json:"tags_filter"`
// Filter by related scene markers (via scene) that meet this criteria
MarkersFilter *SceneMarkerFilterType `json:"markers_filter"`
// Filter by created at
CreatedAt *TimestampCriterionInput `json:"created_at"`
// Filter by updated at
@@ -212,32 +216,32 @@ type PerformerFilterType struct {
}
type PerformerCreateInput struct {
Name string `json:"name"`
Disambiguation *string `json:"disambiguation"`
URL *string `json:"url"` // deprecated
Urls []string `json:"urls"`
Gender *GenderEnum `json:"gender"`
Birthdate *string `json:"birthdate"`
Ethnicity *string `json:"ethnicity"`
Country *string `json:"country"`
EyeColor *string `json:"eye_color"`
Height *string `json:"height"`
HeightCm *int `json:"height_cm"`
Measurements *string `json:"measurements"`
FakeTits *string `json:"fake_tits"`
PenisLength *float64 `json:"penis_length"`
Circumcised *CircumisedEnum `json:"circumcised"`
CareerLength *string `json:"career_length"`
CareerStart *int `json:"career_start"`
CareerEnd *int `json:"career_end"`
Tattoos *string `json:"tattoos"`
Piercings *string `json:"piercings"`
Aliases *string `json:"aliases"`
AliasList []string `json:"alias_list"`
Twitter *string `json:"twitter"` // deprecated
Instagram *string `json:"instagram"` // deprecated
Favorite *bool `json:"favorite"`
TagIds []string `json:"tag_ids"`
Name string `json:"name"`
Disambiguation *string `json:"disambiguation"`
URL *string `json:"url"` // deprecated
Urls []string `json:"urls"`
Gender *GenderEnum `json:"gender"`
Birthdate *string `json:"birthdate"`
Ethnicity *string `json:"ethnicity"`
Country *string `json:"country"`
EyeColor *string `json:"eye_color"`
Height *string `json:"height"`
HeightCm *int `json:"height_cm"`
Measurements *string `json:"measurements"`
FakeTits *string `json:"fake_tits"`
PenisLength *float64 `json:"penis_length"`
Circumcised *CircumcisedEnum `json:"circumcised"`
CareerLength *string `json:"career_length"`
CareerStart *string `json:"career_start"`
CareerEnd *string `json:"career_end"`
Tattoos *string `json:"tattoos"`
Piercings *string `json:"piercings"`
Aliases *string `json:"aliases"`
AliasList []string `json:"alias_list"`
Twitter *string `json:"twitter"` // deprecated
Instagram *string `json:"instagram"` // deprecated
Favorite *bool `json:"favorite"`
TagIds []string `json:"tag_ids"`
// This should be a URL or a base64 encoded data URL
Image *string `json:"image"`
StashIds []StashIDInput `json:"stash_ids"`
@@ -252,33 +256,33 @@ type PerformerCreateInput struct {
}
type PerformerUpdateInput struct {
ID string `json:"id"`
Name *string `json:"name"`
Disambiguation *string `json:"disambiguation"`
URL *string `json:"url"` // deprecated
Urls []string `json:"urls"`
Gender *GenderEnum `json:"gender"`
Birthdate *string `json:"birthdate"`
Ethnicity *string `json:"ethnicity"`
Country *string `json:"country"`
EyeColor *string `json:"eye_color"`
Height *string `json:"height"`
HeightCm *int `json:"height_cm"`
Measurements *string `json:"measurements"`
FakeTits *string `json:"fake_tits"`
PenisLength *float64 `json:"penis_length"`
Circumcised *CircumisedEnum `json:"circumcised"`
CareerLength *string `json:"career_length"`
CareerStart *int `json:"career_start"`
CareerEnd *int `json:"career_end"`
Tattoos *string `json:"tattoos"`
Piercings *string `json:"piercings"`
Aliases *string `json:"aliases"`
AliasList []string `json:"alias_list"`
Twitter *string `json:"twitter"` // deprecated
Instagram *string `json:"instagram"` // deprecated
Favorite *bool `json:"favorite"`
TagIds []string `json:"tag_ids"`
ID string `json:"id"`
Name *string `json:"name"`
Disambiguation *string `json:"disambiguation"`
URL *string `json:"url"` // deprecated
Urls []string `json:"urls"`
Gender *GenderEnum `json:"gender"`
Birthdate *string `json:"birthdate"`
Ethnicity *string `json:"ethnicity"`
Country *string `json:"country"`
EyeColor *string `json:"eye_color"`
Height *string `json:"height"`
HeightCm *int `json:"height_cm"`
Measurements *string `json:"measurements"`
FakeTits *string `json:"fake_tits"`
PenisLength *float64 `json:"penis_length"`
Circumcised *CircumcisedEnum `json:"circumcised"`
CareerLength *string `json:"career_length"`
CareerStart *string `json:"career_start"`
CareerEnd *string `json:"career_end"`
Tattoos *string `json:"tattoos"`
Piercings *string `json:"piercings"`
Aliases *string `json:"aliases"`
AliasList []string `json:"alias_list"`
Twitter *string `json:"twitter"` // deprecated
Instagram *string `json:"instagram"` // deprecated
Favorite *bool `json:"favorite"`
TagIds []string `json:"tag_ids"`
// This should be a URL or a base64 encoded data URL
Image *string `json:"image"`
StashIds []StashIDInput `json:"stash_ids"`

View File

@@ -14,7 +14,7 @@ type FileGetter interface {
type FileFinder interface {
FileGetter
FindAllByPath(ctx context.Context, path string, caseSensitive bool) ([]File, error)
FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]File, error)
FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]File, error)
FindByPath(ctx context.Context, path string, caseSensitive bool) (File, error)
FindByFingerprint(ctx context.Context, fp Fingerprint) ([]File, error)
FindByZipFileID(ctx context.Context, zipFileID FileID) ([]File, error)

View File

@@ -11,10 +11,12 @@ type FolderGetter interface {
// FolderFinder provides methods to find folders.
type FolderFinder interface {
FolderGetter
FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]*Folder, error)
FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]*Folder, error)
FindByPath(ctx context.Context, path string, caseSensitive bool) (*Folder, error)
FindByZipFileID(ctx context.Context, zipFileID FileID) ([]*Folder, error)
FindByParentFolderID(ctx context.Context, parentFolderID FolderID) ([]*Folder, error)
GetManyParentFolderIDs(ctx context.Context, folderIDs []FolderID) ([][]FolderID, error)
GetManySubFolderIDs(ctx context.Context, folderIDs []FolderID) ([][]FolderID, error)
}
type FolderQueryer interface {

View File

@@ -83,6 +83,7 @@ type GalleryWriter interface {
CustomFieldsWriter
AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error
AddFileID(ctx context.Context, id int, fileID FileID) error
AddImages(ctx context.Context, galleryID int, imageIDs ...int) error
RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error

View File

@@ -56,6 +56,8 @@ type TagFilterType struct {
PerformersFilter *PerformerFilterType `json:"performers_filter"`
// Filter by related studios that meet this criteria
StudiosFilter *StudioFilterType `json:"studios_filter"`
// Filter by related scene markers that meet this criteria
MarkersFilter *SceneMarkerFilterType `json:"markers_filter"`
// Filter by created at
CreatedAt *TimestampCriterionInput `json:"created_at"`
// Filter by updated at

View File

@@ -71,10 +71,10 @@ func ToJSON(ctx context.Context, reader ImageAliasStashIDGetter, performer *mode
}
if performer.CareerStart != nil {
newPerformerJSON.CareerStart = performer.CareerStart
newPerformerJSON.CareerStart = performer.CareerStart.String()
}
if performer.CareerEnd != nil {
newPerformerJSON.CareerEnd = performer.CareerEnd
newPerformerJSON.CareerEnd = performer.CareerEnd.String()
}
if err := performer.LoadAliases(ctx, reader); err != nil {

View File

@@ -48,10 +48,10 @@ var (
rating = 5
height = 123
weight = 60
careerStart = 2005
careerEnd = 2015
careerStart, _ = models.ParseDate("2005")
careerEnd, _ = models.ParseDate("2015")
penisLength = 1.23
circumcisedEnum = models.CircumisedEnumCut
circumcisedEnum = models.CircumcisedEnumCut
circumcised = circumcisedEnum.String()
emptyCustomFields = make(map[string]interface{})
@@ -134,8 +134,8 @@ func createFullJSONPerformer(name string, image string, withCustomFields bool) *
URLs: []string{url, twitter, instagram},
Aliases: aliases,
Birthdate: birthDate.String(),
CareerStart: &careerStart,
CareerEnd: &careerEnd,
CareerStart: careerStart.String(),
CareerEnd: careerEnd.String(),
Country: country,
Ethnicity: ethnicity,
EyeColor: eyeColor,

View File

@@ -247,7 +247,7 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) (models.Perfor
}
if performerJSON.Circumcised != "" {
v := models.CircumisedEnum(performerJSON.Circumcised)
v := models.CircumcisedEnum(performerJSON.Circumcised)
newPerformer.Circumcised = &v
}
@@ -285,11 +285,17 @@ func performerJSONToPerformer(performerJSON jsonschema.Performer) (models.Perfor
}
// prefer explicit career_start/career_end, fall back to parsing legacy career_length
if performerJSON.CareerStart != nil || performerJSON.CareerEnd != nil {
newPerformer.CareerStart = performerJSON.CareerStart
newPerformer.CareerEnd = performerJSON.CareerEnd
if performerJSON.CareerStart != "" || performerJSON.CareerEnd != "" {
careerStart, err := models.ParseDate(performerJSON.CareerStart)
if err == nil {
newPerformer.CareerStart = &careerStart
}
careerEnd, err := models.ParseDate(performerJSON.CareerEnd)
if err == nil {
newPerformer.CareerEnd = &careerEnd
}
} else if performerJSON.CareerLength != "" {
start, end, err := utils.ParseYearRangeString(performerJSON.CareerLength)
start, end, err := models.ParseYearRangeString(performerJSON.CareerLength)
if err != nil {
return models.Performer{}, fmt.Errorf("invalid career_length %q: %w", performerJSON.CareerLength, err)
}

View File

@@ -317,15 +317,15 @@ func TestUpdate(t *testing.T) {
}
func TestImportCareerFields(t *testing.T) {
startYear := 2005
endYear := 2015
startYear, _ := models.ParseDate("2005")
endYear, _ := models.ParseDate("2015")
// explicit career_start/career_end should be used directly
t.Run("explicit fields", func(t *testing.T) {
input := jsonschema.Performer{
Name: "test",
CareerStart: &startYear,
CareerEnd: &endYear,
CareerStart: startYear.String(),
CareerEnd: endYear.String(),
}
p, err := performerJSONToPerformer(input)
@@ -338,8 +338,8 @@ func TestImportCareerFields(t *testing.T) {
t.Run("explicit fields override legacy", func(t *testing.T) {
input := jsonschema.Performer{
Name: "test",
CareerStart: &startYear,
CareerEnd: &endYear,
CareerStart: startYear.String(),
CareerEnd: endYear.String(),
CareerLength: "1990 - 1995",
}

View File

@@ -1,6 +1,7 @@
package pkg
import (
"sync"
"time"
)
@@ -10,22 +11,23 @@ type cacheEntry struct {
}
type repositoryCache struct {
mu sync.RWMutex
// cache maps the URL to the last modified time and the data
cache map[string]cacheEntry
}
func (c *repositoryCache) ensureCache() {
if c.cache == nil {
c.cache = make(map[string]cacheEntry)
}
}
func (c *repositoryCache) lastModified(url string) *time.Time {
if c == nil {
return nil
}
c.ensureCache()
c.mu.RLock()
defer c.mu.RUnlock()
if c.cache == nil {
return nil
}
e, found := c.cache[url]
if !found {
@@ -36,7 +38,13 @@ func (c *repositoryCache) lastModified(url string) *time.Time {
}
func (c *repositoryCache) getPackageList(url string) []RemotePackage {
c.ensureCache()
c.mu.RLock()
defer c.mu.RUnlock()
if c.cache == nil {
return nil
}
e, found := c.cache[url]
if !found {
@@ -51,7 +59,13 @@ func (c *repositoryCache) cacheList(url string, lastModified time.Time, data []R
return
}
c.ensureCache()
c.mu.Lock()
defer c.mu.Unlock()
if c.cache == nil {
c.cache = make(map[string]cacheEntry)
}
c.cache[url] = cacheEntry{
lastModified: lastModified,
data: data,

View File

@@ -10,6 +10,7 @@ import (
"net/http"
"net/url"
"path/filepath"
"sync"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
@@ -31,13 +32,14 @@ type Manager struct {
Client *http.Client
cache *repositoryCache
cacheOnce sync.Once
cache *repositoryCache
}
func (m *Manager) getCache() *repositoryCache {
if m.cache == nil {
m.cacheOnce.Do(func() {
m.cache = &repositoryCache{}
}
})
return m.cache
}

View File

@@ -4,6 +4,8 @@ import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/stashapp/stash/pkg/file/video"
"github.com/stashapp/stash/pkg/logger"
@@ -32,12 +34,18 @@ type ScanCreatorUpdater interface {
AddFileID(ctx context.Context, id int, fileID models.FileID) error
}
type ScanGalleryFinderUpdater interface {
FindByPath(ctx context.Context, p string) ([]*models.Gallery, error)
AddSceneIDs(ctx context.Context, galleryID int, sceneIDs []int) error
}
type ScanGenerator interface {
Generate(ctx context.Context, s *models.Scene, f *models.VideoFile) error
}
type ScanHandler struct {
CreatorUpdater ScanCreatorUpdater
CreatorUpdater ScanCreatorUpdater
GalleryFinderUpdater ScanGalleryFinderUpdater
ScanGenerator ScanGenerator
CaptionUpdater video.CaptionUpdater
@@ -127,6 +135,10 @@ func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models.
}
}
if err := h.associateGallery(ctx, existing, f); err != nil {
return err
}
// do this after the commit so that cover generation doesn't hold up the transaction
txn.AddPostCommitHook(ctx, func(ctx context.Context) {
for _, s := range existing {
@@ -160,18 +172,44 @@ func (h *ScanHandler) associateExisting(ctx context.Context, existing []*models.
if err := h.CreatorUpdater.AddFileID(ctx, s.ID, f.ID); err != nil {
return fmt.Errorf("adding file to scene: %w", err)
}
}
// update updated_at time
if !found || updateExisting {
// update updated_at time when file association or content changes
scenePartial := models.NewScenePartial()
if _, err := h.CreatorUpdater.UpdatePartial(ctx, s.ID, scenePartial); err != nil {
return fmt.Errorf("updating scene: %w", err)
}
}
if !found || updateExisting {
h.PluginCache.RegisterPostHooks(ctx, s.ID, hook.SceneUpdatePost, nil, nil)
}
}
return nil
}
func (h *ScanHandler) associateGallery(ctx context.Context, existing []*models.Scene, f models.File) error {
sceneIDs := make([]int, len(existing))
for i, s := range existing {
sceneIDs[i] = s.ID
}
path := f.Base().Path
zipPath := strings.TrimSuffix(path, filepath.Ext(path)) + ".zip"
// find galleries with a file that matches
galleries, err := h.GalleryFinderUpdater.FindByPath(ctx, zipPath)
if err != nil {
return err
}
for _, gallery := range galleries {
// found related Scene
logger.Infof("associate: Scene %s is related to gallery: %d", path, gallery.ID)
if err := h.GalleryFinderUpdater.AddSceneIDs(ctx, gallery.ID, sceneIDs); err != nil {
return err
}
}
return nil
}

114
pkg/scene/scan_test.go Normal file
View File

@@ -0,0 +1,114 @@
package scene
import (
"context"
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
func TestAssociateExisting_UpdatePartialOnContentChange(t *testing.T) {
const (
testSceneID = 1
testFileID = 100
)
existingFile := &models.VideoFile{
BaseFile: &models.BaseFile{ID: models.FileID(testFileID), Path: "test.mp4"},
}
makeScene := func() *models.Scene {
return &models.Scene{
ID: testSceneID,
Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}),
}
}
tests := []struct {
name string
updateExisting bool
expectUpdate bool
}{
{
name: "calls UpdatePartial when file content changed",
updateExisting: true,
expectUpdate: true,
},
{
name: "skips UpdatePartial when file unchanged and already associated",
updateExisting: false,
expectUpdate: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db := mocks.NewDatabase()
db.Scene.On("GetFiles", mock.Anything, testSceneID).Return([]*models.VideoFile{existingFile}, nil)
if tt.expectUpdate {
db.Scene.On("UpdatePartial", mock.Anything, testSceneID, mock.Anything).
Return(&models.Scene{ID: testSceneID}, nil)
}
h := &ScanHandler{
CreatorUpdater: db.Scene,
PluginCache: &plugin.Cache{},
}
db.WithTxnCtx(func(ctx context.Context) {
err := h.associateExisting(ctx, []*models.Scene{makeScene()}, existingFile, tt.updateExisting)
assert.NoError(t, err)
})
if tt.expectUpdate {
db.Scene.AssertCalled(t, "UpdatePartial", mock.Anything, testSceneID, mock.Anything)
} else {
db.Scene.AssertNotCalled(t, "UpdatePartial", mock.Anything, mock.Anything, mock.Anything)
}
})
}
}
func TestAssociateExisting_UpdatePartialOnNewFile(t *testing.T) {
const (
testSceneID = 1
existFileID = 100
newFileID = 200
)
existingFile := &models.VideoFile{
BaseFile: &models.BaseFile{ID: models.FileID(existFileID), Path: "existing.mp4"},
}
newFile := &models.VideoFile{
BaseFile: &models.BaseFile{ID: models.FileID(newFileID), Path: "new.mp4"},
}
scene := &models.Scene{
ID: testSceneID,
Files: models.NewRelatedVideoFiles([]*models.VideoFile{existingFile}),
}
db := mocks.NewDatabase()
db.Scene.On("GetFiles", mock.Anything, testSceneID).Return([]*models.VideoFile{existingFile}, nil)
db.Scene.On("AddFileID", mock.Anything, testSceneID, models.FileID(newFileID)).Return(nil)
db.Scene.On("UpdatePartial", mock.Anything, testSceneID, mock.Anything).
Return(&models.Scene{ID: testSceneID}, nil)
h := &ScanHandler{
CreatorUpdater: db.Scene,
PluginCache: &plugin.Cache{},
}
db.WithTxnCtx(func(ctx context.Context) {
err := h.associateExisting(ctx, []*models.Scene{scene}, newFile, false)
assert.NoError(t, err)
})
db.Scene.AssertCalled(t, "AddFileID", mock.Anything, testSceneID, models.FileID(newFileID))
db.Scene.AssertCalled(t, "UpdatePartial", mock.Anything, testSceneID, mock.Anything)
}

View File

@@ -140,8 +140,8 @@ func (r mappedResult) scrapedPerformer() *models.ScrapedPerformer {
PenisLength: r.stringPtr("PenisLength"),
Circumcised: r.stringPtr("Circumcised"),
CareerLength: r.stringPtr("CareerLength"),
CareerStart: r.IntPtr("CareerStart"),
CareerEnd: r.IntPtr("CareerEnd"),
CareerStart: r.stringPtr("CareerStart"),
CareerEnd: r.stringPtr("CareerEnd"),
Tattoos: r.stringPtr("Tattoos"),
Piercings: r.stringPtr("Piercings"),
Aliases: r.stringPtr("Aliases"),

View File

@@ -20,8 +20,8 @@ type ScrapedPerformerInput struct {
PenisLength *string `json:"penis_length"`
Circumcised *string `json:"circumcised"`
CareerLength *string `json:"career_length"`
CareerStart *int `json:"career_start"`
CareerEnd *int `json:"career_end"`
CareerStart *string `json:"career_start"`
CareerEnd *string `json:"career_end"`
Tattoos *string `json:"tattoos"`
Piercings *string `json:"piercings"`
Aliases *string `json:"aliases"`

View File

@@ -0,0 +1,144 @@
package scraper
import (
"context"
"testing"
"github.com/stashapp/stash/pkg/models"
)
func TestPostScrapePerformerCareerLength(t *testing.T) {
ctx := context.Background()
const related = false
strPtr := func(s string) *string {
return &s
}
tests := []struct {
name string
input models.ScrapedPerformer
want models.ScrapedPerformer
}{
{
"start = 2000",
models.ScrapedPerformer{
CareerStart: strPtr("2000"),
},
models.ScrapedPerformer{
CareerStart: strPtr("2000"),
CareerLength: strPtr("2000 -"),
},
},
{
"end = 2000",
models.ScrapedPerformer{
CareerEnd: strPtr("2000"),
},
models.ScrapedPerformer{
CareerEnd: strPtr("2000"),
CareerLength: strPtr("- 2000"),
},
},
{
"start = 2000, end = 2020",
models.ScrapedPerformer{
CareerStart: strPtr("2000"),
CareerEnd: strPtr("2020"),
},
models.ScrapedPerformer{
CareerStart: strPtr("2000"),
CareerEnd: strPtr("2020"),
CareerLength: strPtr("2000 - 2020"),
},
},
{
"length = 2000 -",
models.ScrapedPerformer{
CareerLength: strPtr("2000 -"),
},
models.ScrapedPerformer{
CareerStart: strPtr("2000"),
CareerLength: strPtr("2000 -"),
},
},
{
"length = - 2010",
models.ScrapedPerformer{
CareerLength: strPtr("- 2010"),
},
models.ScrapedPerformer{
CareerEnd: strPtr("2010"),
CareerLength: strPtr("- 2010"),
},
},
{
"length = 2000 - 2010",
models.ScrapedPerformer{
CareerLength: strPtr("2000 - 2010"),
},
models.ScrapedPerformer{
CareerStart: strPtr("2000"),
CareerEnd: strPtr("2010"),
CareerLength: strPtr("2000 - 2010"),
},
},
{
"invalid start",
models.ScrapedPerformer{
CareerStart: strPtr("two thousand"),
},
models.ScrapedPerformer{
CareerStart: strPtr("two thousand"),
},
},
{
"invalid end",
models.ScrapedPerformer{
CareerEnd: strPtr("two thousand"),
},
models.ScrapedPerformer{
CareerEnd: strPtr("two thousand"),
},
},
{
"invalid career length",
models.ScrapedPerformer{
CareerLength: strPtr("1234 - 4567 - 9224"),
},
models.ScrapedPerformer{
CareerLength: strPtr("1234 - 4567 - 9224"),
},
},
}
compareStrPtr := func(a, b *string) bool {
if a == b {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := &postScraper{}
got, err := c.postScrapePerformer(ctx, tt.input, related)
if err != nil {
t.Fatalf("postScrapePerformer returned error: %v", err)
}
postScraped := got.(models.ScrapedPerformer)
if !compareStrPtr(postScraped.CareerStart, tt.want.CareerStart) {
t.Errorf("CareerStart = %v, want %v", postScraped.CareerStart, tt.want.CareerStart)
}
if !compareStrPtr(postScraped.CareerEnd, tt.want.CareerEnd) {
t.Errorf("CareerEnd = %v, want %v", postScraped.CareerEnd, tt.want.CareerEnd)
}
if !compareStrPtr(postScraped.CareerLength, tt.want.CareerLength) {
t.Errorf("CareerLength = %v, want %v", postScraped.CareerLength, tt.want.CareerLength)
}
})
}
}

View File

@@ -125,23 +125,64 @@ func (c *postScraper) postScrapePerformer(ctx context.Context, p models.ScrapedP
}
}
isEmptyStr := func(s *string) bool { return s == nil || *s == "" }
isEmptyInt := func(s *int) bool { return s == nil || *s == 0 }
// populate career start/end from career length and vice versa
if !isEmptyStr(p.CareerLength) && isEmptyInt(p.CareerStart) && isEmptyInt(p.CareerEnd) {
p.CareerStart, p.CareerEnd, err = utils.ParseYearRangeString(*p.CareerLength)
if err != nil {
logger.Warnf("Could not parse career length %s: %v", *p.CareerLength, err)
}
} else if isEmptyStr(p.CareerLength) && (!isEmptyInt(p.CareerStart) || !isEmptyInt(p.CareerEnd)) {
v := utils.FormatYearRange(p.CareerStart, p.CareerEnd)
p.CareerLength = &v
}
c.postProcessCareerLength(&p)
return p, nil
}
func (c *postScraper) postProcessCareerLength(p *models.ScrapedPerformer) {
isEmptyStr := func(s *string) bool { return s == nil || *s == "" }
// populate career start/end from career length and vice versa
if !isEmptyStr(p.CareerLength) && isEmptyStr(p.CareerStart) && isEmptyStr(p.CareerEnd) {
start, end, err := models.ParseYearRangeString(*p.CareerLength)
if err != nil {
logger.Warnf("Could not parse career length %s: %v", *p.CareerLength, err)
return
}
if start != nil {
startStr := start.String()
p.CareerStart = &startStr
}
if end != nil {
endStr := end.String()
p.CareerEnd = &endStr
}
return
}
// populate career length from career start/end if career length is missing
if isEmptyStr(p.CareerLength) {
var (
start *models.Date
end *models.Date
)
if !isEmptyStr(p.CareerStart) {
date, err := models.ParseDate(*p.CareerStart)
if err != nil {
logger.Warnf("Could not parse career start %s: %v", *p.CareerStart, err)
return
}
start = &date
}
if !isEmptyStr(p.CareerEnd) {
date, err := models.ParseDate(*p.CareerEnd)
if err != nil {
logger.Warnf("Could not parse career end %s: %v", *p.CareerEnd, err)
return
}
end = &date
}
v := models.FormatYearRange(start, end)
p.CareerLength = &v
}
}
func (c *postScraper) postScrapeMovie(ctx context.Context, m models.ScrapedMovie, related bool) (_ ScrapedContent, err error) {
r := c.repository
tqb := r.TagFinder

View File

@@ -17,6 +17,12 @@ func queryURLParametersFromScene(scene *models.Scene) queryURLParameters {
ret["oshash"] = scene.OSHash
ret["filename"] = filepath.Base(scene.Path)
// pull phash from primary file
phashFingerprints := scene.Files.Primary().Base().Fingerprints.Filter(models.FingerprintTypePhash)
if len(phashFingerprints) > 0 {
ret["phash"] = phashFingerprints[0].Value()
}
if scene.Title != "" {
ret["title"] = scene.Title
}

View File

@@ -1089,11 +1089,16 @@ func (h *stashIDsCriterionHandler) handle(ctx context.Context, f *filterBuilder)
}
type relatedFilterHandler struct {
relatedIDCol string
relatedRepo repository
// column on the primary table that relates to the related table (eg scene_id)
relatedIDCol string
// repository for the related table (eg sceneRepository)
relatedRepo repository
// handler for the filter on the related table
relatedHandler criterionHandler
joinFn func(f *filterBuilder)
directJoin bool
// optional function to perform the necessary join(s) to the related table
joinFn func(f *filterBuilder)
// if true, related filter handler will be run using the existing filterBuilder instead of a subquery.
directJoin bool
}
func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) {
@@ -1124,7 +1129,7 @@ func (h *relatedFilterHandler) handle(ctx context.Context, f *filterBuilder) {
return
}
f.addWhere(fmt.Sprintf("%s IN ("+subQuery.toSQL(false)+")", h.relatedIDCol), subQuery.args...)
f.addWhere(fmt.Sprintf("%s IN ("+subQuery.toSQL(false)+")", h.relatedIDCol), subQuery.allArgs()...)
}
type phashDistanceCriterionHandler struct {

View File

@@ -261,8 +261,8 @@ func (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs str
h.innerJoin(f, joinAs, cc.Field)
f.addWhere(fmt.Sprintf("%[1]s.value IN %s", joinAs, getInBinding(len(cv))), cv...)
case models.CriterionModifierNotEquals:
h.innerJoin(f, joinAs, cc.Field)
f.addWhere(fmt.Sprintf("%[1]s.value NOT IN %s", joinAs, getInBinding(len(cv))), cv...)
h.leftJoin(f, joinAs, cc.Field)
f.addWhere(fmt.Sprintf("(%[1]s.value NOT IN %s OR %[1]s.value IS NULL)", joinAs, getInBinding(len(cv))), cv...)
case models.CriterionModifierIncludes:
clauses := make([]sqlClause, len(cv))
for i, v := range cv {
@@ -272,7 +272,7 @@ func (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs str
f.whereClauses = append(f.whereClauses, clauses...)
case models.CriterionModifierExcludes:
for _, v := range cv {
f.addWhere(fmt.Sprintf("%[1]s.value NOT LIKE ?", joinAs), fmt.Sprintf("%%%v%%", v))
f.addWhere(fmt.Sprintf("(%[1]s.value NOT LIKE ? OR %[1]s.value IS NULL)", joinAs), fmt.Sprintf("%%%v%%", v))
}
h.leftJoin(f, joinAs, cc.Field)
case models.CriterionModifierMatchesRegex:
@@ -315,8 +315,8 @@ func (h *customFieldsFilterHandler) handleCriterion(f *filterBuilder, joinAs str
h.innerJoin(f, joinAs, cc.Field)
f.addWhere(fmt.Sprintf("%s.value BETWEEN ? AND ?", joinAs), cv[0], cv[1])
case models.CriterionModifierNotBetween:
h.innerJoin(f, joinAs, cc.Field)
f.addWhere(fmt.Sprintf("%s.value NOT BETWEEN ? AND ?", joinAs), cv[0], cv[1])
h.leftJoin(f, joinAs, cc.Field)
f.addWhere(fmt.Sprintf("(%s.value NOT BETWEEN ? AND ? OR %[1]s.value IS NULL)", joinAs), cv[0], cv[1])
case models.CriterionModifierLessThan:
if len(cv) != 1 {
f.setError(fmt.Errorf("expected 1 value for custom field criterion modifier LESS_THAN, got %d", len(cv)))

View File

@@ -34,7 +34,7 @@ const (
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
)
var appSchemaVersion uint = 83
var appSchemaVersion uint = 85
//go:embed migrations/*.sql
var migrationsBox embed.FS

View File

@@ -695,7 +695,7 @@ func (qb *FileStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.SelectD
// FindAllByPaths returns the all files that are within any of the given paths.
// Returns all if limit is < 0.
// Returns all files if p is empty.
func (qb *FileStore) FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]models.File, error) {
func (qb *FileStore) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]models.File, error) {
table := qb.table()
folderTable := folderTableMgr.table
@@ -706,6 +706,10 @@ func (qb *FileStore) FindAllInPaths(ctx context.Context, p []string, limit, offs
q = qb.allInPaths(q, p)
if !includeZipContents {
q = q.Where(table.Col("zip_file_id").IsNull())
}
if limit > -1 {
q = q.Limit(uint(limit))
}
@@ -975,7 +979,7 @@ func (qb *FileStore) queryGroupedFields(ctx context.Context, options models.File
Megapixels float64
Size int64
}{}
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil {
return nil, err
}

View File

@@ -238,22 +238,32 @@ func (qb *fileFilterHandler) hashesCriterionHandler(hashes []*models.Fingerprint
t := fmt.Sprintf("file_fingerprints_%d", i)
f.addLeftJoin(fingerprintTable, t, fmt.Sprintf("files.id = %s.file_id AND %s.type = ?", t, t), hash.Type)
value, _ := utils.StringToPhash(hash.Value)
distance := 0
if hash.Distance != nil {
distance = *hash.Distance
}
if distance > 0 {
// needed to avoid a type mismatch
f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t))
f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance)
// Only phash supports distance matching and is stored as integer
if hash.Type == models.FingerprintTypePhash {
value, err := utils.StringToPhash(hash.Value)
if err != nil {
f.setError(fmt.Errorf("invalid phash value: %w", err))
return
}
if distance > 0 {
// needed to avoid a type mismatch
f.addWhere(fmt.Sprintf("typeof(%s.fingerprint) = 'integer'", t))
f.addWhere(fmt.Sprintf("phash_distance(%s.fingerprint, ?) < ?", t), value, distance)
} else {
intCriterionHandler(&models.IntCriterionInput{
Value: int(value),
Modifier: models.CriterionModifierEquals,
}, t+".fingerprint", nil)(ctx, f)
}
} else {
// use the default handler
intCriterionHandler(&models.IntCriterionInput{
Value: int(value),
Modifier: models.CriterionModifierEquals,
}, t+".fingerprint", nil)(ctx, f)
// All other fingerprint types (md5, oshash, sha1, etc.) are stored as strings
// Use exact match for string-based fingerprints
f.addWhere(fmt.Sprintf("%s.fingerprint = ?", t), hash.Value)
}
}
}

View File

@@ -9,6 +9,7 @@ import (
"testing"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
"github.com/stretchr/testify/assert"
)
@@ -81,7 +82,45 @@ func TestFileQuery(t *testing.T) {
includeIDs: []models.FileID{fileIDs[fileIdxInZip]},
excludeIdxs: []int{fileIdxStartImageFiles},
},
// TODO - add more tests for other file filters
{
name: "hashes md5",
filter: &models.FileFilterType{
Hashes: []*models.FingerprintFilterInput{
{
Type: models.FingerprintTypeMD5,
Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "md5"),
},
},
},
includeIdxs: []int{fileIdxStartVideoFiles},
excludeIdxs: []int{fileIdxStartImageFiles},
},
{
name: "hashes oshash",
filter: &models.FileFilterType{
Hashes: []*models.FingerprintFilterInput{
{
Type: models.FingerprintTypeOshash,
Value: getPrefixedStringValue("file", fileIdxStartVideoFiles, "oshash"),
},
},
},
includeIdxs: []int{fileIdxStartVideoFiles},
excludeIdxs: []int{fileIdxStartImageFiles},
},
{
name: "hashes phash",
filter: &models.FileFilterType{
Hashes: []*models.FingerprintFilterInput{
{
Type: models.FingerprintTypePhash,
Value: utils.PhashToString(getFilePhash(fileIdxStartImageFiles)),
},
},
},
includeIdxs: []int{fileIdxStartImageFiles},
excludeIdxs: []int{fileIdxStartVideoFiles},
},
}
for _, tt := range tests {

View File

@@ -572,7 +572,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) {
{
"by MD5",
models.Fingerprint{
Type: "MD5",
Type: models.FingerprintTypeMD5,
Fingerprint: getPrefixedStringValue("file", fileIdxZip, "md5"),
},
[]models.File{makeFileWithID(fileIdxZip)},
@@ -581,7 +581,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) {
{
"by OSHASH",
models.Fingerprint{
Type: "OSHASH",
Type: models.FingerprintTypeOshash,
Fingerprint: getPrefixedStringValue("file", fileIdxZip, "oshash"),
},
[]models.File{makeFileWithID(fileIdxZip)},
@@ -590,7 +590,7 @@ func TestFileStore_FindByFingerprint(t *testing.T) {
{
"non-existing",
models.Fingerprint{
Type: "OSHASH",
Type: models.FingerprintTypeOshash,
Fingerprint: "foo",
},
nil,

View File

@@ -20,6 +20,7 @@ const folderIDColumn = "folder_id"
type folderRow struct {
ID models.FolderID `db:"id" goqu:"skipinsert"`
Basename string `db:"basename"`
Path string `db:"path"`
ZipFileID null.Int `db:"zip_file_id"`
ParentFolderID null.Int `db:"parent_folder_id"`
@@ -30,6 +31,8 @@ type folderRow struct {
func (r *folderRow) fromFolder(o models.Folder) {
r.ID = o.ID
// derive basename from path
r.Basename = filepath.Base(o.Path)
r.Path = o.Path
r.ZipFileID = nullIntFromFileIDPtr(o.ZipFileID)
r.ParentFolderID = nullIntFromFolderIDPtr(o.ParentFolderID)
@@ -322,6 +325,126 @@ func (qb *FolderStore) FindByParentFolderID(ctx context.Context, parentFolderID
return ret, nil
}
func (qb *FolderStore) GetManyParentFolderIDs(ctx context.Context, folderIDs []models.FolderID) ([][]models.FolderID, error) {
table := qb.table()
// SQL recursive query to get all parent folder IDs for each folder ID
/*
WITH RECURSIVE parent_folders AS (
SELECT id, parent_folder_id
FROM folders
WHERE id IN (folderIDs)
UNION ALL
SELECT f.id, f.parent_folder_id
FROM folders f
INNER JOIN parent_folders pf ON f.id = pf.parent_folder_id
)
SELECT id, parent_folder_id FROM parent_folders;
*/
const parentFolders = "parent_folders"
const parentFolderID = "parent_folder_id"
const parentID = "parent_id"
const foldersAlias = "f"
const parentFoldersAlias = "pf"
foldersAliasedI := table.As(foldersAlias)
parentFoldersI := goqu.T(parentFolders).As(parentFoldersAlias)
q := dialect.From(parentFolders).Prepared(true).
WithRecursive(parentFolders,
dialect.From(table).Select(table.Col(idColumn), table.Col(parentFolderID).As(parentID)).
Where(table.Col(idColumn).In(folderIDs)).
Union(
dialect.From(foldersAliasedI).InnerJoin(
parentFoldersI,
goqu.On(foldersAliasedI.Col(idColumn).Eq(parentFoldersI.Col(parentID))),
).Select(foldersAliasedI.Col(idColumn), foldersAliasedI.Col(parentFolderID).As(parentID)),
),
).Select(idColumn, parentID)
type resultRow struct {
FolderID models.FolderID `db:"id"`
ParentFolderID null.Int `db:"parent_id"`
}
folderMap := make(map[models.FolderID]models.FolderID)
if err := queryFunc(ctx, q, false, func(r *sqlx.Rows) error {
var row resultRow
if err := r.StructScan(&row); err != nil {
return err
}
if row.ParentFolderID.Valid {
folderMap[row.FolderID] = models.FolderID(row.ParentFolderID.Int64)
} else {
folderMap[row.FolderID] = 0
}
return nil
}); err != nil {
return nil, err
}
ret := make([][]models.FolderID, len(folderIDs))
for i, folderID := range folderIDs {
var parents []models.FolderID
currentID := folderID
for {
parentID, exists := folderMap[currentID]
if !exists || parentID == 0 {
break
}
parents = append(parents, parentID)
currentID = parentID
}
ret[i] = parents
}
return ret, nil
}
func (qb *FolderStore) GetManySubFolderIDs(ctx context.Context, parentFolderIDs []models.FolderID) ([][]models.FolderID, error) {
table := qb.table()
q := dialect.From(table).Select(
table.Col(idColumn),
table.Col("parent_folder_id"),
).Where(qb.table().Col("parent_folder_id").In(parentFolderIDs))
sql, args, err := q.ToSQL()
if err != nil {
return nil, fmt.Errorf("building query: %w", err)
}
var results []struct {
FolderID int `db:"id"`
ParentFolderID models.FolderID `db:"parent_folder_id"`
}
if err := querySelect(ctx, sql, args, &results); err != nil {
return nil, fmt.Errorf("getting folders by parent folder ids %v: %w", parentFolderIDs, err)
}
retMap := make(map[models.FolderID][]models.FolderID)
for _, v := range results {
retMap[v.ParentFolderID] = append(retMap[v.ParentFolderID], models.FolderID(v.FolderID))
}
ret := make([][]models.FolderID, len(parentFolderIDs))
for i, parentID := range parentFolderIDs {
ret[i] = retMap[parentID]
}
return ret, nil
}
func (qb *FolderStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.SelectDataset {
table := qb.table()
@@ -340,10 +463,14 @@ func (qb *FolderStore) allInPaths(q *goqu.SelectDataset, p []string) *goqu.Selec
// FindAllInPaths returns the all folders that are or are within any of the given paths.
// Returns all if limit is < 0.
// Returns all folders if p is empty.
func (qb *FolderStore) FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]*models.Folder, error) {
func (qb *FolderStore) FindAllInPaths(ctx context.Context, p []string, includeZipContents bool, limit, offset int) ([]*models.Folder, error) {
q := qb.selectDataset().Prepared(true)
q = qb.allInPaths(q, p)
if !includeZipContents {
q = q.Where(qb.table().Col("zip_file_id").IsNull())
}
if limit > -1 {
q = q.Limit(uint(limit))
}
@@ -513,7 +640,7 @@ func (qb *FolderStore) queryGroupedFields(ctx context.Context, options models.Fo
Megapixels float64
Size int64
}{}
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.args, &out); err != nil {
if err := qb.repository.queryStruct(ctx, aggregateQuery.toSQL(includeSortPagination), query.allArgs(), &out); err != nil {
return nil, err
}
@@ -527,6 +654,7 @@ var folderSortOptions = sortOptions{
"created_at",
"id",
"path",
"basename",
"random",
"updated_at",
}

View File

@@ -65,6 +65,7 @@ func (qb *folderFilterHandler) criterionHandler() criterionHandler {
folderFilter := qb.folderFilter
return compoundHandler{
stringCriterionHandler(folderFilter.Path, qb.table.Col("path")),
stringCriterionHandler(folderFilter.Basename, qb.table.Col("basename")),
&timestampCriterionHandler{folderFilter.ModTime, qb.table.Col("mod_time"), nil},
qb.parentFolderCriterionHandler(folderFilter.ParentFolder),

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