Compare commits

...

75 Commits

Author SHA1 Message Date
WithoutPants
857e673d3e Codeberg weblate (#6416)
* Translated using Weblate (Czech)

Currently translated at 100.0% (1250 of 1250 strings)

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

* Translated using Weblate (Japanese)

Currently translated at 82.0% (1026 of 1250 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.1% (864 of 1250 strings)

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

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 69.1% (864 of 1250 strings)

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

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

Currently translated at 100.0% (1250 of 1250 strings)

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

* Translated using Weblate (Indonesian)

Currently translated at 48.3% (604 of 1250 strings)

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

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1251 of 1251 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1251 of 1251 strings)

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

---------

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

Currently translated at 100.0% (1233 of 1233 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1236 of 1236 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1236 of 1236 strings)

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

* Update translation files

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

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

* Update translation files

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

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

* Translated using Weblate (French)

Currently translated at 100.0% (1242 of 1242 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1242 of 1242 strings)

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

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1242 of 1242 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1242 of 1242 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1243 of 1243 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 78.6% (978 of 1243 strings)

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

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1243 of 1243 strings)

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

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

Currently translated at 100.0% (1243 of 1243 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1243 of 1243 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1245 of 1245 strings)

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

* Translated using Weblate (Hindi)

Currently translated at 5.3% (67 of 1245 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1245 of 1245 strings)

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

* Translated using Weblate (German)

Currently translated at 98.0% (1221 of 1245 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1245 of 1245 strings)

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

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1250 of 1250 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1250 of 1250 strings)

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

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

Currently translated at 99.9% (1249 of 1250 strings)

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

---------

Co-authored-by: AlpacaSerious <alpacaserious@noreply.codeberg.org>
Co-authored-by: doodoo <doodoo@noreply.codeberg.org>
Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: donlothario <donlothario@noreply.codeberg.org>
Co-authored-by: Zesty6249 <zesty6249@noreply.codeberg.org>
Co-authored-by: yec <yec@noreply.codeberg.org>
Co-authored-by: PhilipWaldman <philipwaldman@noreply.codeberg.org>
Co-authored-by: wql219 <wql219@noreply.codeberg.org>
Co-authored-by: asasin235 <asasin235@noreply.codeberg.org>
Co-authored-by: lugged9922 <lugged9922@noreply.codeberg.org>
Co-authored-by: upstairs <upstairs@noreply.codeberg.org>
2025-12-11 14:12:07 +11:00
CJ
f1e54bfc73 Optimize Tag List Page Performance (#6398) 2025-12-11 13:59:19 +11:00
WithoutPants
ebfe5c4b5c Update changelog 2025-12-11 13:47:28 +11:00
WithoutPants
11417590ee Allow string list input to be orderable (#6397)
* Allow string list input to be orderable
* Make alias fields not orderable
* Adjust styling for URL list controls
2025-12-11 13:07:05 +11:00
WithoutPants
0980daa99e Fix issues linking a tag that already exists in the tag list (#6395)
* Add stash-id to existing when linking tag
* Validate id list for duplicates in find queries
* Filter out duplicate ids after linking tag
2025-12-11 11:45:56 +11:00
WithoutPants
5f0d4e811d Revert "Feature Request: Sort All Urls Alphabetically (#6352)" (#6396)
This reverts commit 061d21dede.
2025-12-11 11:38:20 +11:00
ghuds540
a4816b4cc9 Respect user preference for type-to-create in image/scene multi-select form (#6376) 2025-12-11 08:22:29 +11:00
Gykes
ba0102f2a6 use initialstate for scene performers in tagger (#6391) 2025-12-11 08:07:16 +11:00
CJ
fe41561dfe add autostart button to videoplayer (#6368) 2025-12-11 08:01:38 +11:00
WithoutPants
7fded66bfa Improve tag stash-id handling in tagger and scraper dialogs (#6389)
* Change link button icon and separate into component
* Add create/link tag dialog
* Add titles to buttons
* Add ability to link existing tags in scrape dialogs
* Move create link dialog
* Allow tags to have multiple stash-ids from the same endpoint
2025-12-09 13:55:11 +11:00
WithoutPants
945d679158 Refactor and restyle scrape dialog on smaller viewports (#6387)
* Improve string-list-input styling
* Rename ScrapedDialog file
* Move ScrapeDialog into separate file
* Refactor scrape dialog row inputs
* Refactor new value handling
* Add context for labels
* Refactor scrape dialog to accept children
* Add existing/scraped labels for smaller viewports
2025-12-09 07:29:41 +11:00
WithoutPants
7db394bbea Date precision (#6359)
* Remove month/year only formats from ParseDateStringAsTime
* Add precision field to Date and handle parsing year/month-only dates
* Add date precision columns for date columns
* Adjust UI to account for fuzzy dates
2025-12-08 09:11:40 +11:00
WithoutPants
eb9d0705bc Query for image query result metadata separately (#6370)
* Make a separate query for loading image query metadata
2025-12-08 08:47:35 +11:00
WithoutPants
0fd7a2ac20 SQL performance improvements (#6378)
* Change queryStruct to use tx.Get instead of queryFunc

Using queryFunc meant that the performance logging was inaccurate due to the query actually being executed during the call to Scan.

* Only add join args if join was added

* Omit joins that are only used for sorting when skipping sorting

Should provide some marginal improvement on systems with a lot of items.

* Make all calls to the database pass context.

This means that long queries can be cancelled by navigating to another page. Previously the query would continue to run, impacting on future queries.
2025-12-08 08:08:31 +11:00
WithoutPants
e2dff05081 Replace ValueOnlyContext with context.WithoutCancel (#6379) 2025-12-08 07:59:42 +11:00
Gykes
061d21dede Feature Request: Sort All Urls Alphabetically (#6352) 2025-12-05 14:05:46 +11:00
WithoutPants
88a149c085 Correct sidebar styling on details pages (#6377)
* Remove margin-bottom on xs to fix styling weirdness
* Only set sidebar height when sidebar visible
2025-12-05 09:04:16 +11:00
WithoutPants
d994df2900 Don't convert config file location to absolute during setup (#6373)
This was originally done for #3304. The ffmpeg code has been redone since and this is no longer necessary. It was also resulting in the scraper and plugin paths being absolute, despite all the others being relative to the provided config path.
2025-12-05 08:46:31 +11:00
Gykes
39fd8a6550 Feature: Manual StashId Search - Tags (#6374) 2025-12-04 11:20:29 +11:00
Gykes
877491e62b Manually Search Stash ID - Edit Page - Scenes, Studios (#6340) 2025-12-04 09:09:49 +11:00
DogmaDragon
3d044896ad Update Auto Tag/Identify documentation (#6371)
* Update Auto Tag documentation
* Update Identify documentation
2025-12-04 07:48:36 +11:00
WithoutPants
63e8830db4 Truncate custom field display to 5 lines (#6361) 2025-12-04 07:28:30 +11:00
WithoutPants
0bc4faef2a Add support for removing custom field keys (#6362) 2025-12-04 07:28:06 +11:00
WithoutPants
ee61fc879b Add nil check for scraped measurements (#6367) 2025-12-04 07:27:47 +11:00
WithoutPants
e02ef436a5 Fix batch tag update when studio/performer has no stash id (#6369)
* Handle batch tagging where stash id not set

Should search by name for these

* Don't set empty stash ids
2025-12-04 07:26:41 +11:00
Shadesbird
41f0612025 Update Identify.md - Add advanced settings hint (#6372)
Did not find this feature by myself. Had to have a forum discussion to realise this feature exists and is hidden in the advanced settings.

Added hint that this is an advanced setting.
2025-12-04 07:26:23 +11:00
WithoutPants
730e877e73 [RFC] Refactor scene list toolbar (#6322)
* Revert scene list toolbar to use common filtered list toolbar
* Add unobtrusive sidebar toggle button
* Revert small device sidebar changes
* Minor styling fixes
2025-12-03 14:59:15 +11:00
WithoutPants
e213fde0cc Return error when scanning avif in zip (#6356) 2025-12-02 14:27:29 +11:00
hckrman101
69fd073d5d Add option for instant transitions in lightbox (#6354) 2025-12-02 14:25:46 +11:00
Otter Bot Society
5f16547e58 Add fallback for 0-dimension images (webp animations) (#6342) 2025-12-02 14:14:42 +11:00
feederbox826
90dd0b58d8 add WakeLockSentinel (#6331)
* add WakeLockSentinel

prevents screen from sleeping ONLY in secure contexts (localhost, https)

closes #2884

* format, add types

* [wake-sentinel] add more releases, comments

release wakelock on dispose and end, call out secure contexts in error message
2025-12-02 12:57:54 +11:00
WithoutPants
4017c42fe2 Handle modified files where the case of the filename changed on case-insensitive filesystems (#6327)
* Find existing files with case insensitivity if filesystem is case insensitive
* Handle case change in folders
* Optimise to only test file system case sensitivity if the first query found nothing

This limits the overhead to new paths, and adds an extra query for new paths to windows installs
2025-12-02 12:53:37 +11:00
Gykes
49fd47562e Bugfix: Fix New Tagger Gender Setting Select (#6351) 2025-12-02 12:52:16 +11:00
WithoutPants
84e24eb612 Refactor scraping to include related object fields (#6266)
* Refactor scraper post-processing and process related objects consistently
* Refactor image processing
* Scrape related studio fields consistently
* Don't set image on related objects
2025-12-02 12:49:44 +11:00
Gykes
c6ae43c1d6 Feature Request: Vips AVIF Support (#6337) 2025-11-28 15:00:23 +11:00
dependabot[bot]
de8139cf1b Bump golang.org/x/crypto from 0.38.0 to 0.45.0 (#6300)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.38.0 to 0.45.0.
- [Commits](https://github.com/golang/crypto/compare/v0.38.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.45.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-28 14:05:19 +11:00
WithoutPants
0ca416f75a Ignore empty alias in studio partial (#6338) 2025-11-28 13:54:21 +11:00
WithoutPants
1bc32a3099 Add sticky selection toolbar (#6320) 2025-11-28 13:52:30 +11:00
WithoutPants
d1ee64d36f Change show male performers option into list of gender checkboxes (#6321) 2025-11-28 13:51:20 +11:00
Gykes
e052a431d1 Feature Request: Bulk Add by StashID and Name (#6310) 2025-11-28 13:19:14 +11:00
feederbox826
7e66ce8a49 trigger play count on player ended (#6334) 2025-11-28 11:56:54 +11:00
Gykes
88747b962a allow partial dates (#6333) 2025-11-28 11:55:18 +11:00
Gykes
97c01c70b3 update mac notification (#6329) 2025-11-28 11:48:23 +11:00
feederbox826
a3ed381901 [hwaccel] increase timeout, fix formatting (#6328)
Thanks to @Gykes for helping find the formatting error
2025-11-28 11:47:34 +11:00
DogmaDragon
b3da730a05 docs: Improve README clarity and formatting 2025-11-28 00:24:06 +02:00
Gykes
e0c1d4c51d Manually Search Stash ID - Edit Page (#6284) 2025-11-28 07:32:29 +11:00
Gykes
90d1b2df2d Feature: AVIF support (#6288) 2025-11-28 07:19:32 +11:00
Gykes
4ef3a605dd Bugfix: Update Markers to % Base Calc (#6323)
* update to % base calc
* add min-width
2025-11-27 14:57:57 +11:00
WithoutPants
f811590021 Debug log stderr when thumbnail generation fails 2025-11-27 14:04:06 +11:00
Gykes
0bd78f4b62 Bugfix: Add Trimspace to New Objects (#6226) 2025-11-27 07:48:56 +11:00
Gykes
a8bb9ae4d3 Show fingerprints when 0 scens (#6316) 2025-11-26 13:57:15 +11:00
Gykes
d10995302d Feature: Add trash support (#6237) 2025-11-26 13:38:19 +11:00
Gykes
d14053b570 Bugfix: Tagger Ignoing Disambiguation When Linking Performer (#6308) 2025-11-26 12:06:13 +11:00
WithoutPants
ca357b9eb3 Codeberg weblate (#6318)
* Translated using Weblate (Russian)

Currently translated at 100.0% (1219 of 1219 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1222 of 1222 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1222 of 1222 strings)

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

* Translated using Weblate (German)

Currently translated at 100.0% (1222 of 1222 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1222 of 1222 strings)

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

* Translated using Weblate (Japanese)

Currently translated at 83.9% (1026 of 1222 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1222 of 1222 strings)

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

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1222 of 1222 strings)

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

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

Currently translated at 100.0% (1222 of 1222 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 96.7% (1182 of 1222 strings)

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

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

Currently translated at 100.0% (1222 of 1222 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1222 of 1222 strings)

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

* Translated using Weblate (Korean)

Currently translated at 100.0% (1222 of 1222 strings)

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

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1222 of 1222 strings)

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

* Translated using Weblate (Ukrainian)

Currently translated at 87.4% (1069 of 1222 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 (Estonian)

Currently translated at 98.7% (1217 of 1233 strings)

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

* Translated using Weblate (French)

Currently translated at 100.0% (1233 of 1233 strings)

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

* Translated using Weblate (Czech)

Currently translated at 98.4% (1214 of 1233 strings)

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

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

Currently translated at 99.6% (1229 of 1233 strings)

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

* Translated using Weblate (Czech)

Currently translated at 100.0% (1233 of 1233 strings)

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

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

Currently translated at 99.0% (1221 of 1233 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 96.8% (1194 of 1233 strings)

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

* Translated using Weblate (Japanese)

Currently translated at 82.7% (1020 of 1233 strings)

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

* Translated using Weblate (German)

Currently translated at 99.3% (1225 of 1233 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 97.2% (1199 of 1233 strings)

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

* Translated using Weblate (Dutch)

Currently translated at 79.1% (976 of 1233 strings)

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

* Translated using Weblate (Bulgarian)

Currently translated at 25.1% (310 of 1233 strings)

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

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1233 of 1233 strings)

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

* Translated using Weblate (Estonian)

Currently translated at 100.0% (1233 of 1233 strings)

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

---------

Co-authored-by: direnyx <direnyx@noreply.codeberg.org>
Co-authored-by: lugged9922 <lugged9922@noreply.codeberg.org>
Co-authored-by: yec <yec@noreply.codeberg.org>
Co-authored-by: Marly21 <marly21@noreply.codeberg.org>
Co-authored-by: doodoo <doodoo@noreply.codeberg.org>
Co-authored-by: tobakumap <tobakumap@noreply.codeberg.org>
Co-authored-by: Zesty6249 <zesty6249@noreply.codeberg.org>
Co-authored-by: wql219 <wql219@noreply.codeberg.org>
Co-authored-by: donlothario <donlothario@noreply.codeberg.org>
Co-authored-by: danny60718 <danny60718@noreply.codeberg.org>
Co-authored-by: AlpacaSerious <alpacaserious@noreply.codeberg.org>
Co-authored-by: ves10023 <ves10023@noreply.codeberg.org>
Co-authored-by: Codeberg Translate <translate@codeberg.org>
Co-authored-by: NymeriaCZ <nymeriacz@noreply.codeberg.org>
Co-authored-by: 2307777 <2307777@noreply.codeberg.org>
Co-authored-by: hirokazuk <hirokazuk@noreply.codeberg.org>
Co-authored-by: PhilipWaldman <philipwaldman@noreply.codeberg.org>
Co-authored-by: Gundir <gundir@noreply.codeberg.org>
2025-11-25 17:46:23 +11:00
WithoutPants
6892c7151c Update changelog 2025-11-25 17:37:52 +11:00
WithoutPants
d6a2953371 Refactor filtered list toolbar (#6317)
* Refactor list operation buttons into a single button group
* Refactor ListFilter into FilteredListToolbar and restyle
* Move zoom keybinds out of zoom control
* Use button group for display mode select
* Hide zoom slider on xs devices
2025-11-25 17:36:13 +11:00
feederbox826
50ad3c0778 [MediaSession] fall back to performers if studio not available (#6315) 2025-11-25 14:41:01 +11:00
WithoutPants
dc520e2b2f Ignore empty studio alias in ScrapedStudio (#6313) 2025-11-25 10:11:39 +11:00
Slick Daddy
ecd9c6ec5b Show O Counter in Studio card (#5982)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2025-11-25 10:06:36 +11:00
feederbox826
ca8ee6bc2a add MediaSession plugin (#6298) 2025-11-25 09:12:23 +11:00
Gykes
5d02f916c2 Check for dupe IDs against boxes (#6309) 2025-11-25 08:58:57 +11:00
DogmaDragon
e176cf5f71 Document "# requires" in the plugin config (#6306)
* Document "# requires" in the plugin config
* Add missing line breaks in UIPluginApi documentation
2025-11-25 08:35:05 +11:00
Gykes
2cac7d5b20 Bugfix: Add extra date formats. (#6305) 2025-11-25 08:17:51 +11:00
271 changed files with 8806 additions and 4793 deletions

View File

@@ -10,7 +10,8 @@
[![GitHub issues by-label](https://img.shields.io/github/issues-raw/stashapp/stash/bounty)](https://github.com/stashapp/stash/labels/bounty)
### **Stash is a self-hosted webapp written in Go which organizes and serves your diverse content collection, catering to both your SFW and NSFW needs.**
![demo image](docs/readme_assets/demo_image.png)
![Screenshot of Stash web application interface](docs/readme_assets/demo_image.png)
* Stash gathers information about videos in your collection from the internet, and is extensible through the use of community-built plugins for a large number of content producers and sites.
* Stash supports a wide variety of both video and image formats.
@@ -19,80 +20,88 @@
You can [watch a SFW demo video](https://vimeo.com/545323354) to see it in action.
For further information you can consult the [documentation](https://docs.stashapp.cc) or [read the in-app manual](ui/v2.5/src/docs/en).
For further information you can consult the [documentation](https://docs.stashapp.cc) or access the in-app manual from within the application (also available at [docs.stashapp.cc/in-app-manual](https://docs.stashapp.cc/in-app-manual)).
# Installing Stash
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 doesn't support anymore _Windows 7, 8, Server 2008 and Server 2012._
Windows 10 or Server 2016 are at least required.
As of version 0.27.0, Stash no longer supports _Windows 7, 8, Server 2008 and Server 2012._
At least Windows 10 or Server 2016 is required.
#### Mac Users:
As of version 0.29.0, Stash requires at least _macOS 11 Big Sur._
Stash can still be ran through docker on older versions of macOS
As of version 0.29.0, Stash requires _macOS 11 Big Sur_ or later.
Stash can still be run through docker on older versions of macOS.
<img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS | <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker
:---:|:---:|:---:|:---:
[Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe)</sub></sup> | [Latest Release](https://github.com/stashapp/stash/releases/latest/download/Stash.app.zip) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/Stash.app.zip)</sub></sup> | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux) <br /> <sup><sub>[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)</sub></sup> <br /> [More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md) <br /> <sup><sub>[Sample docker-compose.yml](docker/production/docker-compose.yml)</sub></sup>
Download links for other platforms and architectures are available on the [Releases page](https://github.com/stashapp/stash/releases).
Download links for other platforms and architectures are available on the [Releases](https://github.com/stashapp/stash/releases) page.
## First Run
#### Windows/macOS Users: Security Prompt
On Windows or macOS, running the app might present a security prompt since the binary isn't yet signed.
On Windows or macOS, running the app might present a security prompt since the application binary isn't yet signed.
On Windows, bypass this by clicking "more info" and then the "run anyway" button. On macOS, Control+Click the app, click "Open", and then "Open" again.
- On Windows, bypass this by clicking "more info" and then the "run anyway" button.
- On macOS, Control+Click the app, click "Open", and then "Open" again.
#### FFmpeg
Stash requires FFmpeg. If you don't have it installed, Stash will download a copy for you. It is recommended that Linux users install `ffmpeg` from their distro's package manager.
#### ffmpeg
Stash requires FFmpeg. If you don't have it installed, Stash will prompt you to download a copy during setup. It is recommended that Linux users install `ffmpeg` from their distro's package manager.
# Usage
## Quickstart Guide
Stash is a web-based application. Once the application is running, the interface is available (by default) from http://localhost:9999.
Stash is a web-based application. Once the application is running, the interface is available (by default) from `http://localhost:9999`.
On first run, Stash will prompt you for some configuration options and media directories to index, called "Scanning" in Stash. After scanning, your media will be available for browsing, curating, editing, and tagging.
Stash can pull metadata (performers, tags, descriptions, studios, and more) directly from many sites through the use of [scrapers](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/docs/en/Manual/Scraping.md), which integrate directly into Stash. Identifying an entire collection will typically require a mix of multiple sources:
- The project maintains [StashDB](https://stashdb.org/), a crowd-sourced repository of scene, studio, and performer information. Connecting it to Stash will allow you to automatically identify much of a typical media collection. It runs on our stash-box software and is primarily focused on mainstream digital scenes and studios. Instructions, invite codes, and more can be found in this guide to [Accessing StashDB](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stashdb/).
- The stashapp team maintains [StashDB](https://stashdb.org/), a crowd-sourced repository of scene, studio, and performer information. Connecting it to Stash will allow you to automatically identify much of a typical media collection. It runs on our stash-box software and is primarily focused on mainstream digital scenes and studios. Instructions, invite codes, and more can be found in this guide to [Accessing StashDB](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stashdb/).
- Several community-managed stash-box databases can also be connected to Stash in a similar manner. Each one serves a slightly different niche and follows their own methodology. A rundown of each stash-box, their differences, and the information you need to sign up can be found in this guide to [Accessing Stash-Boxes](https://guidelines.stashdb.org/docs/faq_getting-started/stashdb/accessing-stash-boxes/).
- Many community-maintained scrapers can also be downloaded, installed, and updated from within Stash, allowing you to pull data from a wide range of other websites and databases. They can be found by navigating to Settings -> Metadata Providers -> Available Scrapers -> Community (stable). These can be trickier to use than a stash-box because every scraper works a little differently. For more information, please visit the [CommunityScrapers repository](https://github.com/stashapp/CommunityScrapers).
- Many community-maintained scrapers can also be downloaded, installed, and updated from within Stash, allowing you to pull data from a wide range of other websites and databases. They can be found by navigating to `Settings Metadata Providers Available Scrapers Community (stable)`. These can be trickier to use than a stash-box because every scraper works a little differently. For more information, please visit the [CommunityScrapers repository](https://github.com/stashapp/CommunityScrapers).
- All of the above methods of scraping data into Stash are also covered in more detail in our [Guide to Scraping](https://docs.stashapp.cc/beginner-guides/guide-to-scraping/).
<sub>[StashDB](http://stashdb.org) is the canonical instance of our open source metadata API, [stash-box](https://github.com/stashapp/stash-box).</sub>
# Translation
[![Translate](https://translate.codeberg.org/widget/stash/stash/svg-badge.svg)](https://translate.codeberg.org/engage/stash/)
Stash is available in 32 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash into your language, you can make an account at [Codeberg's Weblate](https://translate.codeberg.org/projects/stash/stash/) to get started contributing new languages or improving existing ones. Thanks!
Stash is available in 32 languages (so far!) and it could be in your language too. We use Weblate to coordinate community translations. If you want to help us translate Stash, you can make an account at [Codeberg's Weblate](https://translate.codeberg.org/projects/stash/stash/) to contribute to new or existing languages. Thanks!
The badge below shows the current translation status of Stash across all supported languages:
[![Translation status](https://translate.codeberg.org/widget/stash/stash/multi-auto.svg)](https://translate.codeberg.org/engage/stash/)
## Join Our Community
# Support & Resources
We are excited to announce that we have a new home for support, feature requests, and discussions related to Stash and its associated projects. Join our community on the [Discourse forum](https://discourse.stashapp.cc) to connect with other users, share your ideas, and get help from fellow enthusiasts.
Need help or want to get involved? Start with the documentation, then reach out to the community if you need further assistance.
# Support (FAQ)
- 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.
- 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.
Check out our documentation on [Stash-Docs](https://docs.stashapp.cc) for information about the software, questions, guides, add-ons and more.
For more help you can:
* Check the in-app documentation, in the top right corner of the app (it's also mirrored on [Stash-Docs](https://docs.stashapp.cc/in-app-manual))
* Join our [community forum](https://discourse.stashapp.cc)
* Join the [Discord server](https://discord.gg/2TsNFKt)
* Start a [discussion on GitHub](https://github.com/stashapp/stash/discussions)
# Customization
## Themes and CSS Customization
There is a [directory of community-created themes](https://docs.stashapp.cc/themes/list) on Stash-Docs.
You can also change the Stash interface to fit your desired style with various snippets from [Custom CSS snippets](https://docs.stashapp.cc/themes/custom-css-snippets).
- 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

16
go.mod
View File

@@ -55,12 +55,12 @@ require (
github.com/vektra/mockery/v2 v2.10.0
github.com/xWTF/chardet v0.0.0-20230208095535-c780f2ac244e
github.com/zencoder/go-dash/v3 v3.0.2
golang.org/x/crypto v0.38.0
golang.org/x/crypto v0.45.0
golang.org/x/image v0.18.0
golang.org/x/net v0.40.0
golang.org/x/sys v0.33.0
golang.org/x/term v0.32.0
golang.org/x/text v0.25.0
golang.org/x/net v0.47.0
golang.org/x/sys v0.38.0
golang.org/x/term v0.37.0
golang.org/x/text v0.31.0
golang.org/x/time v0.10.0
gopkg.in/guregu/null.v4 v4.0.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
@@ -121,9 +121,9 @@ require (
github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.yaml.in/yaml/v3 v3.0.3 // indirect
golang.org/x/mod v0.24.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/tools v0.33.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/tools v0.38.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

32
go.sum
View File

@@ -664,8 +664,8 @@ golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5y
golang.org/x/crypto v0.0.0-20211215165025-cf75a172585e/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20220112180741-5e0467b6c7ce/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -707,8 +707,8 @@ golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.5.0/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.5.1/go.mod h1:5OXOZSfqPIIbmVBIIKWRFfZjPR0E5r58TLhUjH0a2Ro=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181023162649-9b4f9f5ad519/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -758,8 +758,8 @@ golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -789,8 +789,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -872,13 +872,13 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
@@ -890,8 +890,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@@ -956,8 +956,8 @@ golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo=
golang.org/x/tools v0.1.8/go.mod h1:nABZi5QlRsZVlzPpHl034qft6wpY4eDcsTt5AaioBiU=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=

View File

@@ -165,6 +165,12 @@ type Query {
input: ScrapeSingleStudioInput!
): [ScrapedStudio!]!
"Scrape for a single tag"
scrapeSingleTag(
source: ScraperSourceInput!
input: ScrapeSingleTagInput!
): [ScrapedTag!]!
"Scrape for a single performer"
scrapeSinglePerformer(
source: ScraperSourceInput!

View File

@@ -69,6 +69,8 @@ input ConfigGeneralInput {
databasePath: String
"Path to backup directory"
backupDirectoryPath: String
"Path to trash directory - if set, deleted files will be moved here instead of being permanently deleted"
deleteTrashPath: String
"Path to generated files"
generatedPath: String
"Path to import/export files"
@@ -191,6 +193,8 @@ type ConfigGeneralResult {
databasePath: String!
"Path to backup directory"
backupDirectoryPath: String!
"Path to trash directory - if set, deleted files will be moved here instead of being permanently deleted"
deleteTrashPath: String!
"Path to generated files"
generatedPath: String!
"Path to import/export files"
@@ -315,6 +319,7 @@ input ConfigDisableDropdownCreateInput {
tag: Boolean
studio: Boolean
movie: Boolean
gallery: Boolean
}
enum ImageLightboxDisplayMode {
@@ -335,6 +340,7 @@ input ConfigImageLightboxInput {
resetZoomOnNav: Boolean
scrollMode: ImageLightboxScrollMode
scrollAttemptsBeforeChange: Int
disableAnimation: Boolean
}
type ConfigImageLightboxResult {
@@ -344,6 +350,7 @@ type ConfigImageLightboxResult {
resetZoomOnNav: Boolean
scrollMode: ImageLightboxScrollMode
scrollAttemptsBeforeChange: Int!
disableAnimation: Boolean
}
input ConfigInterfaceInput {
@@ -413,6 +420,7 @@ type ConfigDisableDropdownCreate {
tag: Boolean!
studio: Boolean!
movie: Boolean!
gallery: Boolean!
}
type ConfigInterfaceResult {

View File

@@ -606,6 +606,9 @@ input TagFilterType {
"Filter by autotag ignore value"
ignore_auto_tag: Boolean
"Filter by StashID"
stash_id_endpoint: StashIDCriterionInput
"Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType
"Filter by related images that meet this criteria"

View File

@@ -344,4 +344,6 @@ input CustomFieldsInput {
full: Map
"If populated, only the keys in this map will be updated"
partial: Map
"Remove any keys in this list"
remove: [String!]
}

View File

@@ -198,6 +198,13 @@ input ScrapeSingleStudioInput {
query: String
}
input ScrapeSingleTagInput {
"""
Query can be either a name or a Stash ID
"""
query: String
}
input ScrapeSinglePerformerInput {
"Instructs to query by string"
query: String
@@ -281,7 +288,10 @@ type StashBoxFingerprint {
duration: Int!
}
"If neither ids nor names are set, tag all items"
"""
Accepts either ids, or a combination of names and stash_ids.
If none are set, then all existing items will be tagged.
"""
input StashBoxBatchTagInput {
"Stash endpoint to use for the tagging"
endpoint: Int @deprecated(reason: "use stash_box_endpoint")
@@ -293,12 +303,17 @@ input StashBoxBatchTagInput {
refresh: Boolean!
"If batch adding studios, should their parent studios also be created?"
createParent: Boolean!
"If set, only tag these ids"
"""
IDs in stash of the items to update.
If set, names and stash_ids fields will be ignored.
"""
ids: [ID!]
"If set, only tag these names"
"Names of the items in the stash-box instance to search for and create"
names: [String!]
"If set, only tag these performer ids"
"Stash IDs of the items in the stash-box instance to search for and create"
stash_ids: [String!]
"IDs in stash of the performers to update"
performer_ids: [ID!] @deprecated(reason: "use ids")
"If set, only tag these performer names"
"Names of the performers in the stash-box instance to search for and create"
performer_names: [String!] @deprecated(reason: "use names")
}

View File

@@ -25,6 +25,7 @@ type Studio {
updated_at: Time!
groups: [Group!]!
movies: [Movie!]! @deprecated(reason: "use groups instead")
o_counter: Int
}
input StudioCreateInput {

View File

@@ -170,6 +170,21 @@ query FindStudio($id: ID, $name: String) {
}
}
query FindTag($id: ID, $name: String) {
findTag(id: $id, name: $name) {
...TagFragment
}
}
query QueryTags($input: TagQueryInput!) {
queryTags(input: $input) {
count
tags {
...TagFragment
}
}
}
mutation SubmitFingerprint($input: FingerprintSubmission!) {
submitFingerprint(input: $input)
}

View File

@@ -98,7 +98,7 @@ func (t changesetTranslator) string(value *string) string {
return ""
}
return *value
return strings.TrimSpace(*value)
}
func (t changesetTranslator) optionalString(value *string, field string) models.OptionalString {
@@ -106,7 +106,12 @@ func (t changesetTranslator) optionalString(value *string, field string) models.
return models.OptionalString{}
}
return models.NewOptionalStringPtr(value)
if value == nil {
return models.NewOptionalStringPtr(nil)
}
trimmed := strings.TrimSpace(*value)
return models.NewOptionalString(trimmed)
}
func (t changesetTranslator) optionalDate(value *string, field string) (models.OptionalDate, error) {
@@ -318,8 +323,14 @@ func (t changesetTranslator) updateStrings(value []string, field string) *models
return nil
}
// Trim whitespace from each string
trimmedValues := make([]string, len(value))
for i, v := range value {
trimmedValues[i] = strings.TrimSpace(v)
}
return &models.UpdateStrings{
Values: value,
Values: trimmedValues,
Mode: models.RelationshipUpdateModeSet,
}
}
@@ -329,8 +340,14 @@ func (t changesetTranslator) updateStringsBulk(value *BulkUpdateStrings, field s
return nil
}
// Trim whitespace from each string
trimmedValues := make([]string, len(value.Values))
for i, v := range value.Values {
trimmedValues[i] = strings.TrimSpace(v)
}
return &models.UpdateStrings{
Values: value.Values,
Values: trimmedValues,
Mode: value.Mode,
}
}
@@ -448,7 +465,7 @@ func groupsDescriptionsFromGroupInput(input []*GroupDescriptionInput) ([]models.
GroupID: gID,
}
if v.Description != nil {
ret[i].Description = *v.Description
ret[i].Description = strings.TrimSpace(*v.Description)
}
}

View File

@@ -0,0 +1,12 @@
package api
import "github.com/stashapp/stash/pkg/models"
func handleUpdateCustomFields(input models.CustomFieldsInput) models.CustomFieldsInput {
ret := input
// convert json.Numbers to int/float
ret.Full = convertMapJSONNumbers(ret.Full)
ret.Partial = convertMapJSONNumbers(ret.Partial)
return ret
}

View File

@@ -26,6 +26,7 @@ var imageBoxExts = []string{
".gif",
".svg",
".webp",
".avif",
}
func newImageBox(box fs.FS) (*imageBox, error) {

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

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

View File

@@ -143,6 +143,24 @@ func (r *studioResolver) MovieCount(ctx context.Context, obj *models.Studio, dep
return r.GroupCount(ctx, obj, depth)
}
func (r *studioResolver) OCounter(ctx context.Context, obj *models.Studio) (ret *int, err error) {
var res_scene int
var res_image int
var res int
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
res_scene, err = r.repository.Scene.OCountByStudioID(ctx, obj.ID)
if err != nil {
return err
}
res_image, err = r.repository.Image.OCountByStudioID(ctx, obj.ID)
return err
}); err != nil {
return nil, err
}
res = res_scene + res_image
return &res, nil
}
func (r *studioResolver) ParentStudio(ctx context.Context, obj *models.Studio) (ret *models.Studio, err error) {
if obj.ParentID == nil {
return nil, nil

View File

@@ -150,6 +150,15 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
c.SetString(config.BackupDirectoryPath, *input.BackupDirectoryPath)
}
existingDeleteTrashPath := c.GetDeleteTrashPath()
if input.DeleteTrashPath != nil && existingDeleteTrashPath != *input.DeleteTrashPath {
if err := validateDir(config.DeleteTrashPath, *input.DeleteTrashPath, true); err != nil {
return makeConfigGeneralResult(), err
}
c.SetString(config.DeleteTrashPath, *input.DeleteTrashPath)
}
existingGeneratedPath := c.GetGeneratedPath()
if input.GeneratedPath != nil && existingGeneratedPath != *input.GeneratedPath {
if err := validateDir(config.Generated, *input.GeneratedPath, false); err != nil {
@@ -484,6 +493,8 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI
r.setConfigString(config.ImageLightboxScrollModeKey, (*string)(options.ScrollMode))
r.setConfigInt(config.ImageLightboxScrollAttemptsBeforeChange, options.ScrollAttemptsBeforeChange)
r.setConfigBool(config.ImageLightboxDisableAnimation, options.DisableAnimation)
}
if input.CSS != nil {
@@ -510,6 +521,7 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI
r.setConfigBool(config.DisableDropdownCreateStudio, ddc.Studio)
r.setConfigBool(config.DisableDropdownCreateTag, ddc.Tag)
r.setConfigBool(config.DisableDropdownCreateMovie, ddc.Movie)
r.setConfigBool(config.DisableDropdownCreateGallery, ddc.Gallery)
}
r.setConfigString(config.HandyKey, input.HandyKey)

View File

@@ -149,7 +149,9 @@ func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret b
return false, fmt.Errorf("converting ids: %w", err)
}
fileDeleter := file.NewDeleter()
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
fileDeleter := file.NewDeleterWithTrash(trashPath)
destroyer := &file.ZipDestroyer{
FileDestroyer: r.repository.File,
FolderDestroyer: r.repository.Folder,

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"os"
"strconv"
"strings"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/file"
@@ -43,7 +44,7 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
// Populate a new gallery from the input
newGallery := models.NewGallery()
newGallery.Title = input.Title
newGallery.Title = strings.TrimSpace(input.Title)
newGallery.Code = translator.string(input.Code)
newGallery.Details = translator.string(input.Details)
newGallery.Photographer = translator.string(input.Photographer)
@@ -74,9 +75,9 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
}
if input.Urls != nil {
newGallery.URLs = models.NewRelatedStrings(input.Urls)
newGallery.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
} else if input.URL != nil {
newGallery.URLs = models.NewRelatedStrings([]string{*input.URL})
newGallery.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)})
}
// Start the transaction and save the gallery
@@ -333,10 +334,12 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall
return false, fmt.Errorf("converting ids: %w", err)
}
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
var galleries []*models.Gallery
var imgsDestroyed []*models.Image
fileDeleter := &image.FileDeleter{
Deleter: file.NewDeleter(),
Deleter: file.NewDeleterWithTrash(trashPath),
Paths: manager.GetInstance().Paths,
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strconv"
"strings"
"github.com/stashapp/stash/internal/static"
"github.com/stashapp/stash/pkg/group"
@@ -21,7 +22,7 @@ func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*mo
// Populate a new group from the input
newGroup := models.NewGroup()
newGroup.Name = input.Name
newGroup.Name = strings.TrimSpace(input.Name)
newGroup.Aliases = translator.string(input.Aliases)
newGroup.Duration = input.Duration
newGroup.Rating = input.Rating100
@@ -55,7 +56,7 @@ func groupFromGroupCreateInput(ctx context.Context, input GroupCreateInput) (*mo
}
if input.Urls != nil {
newGroup.URLs = models.NewRelatedStrings(input.Urls)
newGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
}
return &newGroup, nil

View File

@@ -308,9 +308,11 @@ func (r *mutationResolver) ImageDestroy(ctx context.Context, input models.ImageD
return false, fmt.Errorf("converting id: %w", err)
}
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
var i *models.Image
fileDeleter := &image.FileDeleter{
Deleter: file.NewDeleter(),
Deleter: file.NewDeleterWithTrash(trashPath),
Paths: manager.GetInstance().Paths,
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
@@ -348,9 +350,11 @@ func (r *mutationResolver) ImagesDestroy(ctx context.Context, input models.Image
return false, fmt.Errorf("converting ids: %w", err)
}
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
var images []*models.Image
fileDeleter := &image.FileDeleter{
Deleter: file.NewDeleter(),
Deleter: file.NewDeleterWithTrash(trashPath),
Paths: manager.GetInstance().Paths,
}
if err := r.withTxn(ctx, func(ctx context.Context) error {

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strconv"
"strings"
"github.com/stashapp/stash/internal/static"
"github.com/stashapp/stash/pkg/models"
@@ -32,7 +33,7 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp
// Populate a new group from the input
newGroup := models.NewGroup()
newGroup.Name = input.Name
newGroup.Name = strings.TrimSpace(input.Name)
newGroup.Aliases = translator.string(input.Aliases)
newGroup.Duration = input.Duration
newGroup.Rating = input.Rating100
@@ -56,9 +57,9 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp
}
if input.Urls != nil {
newGroup.URLs = models.NewRelatedStrings(input.Urls)
newGroup.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
} else if input.URL != nil {
newGroup.URLs = models.NewRelatedStrings([]string{*input.URL})
newGroup.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)})
}
// Process the base 64 encoded image string

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strconv"
"strings"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
@@ -37,9 +38,9 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
// Populate a new performer from the input
newPerformer := models.NewPerformer()
newPerformer.Name = input.Name
newPerformer.Name = strings.TrimSpace(input.Name)
newPerformer.Disambiguation = translator.string(input.Disambiguation)
newPerformer.Aliases = models.NewRelatedStrings(input.AliasList)
newPerformer.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.AliasList))
newPerformer.Gender = input.Gender
newPerformer.Ethnicity = translator.string(input.Ethnicity)
newPerformer.Country = translator.string(input.Country)
@@ -62,17 +63,17 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
newPerformer.URLs = models.NewRelatedStrings([]string{})
if input.URL != nil {
newPerformer.URLs.Add(*input.URL)
newPerformer.URLs.Add(strings.TrimSpace(*input.URL))
}
if input.Twitter != nil {
newPerformer.URLs.Add(utils.URLFromHandle(*input.Twitter, twitterURL))
newPerformer.URLs.Add(utils.URLFromHandle(strings.TrimSpace(*input.Twitter), twitterURL))
}
if input.Instagram != nil {
newPerformer.URLs.Add(utils.URLFromHandle(*input.Instagram, instagramURL))
newPerformer.URLs.Add(utils.URLFromHandle(strings.TrimSpace(*input.Instagram), instagramURL))
}
if input.Urls != nil {
newPerformer.URLs.Add(input.Urls...)
newPerformer.URLs.Add(stringslice.TrimSpace(input.Urls)...)
}
var err error
@@ -296,10 +297,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
return nil, fmt.Errorf("converting tag ids: %w", err)
}
updatedPerformer.CustomFields = input.CustomFields
// convert json.Numbers to int/float
updatedPerformer.CustomFields.Full = convertMapJSONNumbers(updatedPerformer.CustomFields.Full)
updatedPerformer.CustomFields.Partial = convertMapJSONNumbers(updatedPerformer.CustomFields.Partial)
updatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields)
var imageData []byte
imageIncluded := translator.hasField("image")
@@ -416,6 +414,10 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
return nil, fmt.Errorf("converting tag ids: %w", err)
}
if input.CustomFields != nil {
updatedPerformer.CustomFields = handleUpdateCustomFields(*input.CustomFields)
}
ret := []*models.Performer{}
// Start the transaction and save the performers

View File

@@ -32,7 +32,7 @@ func (r *mutationResolver) SaveFilter(ctx context.Context, input SaveFilterInput
f := models.SavedFilter{
Mode: input.Mode,
Name: input.Name,
Name: strings.TrimSpace(input.Name),
FindFilter: input.FindFilter,
ObjectFilter: input.ObjectFilter,
UIOptions: input.UIOptions,

View File

@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/stashapp/stash/internal/manager"
@@ -62,9 +63,9 @@ func (r *mutationResolver) SceneCreate(ctx context.Context, input models.SceneCr
}
if input.Urls != nil {
newScene.URLs = models.NewRelatedStrings(input.Urls)
newScene.URLs = models.NewRelatedStrings(stringslice.TrimSpace(input.Urls))
} else if input.URL != nil {
newScene.URLs = models.NewRelatedStrings([]string{*input.URL})
newScene.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)})
}
newScene.PerformerIDs, err = translator.relatedIds(input.PerformerIds)
@@ -428,10 +429,11 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
}
fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm()
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
var s *models.Scene
fileDeleter := &scene.FileDeleter{
Deleter: file.NewDeleter(),
Deleter: file.NewDeleterWithTrash(trashPath),
FileNamingAlgo: fileNamingAlgo,
Paths: manager.GetInstance().Paths,
}
@@ -482,9 +484,10 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
var scenes []*models.Scene
fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm()
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
fileDeleter := &scene.FileDeleter{
Deleter: file.NewDeleter(),
Deleter: file.NewDeleterWithTrash(trashPath),
FileNamingAlgo: fileNamingAlgo,
Paths: manager.GetInstance().Paths,
}
@@ -593,8 +596,9 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput
}
mgr := manager.GetInstance()
trashPath := mgr.Config.GetDeleteTrashPath()
fileDeleter := &scene.FileDeleter{
Deleter: file.NewDeleter(),
Deleter: file.NewDeleterWithTrash(trashPath),
FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(),
Paths: mgr.Paths,
}
@@ -650,7 +654,7 @@ func (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input SceneMar
// Populate a new scene marker from the input
newMarker := models.NewSceneMarker()
newMarker.Title = input.Title
newMarker.Title = strings.TrimSpace(input.Title)
newMarker.Seconds = input.Seconds
newMarker.PrimaryTagID = primaryTagID
newMarker.SceneID = sceneID
@@ -736,9 +740,10 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar
}
mgr := manager.GetInstance()
trashPath := mgr.Config.GetDeleteTrashPath()
fileDeleter := &scene.FileDeleter{
Deleter: file.NewDeleter(),
Deleter: file.NewDeleterWithTrash(trashPath),
FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(),
Paths: mgr.Paths,
}
@@ -949,9 +954,10 @@ func (r *mutationResolver) SceneMarkersDestroy(ctx context.Context, markerIDs []
var markers []*models.SceneMarker
fileNamingAlgo := manager.GetInstance().Config.GetVideoFileNamingAlgorithm()
trashPath := manager.GetInstance().Config.GetDeleteTrashPath()
fileDeleter := &scene.FileDeleter{
Deleter: file.NewDeleter(),
Deleter: file.NewDeleterWithTrash(trashPath),
FileNamingAlgo: fileNamingAlgo,
Paths: manager.GetInstance().Paths,
}

View File

@@ -39,7 +39,7 @@ func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input
}
func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) {
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint)
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck
if err != nil {
return "", err
}
@@ -49,7 +49,7 @@ func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input
}
func (r *mutationResolver) StashBoxBatchStudioTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) {
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint)
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck
if err != nil {
return "", err
}

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strconv"
"strings"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin/hook"
@@ -32,23 +33,23 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
// Populate a new studio from the input
newStudio := models.NewStudio()
newStudio.Name = input.Name
newStudio.Name = strings.TrimSpace(input.Name)
newStudio.Rating = input.Rating100
newStudio.Favorite = translator.bool(input.Favorite)
newStudio.Details = translator.string(input.Details)
newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
newStudio.Aliases = models.NewRelatedStrings(input.Aliases)
newStudio.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.Aliases))
newStudio.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs())
var err error
newStudio.URLs = models.NewRelatedStrings([]string{})
if input.URL != nil {
newStudio.URLs.Add(*input.URL)
newStudio.URLs.Add(strings.TrimSpace(*input.URL))
}
if input.Urls != nil {
newStudio.URLs.Add(input.Urls...)
newStudio.URLs.Add(stringslice.TrimSpace(input.Urls)...)
}
newStudio.ParentID, err = translator.intPtrFromString(input.ParentID)

View File

@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"strconv"
"strings"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
@@ -32,9 +33,9 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
// Populate a new tag from the input
newTag := models.NewTag()
newTag.Name = input.Name
newTag.Name = strings.TrimSpace(input.Name)
newTag.SortName = translator.string(input.SortName)
newTag.Aliases = models.NewRelatedStrings(input.Aliases)
newTag.Aliases = models.NewRelatedStrings(stringslice.TrimSpace(input.Aliases))
newTag.Favorite = translator.bool(input.Favorite)
newTag.Description = translator.string(input.Description)
newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)

View File

@@ -82,6 +82,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
Stashes: config.GetStashPaths(),
DatabasePath: config.GetDatabasePath(),
BackupDirectoryPath: config.GetBackupDirectoryPath(),
DeleteTrashPath: config.GetDeleteTrashPath(),
GeneratedPath: config.GetGeneratedPath(),
MetadataPath: config.GetMetadataPath(),
ConfigFilePath: config.GetConfigFile(),

View File

@@ -29,7 +29,7 @@ func (r *queryResolver) FindFile(ctx context.Context, id *string, path *string)
ret = files[0]
}
case path != nil:
ret, err = qb.FindByPath(ctx, *path)
ret, err = qb.FindByPath(ctx, *path, true)
if err == nil && ret == nil {
return errors.New("file not found")
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,7 +9,6 @@ import (
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *string) (*models.Scene, error) {
@@ -83,7 +82,7 @@ func (r *queryResolver) FindScenes(
filter *models.FindFilterType,
) (ret *FindScenesResultType, err error) {
if len(ids) > 0 {
sceneIDs, err = stringslice.StringSliceToIntSlice(ids)
sceneIDs, err = handleIDList(ids, "ids")
if err != nil {
return nil, err
}

View File

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

View File

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

View File

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

View File

@@ -350,7 +350,46 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S
return nil, nil
}
return nil, errors.New("stash_box_index must be set")
return nil, errors.New("stash_box_endpoint must be set")
}
func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Source, input ScrapeSingleTagInput) ([]*models.ScrapedTag, error) {
if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil {
b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint)
if err != nil {
return nil, err
}
client := r.newStashBoxClient(*b)
var ret []*models.ScrapedTag
out, err := client.QueryTag(ctx, *input.Query)
if err != nil {
return nil, err
} else if out != nil {
ret = append(ret, out...)
}
if len(ret) > 0 {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
for _, tag := range ret {
if err := match.ScrapedTag(ctx, r.repository.Tag, tag, b.Endpoint); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
}
return ret, nil
}
return nil, nil
}
return nil, errors.New("stash_box_endpoint must be set")
}
func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scraper.Source, input ScrapeSinglePerformerInput) ([]*models.ScrapedPerformer, error) {

View File

@@ -225,7 +225,7 @@ func createSceneFile(ctx context.Context, name string, folderStore models.Folder
}
func getOrCreateFolder(ctx context.Context, folderStore models.FolderFinderCreator, folderPath string) (*models.Folder, error) {
f, err := folderStore.FindByPath(ctx, folderPath)
f, err := folderStore.FindByPath(ctx, folderPath, true)
if err != nil {
return nil, fmt.Errorf("getting folder by path: %w", err)
}

View File

@@ -3,6 +3,7 @@
package desktop
import (
"runtime"
"strings"
"github.com/kermieisinthehouse/systray"
@@ -20,7 +21,12 @@ func startSystray(exit chan int, faviconProvider FaviconProvider) {
// system is started from a non-terminal method, e.g. double-clicking an icon.
c := config.GetInstance()
if c.GetShowOneTimeMovedNotification() {
SendNotification("Stash has moved!", "Stash now runs in your tray, instead of a terminal window.")
// Use platform-appropriate terminology
location := "tray"
if runtime.GOOS == "darwin" {
location = "menu bar"
}
SendNotification("Stash has moved!", "Stash now runs in your "+location+", instead of a terminal window.")
c.SetBool(config.ShowOneTimeMovedNotification, false)
if err := c.Write(); err != nil {
logger.Errorf("Error while writing configuration file: %v", err)

View File

@@ -27,7 +27,7 @@ import (
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
const defaultProtocolInfo = "http-get:*:video/mpeg:*,http-get:*:video/mp4:*,http-get:*:video/vnd.dlna.mpeg-tts:*,http-get:*:video/avi:*,http-get:*:video/x-matroska:*,http-get:*:video/x-ms-wmv:*,http-get:*:video/wtv:*,http-get:*:audio/mpeg:*,http-get:*:audio/mp3:*,http-get:*:audio/mp4:*,http-get:*:audio/x-ms-wma*,http-get:*:audio/wav:*,http-get:*:audio/L16:*,http-get:*image/jpeg:*,http-get:*image/png:*,http-get:*image/gif:*,http-get:*image/tiff:*"
const defaultProtocolInfo = "http-get:*:video/mpeg:*,http-get:*:video/mp4:*,http-get:*:video/vnd.dlna.mpeg-tts:*,http-get:*:video/avi:*,http-get:*:video/x-matroska:*,http-get:*:video/x-ms-wmv:*,http-get:*:video/wtv:*,http-get:*:audio/mpeg:*,http-get:*:audio/mp3:*,http-get:*:audio/mp4:*,http-get:*:audio/x-ms-wma*,http-get:*:audio/wav:*,http-get:*:audio/L16:*,http-get:*image/jpeg:*,http-get:*image/png:*,http-get:*image/gif:*,http-get:*image/tiff:*,http-get:*:image/avif:*"
type connectionManagerService struct {
*Server

View File

@@ -209,6 +209,7 @@ const (
ImageLightboxResetZoomOnNav = "image_lightbox.reset_zoom_on_nav"
ImageLightboxScrollModeKey = "image_lightbox.scroll_mode"
ImageLightboxScrollAttemptsBeforeChange = "image_lightbox.scroll_attempts_before_change"
ImageLightboxDisableAnimation = "image_lightbox.disable_animation"
UI = "ui"
@@ -218,6 +219,7 @@ const (
DisableDropdownCreateStudio = "disable_dropdown_create.studio"
DisableDropdownCreateTag = "disable_dropdown_create.tag"
DisableDropdownCreateMovie = "disable_dropdown_create.movie"
DisableDropdownCreateGallery = "disable_dropdown_create.gallery"
HandyKey = "handy_key"
FunscriptOffset = "funscript_offset"
@@ -272,6 +274,9 @@ const (
DeleteGeneratedDefault = "defaults.delete_generated"
deleteGeneratedDefaultDefault = true
// Trash/Recycle Bin options
DeleteTrashPath = "delete_trash_path"
// Desktop Integration Options
NoBrowser = "nobrowser"
NoBrowserDefault = false
@@ -290,7 +295,7 @@ const (
// slice default values
var (
defaultVideoExtensions = []string{"m4v", "mp4", "mov", "wmv", "avi", "mpg", "mpeg", "rmvb", "rm", "flv", "asf", "mkv", "webm", "f4v"}
defaultImageExtensions = []string{"png", "jpg", "jpeg", "gif", "webp"}
defaultImageExtensions = []string{"png", "jpg", "jpeg", "gif", "webp", "avif"}
defaultGalleryExtensions = []string{"zip", "cbz"}
defaultMenuItems = []string{"scenes", "images", "groups", "markers", "galleries", "performers", "studios", "tags"}
)
@@ -1293,6 +1298,10 @@ func (i *Config) GetImageLightboxOptions() ConfigImageLightboxResult {
if v := i.with(ImageLightboxScrollAttemptsBeforeChange); v != nil {
ret.ScrollAttemptsBeforeChange = v.Int(ImageLightboxScrollAttemptsBeforeChange)
}
if v := i.with(ImageLightboxDisableAnimation); v != nil {
value := v.Bool(ImageLightboxDisableAnimation)
ret.DisableAnimation = &value
}
return ret
}
@@ -1303,6 +1312,7 @@ func (i *Config) GetDisableDropdownCreate() *ConfigDisableDropdownCreate {
Studio: i.getBool(DisableDropdownCreateStudio),
Tag: i.getBool(DisableDropdownCreateTag),
Movie: i.getBool(DisableDropdownCreateMovie),
Gallery: i.getBool(DisableDropdownCreateGallery),
}
}
@@ -1469,6 +1479,14 @@ func (i *Config) GetDeleteGeneratedDefault() bool {
return i.getBoolDefault(DeleteGeneratedDefault, deleteGeneratedDefaultDefault)
}
func (i *Config) GetDeleteTrashPath() string {
return i.getString(DeleteTrashPath)
}
func (i *Config) SetDeleteTrashPath(value string) {
i.SetString(DeleteTrashPath, value)
}
// GetDefaultIdentifySettings returns the default Identify task settings.
// Returns nil if the settings could not be unmarshalled, or if it
// has not been set.

View File

@@ -13,6 +13,7 @@ type ConfigImageLightboxResult struct {
ResetZoomOnNav *bool `json:"resetZoomOnNav"`
ScrollMode *ImageLightboxScrollMode `json:"scrollMode"`
ScrollAttemptsBeforeChange int `json:"scrollAttemptsBeforeChange"`
DisableAnimation *bool `json:"disableAnimation"`
}
type ImageLightboxDisplayMode string
@@ -104,4 +105,5 @@ type ConfigDisableDropdownCreate struct {
Tag bool `json:"tag"`
Studio bool `json:"studio"`
Movie bool `json:"movie"`
Gallery bool `json:"gallery"`
}

View File

@@ -313,6 +313,7 @@ func (s *Manager) RefreshFFMpeg(ctx context.Context) {
s.FFMpeg = ffmpeg.NewEncoder(ffmpegPath)
s.FFProbe = ffmpeg.NewFFProbe(ffprobePath)
s.FFMpeg.InitHWSupport(ctx)
// initialise hardware support with background context
s.FFMpeg.InitHWSupport(context.Background())
}
}

View File

@@ -219,8 +219,11 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error {
// paths since they must not be relative. The config file property is
// resolved to an absolute path when stash is run normally, so convert
// relative paths to absolute paths during setup.
configFile, _ := filepath.Abs(input.ConfigLocation)
// #6287 - this should no longer be necessary since the ffmpeg code
// converts to absolute paths. Converting the config location to
// absolute means that scraper and plugin paths default to absolute
// which we don't want.
configFile := input.ConfigLocation
configDir := filepath.Dir(configFile)
if exists, _ := fsutil.DirExists(configDir); !exists {

View File

@@ -294,6 +294,7 @@ func (s *Manager) Clean(ctx context.Context, input CleanMetadataInput) int {
Handlers: []file.CleanHandler{
&cleanHandler{},
},
TrashPath: s.Config.GetDeleteTrashPath(),
}
j := cleanJob{
@@ -364,9 +365,37 @@ func (s *Manager) MigrateHash(ctx context.Context) int {
return s.JobManager.Add(ctx, "Migrating scene hashes...", j)
}
// If neither ids nor names are set, tag all items
// batchTagType indicates which batch tagging mode to use
type batchTagType int
const (
batchTagByIds batchTagType = iota
batchTagByNamesOrStashIds
batchTagAll
)
// getBatchTagType determines the batch tag mode based on the input
func (input StashBoxBatchTagInput) getBatchTagType(hasPerformerFields bool) batchTagType {
switch {
case len(input.Ids) > 0:
return batchTagByIds
case hasPerformerFields && len(input.PerformerIds) > 0:
return batchTagByIds
case len(input.StashIDs) > 0 || len(input.Names) > 0:
return batchTagByNamesOrStashIds
case hasPerformerFields && len(input.PerformerNames) > 0:
return batchTagByNamesOrStashIds
default:
return batchTagAll
}
}
// Accepts either ids, or a combination of names and stash_ids.
// If none are set, then all existing items will be tagged.
type StashBoxBatchTagInput struct {
// Stash endpoint to use for the tagging - deprecated - use StashBoxEndpoint
// Stash endpoint to use for the tagging
//
// Deprecated: use StashBoxEndpoint
Endpoint *int `json:"endpoint"`
StashBoxEndpoint *string `json:"stash_box_endpoint"`
// Fields to exclude when executing the tagging
@@ -375,128 +404,143 @@ type StashBoxBatchTagInput struct {
Refresh bool `json:"refresh"`
// If batch adding studios, should their parent studios also be created?
CreateParent bool `json:"createParent"`
// If set, only tag these ids
// IDs in stash of the items to update.
// If set, names and stash_ids fields will be ignored.
Ids []string `json:"ids"`
// If set, only tag these names
// Names of the items in the stash-box instance to search for and create
Names []string `json:"names"`
// If set, only tag these performer ids
// Stash IDs of the items in the stash-box instance to search for and create
StashIDs []string `json:"stash_ids"`
// IDs in stash of the performers to update
//
// Deprecated: please use Ids
// Deprecated: use Ids
PerformerIds []string `json:"performer_ids"`
// If set, only tag these performer names
// Names of the performers in the stash-box instance to search for and create
//
// Deprecated: please use Names
// Deprecated: use Names
PerformerNames []string `json:"performer_names"`
}
func (s *Manager) batchTagPerformersByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
var tasks []Task
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
performerQuery := s.Repository.Performer
ids := input.Ids
if len(ids) == 0 {
ids = input.PerformerIds //nolint:staticcheck
}
for _, performerID := range ids {
if id, err := strconv.Atoi(performerID); err == nil {
performer, err := performerQuery.Find(ctx, id)
if err != nil {
return err
}
if err := performer.LoadStashIDs(ctx, performerQuery); err != nil {
return fmt.Errorf("loading performer stash ids: %w", err)
}
hasStashID := performer.StashIDs.ForEndpoint(box.Endpoint) != nil
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
tasks = append(tasks, &stashBoxBatchPerformerTagTask{
performer: performer,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
}
return nil
})
return tasks, err
}
func (s *Manager) batchTagPerformersByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task {
var tasks []Task
for i := range input.StashIDs {
stashID := input.StashIDs[i]
if len(stashID) > 0 {
tasks = append(tasks, &stashBoxBatchPerformerTagTask{
stashID: &stashID,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
names := input.Names
if len(names) == 0 {
names = input.PerformerNames //nolint:staticcheck
}
for i := range names {
name := names[i]
if len(name) > 0 {
tasks = append(tasks, &stashBoxBatchPerformerTagTask{
name: &name,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
return tasks
}
func (s *Manager) batchTagAllPerformers(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
var tasks []Task
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
performerQuery := s.Repository.Performer
var performers []*models.Performer
var err error
performers, err = performerQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint)
if err != nil {
return fmt.Errorf("error querying performers: %v", err)
}
for _, performer := range performers {
if err := performer.LoadStashIDs(ctx, performerQuery); err != nil {
return fmt.Errorf("error loading stash ids for performer %s: %v", performer.Name, err)
}
tasks = append(tasks, &stashBoxBatchPerformerTagTask{
performer: performer,
box: box,
excludedFields: input.ExcludeFields,
})
}
return nil
})
return tasks, err
}
func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {
logger.Infof("Initiating stash-box batch performer tag")
var tasks []StashBoxBatchTagTask
var tasks []Task
var err error
// The gocritic linter wants to turn this ifElseChain into a switch.
// however, such a switch would contain quite large blocks for each section
// and would arguably be hard to read.
//
// This is why we mark this section nolint. In principle, we should look to
// rewrite the section at some point, to avoid the linter warning.
if len(input.Ids) > 0 || len(input.PerformerIds) > 0 { //nolint:gocritic
// The user has chosen only to tag the items on the current page
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
performerQuery := s.Repository.Performer
switch input.getBatchTagType(true) {
case batchTagByIds:
tasks, err = s.batchTagPerformersByIds(ctx, input, box)
case batchTagByNamesOrStashIds:
tasks = s.batchTagPerformersByNamesOrStashIds(input, box)
case batchTagAll:
tasks, err = s.batchTagAllPerformers(ctx, input, box)
}
idsToUse := input.PerformerIds
if len(input.Ids) > 0 {
idsToUse = input.Ids
}
for _, performerID := range idsToUse {
if id, err := strconv.Atoi(performerID); err == nil {
performer, err := performerQuery.Find(ctx, id)
if err == nil {
if err := performer.LoadStashIDs(ctx, performerQuery); err != nil {
return fmt.Errorf("loading performer stash ids: %w", err)
}
// Check if the user wants to refresh existing or new items
hasStashID := performer.StashIDs.ForEndpoint(box.Endpoint) != nil
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
tasks = append(tasks, StashBoxBatchTagTask{
performer: performer,
refresh: input.Refresh,
box: box,
excludedFields: input.ExcludeFields,
taskType: Performer,
})
}
} else {
return err
}
}
}
return nil
}); err != nil {
return err
}
} else if len(input.Names) > 0 || len(input.PerformerNames) > 0 {
// The user is batch adding performers
namesToUse := input.PerformerNames
if len(input.Names) > 0 {
namesToUse = input.Names
}
for i := range namesToUse {
name := namesToUse[i]
if len(name) > 0 {
tasks = append(tasks, StashBoxBatchTagTask{
name: &name,
refresh: false,
box: box,
excludedFields: input.ExcludeFields,
taskType: Performer,
})
}
}
} else { //nolint:gocritic
// The gocritic linter wants to fold this if-block into the else on the line above.
// However, this doesn't really help with readability of the current section. Mark it
// as nolint for now. In the future we'd like to rewrite this code by factoring some of
// this into separate functions.
// The user has chosen to tag every item in their database
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
performerQuery := s.Repository.Performer
var performers []*models.Performer
var err error
if input.Refresh {
performers, err = performerQuery.FindByStashIDStatus(ctx, true, box.Endpoint)
} else {
performers, err = performerQuery.FindByStashIDStatus(ctx, false, box.Endpoint)
}
if err != nil {
return fmt.Errorf("error querying performers: %v", err)
}
for _, performer := range performers {
if err := performer.LoadStashIDs(ctx, performerQuery); err != nil {
return fmt.Errorf("error loading stash ids for performer %s: %v", performer.Name, err)
}
tasks = append(tasks, StashBoxBatchTagTask{
performer: performer,
refresh: input.Refresh,
box: box,
excludedFields: input.ExcludeFields,
taskType: Performer,
})
}
return nil
}); err != nil {
return err
}
if err != nil {
return err
}
if len(tasks) == 0 {
@@ -508,7 +552,7 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, box *models.Sta
logger.Infof("Starting stash-box batch operation for %d performers", len(tasks))
for _, task := range tasks {
progress.ExecuteTask(task.Description(), func() {
progress.ExecuteTask(task.GetDescription(), func() {
task.Start(ctx)
})
@@ -521,103 +565,116 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, box *models.Sta
return s.JobManager.Add(ctx, "Batch stash-box performer tag...", j)
}
func (s *Manager) batchTagStudiosByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
var tasks []Task
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
studioQuery := s.Repository.Studio
for _, studioID := range input.Ids {
if id, err := strconv.Atoi(studioID); err == nil {
studio, err := studioQuery.Find(ctx, id)
if err != nil {
return err
}
if err := studio.LoadStashIDs(ctx, studioQuery); err != nil {
return fmt.Errorf("loading studio stash ids: %w", err)
}
hasStashID := studio.StashIDs.ForEndpoint(box.Endpoint) != nil
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
tasks = append(tasks, &stashBoxBatchStudioTagTask{
studio: studio,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
}
return nil
})
return tasks, err
}
func (s *Manager) batchTagStudiosByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task {
var tasks []Task
for i := range input.StashIDs {
stashID := input.StashIDs[i]
if len(stashID) > 0 {
tasks = append(tasks, &stashBoxBatchStudioTagTask{
stashID: &stashID,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
for i := range input.Names {
name := input.Names[i]
if len(name) > 0 {
tasks = append(tasks, &stashBoxBatchStudioTagTask{
name: &name,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
return tasks
}
func (s *Manager) batchTagAllStudios(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
var tasks []Task
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
studioQuery := s.Repository.Studio
var studios []*models.Studio
var err error
studios, err = studioQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint)
if err != nil {
return fmt.Errorf("error querying studios: %v", err)
}
for _, studio := range studios {
tasks = append(tasks, &stashBoxBatchStudioTagTask{
studio: studio,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
}
return nil
})
return tasks, err
}
func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {
logger.Infof("Initiating stash-box batch studio tag")
var tasks []StashBoxBatchTagTask
var tasks []Task
var err error
// The gocritic linter wants to turn this ifElseChain into a switch.
// however, such a switch would contain quite large blocks for each section
// and would arguably be hard to read.
//
// This is why we mark this section nolint. In principle, we should look to
// rewrite the section at some point, to avoid the linter warning.
if len(input.Ids) > 0 { //nolint:gocritic
// The user has chosen only to tag the items on the current page
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
studioQuery := s.Repository.Studio
switch input.getBatchTagType(false) {
case batchTagByIds:
tasks, err = s.batchTagStudiosByIds(ctx, input, box)
case batchTagByNamesOrStashIds:
tasks = s.batchTagStudiosByNamesOrStashIds(input, box)
case batchTagAll:
tasks, err = s.batchTagAllStudios(ctx, input, box)
}
for _, studioID := range input.Ids {
if id, err := strconv.Atoi(studioID); err == nil {
studio, err := studioQuery.Find(ctx, id)
if err == nil {
if err := studio.LoadStashIDs(ctx, studioQuery); err != nil {
return fmt.Errorf("loading studio stash ids: %w", err)
}
// Check if the user wants to refresh existing or new items
hasStashID := studio.StashIDs.ForEndpoint(box.Endpoint) != nil
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
tasks = append(tasks, StashBoxBatchTagTask{
studio: studio,
refresh: input.Refresh,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
taskType: Studio,
})
}
} else {
return err
}
}
}
return nil
}); err != nil {
logger.Error(err.Error())
}
} else if len(input.Names) > 0 {
// The user is batch adding studios
for i := range input.Names {
name := input.Names[i]
if len(name) > 0 {
tasks = append(tasks, StashBoxBatchTagTask{
name: &name,
refresh: false,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
taskType: Studio,
})
}
}
} else { //nolint:gocritic
// The gocritic linter wants to fold this if-block into the else on the line above.
// However, this doesn't really help with readability of the current section. Mark it
// as nolint for now. In the future we'd like to rewrite this code by factoring some of
// this into separate functions.
// The user has chosen to tag every item in their database
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
studioQuery := s.Repository.Studio
var studios []*models.Studio
var err error
if input.Refresh {
studios, err = studioQuery.FindByStashIDStatus(ctx, true, box.Endpoint)
} else {
studios, err = studioQuery.FindByStashIDStatus(ctx, false, box.Endpoint)
}
if err != nil {
return fmt.Errorf("error querying studios: %v", err)
}
for _, studio := range studios {
tasks = append(tasks, StashBoxBatchTagTask{
studio: studio,
refresh: input.Refresh,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
taskType: Studio,
})
}
return nil
}); err != nil {
return err
}
if err != nil {
return err
}
if len(tasks) == 0 {
@@ -629,7 +686,7 @@ func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, box *models.StashB
logger.Infof("Starting stash-box batch operation for %d studios", len(tasks))
for _, task := range tasks {
progress.ExecuteTask(task.Description(), func() {
progress.ExecuteTask(task.GetDescription(), func() {
task.Start(ctx)
})

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"os/exec"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/image"
@@ -20,6 +21,13 @@ func (t *GenerateImageThumbnailTask) GetDescription() string {
return fmt.Sprintf("Generating Thumbnail for image %s", t.Image.Path)
}
func (t *GenerateImageThumbnailTask) logStderr(err error) {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
logger.Debugf("[generator] error output: %s", exitErr.Stderr)
}
}
func (t *GenerateImageThumbnailTask) Start(ctx context.Context) {
if !t.required() {
return
@@ -46,14 +54,15 @@ func (t *GenerateImageThumbnailTask) Start(ctx context.Context) {
if err != nil {
// don't log for animated images
if !errors.Is(err, image.ErrNotSupportedForThumbnail) {
logger.Errorf("[generator] getting thumbnail for image %s: %w", path, err)
logger.Errorf("[generator] getting thumbnail for image %s: %s", path, err.Error())
t.logStderr(err)
}
return
}
err = fsutil.WriteFile(thumbPath, data)
if err != nil {
logger.Errorf("[generator] writing thumbnail for image %s: %w", path, err)
logger.Errorf("[generator] writing thumbnail for image %s: %s", path, err.Error())
return
}
}

View File

@@ -14,57 +14,33 @@ import (
"github.com/stashapp/stash/pkg/studio"
)
type StashBoxTagTaskType int
const (
Performer StashBoxTagTaskType = iota
Studio
)
type StashBoxBatchTagTask struct {
// stashBoxBatchPerformerTagTask is used to tag or create performers from stash-box.
//
// Two modes of operation:
// - Update existing performer: set performer to update from stash-box data
// - Create new performer: set name or stashID to search stash-box and create locally
type stashBoxBatchPerformerTagTask struct {
box *models.StashBox
name *string
stashID *string
performer *models.Performer
studio *models.Studio
refresh bool
createParent bool
excludedFields []string
taskType StashBoxTagTaskType
}
func (t *StashBoxBatchTagTask) Start(ctx context.Context) {
switch t.taskType {
case Performer:
t.stashBoxPerformerTag(ctx)
case Studio:
t.stashBoxStudioTag(ctx)
func (t *stashBoxBatchPerformerTagTask) getName() string {
switch {
case t.name != nil:
return *t.name
case t.stashID != nil:
return *t.stashID
case t.performer != nil:
return t.performer.Name
default:
logger.Errorf("Error starting batch task, unknown task_type %d", t.taskType)
return ""
}
}
func (t *StashBoxBatchTagTask) Description() string {
if t.taskType == Performer {
var name string
if t.name != nil {
name = *t.name
} else {
name = t.performer.Name
}
return fmt.Sprintf("Tagging performer %s from stash-box", name)
} else if t.taskType == Studio {
var name string
if t.name != nil {
name = *t.name
} else {
name = t.studio.Name
}
return fmt.Sprintf("Tagging studio %s from stash-box", name)
}
return fmt.Sprintf("Unknown tagging task type %d from stash-box", t.taskType)
}
func (t *StashBoxBatchTagTask) stashBoxPerformerTag(ctx context.Context) {
func (t *stashBoxBatchPerformerTagTask) Start(ctx context.Context) {
performer, err := t.findStashBoxPerformer(ctx)
if err != nil {
logger.Errorf("Error fetching performer data from stash-box: %v", err)
@@ -76,21 +52,18 @@ func (t *StashBoxBatchTagTask) stashBoxPerformerTag(ctx context.Context) {
excluded[field] = true
}
// performer will have a value if pulling from Stash-box by Stash ID or name was successful
if performer != nil {
t.processMatchedPerformer(ctx, performer, excluded)
} else {
var name string
if t.name != nil {
name = *t.name
} else if t.performer != nil {
name = t.performer.Name
}
logger.Infof("No match found for %s", name)
logger.Infof("No match found for %s", t.getName())
}
}
func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*models.ScrapedPerformer, error) {
func (t *stashBoxBatchPerformerTagTask) GetDescription() string {
return fmt.Sprintf("Tagging performer %s from stash-box", t.getName())
}
func (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Context) (*models.ScrapedPerformer, error) {
var performer *models.ScrapedPerformer
var err error
@@ -98,7 +71,24 @@ func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*mode
client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))
if t.refresh {
switch {
case t.name != nil:
performer, err = client.FindPerformerByName(ctx, *t.name)
case t.stashID != nil:
performer, err = client.FindPerformerByID(ctx, *t.stashID)
if performer != nil && performer.RemoteMergedIntoId != nil {
mergedPerformer, err := t.handleMergedPerformer(ctx, performer, client)
if err != nil {
return nil, err
}
if mergedPerformer != nil {
logger.Infof("Performer id %s merged into %s, updating local performer", *t.stashID, *performer.RemoteMergedIntoId)
performer = mergedPerformer
}
}
case t.performer != nil: // tagging or updating existing performer
var remoteID string
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
qb := r.Performer
@@ -118,6 +108,7 @@ func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*mode
}); err != nil {
return nil, err
}
if remoteID != "" {
performer, err = client.FindPerformerByID(ctx, remoteID)
@@ -132,15 +123,10 @@ func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*mode
performer = mergedPerformer
}
}
}
} else {
var name string
if t.name != nil {
name = *t.name
} else {
name = t.performer.Name
// find by performer name instead
performer, err = client.FindPerformerByName(ctx, t.performer.Name)
}
performer, err = client.FindPerformerByName(ctx, name)
}
if performer != nil {
@@ -154,7 +140,7 @@ func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*mode
return performer, err
}
func (t *StashBoxBatchTagTask) handleMergedPerformer(ctx context.Context, performer *models.ScrapedPerformer, client *stashbox.Client) (mergedPerformer *models.ScrapedPerformer, err error) {
func (t *stashBoxBatchPerformerTagTask) handleMergedPerformer(ctx context.Context, performer *models.ScrapedPerformer, client *stashbox.Client) (mergedPerformer *models.ScrapedPerformer, err error) {
mergedPerformer, err = client.FindPerformerByID(ctx, *performer.RemoteMergedIntoId)
if err != nil {
return nil, fmt.Errorf("loading merged performer %s from stashbox", *performer.RemoteMergedIntoId)
@@ -169,8 +155,7 @@ func (t *StashBoxBatchTagTask) handleMergedPerformer(ctx context.Context, perfor
return mergedPerformer, nil
}
func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool) {
// Refreshing an existing performer
func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool) {
if t.performer != nil {
storedID, _ := strconv.Atoi(*p.StoredID)
@@ -180,7 +165,6 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m
return
}
// Start the transaction and update the performer
r := instance.Repository
err = r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Performer
@@ -226,8 +210,8 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m
} else {
logger.Infof("Updated performer %s", *p.Name)
}
} else if t.name != nil && p.Name != nil {
// Creating a new performer
} else {
// no existing performer, create a new one
newPerformer := p.ToPerformer(t.box.Endpoint, excluded)
image, err := p.GetImage(ctx, excluded)
if err != nil {
@@ -263,7 +247,34 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m
}
}
func (t *StashBoxBatchTagTask) stashBoxStudioTag(ctx context.Context) {
// stashBoxBatchStudioTagTask is used to tag or create studios from stash-box.
//
// Two modes of operation:
// - Update existing studio: set studio to update from stash-box data
// - Create new studio: set name or stashID to search stash-box and create locally
type stashBoxBatchStudioTagTask struct {
box *models.StashBox
name *string
stashID *string
studio *models.Studio
createParent bool
excludedFields []string
}
func (t *stashBoxBatchStudioTagTask) getName() string {
switch {
case t.name != nil:
return *t.name
case t.stashID != nil:
return *t.stashID
case t.studio != nil:
return t.studio.Name
default:
return ""
}
}
func (t *stashBoxBatchStudioTagTask) Start(ctx context.Context) {
studio, err := t.findStashBoxStudio(ctx)
if err != nil {
logger.Errorf("Error fetching studio data from stash-box: %v", err)
@@ -275,21 +286,18 @@ func (t *StashBoxBatchTagTask) stashBoxStudioTag(ctx context.Context) {
excluded[field] = true
}
// studio will have a value if pulling from Stash-box by Stash ID or name was successful
if studio != nil {
t.processMatchedStudio(ctx, studio, excluded)
} else {
var name string
if t.name != nil {
name = *t.name
} else if t.studio != nil {
name = t.studio.Name
}
logger.Infof("No match found for %s", name)
logger.Infof("No match found for %s", t.getName())
}
}
func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.ScrapedStudio, error) {
func (t *stashBoxBatchStudioTagTask) GetDescription() string {
return fmt.Sprintf("Tagging studio %s from stash-box", t.getName())
}
func (t *stashBoxBatchStudioTagTask) findStashBoxStudio(ctx context.Context) (*models.ScrapedStudio, error) {
var studio *models.ScrapedStudio
var err error
@@ -297,7 +305,12 @@ func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.
client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))
if t.refresh {
switch {
case t.name != nil:
studio, err = client.FindStudio(ctx, *t.name)
case t.stashID != nil:
studio, err = client.FindStudio(ctx, *t.stashID)
case t.studio != nil:
var remoteID string
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
if !t.studio.StashIDs.Loaded() {
@@ -315,17 +328,13 @@ func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.
}); err != nil {
return nil, err
}
if remoteID != "" {
studio, err = client.FindStudio(ctx, remoteID)
}
} else {
var name string
if t.name != nil {
name = *t.name
} else {
name = t.studio.Name
// find by studio name instead
studio, err = client.FindStudio(ctx, t.studio.Name)
}
studio, err = client.FindStudio(ctx, name)
}
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
@@ -343,8 +352,7 @@ func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.
return studio, err
}
func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *models.ScrapedStudio, excluded map[string]bool) {
// Refreshing an existing studio
func (t *stashBoxBatchStudioTagTask) processMatchedStudio(ctx context.Context, s *models.ScrapedStudio, excluded map[string]bool) {
if t.studio != nil {
storedID, _ := strconv.Atoi(*s.StoredID)
@@ -361,7 +369,6 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
return
}
// Start the transaction and update the studio
r := instance.Repository
err = r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Studio
@@ -394,8 +401,8 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
} else {
logger.Infof("Updated studio %s", s.Name)
}
} else if t.name != nil && s.Name != "" {
// Creating a new studio
} else if s.Name != "" {
// no existing studio, create a new one
if s.Parent != nil && t.createParent {
err := t.processParentStudio(ctx, s.Parent, excluded)
if err != nil {
@@ -410,7 +417,6 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
return
}
// Start the transaction and save the studio
r := instance.Repository
err = r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Studio
@@ -439,9 +445,8 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
}
}
func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *models.ScrapedStudio, excluded map[string]bool) error {
func (t *stashBoxBatchStudioTagTask) processParentStudio(ctx context.Context, parent *models.ScrapedStudio, excluded map[string]bool) error {
if parent.StoredID == nil {
// The parent needs to be created
newParentStudio := parent.ToStudio(t.box.Endpoint, excluded)
image, err := parent.GetImage(ctx, excluded)
@@ -450,7 +455,6 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *
return err
}
// Start the transaction and save the studio
r := instance.Repository
err = r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Studio
@@ -476,7 +480,6 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *
}
return err
} else {
// The parent studio matched an existing one and the user has chosen in the UI to link and/or update it
storedID, _ := strconv.Atoi(*parent.StoredID)
image, err := parent.GetImage(ctx, excluded)
@@ -485,7 +488,6 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *
return err
}
// Start the transaction and update the studio
r := instance.Repository
err = r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Studio

View File

@@ -36,6 +36,32 @@ const minHeight int = 480
// Tests all (given) hardware codec's
func (f *FFMpeg) InitHWSupport(ctx context.Context) {
// do the hardware codec tests in a separate goroutine to avoid blocking
done := make(chan struct{})
go func() {
f.initHWSupport(ctx)
close(done)
}()
// log if the initialization takes too long
const hwInitLogTimeoutSecondsDefault = 5
hwInitLogTimeoutSeconds := hwInitLogTimeoutSecondsDefault * time.Second
timer := time.NewTimer(hwInitLogTimeoutSeconds)
go func() {
select {
case <-timer.C:
logger.Warnf("[InitHWSupport] Hardware codec initialization is taking longer than %s...", hwInitLogTimeoutSeconds)
logger.Info("[InitHWSupport] Hardware encoding will not be available until initialization is complete.")
case <-done:
if !timer.Stop() {
<-timer.C
}
}
}()
}
func (f *FFMpeg) initHWSupport(ctx context.Context) {
var hwCodecSupport []VideoCodec
// Note that the first compatible codec is returned, so order is important
@@ -69,7 +95,7 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
args = args.Output("-")
// #6064 - add timeout to context to prevent hangs
const hwTestTimeoutSecondsDefault = 1
const hwTestTimeoutSecondsDefault = 10
hwTestTimeoutSeconds := hwTestTimeoutSecondsDefault * time.Second
// allow timeout to be overridden with environment variable
@@ -83,6 +109,7 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
defer cancel()
cmd := f.Command(testCtx, args)
cmd.WaitDelay = time.Second
logger.Tracef("[InitHWSupport] Testing codec %s: %v", codec, cmd.Args)
var stderr bytes.Buffer
@@ -90,7 +117,7 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
if err := cmd.Run(); err != nil {
if testCtx.Err() != nil {
logger.Debugf("[InitHWSupport] Codec %s test timed out after %d seconds", codec, hwTestTimeoutSeconds)
logger.Debugf("[InitHWSupport] Codec %s test timed out after %s", codec, hwTestTimeoutSeconds)
continue
}
@@ -112,6 +139,8 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
}
logger.Info(outstr)
f.hwCodecSupportMutex.Lock()
defer f.hwCodecSupportMutex.Unlock()
f.hwCodecSupport = hwCodecSupport
}
@@ -411,7 +440,7 @@ func (f *FFMpeg) hwMaxResFilter(toCodec VideoCodec, vf *models.VideoFile, reqHei
// Return if a hardware accelerated for HLS is available
func (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec {
for _, element := range f.hwCodecSupport {
for _, element := range f.getHWCodecSupport() {
switch element {
case VideoCodecN264,
VideoCodecN264H,
@@ -429,7 +458,7 @@ func (f *FFMpeg) hwCodecHLSCompatible() *VideoCodec {
// Return if a hardware accelerated codec for MP4 is available
func (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec {
for _, element := range f.hwCodecSupport {
for _, element := range f.getHWCodecSupport() {
switch element {
case VideoCodecN264,
VideoCodecN264H,
@@ -445,7 +474,7 @@ func (f *FFMpeg) hwCodecMP4Compatible() *VideoCodec {
// Return if a hardware accelerated codec for WebM is available
func (f *FFMpeg) hwCodecWEBMCompatible() *VideoCodec {
for _, element := range f.hwCodecSupport {
for _, element := range f.getHWCodecSupport() {
switch element {
case VideoCodecIVP9,
VideoCodecVVP9:

View File

@@ -10,6 +10,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
stashExec "github.com/stashapp/stash/pkg/exec"
"github.com/stashapp/stash/pkg/fsutil"
@@ -216,9 +217,10 @@ func (v Version) String() string {
// FFMpeg provides an interface to ffmpeg.
type FFMpeg struct {
ffmpeg string
version Version
hwCodecSupport []VideoCodec
ffmpeg string
version Version
hwCodecSupport []VideoCodec
hwCodecSupportMutex sync.RWMutex
}
// Creates a new FFMpeg encoder
@@ -241,3 +243,9 @@ func (f *FFMpeg) Command(ctx context.Context, args []string) *exec.Cmd {
func (f *FFMpeg) Path() string {
return f.ffmpeg
}
func (f *FFMpeg) getHWCodecSupport() []VideoCodec {
f.hwCodecSupportMutex.RLock()
defer f.hwCodecSupportMutex.RUnlock()
return f.hwCodecSupport
}

View File

@@ -18,7 +18,8 @@ type Cleaner struct {
FS models.FS
Repository Repository
Handlers []CleanHandler
Handlers []CleanHandler
TrashPath string
}
type cleanJob struct {
@@ -392,7 +393,7 @@ func (j *cleanJob) shouldCleanFolder(ctx context.Context, f *models.Folder) bool
func (j *cleanJob) deleteFile(ctx context.Context, fileID models.FileID, fn string) {
// delete associated objects
fileDeleter := NewDeleter()
fileDeleter := NewDeleterWithTrash(j.TrashPath)
r := j.Repository
if err := r.WithTxn(ctx, func(ctx context.Context) error {
fileDeleter.RegisterHooks(ctx)
@@ -410,7 +411,7 @@ func (j *cleanJob) deleteFile(ctx context.Context, fileID models.FileID, fn stri
func (j *cleanJob) deleteFolder(ctx context.Context, folderID models.FolderID, fn string) {
// delete associated objects
fileDeleter := NewDeleter()
fileDeleter := NewDeleterWithTrash(j.TrashPath)
r := j.Repository
if err := r.WithTxn(ctx, func(ctx context.Context) error {
fileDeleter.RegisterHooks(ctx)

View File

@@ -58,20 +58,33 @@ func newRenamerRemoverImpl() renamerRemoverImpl {
// Deleter is used to safely delete files and directories from the filesystem.
// During a transaction, files and directories are marked for deletion using
// the Files and Dirs methods. This will rename the files/directories to be
// deleted. If the transaction is rolled back, then the files/directories can
// be restored to their original state with the Abort method. If the
// transaction is committed, the marked files are then deleted from the
// filesystem using the Complete method.
// the Files and Dirs methods. If TrashPath is set, files are moved to trash
// immediately. Otherwise, they are renamed with a .delete suffix. If the
// transaction is rolled back, then the files/directories can be restored to
// their original state with the Rollback method. If the transaction is
// committed, the marked files are then deleted from the filesystem using the
// Commit method.
type Deleter struct {
RenamerRemover RenamerRemover
files []string
dirs []string
TrashPath string // if set, files will be moved to this directory instead of being permanently deleted
trashedPaths map[string]string // map of original path -> trash path (only used when TrashPath is set)
}
func NewDeleter() *Deleter {
return &Deleter{
RenamerRemover: newRenamerRemoverImpl(),
TrashPath: "",
trashedPaths: make(map[string]string),
}
}
func NewDeleterWithTrash(trashPath string) *Deleter {
return &Deleter{
RenamerRemover: newRenamerRemoverImpl(),
TrashPath: trashPath,
trashedPaths: make(map[string]string),
}
}
@@ -92,6 +105,17 @@ func (d *Deleter) RegisterHooks(ctx context.Context) {
// Abort should be called to restore marked files if this function returns an
// error.
func (d *Deleter) Files(paths []string) error {
return d.filesInternal(paths, false)
}
// FilesWithoutTrash designates files to be deleted, bypassing the trash directory.
// Files will be permanently deleted even if TrashPath is configured.
// This is useful for deleting generated files that can be easily recreated.
func (d *Deleter) FilesWithoutTrash(paths []string) error {
return d.filesInternal(paths, true)
}
func (d *Deleter) filesInternal(paths []string, bypassTrash bool) error {
for _, p := range paths {
// fail silently if the file does not exist
if _, err := d.RenamerRemover.Stat(p); err != nil {
@@ -103,7 +127,7 @@ func (d *Deleter) Files(paths []string) error {
return fmt.Errorf("check file %q exists: %w", p, err)
}
if err := d.renameForDelete(p); err != nil {
if err := d.renameForDelete(p, bypassTrash); err != nil {
return fmt.Errorf("marking file %q for deletion: %w", p, err)
}
d.files = append(d.files, p)
@@ -118,6 +142,17 @@ func (d *Deleter) Files(paths []string) error {
// Abort should be called to restore marked files/directories if this function returns an
// error.
func (d *Deleter) Dirs(paths []string) error {
return d.dirsInternal(paths, false)
}
// DirsWithoutTrash designates directories to be deleted, bypassing the trash directory.
// Directories will be permanently deleted even if TrashPath is configured.
// This is useful for deleting generated directories that can be easily recreated.
func (d *Deleter) DirsWithoutTrash(paths []string) error {
return d.dirsInternal(paths, true)
}
func (d *Deleter) dirsInternal(paths []string, bypassTrash bool) error {
for _, p := range paths {
// fail silently if the file does not exist
if _, err := d.RenamerRemover.Stat(p); err != nil {
@@ -129,7 +164,7 @@ func (d *Deleter) Dirs(paths []string) error {
return fmt.Errorf("check directory %q exists: %w", p, err)
}
if err := d.renameForDelete(p); err != nil {
if err := d.renameForDelete(p, bypassTrash); err != nil {
return fmt.Errorf("marking directory %q for deletion: %w", p, err)
}
d.dirs = append(d.dirs, p)
@@ -150,33 +185,65 @@ func (d *Deleter) Rollback() {
d.files = nil
d.dirs = nil
d.trashedPaths = make(map[string]string)
}
// Commit deletes all files marked for deletion and clears the marked list.
// When using trash, files have already been moved during renameForDelete, so
// this just clears the tracking. Otherwise, permanently delete the .delete files.
// Any errors encountered are logged. All files will be attempted, regardless
// of the errors encountered.
func (d *Deleter) Commit() {
for _, f := range d.files {
if err := d.RenamerRemover.Remove(f + deleteFileSuffix); err != nil {
logger.Warnf("Error deleting file %q: %v", f+deleteFileSuffix, err)
if d.TrashPath != "" {
// Files were already moved to trash during renameForDelete, just clear tracking
logger.Debugf("Commit: %d files and %d directories already in trash, clearing tracking", len(d.files), len(d.dirs))
} else {
// Permanently delete files and directories marked with .delete suffix
for _, f := range d.files {
if err := d.RenamerRemover.Remove(f + deleteFileSuffix); err != nil {
logger.Warnf("Error deleting file %q: %v", f+deleteFileSuffix, err)
}
}
}
for _, f := range d.dirs {
if err := d.RenamerRemover.RemoveAll(f + deleteFileSuffix); err != nil {
logger.Warnf("Error deleting directory %q: %v", f+deleteFileSuffix, err)
for _, f := range d.dirs {
if err := d.RenamerRemover.RemoveAll(f + deleteFileSuffix); err != nil {
logger.Warnf("Error deleting directory %q: %v", f+deleteFileSuffix, err)
}
}
}
d.files = nil
d.dirs = nil
d.trashedPaths = make(map[string]string)
}
func (d *Deleter) renameForDelete(path string) error {
func (d *Deleter) renameForDelete(path string, bypassTrash bool) error {
if d.TrashPath != "" && !bypassTrash {
// Move file to trash immediately
trashDest, err := fsutil.MoveToTrash(path, d.TrashPath)
if err != nil {
return err
}
d.trashedPaths[path] = trashDest
logger.Infof("Moved %q to trash at %s", path, trashDest)
return nil
}
// Standard behavior: rename with .delete suffix (or when bypassing trash)
return d.RenamerRemover.Rename(path, path+deleteFileSuffix)
}
func (d *Deleter) renameForRestore(path string) error {
if d.TrashPath != "" {
// Restore file from trash
trashPath, ok := d.trashedPaths[path]
if !ok {
return fmt.Errorf("no trash path found for %q", path)
}
return d.RenamerRemover.Rename(trashPath, path)
}
// Standard behavior: restore from .delete suffix
return d.RenamerRemover.Rename(path+deleteFileSuffix, path)
}

View File

@@ -15,7 +15,9 @@ import (
// Does not create any folders in the file system
func GetOrCreateFolderHierarchy(ctx context.Context, fc models.FolderFinderCreator, path string) (*models.Folder, error) {
// get or create folder hierarchy
folder, err := fc.FindByPath(ctx, path)
// assume case sensitive when searching for the folder
const caseSensitive = true
folder, err := fc.FindByPath(ctx, path, caseSensitive)
if err != nil {
return nil, err
}

View File

@@ -2,8 +2,11 @@ package image
import (
"context"
"errors"
"fmt"
"image"
"path/filepath"
"strings"
_ "image/gif"
_ "image/jpeg"
@@ -17,6 +20,8 @@ import (
_ "golang.org/x/image/webp"
)
var ErrUnsupportedAVIFInZip = errors.New("AVIF images in zip files is unsupported")
// Decorator adds image specific fields to a File.
type Decorator struct {
FFProbe *ffmpeg.FFProbe
@@ -28,6 +33,10 @@ func (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) (
// ignore clips in non-OsFS filesystems as ffprobe cannot read them
// TODO - copy to temp file if not an OsFS
if _, isOs := fs.(*file.OsFS); !isOs {
// AVIF images inside zip files are not supported
if strings.ToLower(filepath.Ext(base.Path)) == ".avif" {
return nil, fmt.Errorf("%w: %s", ErrUnsupportedAVIFInZip, base.Path)
}
logger.Debugf("assuming ImageFile for non-OsFS file %q", base.Path)
return decorateFallback(fs, f)
}
@@ -67,6 +76,25 @@ func (d *Decorator) Decorate(ctx context.Context, fs models.FS, f models.File) (
Height: probe.Height,
}
// FFprobe has a known bug where it returns 0x0 dimensions for some animated WebP files
// Fall back to image.DecodeConfig in this case.
// See: https://trac.ffmpeg.org/ticket/4907
if ret.Width == 0 || ret.Height == 0 {
logger.Warnf("FFprobe returned invalid dimensions (%dx%d) for %q, trying fallback decoder", ret.Width, ret.Height, base.Path)
c, format, err := decodeConfig(fs, base.Path)
if err != nil {
logger.Warnf("Fallback decoder failed for %q: %s. Proceeding with original FFprobe result", base.Path, err)
} else {
ret.Width = c.Width
ret.Height = c.Height
// Update format if it differs (fallback decoder may be more accurate)
if format != "" && format != ret.Format {
logger.Debugf("Updating format from %q to %q for %q", ret.Format, format, base.Path)
ret.Format = format
}
}
}
adjustForOrientation(fs, base.Path, ret)
return ret, nil

View File

@@ -120,7 +120,7 @@ func (i *Importer) baseFileJSONToBaseFile(ctx context.Context, baseJSON *jsonsch
func (i *Importer) populateZipFileID(ctx context.Context, f *models.DirEntry) error {
zipFilePath := i.Input.DirEntry().ZipFile
if zipFilePath != "" {
zf, err := i.ReaderWriter.FindByPath(ctx, zipFilePath)
zf, err := i.ReaderWriter.FindByPath(ctx, zipFilePath, true)
if err != nil {
return fmt.Errorf("error finding file by path %q: %v", zipFilePath, err)
}
@@ -146,7 +146,7 @@ func (i *Importer) Name() string {
func (i *Importer) FindExistingID(ctx context.Context) (*int, error) {
path := i.Input.DirEntry().Path
existing, err := i.ReaderWriter.FindByPath(ctx, path)
existing, err := i.ReaderWriter.FindByPath(ctx, path, true)
if err != nil {
return nil, err
}
@@ -176,7 +176,7 @@ func (i *Importer) createFolderHierarchy(ctx context.Context, p string) (*models
}
func (i *Importer) getOrCreateFolder(ctx context.Context, path string, parent *models.Folder) (*models.Folder, error) {
folder, err := i.FolderStore.FindByPath(ctx, path)
folder, err := i.FolderStore.FindByPath(ctx, path, true)
if err != nil {
return nil, err
}

View File

@@ -15,7 +15,6 @@ import (
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/txn"
"github.com/stashapp/stash/pkg/utils"
)
const (
@@ -443,7 +442,10 @@ func (s *scanJob) getFolderID(ctx context.Context, path string) (*models.FolderI
return &v, nil
}
ret, err := s.Repository.Folder.FindByPath(ctx, path)
// assume case sensitive when searching for the folder
const caseSensitive = true
ret, err := s.Repository.Folder.FindByPath(ctx, path, caseSensitive)
if err != nil {
return nil, err
}
@@ -473,7 +475,10 @@ func (s *scanJob) getZipFileID(ctx context.Context, zipFile *scanFile) (*models.
return &v, nil
}
ret, err := s.Repository.File.FindByPath(ctx, path)
// assume case sensitive when searching for the zip file
const caseSensitive = true
ret, err := s.Repository.File.FindByPath(ctx, path, caseSensitive)
if err != nil {
return nil, fmt.Errorf("getting zip file ID for %q: %w", path, err)
}
@@ -493,11 +498,26 @@ func (s *scanJob) handleFolder(ctx context.Context, file scanFile) error {
defer s.incrementProgress(file)
// determine if folder already exists in data store (by path)
f, err := s.Repository.Folder.FindByPath(ctx, path)
// assume case sensitive by default
f, err := s.Repository.Folder.FindByPath(ctx, path, true)
if err != nil {
return fmt.Errorf("checking for existing folder %q: %w", path, err)
}
// #1426 / #6326 - if folder is in a case-insensitive filesystem, then try
// case insensitive searching
// assume case sensitive if in zip
if f == nil && file.ZipFileID == nil {
caseSensitive, _ := file.fs.IsPathCaseSensitive(file.Path)
if !caseSensitive {
f, err = s.Repository.Folder.FindByPath(ctx, path, false)
if err != nil {
return fmt.Errorf("checking for existing folder %q: %w", path, err)
}
}
}
// if folder not exists, create it
if f == nil {
f, err = s.onNewFolder(ctx, file)
@@ -611,10 +631,18 @@ func (s *scanJob) onExistingFolder(ctx context.Context, f scanFile, existing *mo
// update if mod time is changed
entryModTime := f.ModTime
if !entryModTime.Equal(existing.ModTime) {
existing.Path = f.Path
existing.ModTime = entryModTime
update = true
}
// #6326 - update if path has changed - should only happen if case is
// changed and filesystem is case insensitive
if existing.Path != f.Path {
existing.Path = f.Path
update = true
}
// update if zip file ID has changed
fZfID := f.ZipFileID
existingZfID := existing.ZipFileID
@@ -647,15 +675,31 @@ func (s *scanJob) handleFile(ctx context.Context, f scanFile) error {
defer s.incrementProgress(f)
var ff models.File
// don't use a transaction to check if new or existing
if err := s.withDB(ctx, func(ctx context.Context) error {
// determine if file already exists in data store
// assume case sensitive when searching for the file to begin with
var err error
ff, err = s.Repository.File.FindByPath(ctx, f.Path)
ff, err = s.Repository.File.FindByPath(ctx, f.Path, true)
if err != nil {
return fmt.Errorf("checking for existing file %q: %w", f.Path, err)
}
// #1426 / #6326 - if file is in a case-insensitive filesystem, then try
// case insensitive search
// assume case sensitive if in zip
if ff == nil && f.ZipFileID != nil {
caseSensitive, _ := f.fs.IsPathCaseSensitive(f.Path)
if !caseSensitive {
ff, err = s.Repository.File.FindByPath(ctx, f.Path, false)
if err != nil {
return fmt.Errorf("checking for existing file %q: %w", f.Path, err)
}
}
}
if ff == nil {
// returns a file only if it is actually new
ff, err = s.onNewFile(ctx, f)
@@ -674,7 +718,7 @@ func (s *scanJob) handleFile(ctx context.Context, f scanFile) error {
// scan zip files with a different context that is not cancellable
// cancelling while scanning zip file contents results in the scan
// contents being partially completed
zipCtx := utils.ValueOnlyContext{Context: ctx}
zipCtx := context.WithoutCancel(ctx)
if err := s.scanZipFile(zipCtx, f); err != nil {
logger.Errorf("Error scanning zip file %q: %v", f.Path, err)
@@ -879,6 +923,7 @@ func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.F
// #1426 - if file exists but is a case-insensitive match for the
// original filename, and the filesystem is case-insensitive
// then treat it as a move
// #6326 - this should now be handled earlier, and this shouldn't be necessary
if caseSensitive, _ := fs.IsPathCaseSensitive(other.Base().Path); !caseSensitive {
// treat as a move
missing = append(missing, other)
@@ -1026,7 +1071,8 @@ func (s *scanJob) onExistingFile(ctx context.Context, f scanFile, existing model
path := base.Path
fileModTime := f.ModTime
updated := !fileModTime.Equal(base.ModTime)
// #6326 - also force a rescan if the basename changed
updated := !fileModTime.Equal(base.ModTime) || base.Basename != f.Basename
forceRescan := s.options.Rescan
if !updated && !forceRescan {
@@ -1041,6 +1087,8 @@ func (s *scanJob) onExistingFile(ctx context.Context, f scanFile, existing model
logger.Infof("%s has been updated: rescanning", path)
}
// #6326 - update basename in case it changed
base.Basename = f.Basename
base.ModTime = fileModTime
base.Size = f.Size
base.UpdatedAt = time.Now()

View File

@@ -97,7 +97,7 @@ func AssociateCaptions(ctx context.Context, captionPath string, txnMgr txn.Manag
captionPrefix := getCaptionPrefix(captionPath)
if err := txn.WithTxn(ctx, txnMgr, func(ctx context.Context) error {
var err error
files, er := fqb.FindAllByPath(ctx, captionPrefix+"*")
files, er := fqb.FindAllByPath(ctx, captionPrefix+"*", true)
if er != nil {
return fmt.Errorf("searching for scene %s: %w", captionPrefix, er)

43
pkg/fsutil/trash.go Normal file
View File

@@ -0,0 +1,43 @@
package fsutil
import (
"fmt"
"os"
"path/filepath"
"time"
)
// MoveToTrash moves a file or directory to a custom trash directory.
// If a file with the same name already exists in the trash, a timestamp is appended.
// Returns the destination path where the file was moved to.
func MoveToTrash(sourcePath string, trashPath string) (string, error) {
// Get absolute path for the source
absSourcePath, err := filepath.Abs(sourcePath)
if err != nil {
return "", fmt.Errorf("failed to get absolute path: %w", err)
}
// Ensure trash directory exists
if err := os.MkdirAll(trashPath, 0755); err != nil {
return "", fmt.Errorf("failed to create trash directory: %w", err)
}
// Get the base name of the file/directory
baseName := filepath.Base(absSourcePath)
destPath := filepath.Join(trashPath, baseName)
// If a file with the same name already exists in trash, append timestamp
if _, err := os.Stat(destPath); err == nil {
ext := filepath.Ext(baseName)
nameWithoutExt := baseName[:len(baseName)-len(ext)]
timestamp := time.Now().Format("20060102-150405")
destPath = filepath.Join(trashPath, fmt.Sprintf("%s_%s%s", nameWithoutExt, timestamp, ext))
}
// Move the file to trash using SafeMove to support cross-filesystem moves
if err := SafeMove(absSourcePath, destPath); err != nil {
return "", fmt.Errorf("failed to move to trash: %w", err)
}
return destPath, nil
}

View File

@@ -265,7 +265,7 @@ func (i *Importer) populateFilesFolder(ctx context.Context) error {
for _, ref := range i.Input.ZipFiles {
path := ref
f, err := i.FileFinder.FindByPath(ctx, path)
f, err := i.FileFinder.FindByPath(ctx, path, true)
if err != nil {
return fmt.Errorf("error finding file: %w", err)
}
@@ -281,7 +281,7 @@ func (i *Importer) populateFilesFolder(ctx context.Context) error {
if i.Input.FolderPath != "" {
path := i.Input.FolderPath
f, err := i.FolderFinder.FindByPath(ctx, path)
f, err := i.FolderFinder.FindByPath(ctx, path, true)
if err != nil {
return fmt.Errorf("error finding folder: %w", err)
}

View File

@@ -19,6 +19,7 @@ type FileDeleter struct {
}
// MarkGeneratedFiles marks for deletion the generated files for the provided image.
// Generated files bypass trash and are permanently deleted since they can be regenerated.
func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error {
var files []string
thumbPath := d.Paths.Generated.GetThumbnailPath(image.Checksum, models.DefaultGthumbWidth)
@@ -32,7 +33,7 @@ func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error {
files = append(files, prevPath)
}
return d.Files(files)
return d.FilesWithoutTrash(files)
}
// Destroy destroys an image, optionally marking the file and generated files for deletion.

View File

@@ -110,7 +110,7 @@ func (i *Importer) populateFiles(ctx context.Context) error {
for _, ref := range i.Input.Files {
path := ref
f, err := i.FileFinder.FindByPath(ctx, path)
f, err := i.FileFinder.FindByPath(ctx, path, true)
if err != nil {
return fmt.Errorf("error finding file: %w", err)
}

View File

@@ -22,12 +22,8 @@ const ffmpegImageQuality = 5
var vipsPath string
var once sync.Once
var (
ErrUnsupportedImageFormat = errors.New("unsupported image format")
// ErrNotSupportedForThumbnail is returned if the image format is not supported for thumbnail generation
ErrNotSupportedForThumbnail = errors.New("unsupported image format for thumbnail")
)
// ErrNotSupportedForThumbnail is returned if the image format is not supported for thumbnail generation
var ErrNotSupportedForThumbnail = errors.New("unsupported image format for thumbnail")
type ThumbnailEncoder struct {
FFMpeg *ffmpeg.FFMpeg
@@ -83,8 +79,9 @@ func (e *ThumbnailEncoder) GetThumbnail(f models.File, maxSize int) ([]byte, err
data := buf.Bytes()
format := ""
if imageFile, ok := f.(*models.ImageFile); ok {
format := imageFile.Format
format = imageFile.Format
animated := imageFile.Format == formatGif
// #2266 - if image is webp, then determine if it is animated
@@ -96,6 +93,19 @@ func (e *ThumbnailEncoder) GetThumbnail(f models.File, maxSize int) ([]byte, err
if animated {
return nil, fmt.Errorf("%w: %s", ErrNotSupportedForThumbnail, format)
}
// AVIF cannot be read from stdin, must use file path
// AVIF in zip files is not supported
// Note: No Windows check needed here since we use file path, not stdin
if format == "avif" {
if f.Base().ZipFileID != nil {
return nil, fmt.Errorf("%w: AVIF in zip file", ErrNotSupportedForThumbnail)
}
if e.vips != nil {
return e.vips.ImageThumbnailPath(f.Base().Path, maxSize)
}
return e.ffmpegImageThumbnailPath(f.Base().Path, maxSize)
}
}
// Videofiles can only be thumbnailed with ffmpeg
@@ -104,11 +114,15 @@ func (e *ThumbnailEncoder) GetThumbnail(f models.File, maxSize int) ([]byte, err
}
// vips has issues loading files from stdin on Windows
if e.vips != nil && runtime.GOOS != "windows" {
return e.vips.ImageThumbnail(buf, maxSize)
} else {
return e.ffmpegImageThumbnail(buf, maxSize)
if e.vips != nil {
if runtime.GOOS == "windows" && f.Base().ZipFileID == nil {
return e.vips.ImageThumbnailPath(f.Base().Path, maxSize)
}
if runtime.GOOS != "windows" {
return e.vips.ImageThumbnail(buf, maxSize)
}
}
return e.ffmpegImageThumbnail(buf, maxSize)
}
// GetPreview returns the preview clip of the provided image clip resized to
@@ -130,16 +144,32 @@ func (e *ThumbnailEncoder) GetPreview(inPath string, outPath string, maxSize int
}
func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, maxSize int) ([]byte, error) {
args := transcoder.ImageThumbnail("-", transcoder.ImageThumbnailOptions{
options := transcoder.ImageThumbnailOptions{
OutputFormat: ffmpeg.ImageFormatJpeg,
OutputPath: "-",
MaxDimensions: maxSize,
Quality: ffmpegImageQuality,
})
}
args := transcoder.ImageThumbnail("-", options)
return e.FFMpeg.GenerateOutput(context.TODO(), args, image)
}
// ffmpegImageThumbnailPath generates a thumbnail from a file path (used for AVIF which can't be piped)
func (e *ThumbnailEncoder) ffmpegImageThumbnailPath(inputPath string, maxSize int) ([]byte, error) {
options := transcoder.ImageThumbnailOptions{
OutputFormat: ffmpeg.ImageFormatJpeg,
OutputPath: "-",
MaxDimensions: maxSize,
Quality: ffmpegImageQuality,
}
args := transcoder.ImageThumbnail(inputPath, options)
return e.FFMpeg.GenerateOutput(context.TODO(), args, nil)
}
func (e *ThumbnailEncoder) getClipPreview(inPath string, outPath string, maxSize int, clipDuration float64, frameRate float64) error {
var thumbFilter ffmpeg.VideoFilter
thumbFilter = thumbFilter.ScaleMaxSize(maxSize)

View File

@@ -24,6 +24,38 @@ func (e *vipsEncoder) ImageThumbnail(image *bytes.Buffer, maxSize int) ([]byte,
return []byte(data), err
}
// ImageThumbnailPath generates a thumbnail from a file path instead of stdin.
// This is required for formats like AVIF that need random file access (seeking)
// which stdin cannot provide.
func (e *vipsEncoder) ImageThumbnailPath(path string, maxSize int) ([]byte, error) {
// vips thumbnail syntax: thumbnail input output width [options]
// Using .jpg[Q=70,strip] as output writes to stdout
args := []string{
"thumbnail",
path,
".jpg[Q=70,strip]",
fmt.Sprint(maxSize),
"--size", "down",
}
cmd := exec.Command(string(*e), args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return nil, err
}
if err := cmd.Wait(); err != nil {
logger.Errorf("image encoder error when running command <%s>: %s", strings.Join(cmd.Args, " "), stderr.String())
return nil, err
}
return stdout.Bytes(), nil
}
func (e *vipsEncoder) run(args []string, stdin *bytes.Buffer) (string, error) {
cmd := exec.Command(string(*e), args...)

View File

@@ -7,7 +7,6 @@ import (
"time"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/utils"
)
const maxGraveyardSize = 10
@@ -179,7 +178,8 @@ func (m *Manager) dispatch(ctx context.Context, j *Job) (done chan struct{}) {
j.StartTime = &t
j.Status = StatusRunning
ctx, cancelFunc := context.WithCancel(utils.ValueOnlyContext{Context: ctx})
// create a cancellable context for the job that is not canceled by the outer context
ctx, cancelFunc := context.WithCancel(context.WithoutCancel(ctx))
j.cancelFunc = cancelFunc
done = make(chan struct{})

View File

@@ -9,6 +9,8 @@ type CustomFieldsInput struct {
Full map[string]interface{} `json:"full"`
// If populated, only the keys in this map will be updated
Partial map[string]interface{} `json:"partial"`
// Remove any keys in this list
Remove []string `json:"remove"`
}
type CustomFieldsReader interface {

View File

@@ -1,31 +1,63 @@
package models
import (
"fmt"
"time"
"github.com/stashapp/stash/pkg/utils"
)
type DatePrecision int
const (
// default precision is day
DatePrecisionDay DatePrecision = iota
DatePrecisionMonth
DatePrecisionYear
)
// Date wraps a time.Time with a format of "YYYY-MM-DD"
type Date struct {
time.Time
Precision DatePrecision
}
const dateFormat = "2006-01-02"
var dateFormatPrecision = []string{
"2006-01-02",
"2006-01",
"2006",
}
func (d Date) String() string {
return d.Format(dateFormat)
return d.Format(dateFormatPrecision[d.Precision])
}
func (d Date) After(o Date) bool {
return d.Time.After(o.Time)
}
// ParseDate uses utils.ParseDateStringAsTime to parse a string into a date.
// ParseDate tries to parse the input string into a date using utils.ParseDateStringAsTime.
// If that fails, it attempts to parse the string with decreasing precision (month, then year).
// It returns a Date struct with the appropriate precision set, or an error if all parsing attempts fail.
func ParseDate(s string) (Date, error) {
var errs []error
// default parse to day precision
ret, err := utils.ParseDateStringAsTime(s)
if err != nil {
return Date{}, err
if err == nil {
return Date{Time: ret, Precision: DatePrecisionDay}, nil
}
return Date{Time: ret}, nil
errs = append(errs, err)
// try month and year precision
for i, format := range dateFormatPrecision[1:] {
ret, err := time.Parse(format, s)
if err == nil {
return Date{Time: ret, Precision: DatePrecision(i + 1)}, nil
}
errs = append(errs, err)
}
return Date{}, fmt.Errorf("failed to parse date %q: %v", s, errs)
}

50
pkg/models/date_test.go Normal file
View File

@@ -0,0 +1,50 @@
package models
import (
"testing"
"time"
)
func TestParseDateStringAsTime(t *testing.T) {
tests := []struct {
name string
input string
output Date
expectError bool
}{
// Full date formats (existing support)
{"RFC3339", "2014-01-02T15:04:05Z", Date{Time: time.Date(2014, 1, 2, 15, 4, 5, 0, time.UTC), Precision: DatePrecisionDay}, false},
{"Date only", "2014-01-02", Date{Time: time.Date(2014, 1, 2, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionDay}, false},
{"Date with time", "2014-01-02 15:04:05", Date{Time: time.Date(2014, 1, 2, 15, 4, 5, 0, time.UTC), Precision: DatePrecisionDay}, false},
// Partial date formats (new support)
{"Year-Month", "2006-08", Date{Time: time.Date(2006, 8, 1, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionMonth}, false},
{"Year only", "2014", Date{Time: time.Date(2014, 1, 1, 0, 0, 0, 0, time.UTC), Precision: DatePrecisionYear}, false},
// Invalid formats
{"Invalid format", "not-a-date", Date{}, true},
{"Empty string", "", Date{}, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := ParseDate(tt.input)
if tt.expectError {
if err == nil {
t.Errorf("Expected error for input %q, but got none", tt.input)
}
return
}
if err != nil {
t.Errorf("Unexpected error for input %q: %v", tt.input, err)
return
}
if !result.Time.Equal(tt.output.Time) || result.Precision != tt.output.Precision {
t.Errorf("For input %q, expected output %+v, got %+v", tt.input, tt.output, result)
}
})
}
}

View File

@@ -130,13 +130,13 @@ func (_m *FileReaderWriter) Find(ctx context.Context, id ...models.FileID) ([]mo
return r0, r1
}
// FindAllByPath provides a mock function with given fields: ctx, path
func (_m *FileReaderWriter) FindAllByPath(ctx context.Context, path string) ([]models.File, error) {
ret := _m.Called(ctx, path)
// FindAllByPath provides a mock function with given fields: ctx, path, caseSensitive
func (_m *FileReaderWriter) FindAllByPath(ctx context.Context, path string, caseSensitive bool) ([]models.File, error) {
ret := _m.Called(ctx, path, caseSensitive)
var r0 []models.File
if rf, ok := ret.Get(0).(func(context.Context, string) []models.File); ok {
r0 = rf(ctx, path)
if rf, ok := ret.Get(0).(func(context.Context, string, bool) []models.File); ok {
r0 = rf(ctx, path, caseSensitive)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]models.File)
@@ -144,8 +144,8 @@ func (_m *FileReaderWriter) FindAllByPath(ctx context.Context, path string) ([]m
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, path)
if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
r1 = rf(ctx, path, caseSensitive)
} else {
r1 = ret.Error(1)
}
@@ -222,13 +222,13 @@ func (_m *FileReaderWriter) FindByFingerprint(ctx context.Context, fp models.Fin
return r0, r1
}
// FindByPath provides a mock function with given fields: ctx, path
func (_m *FileReaderWriter) FindByPath(ctx context.Context, path string) (models.File, error) {
ret := _m.Called(ctx, path)
// FindByPath provides a mock function with given fields: ctx, path, caseSensitive
func (_m *FileReaderWriter) FindByPath(ctx context.Context, path string, caseSensitive bool) (models.File, error) {
ret := _m.Called(ctx, path, caseSensitive)
var r0 models.File
if rf, ok := ret.Get(0).(func(context.Context, string) models.File); ok {
r0 = rf(ctx, path)
if rf, ok := ret.Get(0).(func(context.Context, string, bool) models.File); ok {
r0 = rf(ctx, path, caseSensitive)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(models.File)
@@ -236,8 +236,8 @@ func (_m *FileReaderWriter) FindByPath(ctx context.Context, path string) (models
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, path)
if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
r1 = rf(ctx, path, caseSensitive)
} else {
r1 = ret.Error(1)
}

View File

@@ -132,13 +132,13 @@ func (_m *FolderReaderWriter) FindByParentFolderID(ctx context.Context, parentFo
return r0, r1
}
// FindByPath provides a mock function with given fields: ctx, path
func (_m *FolderReaderWriter) FindByPath(ctx context.Context, path string) (*models.Folder, error) {
ret := _m.Called(ctx, path)
// FindByPath provides a mock function with given fields: ctx, path, caseSensitive
func (_m *FolderReaderWriter) FindByPath(ctx context.Context, path string, caseSensitive bool) (*models.Folder, error) {
ret := _m.Called(ctx, path, caseSensitive)
var r0 *models.Folder
if rf, ok := ret.Get(0).(func(context.Context, string) *models.Folder); ok {
r0 = rf(ctx, path)
if rf, ok := ret.Get(0).(func(context.Context, string, bool) *models.Folder); ok {
r0 = rf(ctx, path, caseSensitive)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.Folder)
@@ -146,8 +146,8 @@ func (_m *FolderReaderWriter) FindByPath(ctx context.Context, path string) (*mod
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, path)
if rf, ok := ret.Get(1).(func(context.Context, string, bool) error); ok {
r1 = rf(ctx, path, caseSensitive)
} else {
r1 = ret.Error(1)
}

View File

@@ -594,6 +594,27 @@ func (_m *ImageReaderWriter) OCountByPerformerID(ctx context.Context, performerI
return r0, r1
}
// OCountByStudioID provides a mock function with given fields: ctx, studioID
func (_m *ImageReaderWriter) OCountByStudioID(ctx context.Context, studioID int) (int, error) {
ret := _m.Called(ctx, studioID)
var r0 int
if rf, ok := ret.Get(0).(func(context.Context, int) int); ok {
r0 = rf(ctx, studioID)
} else {
r0 = ret.Get(0).(int)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, studioID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Query provides a mock function with given fields: ctx, options
func (_m *ImageReaderWriter) Query(ctx context.Context, options models.ImageQueryOptions) (*models.ImageQueryResult, error) {
ret := _m.Called(ctx, options)

View File

@@ -1183,6 +1183,27 @@ func (_m *SceneReaderWriter) OCountByPerformerID(ctx context.Context, performerI
return r0, r1
}
// OCountByStudioID provides a mock function with given fields: ctx, studioID
func (_m *SceneReaderWriter) OCountByStudioID(ctx context.Context, studioID int) (int, error) {
ret := _m.Called(ctx, studioID)
var r0 int
if rf, ok := ret.Get(0).(func(context.Context, int) int); ok {
r0 = rf(ctx, studioID)
} else {
r0 = ret.Get(0).(int)
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, studioID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// PlayDuration provides a mock function with given fields: ctx
func (_m *SceneReaderWriter) PlayDuration(ctx context.Context) (float64, error) {
ret := _m.Called(ctx)

View File

@@ -30,9 +30,9 @@ func (ScrapedStudio) IsScrapedContent() {}
func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Studio {
// Populate a new studio from the input
ret := NewStudio()
ret.Name = s.Name
ret.Name = strings.TrimSpace(s.Name)
if s.RemoteSiteID != nil && endpoint != "" {
if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" {
ret.StashIDs = NewRelatedStashIDs([]StashID{
{
Endpoint: endpoint,
@@ -62,7 +62,7 @@ func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Stu
ret.Details = *s.Details
}
if s.Aliases != nil && !excluded["aliases"] {
if s.Aliases != nil && *s.Aliases != "" && !excluded["aliases"] {
ret.Aliases = NewRelatedStrings(stringslice.FromString(*s.Aliases, ","))
}
@@ -95,37 +95,38 @@ func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[strin
currentTime := time.Now()
if s.Name != "" && !excluded["name"] {
ret.Name = NewOptionalString(s.Name)
ret.Name = NewOptionalString(strings.TrimSpace(s.Name))
}
if len(s.URLs) > 0 {
if !excluded["urls"] {
ret.URLs = &UpdateStrings{
Values: s.URLs,
Values: stringslice.TrimSpace(s.URLs),
Mode: RelationshipUpdateModeSet,
}
}
} else {
urls := []string{}
if s.URL != nil && !excluded["url"] {
urls = append(urls, *s.URL)
urls = append(urls, strings.TrimSpace(*s.URL))
}
if len(urls) > 0 {
ret.URLs = &UpdateStrings{
Values: urls,
Values: stringslice.TrimSpace(urls),
Mode: RelationshipUpdateModeSet,
}
}
}
if s.Details != nil && !excluded["details"] {
ret.Details = NewOptionalString(*s.Details)
ret.Details = NewOptionalString(strings.TrimSpace(*s.Details))
}
if s.Aliases != nil && !excluded["aliases"] {
if s.Aliases != nil && *s.Aliases != "" && !excluded["aliases"] {
ret.Aliases = &UpdateStrings{
Values: stringslice.FromString(*s.Aliases, ","),
Values: stringslice.TrimSpace(stringslice.FromString(*s.Aliases, ",")),
Mode: RelationshipUpdateModeSet,
}
}
@@ -140,7 +141,7 @@ func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[strin
}
}
if s.RemoteSiteID != nil && endpoint != "" {
if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" {
ret.StashIDs = &UpdateStashIDs{
StashIDs: existingStashIDs,
Mode: RelationshipUpdateModeSet,
@@ -197,10 +198,14 @@ func (ScrapedPerformer) IsScrapedContent() {}
func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool) *Performer {
ret := NewPerformer()
currentTime := time.Now()
ret.Name = *p.Name
ret.Name = strings.TrimSpace(*p.Name)
if p.Aliases != nil && !excluded["aliases"] {
ret.Aliases = NewRelatedStrings(stringslice.FromString(*p.Aliases, ","))
aliases := stringslice.FromString(*p.Aliases, ",")
for i, alias := range aliases {
aliases[i] = strings.TrimSpace(alias)
}
ret.Aliases = NewRelatedStrings(aliases)
}
if p.Birthdate != nil && !excluded["birthdate"] {
date, err := ParseDate(*p.Birthdate)
@@ -301,7 +306,7 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool
}
}
if p.RemoteSiteID != nil && endpoint != "" {
if p.RemoteSiteID != nil && endpoint != "" && *p.RemoteSiteID != "" {
ret.StashIDs = NewRelatedStashIDs([]StashID{
{
Endpoint: endpoint,
@@ -430,7 +435,7 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
}
}
if p.RemoteSiteID != nil && endpoint != "" {
if p.RemoteSiteID != nil && endpoint != "" && *p.RemoteSiteID != "" {
ret.StashIDs = &UpdateStashIDs{
StashIDs: existingStashIDs,
Mode: RelationshipUpdateModeSet,
@@ -459,7 +464,7 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag {
ret := NewTag()
ret.Name = t.Name
if t.RemoteSiteID != nil && endpoint != "" {
if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" {
ret.StashIDs = NewRelatedStashIDs([]StashID{
{
Endpoint: endpoint,

View File

@@ -13,9 +13,9 @@ type FileGetter interface {
// FileFinder provides methods to find files.
type FileFinder interface {
FileGetter
FindAllByPath(ctx context.Context, path string) ([]File, error)
FindAllByPath(ctx context.Context, path string, caseSensitive bool) ([]File, error)
FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]File, error)
FindByPath(ctx context.Context, path string) (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)
FindByFileInfo(ctx context.Context, info fs.FileInfo, size int64) ([]File, error)

View File

@@ -12,7 +12,7 @@ type FolderGetter interface {
type FolderFinder interface {
FolderGetter
FindAllInPaths(ctx context.Context, p []string, limit, offset int) ([]*Folder, error)
FindByPath(ctx context.Context, path string) (*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)
}

View File

@@ -38,6 +38,7 @@ type ImageCounter interface {
CountByGalleryID(ctx context.Context, galleryID int) (int, error)
OCount(ctx context.Context) (int, error)
OCountByPerformerID(ctx context.Context, performerID int) (int, error)
OCountByStudioID(ctx context.Context, studioID int) (int, error)
}
// ImageCreator provides methods to create images.

View File

@@ -45,6 +45,7 @@ type SceneCounter interface {
CountMissingOSHash(ctx context.Context) (int, error)
OCountByPerformerID(ctx context.Context, performerID int) (int, error)
OCountByGroupID(ctx context.Context, groupID int) (int, error)
OCountByStudioID(ctx context.Context, studioID int) (int, error)
}
// SceneCreator provides methods to create scenes.

View File

@@ -79,10 +79,23 @@ func (s StashIDInputs) ToStashIDs() StashIDs {
return nil
}
ret := make(StashIDs, len(s))
for i, v := range s {
ret[i] = v.ToStashID()
// #2800 - deduplicate StashIDs based on endpoint and stash_id
ret := make(StashIDs, 0, len(s))
seen := make(map[string]map[string]bool)
for _, v := range s {
stashID := v.ToStashID()
if seen[stashID.Endpoint] == nil {
seen[stashID.Endpoint] = make(map[string]bool)
}
if !seen[stashID.Endpoint][stashID.StashID] {
seen[stashID.Endpoint][stashID.StashID] = true
ret = append(ret, stashID)
}
}
return ret
}

View File

@@ -40,6 +40,8 @@ type TagFilterType struct {
ChildCount *IntCriterionInput `json:"child_count"`
// Filter by autotag ignore value
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
// Filter by StashID Endpoint
StashIDEndpoint *StashIDCriterionInput `json:"stash_id_endpoint"`
// Filter by related scenes that meet this criteria
ScenesFilter *SceneFilterType `json:"scenes_filter"`
// Filter by related images that meet this criteria

View File

@@ -21,6 +21,7 @@ type FileDeleter struct {
}
// MarkGeneratedFiles marks for deletion the generated files for the provided scene.
// Generated files bypass trash and are permanently deleted since they can be regenerated.
func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error {
sceneHash := scene.GetHash(d.FileNamingAlgo)
@@ -32,7 +33,7 @@ func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error {
exists, _ := fsutil.FileExists(markersFolder)
if exists {
if err := d.Dirs([]string{markersFolder}); err != nil {
if err := d.DirsWithoutTrash([]string{markersFolder}); err != nil {
return err
}
}
@@ -75,11 +76,12 @@ func (d *FileDeleter) MarkGeneratedFiles(scene *models.Scene) error {
files = append(files, heatmapPath)
}
return d.Files(files)
return d.FilesWithoutTrash(files)
}
// MarkMarkerFiles deletes generated files for a scene marker with the
// provided scene and timestamp.
// Generated files bypass trash and are permanently deleted since they can be regenerated.
func (d *FileDeleter) MarkMarkerFiles(scene *models.Scene, seconds int) error {
videoPath := d.Paths.SceneMarkers.GetVideoPreviewPath(scene.GetHash(d.FileNamingAlgo), seconds)
imagePath := d.Paths.SceneMarkers.GetWebpPreviewPath(scene.GetHash(d.FileNamingAlgo), seconds)
@@ -102,7 +104,7 @@ func (d *FileDeleter) MarkMarkerFiles(scene *models.Scene, seconds int) error {
files = append(files, screenshotPath)
}
return d.Files(files)
return d.FilesWithoutTrash(files)
}
// Destroy deletes a scene and its associated relationships from the

View File

@@ -164,7 +164,7 @@ func (i *Importer) populateFiles(ctx context.Context) error {
for _, ref := range i.Input.Files {
path := ref
f, err := i.FileFinder.FindByPath(ctx, path)
f, err := i.FileFinder.FindByPath(ctx, path, true)
if err != nil {
return fmt.Errorf("error finding file: %w", err)
}

View File

@@ -16,7 +16,6 @@ import (
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/txn"
)
@@ -262,19 +261,23 @@ func (c Cache) ScrapeName(ctx context.Context, id, query string, ty ScrapeConten
return nil, fmt.Errorf("error while name scraping with scraper %s: %w", id, err)
}
ignoredRegex := c.compileExcludeTagPatterns()
var ignoredTags []string
for i, cc := range content {
var thisIgnoredTags []string
content[i], thisIgnoredTags, err = c.postScrape(ctx, cc, ignoredRegex)
if err != nil {
return nil, fmt.Errorf("error while post-scraping with scraper %s: %w", id, err)
pp := postScraper{
Cache: c,
excludeTagRE: c.compileExcludeTagPatterns(),
}
if err := c.repository.WithReadTxn(ctx, func(ctx context.Context) error {
for i, cc := range content {
content[i], err = pp.postScrape(ctx, cc)
if err != nil {
return fmt.Errorf("error while post-scraping with scraper %s: %w", id, err)
}
}
ignoredTags = sliceutil.AppendUniques(ignoredTags, thisIgnoredTags)
return nil
}); err != nil {
return nil, err
}
LogIgnoredTags(ignoredTags)
LogIgnoredTags(pp.ignoredTags)
return content, nil
}

View File

@@ -37,88 +37,43 @@ func setPerformerImage(ctx context.Context, client *http.Client, p *models.Scrap
return nil
}
func setSceneImage(ctx context.Context, client *http.Client, s *models.ScrapedScene, globalConfig GlobalConfig) error {
// don't try to get the image if it doesn't appear to be a URL
if s.Image == nil || !strings.HasPrefix(*s.Image, "http") {
func setStudioImage(ctx context.Context, client *http.Client, p *models.ScrapedStudio, globalConfig GlobalConfig) error {
// backwards compatibility: we fetch the image if it's a URL and set it to the first image
// Image is deprecated, so only do this if Images is unset
if p.Image == nil || len(p.Images) > 0 {
// nothing to do
return nil
}
img, err := getImage(ctx, *s.Image, client, globalConfig)
// don't try to get the image if it doesn't appear to be a URL
if !strings.HasPrefix(*p.Image, "http") {
p.Images = []string{*p.Image}
return nil
}
img, err := getImage(ctx, *p.Image, client, globalConfig)
if err != nil {
return err
}
s.Image = img
p.Image = img
// Image is deprecated. Use images instead
p.Images = []string{*img}
return nil
}
func setMovieFrontImage(ctx context.Context, client *http.Client, m *models.ScrapedMovie, globalConfig GlobalConfig) error {
// don't try to get the image if it doesn't appear to be a URL
if m.FrontImage == nil || !strings.HasPrefix(*m.FrontImage, "http") {
// nothing to do
func processImageField(ctx context.Context, imageField *string, client *http.Client, globalConfig GlobalConfig) error {
if imageField == nil {
return nil
}
img, err := getImage(ctx, *m.FrontImage, client, globalConfig)
img, err := getImage(ctx, *imageField, client, globalConfig)
if err != nil {
return err
}
m.FrontImage = img
return nil
}
func setMovieBackImage(ctx context.Context, client *http.Client, m *models.ScrapedMovie, globalConfig GlobalConfig) error {
// don't try to get the image if it doesn't appear to be a URL
if m.BackImage == nil || !strings.HasPrefix(*m.BackImage, "http") {
// nothing to do
return nil
}
img, err := getImage(ctx, *m.BackImage, client, globalConfig)
if err != nil {
return err
}
m.BackImage = img
return nil
}
func setGroupFrontImage(ctx context.Context, client *http.Client, m *models.ScrapedGroup, globalConfig GlobalConfig) error {
// don't try to get the image if it doesn't appear to be a URL
if m.FrontImage == nil || !strings.HasPrefix(*m.FrontImage, "http") {
// nothing to do
return nil
}
img, err := getImage(ctx, *m.FrontImage, client, globalConfig)
if err != nil {
return err
}
m.FrontImage = img
return nil
}
func setGroupBackImage(ctx context.Context, client *http.Client, m *models.ScrapedGroup, globalConfig GlobalConfig) error {
// don't try to get the image if it doesn't appear to be a URL
if m.BackImage == nil || !strings.HasPrefix(*m.BackImage, "http") {
// nothing to do
return nil
}
img, err := getImage(ctx, *m.BackImage, client, globalConfig)
if err != nil {
return err
}
m.BackImage = img
*imageField = *img
return nil
}

View File

@@ -11,85 +11,91 @@ import (
"github.com/stashapp/stash/pkg/utils"
)
type postScraper struct {
Cache
excludeTagRE []*regexp.Regexp
// ignoredTags is a list of tags that were ignored during post-processing
ignoredTags []string
}
// postScrape handles post-processing of scraped content. If the content
// requires post-processing, this function fans out to the given content
// type and post-processes it.
func (c Cache) postScrape(ctx context.Context, content ScrapedContent, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
// Assumes called within a read transaction.
func (c *postScraper) postScrape(ctx context.Context, content ScrapedContent) (_ ScrapedContent, err error) {
const related = false
// Analyze the concrete type, call the right post-processing function
switch v := content.(type) {
case *models.ScrapedPerformer:
if v != nil {
return c.postScrapePerformer(ctx, *v, excludeTagRE)
return c.postScrapePerformer(ctx, *v, related)
}
case models.ScrapedPerformer:
return c.postScrapePerformer(ctx, v, excludeTagRE)
return c.postScrapePerformer(ctx, v, related)
case *models.ScrapedScene:
if v != nil {
return c.postScrapeScene(ctx, *v, excludeTagRE)
return c.postScrapeScene(ctx, *v)
}
case models.ScrapedScene:
return c.postScrapeScene(ctx, v, excludeTagRE)
return c.postScrapeScene(ctx, v)
case *models.ScrapedGallery:
if v != nil {
return c.postScrapeGallery(ctx, *v, excludeTagRE)
return c.postScrapeGallery(ctx, *v)
}
case models.ScrapedGallery:
return c.postScrapeGallery(ctx, v, excludeTagRE)
return c.postScrapeGallery(ctx, v)
case *models.ScrapedImage:
if v != nil {
return c.postScrapeImage(ctx, *v, excludeTagRE)
return c.postScrapeImage(ctx, *v)
}
case models.ScrapedImage:
return c.postScrapeImage(ctx, v, excludeTagRE)
return c.postScrapeImage(ctx, v)
case *models.ScrapedMovie:
if v != nil {
return c.postScrapeMovie(ctx, *v, excludeTagRE)
return c.postScrapeMovie(ctx, *v, related)
}
case models.ScrapedMovie:
return c.postScrapeMovie(ctx, v, excludeTagRE)
return c.postScrapeMovie(ctx, v, related)
case *models.ScrapedGroup:
if v != nil {
return c.postScrapeGroup(ctx, *v, excludeTagRE)
return c.postScrapeGroup(ctx, *v, related)
}
case models.ScrapedGroup:
return c.postScrapeGroup(ctx, v, excludeTagRE)
return c.postScrapeGroup(ctx, v, related)
}
// If nothing matches, pass the content through
return content, nil, nil
return content, nil
}
// postScrapeSingle handles post-processing of a single scraped content item.
// This is a convenience function that includes logging the ignored tags, as opposed to logging them in the caller.
func (c Cache) postScrapeSingle(ctx context.Context, content ScrapedContent) (ScrapedContent, error) {
ret, ignoredTags, err := c.postScrape(ctx, content, c.compileExcludeTagPatterns())
func (c *postScraper) filterTags(tags []*models.ScrapedTag) []*models.ScrapedTag {
var ret []*models.ScrapedTag
var thisIgnoredTags []string
ret, thisIgnoredTags = FilterTags(c.excludeTagRE, tags)
c.ignoredTags = sliceutil.AppendUniques(c.ignoredTags, thisIgnoredTags)
return ret
}
func (c *postScraper) postScrapePerformer(ctx context.Context, p models.ScrapedPerformer, related bool) (_ ScrapedContent, err error) {
r := c.repository
tqb := r.TagFinder
tags, err := postProcessTags(ctx, tqb, p.Tags)
if err != nil {
return nil, err
}
LogIgnoredTags(ignoredTags)
return ret, nil
}
func (c Cache) postScrapePerformer(ctx context.Context, p models.ScrapedPerformer, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
tqb := r.TagFinder
tags, err := postProcessTags(ctx, tqb, p.Tags)
if err != nil {
return err
}
p.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
return nil
}); err != nil {
return nil, nil, err
}
p.Tags = c.filterTags(tags)
// post-process - set the image if applicable
if err := setPerformerImage(ctx, c.client, &p, c.globalConfig); err != nil {
logger.Warnf("Could not set image using URL %s: %s", *p.Image, err.Error())
// don't set image for related performers to avoid excessive network calls
if !related {
if err := setPerformerImage(ctx, c.client, &p, c.globalConfig); err != nil {
logger.Warnf("Could not set image using URL %s: %s", *p.Image, err.Error())
}
}
p.Country = resolveCountryName(p.Country)
@@ -119,119 +125,224 @@ func (c Cache) postScrapePerformer(ctx context.Context, p models.ScrapedPerforme
}
}
return p, ignoredTags, nil
return p, nil
}
func (c Cache) postScrapeMovie(ctx context.Context, m models.ScrapedMovie, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
func (c *postScraper) postScrapeMovie(ctx context.Context, m models.ScrapedMovie, related bool) (_ ScrapedContent, err error) {
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
tqb := r.TagFinder
tags, err := postProcessTags(ctx, tqb, m.Tags)
if err != nil {
return err
}
m.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
if m.Studio != nil {
if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, ""); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, nil, err
}
// populate URL/URLs
// if URLs are provided, only use those
if len(m.URLs) > 0 {
m.URL = &m.URLs[0]
} else {
urls := []string{}
if m.URL != nil {
urls = append(urls, *m.URL)
}
if len(urls) > 0 {
m.URLs = urls
}
}
// post-process - set the image if applicable
if err := setMovieFrontImage(ctx, c.client, &m, c.globalConfig); err != nil {
logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err)
}
if err := setMovieBackImage(ctx, c.client, &m, c.globalConfig); err != nil {
logger.Warnf("could not set back image using URL %s: %v", *m.BackImage, err)
}
return m, ignoredTags, nil
}
func (c Cache) postScrapeGroup(ctx context.Context, m models.ScrapedGroup, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
tqb := r.TagFinder
tags, err := postProcessTags(ctx, tqb, m.Tags)
if err != nil {
return err
}
m.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
if m.Studio != nil {
if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, ""); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, nil, err
}
// populate URL/URLs
// if URLs are provided, only use those
if len(m.URLs) > 0 {
m.URL = &m.URLs[0]
} else {
urls := []string{}
if m.URL != nil {
urls = append(urls, *m.URL)
}
if len(urls) > 0 {
m.URLs = urls
}
}
// post-process - set the image if applicable
if err := setGroupFrontImage(ctx, c.client, &m, c.globalConfig); err != nil {
logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err)
}
if err := setGroupBackImage(ctx, c.client, &m, c.globalConfig); err != nil {
logger.Warnf("could not set back image using URL %s: %v", *m.BackImage, err)
}
return m, ignoredTags, nil
}
func (c Cache) postScrapeScenePerformer(ctx context.Context, p models.ScrapedPerformer, excludeTagRE []*regexp.Regexp) (ignoredTags []string, err error) {
tqb := c.repository.TagFinder
tags, err := postProcessTags(ctx, tqb, p.Tags)
tqb := r.TagFinder
tags, err := postProcessTags(ctx, tqb, m.Tags)
if err != nil {
return nil, err
}
p.Tags = tags
p.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
m.Tags = c.filterTags(tags)
p.Country = resolveCountryName(p.Country)
if m.Studio != nil {
if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, ""); err != nil {
return nil, err
}
}
return ignoredTags, nil
// populate URL/URLs
// if URLs are provided, only use those
if len(m.URLs) > 0 {
m.URL = &m.URLs[0]
} else {
urls := []string{}
if m.URL != nil {
urls = append(urls, *m.URL)
}
if len(urls) > 0 {
m.URLs = urls
}
}
// post-process - set the image if applicable
// don't set images for related movies to avoid excessive network calls
if !related {
if err := processImageField(ctx, m.FrontImage, c.client, c.globalConfig); err != nil {
logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err)
}
if err := processImageField(ctx, m.BackImage, c.client, c.globalConfig); err != nil {
logger.Warnf("could not set back image using URL %s: %v", *m.BackImage, err)
}
}
return m, nil
}
func (c Cache) postScrapeScene(ctx context.Context, scene models.ScrapedScene, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
func (c *postScraper) postScrapeGroup(ctx context.Context, m models.ScrapedGroup, related bool) (_ ScrapedContent, err error) {
r := c.repository
tqb := r.TagFinder
tags, err := postProcessTags(ctx, tqb, m.Tags)
if err != nil {
return nil, err
}
m.Tags = c.filterTags(tags)
if m.Studio != nil {
if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, ""); err != nil {
return nil, err
}
}
// populate URL/URLs
// if URLs are provided, only use those
if len(m.URLs) > 0 {
m.URL = &m.URLs[0]
} else {
urls := []string{}
if m.URL != nil {
urls = append(urls, *m.URL)
}
if len(urls) > 0 {
m.URLs = urls
}
}
// post-process - set the image if applicable
// don't set images for related groups to avoid excessive network calls
if !related {
if err := processImageField(ctx, m.FrontImage, c.client, c.globalConfig); err != nil {
logger.Warnf("could not set front image using URL %s: %v", *m.FrontImage, err)
}
if err := processImageField(ctx, m.BackImage, c.client, c.globalConfig); err != nil {
logger.Warnf("could not set back image using URL %s: %v", *m.BackImage, err)
}
}
return m, nil
}
// postScrapeRelatedPerformers post-processes a list of performers.
// It modifies the performers in place.
func (c *postScraper) postScrapeRelatedPerformers(ctx context.Context, items []*models.ScrapedPerformer) error {
for _, p := range items {
if p == nil {
continue
}
const related = true
sc, err := c.postScrapePerformer(ctx, *p, related)
if err != nil {
return err
}
newP := sc.(models.ScrapedPerformer)
*p = newP
if err := match.ScrapedPerformer(ctx, c.repository.PerformerFinder, p, ""); err != nil {
return err
}
}
return nil
}
func (c *postScraper) postScrapeRelatedMovies(ctx context.Context, items []*models.ScrapedMovie) error {
for _, p := range items {
const related = true
sc, err := c.postScrapeMovie(ctx, *p, related)
if err != nil {
return err
}
newP := sc.(models.ScrapedMovie)
*p = newP
matchedID, err := match.ScrapedGroup(ctx, c.repository.GroupFinder, p.StoredID, p.Name)
if err != nil {
return err
}
if matchedID != nil {
p.StoredID = matchedID
}
}
return nil
}
func (c *postScraper) postScrapeRelatedGroups(ctx context.Context, items []*models.ScrapedGroup) error {
for _, p := range items {
const related = true
sc, err := c.postScrapeGroup(ctx, *p, related)
if err != nil {
return err
}
newP := sc.(models.ScrapedGroup)
*p = newP
matchedID, err := match.ScrapedGroup(ctx, c.repository.GroupFinder, p.StoredID, p.Name)
if err != nil {
return err
}
if matchedID != nil {
p.StoredID = matchedID
}
}
return nil
}
func (c *postScraper) postScrapeStudio(ctx context.Context, s models.ScrapedStudio, related bool) (_ ScrapedContent, err error) {
r := c.repository
tqb := r.TagFinder
tags, err := postProcessTags(ctx, tqb, s.Tags)
if err != nil {
return nil, err
}
s.Tags = c.filterTags(tags)
// post-process - set the image if applicable
// don't set image for related studios to avoid excessive network calls
if !related {
if err := setStudioImage(ctx, c.client, &s, c.globalConfig); err != nil {
logger.Warnf("Could not set image using URL %s: %s", *s.Image, err.Error())
}
}
// populate URL/URLs
// if URLs are provided, only use those
if len(s.URLs) > 0 {
s.URL = &s.URLs[0]
} else {
urls := []string{}
if s.URL != nil {
urls = append(urls, *s.URL)
}
if len(urls) > 0 {
s.URLs = urls
}
}
return s, nil
}
func (c *postScraper) postScrapeRelatedStudio(ctx context.Context, s *models.ScrapedStudio) error {
if s == nil {
return nil
}
const related = true
sc, err := c.postScrapeStudio(ctx, *s, related)
if err != nil {
return err
}
newS := sc.(models.ScrapedStudio)
*s = newS
if err = match.ScrapedStudio(ctx, c.repository.StudioFinder, s, ""); err != nil {
return err
}
return nil
}
func (c *postScraper) postScrapeScene(ctx context.Context, scene models.ScrapedScene) (_ ScrapedContent, err error) {
// set the URL/URLs field
if scene.URL == nil && len(scene.URLs) > 0 {
scene.URL = &scene.URLs[0]
@@ -241,92 +352,53 @@ func (c Cache) postScrapeScene(ctx context.Context, scene models.ScrapedScene, e
}
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
pqb := r.PerformerFinder
gqb := r.GroupFinder
tqb := r.TagFinder
sqb := r.StudioFinder
tqb := r.TagFinder
for _, p := range scene.Performers {
if p == nil {
continue
}
if err = c.postScrapeRelatedPerformers(ctx, scene.Performers); err != nil {
return nil, err
}
thisIgnoredTags, err := c.postScrapeScenePerformer(ctx, *p, excludeTagRE)
if err != nil {
return err
}
if err = c.postScrapeRelatedMovies(ctx, scene.Movies); err != nil {
return nil, err
}
if err := match.ScrapedPerformer(ctx, pqb, p, ""); err != nil {
return err
}
if err = c.postScrapeRelatedGroups(ctx, scene.Groups); err != nil {
return nil, err
}
ignoredTags = sliceutil.AppendUniques(ignoredTags, thisIgnoredTags)
// HACK - if movies was returned but not groups, add the groups from the movies
// if groups was returned but not movies, add the movies from the groups for backward compatibility
if len(scene.Movies) > 0 && len(scene.Groups) == 0 {
for _, m := range scene.Movies {
g := m.ScrapedGroup()
scene.Groups = append(scene.Groups, &g)
}
for _, p := range scene.Movies {
matchedID, err := match.ScrapedGroup(ctx, gqb, p.StoredID, p.Name)
if err != nil {
return err
}
if matchedID != nil {
p.StoredID = matchedID
}
} else if len(scene.Groups) > 0 && len(scene.Movies) == 0 {
for _, g := range scene.Groups {
m := g.ScrapedMovie()
scene.Movies = append(scene.Movies, &m)
}
}
for _, p := range scene.Groups {
matchedID, err := match.ScrapedGroup(ctx, gqb, p.StoredID, p.Name)
if err != nil {
return err
}
tags, err := postProcessTags(ctx, tqb, scene.Tags)
if err != nil {
return nil, err
}
scene.Tags = c.filterTags(tags)
if matchedID != nil {
p.StoredID = matchedID
}
}
// HACK - if movies was returned but not groups, add the groups from the movies
// if groups was returned but not movies, add the movies from the groups for backward compatibility
if len(scene.Movies) > 0 && len(scene.Groups) == 0 {
for _, m := range scene.Movies {
g := m.ScrapedGroup()
scene.Groups = append(scene.Groups, &g)
}
} else if len(scene.Groups) > 0 && len(scene.Movies) == 0 {
for _, g := range scene.Groups {
m := g.ScrapedMovie()
scene.Movies = append(scene.Movies, &m)
}
}
tags, err := postProcessTags(ctx, tqb, scene.Tags)
if err != nil {
return err
}
scene.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
if scene.Studio != nil {
err := match.ScrapedStudio(ctx, sqb, scene.Studio, "")
if err != nil {
return err
}
}
return nil
}); err != nil {
return nil, nil, err
if err := c.postScrapeRelatedStudio(ctx, scene.Studio); err != nil {
return nil, err
}
// post-process - set the image if applicable
if err := setSceneImage(ctx, c.client, &scene, c.globalConfig); err != nil {
if err := processImageField(ctx, scene.Image, c.client, c.globalConfig); err != nil {
logger.Warnf("Could not set image using URL %s: %v", *scene.Image, err)
}
return scene, ignoredTags, nil
return scene, nil
}
func (c Cache) postScrapeGallery(ctx context.Context, g models.ScrapedGallery, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
func (c *postScraper) postScrapeGallery(ctx context.Context, g models.ScrapedGallery) (_ ScrapedContent, err error) {
// set the URL/URLs field
if g.URL == nil && len(g.URLs) > 0 {
g.URL = &g.URLs[0]
@@ -336,70 +408,65 @@ func (c Cache) postScrapeGallery(ctx context.Context, g models.ScrapedGallery, e
}
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
pqb := r.PerformerFinder
tqb := r.TagFinder
sqb := r.StudioFinder
tqb := r.TagFinder
for _, p := range g.Performers {
err := match.ScrapedPerformer(ctx, pqb, p, "")
if err != nil {
return err
}
}
tags, err := postProcessTags(ctx, tqb, g.Tags)
if err != nil {
return err
}
g.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
if g.Studio != nil {
err := match.ScrapedStudio(ctx, sqb, g.Studio, "")
if err != nil {
return err
}
}
return nil
}); err != nil {
return nil, nil, err
if err = c.postScrapeRelatedPerformers(ctx, g.Performers); err != nil {
return nil, err
}
return g, ignoredTags, nil
tags, err := postProcessTags(ctx, tqb, g.Tags)
if err != nil {
return nil, err
}
g.Tags = c.filterTags(tags)
if err := c.postScrapeRelatedStudio(ctx, g.Studio); err != nil {
return nil, err
}
return g, nil
}
func (c Cache) postScrapeImage(ctx context.Context, image models.ScrapedImage, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
func (c *postScraper) postScrapeImage(ctx context.Context, image models.ScrapedImage) (_ ScrapedContent, err error) {
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
pqb := r.PerformerFinder
tqb := r.TagFinder
sqb := r.StudioFinder
tqb := r.TagFinder
for _, p := range image.Performers {
if err := match.ScrapedPerformer(ctx, pqb, p, ""); err != nil {
return err
}
}
if err = c.postScrapeRelatedPerformers(ctx, image.Performers); err != nil {
return nil, err
}
tags, err := postProcessTags(ctx, tqb, image.Tags)
tags, err := postProcessTags(ctx, tqb, image.Tags)
if err != nil {
return nil, err
}
image.Tags = c.filterTags(tags)
if err := c.postScrapeRelatedStudio(ctx, image.Studio); err != nil {
return nil, err
}
return image, nil
}
// postScrapeSingle handles post-processing of a single scraped content item.
// This is a convenience function that includes logging the ignored tags, as opposed to logging them in the caller.
func (c Cache) postScrapeSingle(ctx context.Context, content ScrapedContent) (ret ScrapedContent, err error) {
pp := postScraper{
Cache: c,
excludeTagRE: c.compileExcludeTagPatterns(),
}
if err := c.repository.WithReadTxn(ctx, func(ctx context.Context) error {
ret, err = pp.postScrape(ctx, content)
if err != nil {
return err
}
image.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
if image.Studio != nil {
err := match.ScrapedStudio(ctx, sqb, image.Studio, "")
if err != nil {
return err
}
}
return nil
}); err != nil {
return nil, nil, err
return nil, err
}
return image, ignoredTags, nil
LogIgnoredTags(pp.ignoredTags)
return ret, nil
}

View File

@@ -261,7 +261,7 @@ func (s *xpathScraper) scrapeImageByImage(ctx context.Context, image *models.Ima
func (s *xpathScraper) loadURL(ctx context.Context, url string) (*html.Node, error) {
r, err := loadURL(ctx, url, s.client, s.config, s.globalConfig)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to load URL %q: %w", url, err)
}
ret, err := html.Parse(r)

View File

@@ -44,3 +44,11 @@ func UniqueFold(s []string) []string {
}
return ret
}
// TrimSpace trims whitespace from each string in a slice.
func TrimSpace(s []string) []string {
for i, v := range s {
s[i] = strings.TrimSpace(v)
}
return s
}

View File

@@ -41,18 +41,31 @@ func (s *customFieldsStore) SetCustomFields(ctx context.Context, id int, values
case values.Partial != nil:
partial = true
valMap = values.Partial
default:
return nil
}
if err := s.validateCustomFields(valMap); err != nil {
if valMap != nil {
if err := s.validateCustomFields(valMap, values.Remove); err != nil {
return err
}
if err := s.setCustomFields(ctx, id, valMap, partial); err != nil {
return err
}
}
if err := s.deleteCustomFields(ctx, id, values.Remove); err != nil {
return err
}
return s.setCustomFields(ctx, id, valMap, partial)
return nil
}
func (s *customFieldsStore) validateCustomFields(values map[string]interface{}) error {
func (s *customFieldsStore) validateCustomFields(values map[string]interface{}, deleteKeys []string) error {
// if values is nil, nothing to validate
if values == nil {
return nil
}
// ensure that custom field names are valid
// no leading or trailing whitespace, no empty strings
for k := range values {
@@ -61,6 +74,13 @@ func (s *customFieldsStore) validateCustomFields(values map[string]interface{})
}
}
// ensure delete keys are not also in values
for _, k := range deleteKeys {
if _, ok := values[k]; ok {
return fmt.Errorf("custom field name %q cannot be in both values and delete keys", k)
}
}
return nil
}
@@ -130,6 +150,22 @@ func (s *customFieldsStore) setCustomFields(ctx context.Context, id int, values
return nil
}
func (s *customFieldsStore) deleteCustomFields(ctx context.Context, id int, keys []string) error {
if len(keys) == 0 {
return nil
}
q := dialect.Delete(s.table).
Where(s.fk.Eq(id)).
Where(goqu.I("field").In(keys))
if _, err := exec(ctx, q); err != nil {
return fmt.Errorf("deleting custom fields: %w", err)
}
return nil
}
func (s *customFieldsStore) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) {
q := dialect.Select("field", "value").From(s.table).Where(s.fk.Eq(id))

View File

@@ -64,6 +64,18 @@ func TestSetCustomFields(t *testing.T) {
}),
false,
},
{
"valid remove",
models.CustomFieldsInput{
Remove: []string{"real"},
},
func() map[string]interface{} {
m := getPerformerCustomFields(performerIdx)
delete(m, "real")
return m
}(),
false,
},
{
"leading space full",
models.CustomFieldsInput{
@@ -144,16 +156,38 @@ func TestSetCustomFields(t *testing.T) {
nil,
true,
},
{
"invalid remove full",
models.CustomFieldsInput{
Full: map[string]interface{}{
"key": "value",
},
Remove: []string{"key"},
},
nil,
true,
},
{
"invalid remove partial",
models.CustomFieldsInput{
Partial: map[string]interface{}{
"real": float64(4.56),
},
Remove: []string{"real"},
},
nil,
true,
},
}
// use performer custom fields store
store := db.Performer
id := performerIDs[performerIdx]
assert := assert.New(t)
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
err := store.SetCustomFields(ctx, id, tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("SetCustomFields() error = %v, wantErr %v", err, tt.wantErr)

View File

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

View File

@@ -5,6 +5,7 @@ import (
"time"
"github.com/stashapp/stash/pkg/models"
"gopkg.in/guregu/null.v4"
)
const sqliteDateLayout = "2006-01-02"
@@ -54,12 +55,12 @@ func (d NullDate) Value() (driver.Value, error) {
return d.Date.Format(sqliteDateLayout), nil
}
func (d *NullDate) DatePtr() *models.Date {
func (d *NullDate) DatePtr(precision null.Int) *models.Date {
if d == nil || !d.Valid {
return nil
}
return &models.Date{Time: d.Date}
return &models.Date{Time: d.Date, Precision: models.DatePrecision(precision.Int64)}
}
func NullDateFromDatePtr(d *models.Date) NullDate {
@@ -68,3 +69,11 @@ func NullDateFromDatePtr(d *models.Date) NullDate {
}
return NullDate{Date: d.Time, Valid: true}
}
func datePrecisionFromDatePtr(d *models.Date) null.Int {
if d == nil {
// default to day precision
return null.Int{}
}
return null.IntFrom(int64(d.Precision))
}

View File

@@ -625,9 +625,9 @@ func (qb *FileStore) find(ctx context.Context, id models.FileID) (models.File, e
}
// FindByPath returns the first file that matches the given path. Wildcard characters are supported.
func (qb *FileStore) FindByPath(ctx context.Context, p string) (models.File, error) {
func (qb *FileStore) FindByPath(ctx context.Context, p string, caseSensitive bool) (models.File, error) {
ret, err := qb.FindAllByPath(ctx, p)
ret, err := qb.FindAllByPath(ctx, p, caseSensitive)
if err != nil {
return nil, err
@@ -642,7 +642,7 @@ func (qb *FileStore) FindByPath(ctx context.Context, p string) (models.File, err
// FindAllByPath returns all the files that match the given path.
// Wildcard characters are supported.
func (qb *FileStore) FindAllByPath(ctx context.Context, p string) ([]models.File, error) {
func (qb *FileStore) FindAllByPath(ctx context.Context, p string, caseSensitive bool) ([]models.File, error) {
// separate basename from path
basename := filepath.Base(p)
dirName := filepath.Dir(p)
@@ -657,7 +657,7 @@ func (qb *FileStore) FindAllByPath(ctx context.Context, p string) ([]models.File
// like uses case-insensitive matching. Only use like if wildcards are used
q := qb.selectDataset().Prepared(true)
if strings.Contains(basename, "%") || strings.Contains(dirName, "%") {
if strings.Contains(basename, "%") || strings.Contains(dirName, "%") || !caseSensitive {
q = q.Where(
folderTable.Col("path").Like(dirName),
table.Col("basename").Like(basename),

View File

@@ -551,7 +551,7 @@ func Test_FileStore_FindByPath(t *testing.T) {
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
got, err := qb.FindByPath(ctx, tt.path)
got, err := qb.FindByPath(ctx, tt.path, true)
if (err != nil) != tt.wantErr {
t.Errorf("FileStore.FindByPath() error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -96,6 +96,9 @@ type join struct {
onClause string
joinType string
args []interface{}
// if true, indicates this is required for sorting only
sort bool
}
// equals returns true if the other join alias/table is equal to this one
@@ -127,30 +130,45 @@ func (j join) toSQL() string {
type joins []join
// addUnique only adds if not already present
// returns true if added
func (j *joins) addUnique(newJoin join) bool {
found := false
for i, jj := range *j {
if jj.equals(newJoin) {
found = true
// if sort is false on the new join, but true on the existing, set the false
if !newJoin.sort && jj.sort {
(*j)[i].sort = false
}
break
}
}
if !found {
*j = append(*j, newJoin)
}
return !found
}
func (j *joins) add(newJoins ...join) {
// only add if not already joined
for _, newJoin := range newJoins {
found := false
for _, jj := range *j {
if jj.equals(newJoin) {
found = true
break
}
}
if !found {
*j = append(*j, newJoin)
}
j.addUnique(newJoin)
}
}
func (j *joins) toSQL() string {
func (j *joins) toSQL(includeSortPagination bool) string {
if len(*j) == 0 {
return ""
}
var ret []string
for _, jj := range *j {
// skip sort-only joins if not including sort/pagination
if !includeSortPagination && jj.sort {
continue
}
ret = append(ret, jj.toSQL())
}

View File

@@ -292,8 +292,16 @@ func (qb *FolderStore) FindMany(ctx context.Context, ids []models.FolderID) ([]*
return folders, nil
}
func (qb *FolderStore) FindByPath(ctx context.Context, p string) (*models.Folder, error) {
q := qb.selectDataset().Prepared(true).Where(qb.table().Col("path").Eq(p))
func (qb *FolderStore) FindByPath(ctx context.Context, p string, caseSensitive bool) (*models.Folder, error) {
// use like for case insensitive search
var criterion exp.BooleanExpression
if caseSensitive {
criterion = qb.table().Col("path").Eq(p)
} else {
criterion = qb.table().Col("path").ILike(p)
}
q := qb.selectDataset().Prepared(true).Where(criterion)
ret, err := qb.get(ctx, q)
if err != nil && !errors.Is(err, sql.ErrNoRows) {

View File

@@ -89,7 +89,7 @@ func Test_FolderStore_Create(t *testing.T) {
assert.Equal(copy, s)
// ensure can find the folder
found, err := qb.FindByPath(ctx, path)
found, err := qb.FindByPath(ctx, path, true)
if err != nil {
t.Errorf("FolderStore.Find() error = %v", err)
}
@@ -180,7 +180,7 @@ func Test_FolderStore_Update(t *testing.T) {
return
}
s, err := qb.FindByPath(ctx, path)
s, err := qb.FindByPath(ctx, path, true)
if err != nil {
t.Errorf("FolderStore.Find() error = %v", err)
}
@@ -228,7 +228,7 @@ func Test_FolderStore_FindByPath(t *testing.T) {
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
got, err := qb.FindByPath(ctx, tt.path)
got, err := qb.FindByPath(ctx, tt.path, true)
if (err != nil) != tt.wantErr {
t.Errorf("FolderStore.FindByPath() error = %v, wantErr %v", err, tt.wantErr)
return

View File

@@ -30,12 +30,13 @@ const (
)
type galleryRow struct {
ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"`
Code zero.String `db:"code"`
Date NullDate `db:"date"`
Details zero.String `db:"details"`
Photographer zero.String `db:"photographer"`
ID int `db:"id" goqu:"skipinsert"`
Title zero.String `db:"title"`
Code zero.String `db:"code"`
Date NullDate `db:"date"`
DatePrecision null.Int `db:"date_precision"`
Details zero.String `db:"details"`
Photographer zero.String `db:"photographer"`
// expressed as 1-100
Rating null.Int `db:"rating"`
Organized bool `db:"organized"`
@@ -50,6 +51,7 @@ func (r *galleryRow) fromGallery(o models.Gallery) {
r.Title = zero.StringFrom(o.Title)
r.Code = zero.StringFrom(o.Code)
r.Date = NullDateFromDatePtr(o.Date)
r.DatePrecision = datePrecisionFromDatePtr(o.Date)
r.Details = zero.StringFrom(o.Details)
r.Photographer = zero.StringFrom(o.Photographer)
r.Rating = intFromPtr(o.Rating)
@@ -74,7 +76,7 @@ func (r *galleryQueryRow) resolve() *models.Gallery {
ID: r.ID,
Title: r.Title.String,
Code: r.Code.String,
Date: r.Date.DatePtr(),
Date: r.Date.DatePtr(r.DatePrecision),
Details: r.Details.String,
Photographer: r.Photographer.String,
Rating: nullIntPtr(r.Rating),
@@ -102,7 +104,7 @@ type galleryRowRecord struct {
func (r *galleryRowRecord) fromPartial(o models.GalleryPartial) {
r.setNullString("title", o.Title)
r.setNullString("code", o.Code)
r.setNullDate("date", o.Date)
r.setNullDate("date", "date_precision", o.Date)
r.setNullString("details", o.Details)
r.setNullString("photographer", o.Photographer)
r.setNullInt("rating", o.Rating)
@@ -800,10 +802,12 @@ func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.F
addFileTable := func() {
query.addJoins(
join{
sort: true,
table: galleriesFilesTable,
onClause: "galleries_files.gallery_id = galleries.id",
},
join{
sort: true,
table: fileTable,
onClause: "galleries_files.file_id = files.id",
},
@@ -813,10 +817,12 @@ func (qb *GalleryStore) setGallerySort(query *queryBuilder, findFilter *models.F
addFolderTable := func() {
query.addJoins(
join{
sort: true,
table: folderTable,
onClause: "folders.id = galleries.folder_id",
},
join{
sort: true,
table: folderTable,
as: "file_folder",
onClause: "files.parent_folder_id = file_folder.id",

View File

@@ -32,11 +32,12 @@ const (
)
type groupRow struct {
ID int `db:"id" goqu:"skipinsert"`
Name zero.String `db:"name"`
Aliases zero.String `db:"aliases"`
Duration null.Int `db:"duration"`
Date NullDate `db:"date"`
ID int `db:"id" goqu:"skipinsert"`
Name zero.String `db:"name"`
Aliases zero.String `db:"aliases"`
Duration null.Int `db:"duration"`
Date NullDate `db:"date"`
DatePrecision null.Int `db:"date_precision"`
// expressed as 1-100
Rating null.Int `db:"rating"`
StudioID null.Int `db:"studio_id,omitempty"`
@@ -56,6 +57,7 @@ func (r *groupRow) fromGroup(o models.Group) {
r.Aliases = zero.StringFrom(o.Aliases)
r.Duration = intFromPtr(o.Duration)
r.Date = NullDateFromDatePtr(o.Date)
r.DatePrecision = datePrecisionFromDatePtr(o.Date)
r.Rating = intFromPtr(o.Rating)
r.StudioID = intFromPtr(o.StudioID)
r.Director = zero.StringFrom(o.Director)
@@ -70,7 +72,7 @@ func (r *groupRow) resolve() *models.Group {
Name: r.Name.String,
Aliases: r.Aliases.String,
Duration: nullIntPtr(r.Duration),
Date: r.Date.DatePtr(),
Date: r.Date.DatePtr(r.DatePrecision),
Rating: nullIntPtr(r.Rating),
StudioID: nullIntPtr(r.StudioID),
Director: r.Director.String,
@@ -90,7 +92,7 @@ func (r *groupRowRecord) fromPartial(o models.GroupPartial) {
r.setNullString("name", o.Name)
r.setNullString("aliases", o.Aliases)
r.setNullInt("duration", o.Duration)
r.setNullDate("date", o.Date)
r.setNullDate("date", "date_precision", o.Date)
r.setNullInt("rating", o.Rating)
r.setNullInt("studio_id", o.StudioID)
r.setNullString("director", o.Director)
@@ -518,7 +520,7 @@ func (qb *GroupStore) setGroupSort(query *queryBuilder, findFilter *models.FindF
} else {
// this will give unexpected results if the query is not filtered by a parent group and
// the group has multiple parents and order indexes
query.join(groupRelationsTable, "", "groups.id = groups_relations.sub_id")
query.joinSort(groupRelationsTable, "", "groups.id = groups_relations.sub_id")
query.sortAndPagination += getSort("order_index", direction, groupRelationsTable)
}
case "tag_count":

View File

@@ -34,15 +34,16 @@ type imageRow struct {
Title zero.String `db:"title"`
Code zero.String `db:"code"`
// expressed as 1-100
Rating null.Int `db:"rating"`
Date NullDate `db:"date"`
Details zero.String `db:"details"`
Photographer zero.String `db:"photographer"`
Organized bool `db:"organized"`
OCounter int `db:"o_counter"`
StudioID null.Int `db:"studio_id,omitempty"`
CreatedAt Timestamp `db:"created_at"`
UpdatedAt Timestamp `db:"updated_at"`
Rating null.Int `db:"rating"`
Date NullDate `db:"date"`
DatePrecision null.Int `db:"date_precision"`
Details zero.String `db:"details"`
Photographer zero.String `db:"photographer"`
Organized bool `db:"organized"`
OCounter int `db:"o_counter"`
StudioID null.Int `db:"studio_id,omitempty"`
CreatedAt Timestamp `db:"created_at"`
UpdatedAt Timestamp `db:"updated_at"`
}
func (r *imageRow) fromImage(i models.Image) {
@@ -51,6 +52,7 @@ func (r *imageRow) fromImage(i models.Image) {
r.Code = zero.StringFrom(i.Code)
r.Rating = intFromPtr(i.Rating)
r.Date = NullDateFromDatePtr(i.Date)
r.DatePrecision = datePrecisionFromDatePtr(i.Date)
r.Details = zero.StringFrom(i.Details)
r.Photographer = zero.StringFrom(i.Photographer)
r.Organized = i.Organized
@@ -74,7 +76,7 @@ func (r *imageQueryRow) resolve() *models.Image {
Title: r.Title.String,
Code: r.Code.String,
Rating: nullIntPtr(r.Rating),
Date: r.Date.DatePtr(),
Date: r.Date.DatePtr(r.DatePrecision),
Details: r.Details.String,
Photographer: r.Photographer.String,
Organized: r.Organized,
@@ -103,7 +105,7 @@ func (r *imageRowRecord) fromPartial(i models.ImagePartial) {
r.setNullString("title", i.Title)
r.setNullString("code", i.Code)
r.setNullInt("rating", i.Rating)
r.setNullDate("date", i.Date)
r.setNullDate("date", "date_precision", i.Date)
r.setNullString("details", i.Details)
r.setNullString("photographer", i.Photographer)
r.setBool("organized", i.Organized)
@@ -682,6 +684,20 @@ func (qb *ImageStore) OCountByPerformerID(ctx context.Context, performerID int)
return ret, nil
}
func (qb *ImageStore) OCountByStudioID(ctx context.Context, studioID int) (int, error) {
table := qb.table()
q := dialect.Select(goqu.COALESCE(goqu.SUM("o_counter"), 0)).From(table).Where(
table.Col(studioIDColumn).Eq(studioID),
)
var ret int
if err := querySimple(ctx, q, &ret); err != nil {
return 0, err
}
return ret, nil
}
func (qb *ImageStore) OCount(ctx context.Context) (int, error) {
table := qb.table()
@@ -951,10 +967,12 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod
addFilesJoin := func() {
q.addJoins(
join{
sort: true,
table: imagesFilesTable,
onClause: "images_files.image_id = images.id",
},
join{
sort: true,
table: fileTable,
onClause: "images_files.file_id = files.id",
},
@@ -963,6 +981,7 @@ func (qb *ImageStore) setImageSortAndPagination(q *queryBuilder, findFilter *mod
addFolderJoin := func() {
q.addJoins(join{
sort: true,
table: folderTable,
onClause: "files.parent_folder_id = folders.id",
})

View File

@@ -0,0 +1,13 @@
ALTER TABLE "scenes" ADD COLUMN "date_precision" TINYINT;
ALTER TABLE "images" ADD COLUMN "date_precision" TINYINT;
ALTER TABLE "galleries" ADD COLUMN "date_precision" TINYINT;
ALTER TABLE "groups" ADD COLUMN "date_precision" TINYINT;
ALTER TABLE "performers" ADD COLUMN "birthdate_precision" TINYINT;
ALTER TABLE "performers" ADD COLUMN "death_date_precision" TINYINT;
UPDATE "scenes" SET "date_precision" = 0 WHERE "date" IS NOT NULL;
UPDATE "images" SET "date_precision" = 0 WHERE "date" IS NOT NULL;
UPDATE "galleries" SET "date_precision" = 0 WHERE "date" IS NOT NULL;
UPDATE "groups" SET "date_precision" = 0 WHERE "date" IS NOT NULL;
UPDATE "performers" SET "birthdate_precision" = 0 WHERE "birthdate" IS NOT NULL;
UPDATE "performers" SET "death_date_precision" = 0 WHERE "death_date" IS NOT NULL;

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