Compare commits

..

218 Commits

Author SHA1 Message Date
WithoutPants
2a373a25ca Update changelog 2024-06-27 10:16:42 +10:00
dogwithakeyboard
e116775d60 Check for null disambiguation on validate (#5019) 2024-06-27 10:14:14 +10:00
CJ
c5bafeb15c Address resize loop (#5004) 2024-06-27 09:11:00 +10:00
WithoutPants
205b24499b Fix key for tagger scenes (#5000) 2024-06-27 09:09:33 +10:00
WithoutPants
48035061ec Fix identify clearing parent studio when merging (#4993)
* Refactor ScrapedStudio.ToPartial signature
* Add unit test
* Don't clear parent studio during ToPartial
2024-06-27 09:08:26 +10:00
anonymous-ants
e4b89064b1 Fix typos in docs (en) (#4946) 2024-06-11 08:26:56 +10:00
InfiniteStash
efede32dd7 Fix studio selection in scraping dialogs (#4953) 2024-06-11 08:26:03 +10:00
WithoutPants
d1998cb5b0 Update changelog 2024-06-07 14:54:37 +10:00
WithoutPants
60446af145 Add console javascript object for backwards compatibility (#4944) 2024-06-07 14:53:51 +10:00
WithoutPants
dbfa450ace Fix tag display issue in performer scrape dialog (#4943) 2024-06-07 14:42:48 +10:00
DogmaDragon
4b8af18fab Update manual documentation (#4921) 2024-06-06 14:46:28 +10:00
NodudeWasTaken
124ea609fe Fix hw transcoding not detecting filtering errors (#4934) 2024-06-06 11:58:19 +10:00
WithoutPants
0a07194110 Fix reading task defaults (#4931) 2024-06-05 16:04:14 +10:00
WithoutPants
b232e58b06 Set config file when provided (#4909) 2024-06-03 12:44:15 +10:00
Weblate (bot)
b3f8839ef7 Translations update from Hosted Weblate (#4904)
* Translated using Weblate (French)

Currently translated at 100.0% (1149 of 1149 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/fr/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1149 of 1149 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/sv/

* Translated using Weblate (Thai)

Currently translated at 45.6% (525 of 1149 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/th/

* Translated using Weblate (Catalan)

Currently translated at 38.7% (445 of 1149 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ca/

* Translated using Weblate (French)

Currently translated at 100.0% (1149 of 1149 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/fr/

* Translated using Weblate (French)

Currently translated at 100.0% (1149 of 1149 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/fr/

---------

Co-authored-by: Larsluph <remarso59+weblate@gmail.com>
Co-authored-by: alpacaserious <srhsgsef@gmail.com>
Co-authored-by: PZKL48 <nicorobinhood321@gmail.com>
Co-authored-by: hardwa ps es que Retr0 <west0yss@gmail.com>
Co-authored-by: doodoo <adr.web@hotmail.fr>
2024-06-03 11:51:55 +10:00
WithoutPants
540e80c86b Support patching select sorting function (#4903)
* Fix return types for RegisterComponent and PatchFunction
* Add support for patching TagSelect.sort
* Add support for patching PerformerSelect.sort
* Patch other select component sort functions
* Document patchable functions/components
2024-05-31 18:16:31 +10:00
WithoutPants
eec31723bd Tweak relevant sort algorithm (#4902)
* Remove multi-space before getting words
* Trim names and aliases
2024-05-31 17:50:05 +10:00
WithoutPants
3b146588c6 Fix ffmpeg resolution when in current directory (#4899)
* Use absolute path to resolve ffmpeg in config directory
* Pass absolute config path to plugins
2024-05-30 15:50:27 +10:00
WithoutPants
2b699fcf95 Default create missing to true in Identify (#4873)
* Default create missing to true in Identify
* Update manual
2024-05-30 13:12:07 +10:00
Weblate (bot)
d6158d70a9 Translations update from Hosted Weblate (#4878)
* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1149 of 1149 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Added translation using Weblate (Afrikaans)

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1149 of 1149 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/es/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1149 of 1149 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Afrikaans)

Currently translated at 3.6% (42 of 1149 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/af/

* Add missing language options and rename locale files

---------

Co-authored-by: wql219 <160428035+wql219@users.noreply.github.com>
Co-authored-by: ceresbeet <ceresbeet@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-05-30 10:26:22 +10:00
WithoutPants
cf45ac883e Fix operations not using effective filter on sub-view pages (#4896) 2024-05-29 08:01:54 +10:00
WithoutPants
e4267a0d83 Fix relevance sorting when name/alias contains leading spaces (#4895) 2024-05-29 07:49:23 +10:00
WithoutPants
2ca53714a6 Fix SQL error when querying performers with missing aliases (#4894) 2024-05-29 07:48:35 +10:00
DogmaDragon
0ff0f9c8ec Delete .github/FUNDING.yml (#4887)
Replaced with org-wide funding.yml file. https://github.com/stashapp/.github/blob/main/FUNDING.yml
2024-05-28 16:38:30 +10:00
WithoutPants
9c8bd853c5 Fix lint error 2024-05-28 16:37:13 +10:00
WithoutPants
bf0e0f2210 Add v0.26.0 changelog (#4875) 2024-05-24 12:13:30 +10:00
WithoutPants
c314515b8f Add polyfill for EventTarget constructor (#4874) 2024-05-24 08:06:41 +10:00
WithoutPants
28b5fbfd4d Apply scraped tag exclusions galleries and performers (#4872) 2024-05-24 08:06:23 +10:00
WithoutPants
3dd218e1ba Clarify stash hosted funscript description #4850 2024-05-23 14:35:39 +10:00
WithoutPants
eb67f7f4d6 Fix corrupted frontPageContent keys during migration (#4870)
* Add NestedMap.Delete
* Migrate corrupt frontPageContent keys
2024-05-23 13:59:39 +10:00
WithoutPants
98d210f7f9 Fix inconsistent field names in javascript plugin hooks (#4869) 2024-05-23 11:28:15 +10:00
WithoutPants
4794a1d453 Fix setting pointers corrupting config in memory (#4868) 2024-05-23 10:56:18 +10:00
puc9
77ef16570b Add JS plugin name to the log line (#4867) 2024-05-23 08:05:12 +10:00
WithoutPants
99d97804f4 Change umask when creating config file to exclude user write (#4866) 2024-05-22 14:59:25 +10:00
WithoutPants
89553864f5 Enforce whitelist for sort values (#4865) 2024-05-22 14:59:08 +10:00
WithoutPants
865208844c Fix python not being resolved correctly if not in path (#4864)
* Don't replace plugin exec path if python command. Don't clobber exec
* Fix logging of python resolve errors
2024-05-22 14:57:36 +10:00
Weblate (bot)
062d566195 Translations update from Hosted Weblate (#4694)
* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1136 of 1136 strings)

Co-authored-by: Dee <dongfengweixiao@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/
Translation: stashapp/stash

* Translated using Weblate (Bengali (Bangladesh))

Currently translated at 22.7% (259 of 1139 strings)

Co-authored-by: Faridin Tzy <faridin05saif@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/bn_BD/
Translation: stashapp/stash

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1147 of 1147 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (1146 of 1146 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (1146 of 1146 strings)

Translated using Weblate (Swedish)

Currently translated at 100.0% (1139 of 1139 strings)

Translated using Weblate (Swedish)

Currently translated at 99.5% (1134 of 1139 strings)

Co-authored-by: alpacaserious <srhsgsef@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/sv/
Translation: stashapp/stash

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1147 of 1147 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1147 of 1147 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1146 of 1146 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1146 of 1146 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1139 of 1139 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1139 of 1139 strings)

Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1139 of 1139 strings)

Co-authored-by: wql219 <wanqinglin219@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/
Translation: stashapp/stash

* Translated using Weblate (Czech)

Currently translated at 100.0% (1146 of 1146 strings)

Translated using Weblate (Czech)

Currently translated at 89.7% (1022 of 1139 strings)

Co-authored-by: Nymeria <Tractorb@seznam.cz>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/cs/
Translation: stashapp/stash

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Co-authored-by: Hosted Weblate <hosted@weblate.org>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/
Translation: stashapp/stash

* Translated using Weblate (Korean)

Currently translated at 82.2% (943 of 1146 strings)

Co-authored-by: キムキム厶 <kimukimusi52@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ko/
Translation: stashapp/stash

* Translated using Weblate (Russian)

Currently translated at 100.0% (1147 of 1147 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (1146 of 1146 strings)

Co-authored-by: Old gnome <orpgnome@users.noreply.hosted.weblate.org>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ru/
Translation: stashapp/stash

* Translated using Weblate (Italian)

Currently translated at 79.0% (906 of 1146 strings)

Co-authored-by: Walter Saporiti <monsena@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/it/
Translation: stashapp/stash

* Translated using Weblate (Japanese)

Currently translated at 78.7% (902 of 1146 strings)

Co-authored-by: Furin Kazan <nezoko@digdig.org>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ja/
Translation: stashapp/stash

* Translated using Weblate (Japanese)

Currently translated at 78.7% (902 of 1146 strings)

Co-authored-by: すずひろ <suzuhiroruri@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ja/
Translation: stashapp/stash

* Translated using Weblate (Japanese)

Currently translated at 78.9% (905 of 1146 strings)

Translated using Weblate (Japanese)

Currently translated at 78.7% (902 of 1146 strings)

Co-authored-by: Furin Kazan <nezoko@digdig.org>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ja/
Translation: stashapp/stash

* Translated using Weblate (Japanese)

Currently translated at 83.7% (960 of 1146 strings)

Co-authored-by: すずひろ <suzuhiroruri@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ja/
Translation: stashapp/stash

* Translated using Weblate (Japanese)

Currently translated at 83.7% (960 of 1146 strings)

Co-authored-by: Furin Kazan <nezoko@digdig.org>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ja/
Translation: stashapp/stash

* Translated using Weblate (Spanish)

Currently translated at 74.0% (849 of 1146 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/es/
Translation: stashapp/stash

* Translated using Weblate (Japanese)

Currently translated at 84.5% (969 of 1146 strings)

Co-authored-by: すずひろ <suzuhiroruri@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ja/
Translation: stashapp/stash

* Translated using Weblate (Japanese)

Currently translated at 84.5% (969 of 1146 strings)

Co-authored-by: Furin Kazan <nezoko@digdig.org>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ja/
Translation: stashapp/stash

* Translated using Weblate (Korean)

Currently translated at 97.9% (1122 of 1146 strings)

Translated using Weblate (Korean)

Currently translated at 97.9% (1122 of 1146 strings)

Co-authored-by: 이예찬 <yechan24680@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ko/
Translation: stashapp/stash

* Translated using Weblate (Spanish)

Currently translated at 78.2% (897 of 1146 strings)

Co-authored-by: VoloShiNov <rucholcf@hotmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/es/
Translation: stashapp/stash

* Translated using Weblate (Spanish)

Currently translated at 79.0% (907 of 1147 strings)

Translated using Weblate (Spanish)

Currently translated at 78.4% (899 of 1146 strings)

Translated using Weblate (Spanish)

Currently translated at 78.2% (897 of 1146 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/es/
Translation: stashapp/stash

* Translated using Weblate (Indonesian)

Currently translated at 44.7% (513 of 1147 strings)

Translated using Weblate (Indonesian)

Currently translated at 43.8% (503 of 1146 strings)

Co-authored-by: Tukimin Satrio <k797du3eh@mozmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/id/
Translation: stashapp/stash

* Translated using Weblate (German)

Currently translated at 83.9% (963 of 1147 strings)

Translated using Weblate (German)

Currently translated at 83.5% (957 of 1146 strings)

Co-authored-by: Justus Nacken <justus.nacken@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/de/
Translation: stashapp/stash

* Translated using Weblate (Finnish)

Currently translated at 73.1% (838 of 1146 strings)

Co-authored-by: gimmeliina <jarruraita@outlook.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/fi/
Translation: stashapp/stash

* Translated using Weblate (German)

Currently translated at 83.9% (963 of 1147 strings)

Co-authored-by: Ben <benteske.horny+hostedwebplate@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/de/
Translation: stashapp/stash

* Translated using Weblate (Spanish)

Currently translated at 79.0% (907 of 1147 strings)

Co-authored-by: BodoBaas <rossgelle67@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/es/
Translation: stashapp/stash

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1147 of 1147 strings)

Co-authored-by: Philip Wang <philpw99@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/
Translation: stashapp/stash

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1147 of 1147 strings)

Co-authored-by: 张Ly <zanzhz1101@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/
Translation: stashapp/stash

* Added translation using Weblate (Nepali)

Co-authored-by: Lazy Bone <pcoc2779@gmail.com>

* Translated using Weblate (Catalan)

Currently translated at 9.4% (108 of 1147 strings)

Added translation using Weblate (Catalan)

Co-authored-by: hardwa ps es que Retr0 <west0yss@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ca/
Translation: stashapp/stash

* Translated using Weblate (Czech)

Currently translated at 100.0% (1147 of 1147 strings)

Co-authored-by: Adam Beneš <toohka@protonmail.com>
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/cs/
Translation: stashapp/stash

* Translated using Weblate (Catalan)

Currently translated at 22.1% (254 of 1147 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ca/

* Translated using Weblate (Spanish)

Currently translated at 79.0% (907 of 1147 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/es/

* Translated using Weblate (Catalan)

Currently translated at 29.2% (336 of 1147 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ca/

* Translated using Weblate (Spanish)

Currently translated at 94.0% (1079 of 1147 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/es/

* Translated using Weblate (Spanish)

Currently translated at 99.9% (1146 of 1147 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/es/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1147 of 1147 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Italian)

Currently translated at 78.9% (906 of 1147 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/it/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1147 of 1147 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/es/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1147 of 1147 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/sv/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (1149 of 1149 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/es/

---------

Co-authored-by: Dee <dongfengweixiao@hotmail.com>
Co-authored-by: Faridin Tzy <faridin05saif@gmail.com>
Co-authored-by: alpacaserious <srhsgsef@gmail.com>
Co-authored-by: wql219 <wanqinglin219@hotmail.com>
Co-authored-by: Nymeria <Tractorb@seznam.cz>
Co-authored-by: キムキム厶 <kimukimusi52@gmail.com>
Co-authored-by: Old gnome <orpgnome@users.noreply.hosted.weblate.org>
Co-authored-by: Walter Saporiti <monsena@gmail.com>
Co-authored-by: Furin Kazan <nezoko@digdig.org>
Co-authored-by: すずひろ <suzuhiroruri@gmail.com>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: 이예찬 <yechan24680@gmail.com>
Co-authored-by: VoloShiNov <rucholcf@hotmail.com>
Co-authored-by: Tukimin Satrio <k797du3eh@mozmail.com>
Co-authored-by: Justus Nacken <justus.nacken@gmail.com>
Co-authored-by: gimmeliina <jarruraita@outlook.com>
Co-authored-by: Ben <benteske.horny+hostedwebplate@gmail.com>
Co-authored-by: BodoBaas <rossgelle67@gmail.com>
Co-authored-by: Philip Wang <philpw99@gmail.com>
Co-authored-by: 张Ly <zanzhz1101@gmail.com>
Co-authored-by: Lazy Bone <pcoc2779@gmail.com>
Co-authored-by: hardwa ps es que Retr0 <west0yss@gmail.com>
Co-authored-by: Adam Beneš <toohka@protonmail.com>
Co-authored-by: Faileador <faileador1@gmail.com>
Co-authored-by: wql219 <160428035+wql219@users.noreply.github.com>
Co-authored-by: parduz <parduz@yahoo.it>
2024-05-21 17:03:44 +10:00
WithoutPants
bfc60bb23f Replace viper with koanf (#4845)
* Migrate to koanf
* Use temp logger for crashes before config is initialised
* Remove snake case hacks
* Add migration for config file keys
* Add migration note for new migration
* Renamed viper functions
* Remove front-end viper workaround
* Correctly default scan options
2024-05-21 11:24:47 +10:00
CJ
0fa71be697 Add scan option to force gallery zip rescan (#4113)
* Add scan option to force rescan
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-05-20 13:52:36 +10:00
dependabot[bot]
5ba1ea8fbc Bump vite from 4.5.2 to 4.5.3 in /ui/v2.5 (#4745)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.5.2 to 4.5.3.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v4.5.3/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.5.3/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-20 13:44:56 +10:00
WithoutPants
4d3dc0aec8 Default SetCoverImage to true in identify (#4855) 2024-05-20 13:13:16 +10:00
CJ
b12269e477 update popover delay (#4835)
* adjust leave delay to match enter delay
2024-05-20 13:13:01 +10:00
dogwithakeyboard
e32593023e Add additional fields and restyle Movie select and Gallery select (#4851)
* Add new fields and restyle gallery selector
* Add new fields and style movie selector
2024-05-20 13:10:36 +10:00
bob123491234
3e3e8b95e2 Add scenes filter to galleries (#4840) 2024-05-20 13:04:45 +10:00
WithoutPants
769540be55 Warn if ffmpeg lacks codecs (#4852)
Prefer ffmpeg with codec support if path not explicitly set.
2024-05-20 12:54:44 +10:00
WithoutPants
1ffca39e1d Fix values being reset when changing mode (#4854) 2024-05-20 12:54:29 +10:00
Ivan Pedrazas
dd84714a16 feat: Make DLNA port configurable (#4836)
---------
Signed-off-by: Ivan Pedrazas <ipedrazas@gmail.com>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-05-20 09:33:23 +10:00
damontecres
ad844a225c Return 401 code for ErrUnauthorized (#4842) 2024-05-16 14:30:19 +10:00
dogwithakeyboard
ca5febc65b New scene select with additional fields (#4832) 2024-05-14 14:51:24 +10:00
NotForMyCV
c8aeb7966a Add last_played_at filter (#4829) 2024-05-14 14:40:46 +10:00
CJ
1d565a7cbd Enable track activity by default (#4710)
* enable track Activity by default
* Add v0.26.0 release notes and update "Track Activity" label
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-05-14 12:02:59 +10:00
WithoutPants
408d6fc988 Build UI artifacts (#4824)
* Flag/env var for stash UI location
* Include UI in build artifacts
2024-05-14 12:02:29 +10:00
feederbox826
237a904ca4 add stashapp-tools to default docker install (#4488)
Co-authored-by: feederbox826 <feederbox826@users.noreply.github.com>
2024-05-11 15:26:03 +03:00
WithoutPants
12af7d6515 Fix black screen after migrating with release notes (#4825) 2024-05-10 16:42:33 +10:00
WithoutPants
77ee620877 Fix ffmpeg version detection
Fixes issue where ffmpeg version could not be detected if the version number had no patch number.
2024-05-10 16:36:08 +10:00
NodudeWasTaken
c5fef3977e Full hardware transcoding (#4765) 2024-05-10 15:55:31 +10:00
Dankonite
29859fa4ad Tag Favoriting (#4728)
* Add missing key unbind
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-05-09 12:04:58 +10:00
Serge Levin
1cee1ccfe2 Better support for Samsung SmartTV for built-in DLNA server (#4784) 2024-05-09 09:16:21 +10:00
HookedBehemoth
9cc26f7b75 skip reencoding compatible video streams (#4783)
* skip reencoding compatible video streams
* don't attempt copy on transcode with resize
2024-05-08 13:24:13 +10:00
WithoutPants
c5abe28375 Fix alias issue when tagging performer from stash-box (#4820) 2024-05-08 12:47:18 +10:00
dependabot[bot]
1b99a03847 Bump golang.org/x/net from 0.17.0 to 0.23.0 (#4773)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.17.0 to 0.23.0.
- [Commits](https://github.com/golang/net/compare/v0.17.0...v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-05-08 12:44:10 +10:00
WithoutPants
22d14fd89e Clean duplicate aliases when creating from performer tagger (#4801)
* Clean duplicate aliases when creating from performer tagger
* Use case insensitive name matching
2024-05-03 13:10:35 +10:00
WithoutPants
0bba8889b8 Fix duplicate scene checker select logic (#4800) 2024-05-03 13:10:17 +10:00
WithoutPants
141f60f8fb Fix interactive speed being lost when file is moved (#4799) 2024-05-03 13:10:05 +10:00
WithoutPants
560bdcd60d Fix filterHook not applied to scene card queue (#4798) 2024-05-03 13:09:42 +10:00
Emilo2
c43e7b4351 Select first result when selectedResult is not defined (#4770) 2024-05-02 12:40:43 +10:00
hidewrong
4c0d9d0a07 chore: fix struct names (#4766)
Signed-off-by: hidewrong <hidewrong@outlook.com>
2024-05-02 12:04:54 +10:00
Maista
157b2e7bae Allow movies scraped from the scene scraper dialog to include the director field (#4757) 2024-04-17 10:30:17 +10:00
WithoutPants
ec6acab2f4 Details operation toolbar (#4714)
* Add scene detail header
* Make common count button and add view count
* Add titles to play count and o count buttons
* Move rating from edit panel
* Include frame rate in header
* Remove redundant title/studio
* Improve numeric rating presentation
* Add star where there is no rating header
* Set rating on blur when click to edit
* Add star to numeric rating on gallery wall card
* Apply click to rate on movie page
* Apply click to rate to performer page
* Apply click to rate to studio page
* Fix rating number presentation on list tables
* Add data-value attributes
2024-04-17 10:29:36 +10:00
DogmaDragon
911da87264 Update plugins script language (#4762) 2024-04-16 00:07:57 +03:00
DogmaDragon
f7b87379d4 Merge pull request #4749 from Strategy3637/chore/link-scraping-readme 2024-04-07 20:58:14 +03:00
Strategy3637
ad60f0ebd6 Fix link to scraping documentation in README.md 2024-04-07 18:41:18 +02:00
Rémi Marseault
c83635c7a8 Add wrap on detail item values (#4730)
* Add wrap on detail item values
* Fix CSS rule order to match CI expectations
2024-04-02 18:11:18 +11:00
WithoutPants
034fd4407d Fix selected tagger search result being lost when creating objects (#4715)
* Wrap search result details
* Move utility functions to separate file
* Fix selected result being reset on object create
2024-03-27 10:40:44 +11:00
WithoutPants
7086109d78 Change ffmpeg handling (#4688)
* Make ffmpeg/ffprobe settable and remove auto download
* Detect when ffmpeg not present in setup
* Add download ffmpeg task
* Add download ffmpeg button in system settings
* Download ffmpeg during setup
2024-03-21 12:43:40 +11:00
NodudeWasTaken
a369613d42 bitrate filter (#4713) 2024-03-21 12:36:08 +11:00
WithoutPants
62b8ffb2b6 Apply filter hook to results filter only (#4705) 2024-03-21 12:07:51 +11:00
WithoutPants
213c2830d1 Fix unhandled error (#4700) 2024-03-19 15:08:20 +11:00
dogwithakeyboard
32770203ba Use new studio selector in movie scrape dialog (#4692) 2024-03-19 14:40:58 +11:00
WithoutPants
8c454582c7 Add support for favorite Studios (#4675)
* Backend changes
* Add favorite icon to studio cards
* Add favorite button to studio page
* Add studio favorite filtering
2024-03-14 11:17:44 +11:00
WithoutPants
e5929389b4 Make migration an asynchronous task (#4666)
* Add failed state and error to Job
* Move migration code
* Add websocket monitor
* Make migrate a job managed task
2024-03-14 11:06:23 +11:00
WithoutPants
fa172c2dfd Minor mobile fixes (#4683)
* Show card checkbox on mobile
* Don't focus query field on filter dialog open on touch devices
2024-03-14 11:04:25 +11:00
WithoutPants
9ceea952b6 Replace javascript module otto with goja (#4631)
* Move plugin javascript to own package with goja
* Use javascript package in scraper

Remove otto
2024-03-14 11:03:40 +11:00
bdbenim
49cd214c9d Make directors and photographers clickable in detail view (#4621)
* Make directors and photographers clickable
* Make director clickable on movie details page
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-03-14 10:34:24 +11:00
randemgame
3d0a8f653a Added Sort Performers by Last O At / Last Played At / Play Count and Added Filter Performers by Play Count. Changes to display O Count rather than O-Counter for better consistency. Grammar fixes for 'Interactive Speed' and 'pHash'. (#4649)
* Sort Performers by Last O / View

Added 2 New Sorts 'Last O At' and 'Last Played At' for Performers

* Filter Performers by Play Count

Was not sure whether to label this 'views' as the code does, or 'plays' but chose the latter as it gives parity across the scenes and performers filters.

* Sort Performers by Play Count

Reutilised the prior selectPerformerLastOAtSQL code that was used to filter by play count to additionally provide useful sorting options.

* Replaced O-Counter with O Count

To better match other sort and filter options like Gallery Count, Image Count, Play Count, Scene Count, Tag Count, File Count, Performer Count and Play Count, we should really use O Count rather than O-Counter for increased legibility and coherence.

* Title Case on 'Interactive speed' and correct capitalization for 'phash'

Every other filter/sort option is using Title Case other than 'Interactive speed' which stands out as incorrect. Also, fixing the correct mid-word capitalization on phash to pHash.

* Formatting

Formatted source code and Ran all tests
2024-03-14 10:32:08 +11:00
bob123491234
ae6d1a8109 Add galleries filter to scenes (#4632) 2024-03-14 10:17:57 +11:00
WithoutPants
7ac7963972 Save task options (#4620)
* Support setting nested UI values
* Accept partial for configureUI
* Send partial UI
* Save scan, generate and auto-tag options on change
* Send partials in saveUI
* Save library task options on change
2024-03-14 08:25:16 +11:00
Weblate (bot)
bf7cb78d6d Translations update from Hosted Weblate (#4671)
* Translated using Weblate (Danish)

Currently translated at 83.2% (946 of 1136 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/da/

* Translated using Weblate (Danish)

Currently translated at 91.2% (1037 of 1136 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/da/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 84.7% (963 of 1136 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hant/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 84.7% (963 of 1136 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hant/

---------

Co-authored-by: Heine Olsen <olsen10051988@gmail.com>
Co-authored-by: Still Hsu <dev@stillu.cc>
Co-authored-by: lunautumm <2823105878@qq.com>
2024-03-13 14:14:45 +11:00
WithoutPants
95d0e5dd34 Update changelog 2024-03-13 14:13:13 +11:00
WithoutPants
d995ce7ecb Downgrade astisub due to asticode/go-astisub#99 (#4682) 2024-03-13 10:14:45 +11:00
CJ
3521dc133e play monitoring improvement (#4670) 2024-03-12 13:33:28 +11:00
AdultSun
9f5b1c33f6 Update StashDB details in README.md (#4676)
* Update StashDB details in README.md

- Directs users to new guide in the StashDB docs instead of Discord
- No longer necessary to join Discord/Matrix for new users of StashDB now that invite codes are multi-use
- Updates formatting of the same "Quickstart Guide" section a little

* Expands quickstart language based on DogmaDragon's suggestions
2024-03-12 11:13:47 +11:00
InfiniteStash
c5bc106c1a Fix text color of medium fingerprint matches (#4662) 2024-03-08 14:59:17 +11:00
CJ
9735d0fad1 fix image card width on front page (#4665) 2024-03-08 14:40:00 +11:00
CJ
353d889fd5 fit cards code improvement (#4658) 2024-03-08 14:36:15 +11:00
WithoutPants
c7b2314bb1 Fix image clip webm not being cleaned (#4657) 2024-03-07 09:03:00 +11:00
WithoutPants
4614471ad9 Fix ffmpeg error when trying to scale and copy video (#4660) 2024-03-07 09:02:45 +11:00
Weblate (bot)
7733a214d3 Translations update from Hosted Weblate (#4641)
* Translated using Weblate (Finnish)

Currently translated at 73.5% (835 of 1136 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/fi/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1136 of 1136 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/sv/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1136 of 1136 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Czech)

Currently translated at 82.9% (942 of 1136 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/cs/

* Translated using Weblate (Czech)

Currently translated at 90.4% (1027 of 1136 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/cs/

* Translated using Weblate (Italian)

Currently translated at 78.7% (895 of 1136 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/it/

* Translated using Weblate (Russian)

Currently translated at 100.0% (1136 of 1136 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/ru/

* Translated using Weblate (Indonesian)

Currently translated at 43.7% (497 of 1136 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/id/

---------

Co-authored-by: gimmeliina <jarruraita@outlook.com>
Co-authored-by: alpacaserious <srhsgsef@gmail.com>
Co-authored-by: wql219 <wanqinglin219@hotmail.com>
Co-authored-by: Nymeria <Tractorb@seznam.cz>
Co-authored-by: its-josh4 <myselftdev@gmail.com>
Co-authored-by: Old gnome <orpgnome@users.noreply.hosted.weblate.org>
Co-authored-by: Tukimin Satrio <k797du3eh@mozmail.com>
2024-03-06 10:20:42 +11:00
WithoutPants
cd2f0922ab Update changelog 2024-03-06 10:12:53 +11:00
dogwithakeyboard
f1f6e84aa0 Add bmp to image codec list (#4653) 2024-03-06 09:56:17 +11:00
WithoutPants
22986097c4 Fix code from #4570 2024-03-06 09:52:06 +11:00
WithoutPants
409a200ebc Fix URL prefix re-inserted when redirecting settings page (#4650) 2024-03-05 14:54:07 +11:00
WithoutPants
20ac388f77 Update changelog 2024-03-05 14:46:32 +11:00
CJ
0626a7aea1 fix lightbox display modes (#4644) 2024-03-05 13:37:39 +11:00
Flashy78
2ca9e0f43a Adding disambiguation to performer draft (#4122) 2024-02-29 12:47:20 +11:00
WithoutPants
b4823bec8a Use proxy in utils.ReadImageFromURL (#4637) 2024-02-29 11:28:30 +11:00
WithoutPants
945188a0ba Fix images with , character not rendering (#4636) 2024-02-29 11:28:11 +11:00
Weblate (bot)
b59afd2dcd Translations update from Hosted Weblate (#4634)
* Translated using Weblate (Indonesian)

Currently translated at 33.9% (386 of 1136 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/id/

* Add Indonesian locale option

---------

Co-authored-by: Tukimin Satrio <k797du3eh@mozmail.com>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-02-29 10:18:20 +11:00
Weblate (bot)
9202787be0 Translations update from Hosted Weblate (#4615)
* Translated using Weblate (Swedish)

Currently translated at 100.0% (1135 of 1135 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/sv/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 98.4% (1117 of 1135 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Indonesian)

Currently translated at 13.9% (158 of 1135 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/id/

* Translated using Weblate (Polish)

Currently translated at 85.2% (968 of 1135 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/pl/

* Translated using Weblate (French)

Currently translated at 95.5% (1085 of 1135 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/fr/

* Translated using Weblate (French)

Currently translated at 95.5% (1085 of 1135 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/fr/

* Translated using Weblate (Polish)

Currently translated at 85.3% (969 of 1135 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/pl/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (1135 of 1135 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Czech)

Currently translated at 53.3% (606 of 1135 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/cs/

* Translated using Weblate (Czech)

Currently translated at 56.0% (636 of 1135 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/cs/

---------

Co-authored-by: alpacaserious <srhsgsef@gmail.com>
Co-authored-by: wql219 <wanqinglin219@hotmail.com>
Co-authored-by: Tukimin Satrio <k797du3eh@mozmail.com>
Co-authored-by: Mateusz <mateuszvx8.96@gmail.com>
Co-authored-by: doodoo <adr.web@hotmail.fr>
Co-authored-by: Nathan Lafrenière-Racine <nathan.lafreniere@gmail.com>
Co-authored-by: Nymeria <Tractorb@seznam.cz>
2024-02-28 11:54:37 +11:00
WithoutPants
4999e85fae Fix performer disambiguation styling in select 2024-02-28 11:21:29 +11:00
WithoutPants
2bdf0d9e62 Fix penis length being incorrectly truncated (#4630) 2024-02-28 11:08:28 +11:00
WithoutPants
2e00cb6c5a Document plugin settings 2024-02-28 11:07:31 +11:00
WithoutPants
33857122b8 Add 0.25.0 changelog 2024-02-28 10:18:59 +11:00
CJ
768f74a0b3 Fix unset gallery card width on initialization (#4612) 2024-02-28 09:10:15 +11:00
InfiniteStash
98c428ba4e Improve scene tagger prioritization (#4618) 2024-02-28 08:29:49 +11:00
WithoutPants
fcf249e5f6 Improve plugin hook cyclic detection (#4625)
* Move and rename HookTriggerEnum into separate package
* Move visited plugin hook handler code
* Allow up to ten plugin hook loops
2024-02-28 08:29:25 +11:00
WithoutPants
3a56dd98db Change handlerRequiredFilter to check for .forcegallery (#4627) 2024-02-28 08:28:29 +11:00
WithoutPants
48c287ed76 Add last o sort option (#4626) 2024-02-28 08:28:11 +11:00
NodudeWasTaken
9c6fbfc16f Add filename to image serve (#4616) 2024-02-27 16:11:40 +11:00
Raghavan
6a9175c954 Implement UI event dispatcher/listener (#4492)
* page change event
* expose event to plugin api
* Update UIPluginApi.md
* Add to example plugin
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-02-27 09:27:28 +11:00
WithoutPants
56896d7c7d Fix panic 2024-02-26 15:08:45 +11:00
Weblate (bot)
2e35221003 Translations update from Hosted Weblate (#4585)
* Translated using Weblate (Estonian)

Currently translated at 100.0% (1106 of 1106 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/et/

* Translated using Weblate (Hungarian)

Currently translated at 42.7% (473 of 1106 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/hu/

* Translated using Weblate (Hungarian)

Currently translated at 42.7% (473 of 1106 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/hu/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1106 of 1106 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/sv/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 87.2% (965 of 1106 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 80.9% (895 of 1106 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hant/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 91.4% (1011 of 1106 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 91.4% (1011 of 1106 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 91.4% (1011 of 1106 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 96.6% (1071 of 1108 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 96.6% (1071 of 1108 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 96.6% (1071 of 1108 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 96.7% (1072 of 1108 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 96.7% (1072 of 1108 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 96.8% (1073 of 1108 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 96.8% (1073 of 1108 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 96.8% (1073 of 1108 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Added translation using Weblate (Indonesian)

* Translated using Weblate (Swedish)

Currently translated at 100.0% (1108 of 1108 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/sv/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 97.6% (1098 of 1125 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 97.6% (1098 of 1125 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 97.6% (1098 of 1125 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 97.6% (1098 of 1125 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Indonesian)

Currently translated at 8.3% (94 of 1125 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/id/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 97.6% (1098 of 1125 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 98.0% (1103 of 1125 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 98.4% (1107 of 1125 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 98.4% (1107 of 1125 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/zh_Hans/

---------

Co-authored-by: LauriL <TheLauri@users.noreply.hosted.weblate.org>
Co-authored-by: Kolbász <services@perjel.hu>
Co-authored-by: R Balu <balu.ka@hotmail.com>
Co-authored-by: alpacaserious <srhsgsef@gmail.com>
Co-authored-by: BugGeeker <darling940620@foxmail.com>
Co-authored-by: Y. Chen <daniel.yifu.chen@gmail.com>
Co-authored-by: 宿命 <331874545@qq.com>
Co-authored-by: 张Ly <zanzhz1101@gmail.com>
Co-authored-by: wql219 <wanqinglin219@hotmail.com>
Co-authored-by: Tukimin Satrio <k797du3eh@mozmail.com>
Co-authored-by: celery1806 <qincai233@gmail.com>
2024-02-23 16:36:12 +11:00
WithoutPants
ba1ebba6c0 Add Clean generated files task (#4607)
* Add clean generate task
* Add to library tasks
* Save and read defaults
* Stop handling and logging
* Make filename parsing more robust
2024-02-23 15:56:00 +11:00
WithoutPants
4a3ce8b6ec Fix auto tag from object not honouring the ignore autotag flag (#4610)
* Fix auto tag from object ignoring the ignore autotag field
* Disable auto tag buttons where ignore auto tag is enabled
2024-02-23 14:34:04 +11:00
WithoutPants
4b84ec0d85 Support setting galleries in multiple images (#4608) 2024-02-23 11:42:04 +11:00
WithoutPants
a302fc78ea Fix crash when generating thumbnail during scan 2024-02-23 11:20:07 +11:00
WithoutPants
f2bc3d5567 Log on scene cover fetch error 2024-02-22 14:59:03 +11:00
WithoutPants
a303446bb7 Scene play and o-counter history view and editing (#4532)
Co-authored-by: randemgame <61895715+randemgame@users.noreply.github.com>
2024-02-22 11:28:18 +11:00
WithoutPants
0c2a2190e5 Plugin API improvements (#4603)
* Accept plain map for runPluginTask
* Support running plugin task without task name
* Add interface to run plugin operations
* Update RunPluginTask client mutation
2024-02-22 11:20:21 +11:00
WithoutPants
a8c909e0c9 Add option to generate image thumbnails during generate (#4602)
* Add option to generate image thumbnails
* Limit number of concurrent image thumbnail generation ops
2024-02-22 11:19:23 +11:00
dogwithakeyboard
c4a91d15a6 include primary tag name in search and sort (#4606) 2024-02-22 11:18:29 +11:00
WithoutPants
61bd9233b2 Fix incorrect documentation in Plugins.md 2024-02-22 08:15:40 +11:00
dogwithakeyboard
37acd6b79b restore image performer age filter (#4601) 2024-02-21 11:22:04 +11:00
WithoutPants
5bb9bf902c Show upgradable packages only when checking for updates (#4599)
* Sort upgradable packages to top
* Show upgradable packages only by default
* Fix loading state when refetching
2024-02-21 08:24:18 +11:00
WithoutPants
76e5598876 Improve handling of moved and added video files (#4598)
* If old file path is not in library, treat as move
* Use existing phash if file with same oshash exists
2024-02-20 14:04:31 +11:00
dogwithakeyboard
8b1d4ccc97 Movie scene sorting (#4588) 2024-02-20 14:01:27 +11:00
Raghavan
cff068f519 add pluginApi.d.ts (#4595)
* add pluginApi.d.ts
* Don't lint pluginApi.d.ts
2024-02-20 13:00:44 +11:00
WithoutPants
276bc5a8cb Fix 404 not returning immediately in asset resolver (#4597) 2024-02-20 13:00:27 +11:00
WithoutPants
b4a6cc43d1 Default tag hover popover to right (#4593) 2024-02-20 09:03:06 +11:00
WithoutPants
777fb44ac6 Fix default url not redirecting in studio page (#4592) 2024-02-20 09:02:52 +11:00
bob123491234
f5a42ede2d Add galleries to image edit panel (#4573)
* Add Galleries to ImageEditPanel
* Exclude filesystem-based galleries from selection
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-02-19 14:45:29 +11:00
WithoutPants
7bb38ae6dc Don't error out if a single url not fetched (#4591) 2024-02-19 14:45:11 +11:00
WithoutPants
7d56f1a093 Fix panic 2024-02-19 14:10:41 +11:00
WithoutPants
afd7f02644 Error if duplicate plugin id is loaded (#4571)
* Error if duplicate plugin id is loaded
* Use case insensitive comparison
2024-02-19 13:55:52 +11:00
WithoutPants
93b851eae6 Merge studio stash ids (#4572) 2024-02-19 11:45:04 +11:00
WithoutPants
1dfb960a87 Fix import 2024-02-19 11:43:14 +11:00
WithoutPants
e231812203 Movie select overhaul (#4563)
* Add ids to findMovies input
* Use ids for other find interfaces
* Update client side
* Fix gallery select function
* Replace movie select
* Re-add creatable
* Overhaul movie table
* Remove and deprecated unused code
2024-02-19 10:25:08 +11:00
WithoutPants
e7f610ce18 Fix invalid share causing error during cleaning (#4570) 2024-02-19 10:24:10 +11:00
WithoutPants
6e9718a600 Toast improvements (#4584)
* Change default toast placement
* Position at bottom on mobile
* Show single toast message at a time
* Optionally show dialog for error messages
* Fix circular dependency
* Animate toast
2024-02-19 10:22:34 +11:00
WithoutPants
6fb1c41ae9 Update translation files (#4581)
Updated by "Cleanup translation files" hook in Weblate.

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/

Co-authored-by: Hosted Weblate <hosted@weblate.org>
2024-02-17 14:26:37 +11:00
WithoutPants
5aba3c1a98 Weblate translations update (#4578)
* Translated using Weblate (French)

Currently translated at 100.0% (1056 of 1056 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/fr/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 81.3% (859 of 1056 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/pt_BR/

* Translated using Weblate (Swedish)

Currently translated at 96.3% (1017 of 1056 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/sv/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/

* Translated using Weblate (French)

Currently translated at 100.0% (1056 of 1056 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/fr/

* Translated using Weblate (Swedish)

Currently translated at 99.8% (1054 of 1056 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/sv/

* Translated using Weblate (French)

Currently translated at 100.0% (1056 of 1056 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/fr/

* Added translation using Weblate (Norwegian Bokmål)

* Translated using Weblate (French)

Currently translated at 100.0% (1056 of 1056 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/fr/

* Translated using Weblate (Hungarian)

Currently translated at 37.5% (397 of 1056 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/hu/

* Translated using Weblate (Swedish)

Currently translated at 99.9% (1055 of 1056 strings)

Translation: stashapp/stash
Translate-URL: https://hosted.weblate.org/projects/stashapp/stash/sv/

* Pretty locale files

* Sort locale keys

---------

Co-authored-by: doodoo <adr.web@hotmail.fr>
Co-authored-by: Robert de Abreu Viana <deathrobert2010@gmail.com>
Co-authored-by: alpacaserious <srhsgsef@gmail.com>
Co-authored-by: Hosted Weblate <hosted@weblate.org>
Co-authored-by: Allan Nordhøy <epost@anotheragency.no>
Co-authored-by: R Balu <balu.ka@hotmail.com>
Co-authored-by: Luna Jernberg <droidbittin@gmail.com>
2024-02-17 14:06:40 +11:00
WithoutPants
440c261f5b Developer option: extra blob paths (#4566)
* Allow additional read-only blob paths
* Add developer option to add more blob sources
* Add makefile targets to start and remove build container
* Documentation
2024-02-16 12:39:45 +11:00
WithoutPants
8fc997dfe9 Fix merge not deleting deleted scene generated files (#4567) 2024-02-16 12:38:34 +11:00
WithoutPants
5b9bdadaec Mount edit panels only when first entering (#4564) 2024-02-16 12:37:49 +11:00
WithoutPants
706b61233f Fix country selector in bulk performer edit dialog (#4565)
* Fix country select in edit performers dialog
* Fix edit performer dialog height
2024-02-16 12:37:24 +11:00
yoshnopa
aaf3114194 Bugfix: No Image Upscale for Clips (#4569) 2024-02-16 12:21:36 +11:00
NodudeWasTaken
15aac68a14 Fix segment repeating + cleanup speed calculation (#4557) 2024-02-15 13:46:59 +11:00
WithoutPants
dad4ab6a6f Fix scraped studio not matching existing value (#4548)
* Fix scraped studio not matching existing
* Fix incorrect key value
2024-02-13 12:24:11 +11:00
WithoutPants
e9703e9a6e Fix url not updated for default tab on Performer/Studio/Tag pages (#4538) 2024-02-12 15:08:11 +11:00
WithoutPants
46eb01198a Plugin api improvements (#4546)
* Expose useToast

* Expose components:

- studio/tag/performer/gallery selects
- date input
- country select
- folder select
2024-02-12 15:07:44 +11:00
WithoutPants
235c9c90c2 Add disambiguation to performer link and performer select values (#4541)
* Add disambiguation to PerformerLink
* Add disambiguation to performer select values
2024-02-12 14:03:45 +11:00
WithoutPants
a4bbdcfbae Common studio overlay component (#4540)
* Move GridCard to own directory
* Make common studio overlay component
2024-02-12 14:03:13 +11:00
WithoutPants
8c410a9a14 Wrap card popovers (#4539) 2024-02-12 14:02:46 +11:00
WithoutPants
9981574e82 Add gallery select filter and fix image gallery filtering (#4535)
* Accept gallery ids in findGalleries
* Add gallery select component
* Add and fix image gallery filter
* Show gallery path as alias
2024-02-09 16:42:07 +11:00
WithoutPants
79e72ff3bc Fix UI config mutation not working (#4533) 2024-02-09 12:27:08 +11:00
CJ
a16f3da33e Fix tag popper over card (#4529) 2024-02-08 16:46:55 +11:00
WithoutPants
8770e81ec5 Improve sorting of results when entering text in select fields (#4528)
* Sort select results by relevance
* Apply relevance sorting to studio select
* Apply relevance sorting to filter select
2024-02-07 10:32:19 +11:00
dependabot[bot]
9284ede0fb Bump vite from 4.4.12 to 4.5.2 in /ui/v2.5 (#4477)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.4.12 to 4.5.2.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v4.5.2/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.5.2/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-02-07 10:30:17 +11:00
DingDongSoLong4
2d73912f15 Improve client-side graphql scalar types (#4511)
* Add types to graphql scalars
* Upgrade dependencies
* Override UI config type
* Remove all IUIConfig casts
* Add tableColumns to IUIConfig
* Add BoolMap type, set strictScalars
* Add PluginConfigMap
* Replace any with unknown
* Add SavedObjectFilter and SavedUIOptions
* Remove unused items from CriterionType
2024-02-07 09:49:32 +11:00
dogwithakeyboard
9ac6505241 Studio child filter and sort (#4479) 2024-02-06 13:24:00 +11:00
CJ
a402ee5fa7 support filesize in scene list (#4480) 2024-02-06 13:10:21 +11:00
CJ
a8df95c3a4 Fit cards properly within their containers (#4514)
* created missing cards grids
2024-02-06 13:06:47 +11:00
WithoutPants
330581283a Fix UI crash during setup (#4527) 2024-02-06 11:48:26 +11:00
Raghavan
892d74c98b expose useful libs (#4489)
* expose useful libs
* Update UIPluginApi.md
2024-02-06 11:29:01 +11:00
WithoutPants
de2b28d3f9 Studio select refactor (#4493)
* Add id interface to findStudios
* Replace existing selects
* Remove unused code
* Fix scrape/merge select
* Make clearable
2024-02-06 11:26:16 +11:00
WithoutPants
217c02f181 Fix tag selector in scrape dialogs (#4526) 2024-02-06 10:26:16 +11:00
DingDongSoLong4
3ea31aeb76 Add blank favicon on login page (#4498) 2024-02-06 10:22:32 +11:00
DingDongSoLong4
cf8efa9035 Link improvements and fixes (#4501)
* Add ExternalLink
* Replace <a> with <Link>
2024-02-06 10:21:19 +11:00
CJ
1d0fa27c71 Improve list table readability (#4497)
* fixes mandatory columns bug and consistency issues
2024-02-06 10:18:24 +11:00
NodudeWasTaken
0b82dbf666 Frontend fix media_err_decode on playback (#4506) 2024-02-06 10:08:40 +11:00
CJ
11cafe933a only use details background image when provided (#4515) 2024-01-29 18:04:41 +11:00
DingDongSoLong4
d82c526ada Minor UI fixes (#4509)
* Fix a zero value disabling the SettingModal
* Fix performer disambiguation styling
* Fix performer tagger error message
2024-01-29 17:56:39 +11:00
DogmaDragon
1588d1cb4e Update scene list setting (#4473) 2024-01-24 21:01:35 +11:00
DogmaDragon
64f2071d8c Add note for auto tag (#4490) 2024-01-24 21:00:57 +11:00
DogmaDragon
3573795cf7 Clarify session description (#4468) 2024-01-24 21:00:08 +11:00
WithoutPants
723211a620 Tag select refactor (#4478)
* Add interface to load tags by id
* Use minimal data for tag select queries
* Center image/text in select list
* Overhaul tag select
* Support excludeIds. Comment out image in dropdown
* Replace existing selects
* Remove unused code
* Fix styling of aliases
2024-01-24 20:24:13 +11:00
CJ
dd8da7f339 Improved list view for scenes, galleries and performers (#4368)
Co-authored-by: InfiniteStash <117855276+InfiniteStash@users.noreply.github.com>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-01-17 10:46:09 +11:00
WithoutPants
e7311a60d2 Advanced settings (#4378)
* Add advanced settings mode toggle
* Add advanced settings
2024-01-16 16:22:04 +11:00
flubber1234
29677696fd Add support for disabling mobile media-viewer's fullscreen auto-rotate (#4416) 2024-01-16 15:00:58 +11:00
WithoutPants
403f7c54ef Move zip files while moving folders (#4374) 2024-01-16 14:48:28 +11:00
OFP
75099b38a8 Update Performer gender filter (#4419)
* Update Performer gender filter

Updated the Performer gender filter to allow selection of multiple genders (`IS`) or performers with no gender specified (`IS NULL`).

* Add default modifier for circumcised
* Handle existing saved filters
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-01-16 14:47:33 +11:00
CJ
45e2e12594 Improved scene queue (#4448)
* Improved scene queue
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-01-16 14:40:44 +11:00
dependabot[bot]
ec547e8d30 Bump vite from 4.4.11 to 4.4.12 in /ui/v2.5 (#4348)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.4.11 to 4.4.12.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v4.4.12/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.4.12/packages/vite)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-16 13:59:17 +11:00
dependabot[bot]
e470dc5f52 Bump golang.org/x/crypto from 0.14.0 to 0.17.0 (#4375)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.14.0 to 0.17.0.
- [Commits](https://github.com/golang/crypto/compare/v0.14.0...v0.17.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-01-16 13:50:41 +11:00
keenbed
14bde44597 added support for image orientation filter (#4404)
* added support for image orientation filter
* Add orientation filtering to scenes
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2024-01-16 13:50:17 +11:00
WithoutPants
aeb68a5851 Update changelog 2024-01-15 10:34:59 +11:00
WithoutPants
5cf28cf8af Fix studio name uniqueness validation (#4454) 2024-01-14 12:52:16 +11:00
DingDongSoLong4
08b73581a6 Don't retry failed autoplay (#4450) 2024-01-13 10:50:38 +11:00
WithoutPants
95a2c8d13f Update changelog for bugfix release 2024-01-10 11:21:06 +11:00
DingDongSoLong4
0b131f76df Fix scene marker merging (#4446) 2024-01-10 10:25:05 +11:00
WithoutPants
6271f18979 Fix error when creating/updating performer with alias == name (#4443)
* Filter out performer aliases that match the name
* Validate when creating/updating performer in stash-box task
2024-01-09 14:57:49 +11:00
WithoutPants
ca976a0994 Don't bail on error when scraping all (#4442) 2024-01-09 11:39:00 +11:00
DingDongSoLong4
9859ec61fb Calculate DetailImage fallback width using rem (#4441) 2024-01-09 11:11:46 +11:00
DingDongSoLong4
a998497004 Hide tag input when set tags is disabled (#4440) 2024-01-09 11:09:42 +11:00
bayured
f5e3fe77b7 Update FieldStrategyOverwrite to work when scene has no existing URL (#4412) 2024-01-09 10:23:29 +11:00
WithoutPants
743ab9a52c Sort plugin settings (#4435) 2024-01-09 09:32:26 +11:00
WithoutPants
d23cecfc18 Disable select all checkbox for plugin sources (#4434) 2024-01-09 09:32:16 +11:00
DingDongSoLong4
d8990e655d Fix settings tab links (#4430) 2024-01-08 12:08:09 +11:00
DingDongSoLong4
5b9a96b843 Scene queue autoplay (#4428)
* Remove unnecessary undefined checks
* Respect autostartVideoOnPlaySelected in scene queue
2024-01-08 12:04:30 +11:00
CJ
b968aa3f31 Fixes package manger head border (#4420) 2024-01-08 11:54:14 +11:00
DingDongSoLong4
910c7025dc Fix scraped performer alias matching (#4432) 2024-01-08 11:50:31 +11:00
bayured
ea503833c5 Add join to intCriterionHandler (#4414)
* Add join to intCriterionHandler
* Add join to floatCriterionHandler
2024-01-08 11:48:16 +11:00
cc1234475
6848dec5f4 Add CSP to plugin from the yaml file (#4424) 2024-01-08 11:45:55 +11:00
WithoutPants
bd7d4ac7ff Update changelog for bugfix release 2023-12-29 14:29:49 +11:00
WithoutPants
5a6504b4ba Workaround setting protocol on external player url (#4403) 2023-12-29 14:26:30 +11:00
WithoutPants
f8a93789bb Use 8.14.3-r0 of vips (#4402) 2023-12-28 18:34:34 +11:00
WithoutPants
82cbeff9b5 Revert modal button order change (#4400) 2023-12-28 13:56:05 +11:00
DingDongSoLong4
f32d60f208 Update macOS readme links (#4347)
* Update readme macOS download links

* Update docker readme to point to develop branch
2023-12-27 10:57:28 +02:00
573 changed files with 39295 additions and 21202 deletions

View File

@@ -17,7 +17,7 @@
# GraphQL generated output
pkg/models/generated_*.go
ui/v2.5/src/core/generated-*.tsx
ui/v2.5/src/core/generated-graphql.ts
####
# Jetbrains

12
.github/FUNDING.yml vendored
View File

@@ -1,12 +0,0 @@
# These are supported funding model platforms
github: stashapp
# patreon: # Replace with a single Patreon username
open_collective: stashapp
# ko_fi: # Replace with a single Ko-fi username
# tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
# community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
# liberapay: StashApp
# issuehunt: # Replace with a single IssueHunt username
# otechie: # Replace with a single Otechie username
# custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -92,13 +92,16 @@ jobs:
docker exec -t build /bin/bash -c "make build-cc-linux-arm32v6"
docker exec -t build /bin/bash -c "make build-cc-freebsd"
- name: Zip UI
run: docker exec -t build /bin/bash -c "make zip-ui"
- name: Cleanup build container
run: docker rm -f -v build
- name: Generate checksums
run: |
git describe --tags --exclude latest_develop | tee CHECKSUMS_SHA1
sha1sum dist/Stash.app.zip dist/stash-* | sed 's/dist\///g' | tee -a CHECKSUMS_SHA1
sha1sum dist/Stash.app.zip dist/stash-* dist/stash-ui.zip | sed 's/dist\///g' | tee -a CHECKSUMS_SHA1
echo "STASH_VERSION=$(git describe --tags --exclude latest_develop)" >> $GITHUB_ENV
echo "RELEASE_DATE=$(date +'%Y-%m-%d %H:%M:%S %Z')" >> $GITHUB_ENV
@@ -126,6 +129,14 @@ jobs:
name: stash-linux
path: dist/stash-linux
- name: Upload UI
# only upload for pull requests
if: ${{ github.event_name == 'pull_request' && github.base_ref != 'refs/heads/develop' && github.base_ref != 'refs/heads/master'}}
uses: actions/upload-artifact@v2
with:
name: stash-ui.zip
path: dist/stash-ui.zip
- name: Update latest_develop tag
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
run : git tag -f latest_develop; git push -f --tags
@@ -147,6 +158,7 @@ jobs:
dist/stash-linux-arm32v7
dist/stash-linux-arm32v6
dist/stash-freebsd
dist/stash-ui.zip
CHECKSUMS_SHA1
- name: Master release
@@ -166,6 +178,7 @@ jobs:
dist/stash-linux-arm32v7
dist/stash-linux-arm32v6
dist/stash-freebsd
dist/stash-ui.zip
CHECKSUMS_SHA1
gzip: false

View File

@@ -48,6 +48,11 @@ GO_BUILD_TAGS += sqlite_stat4 sqlite_math_functions
export CGO_ENABLED := 1
# define COMPILER_IMAGE for cross-compilation docker container
ifndef COMPILER_IMAGE
COMPILER_IMAGE := stashapp/compiler:latest
endif
.PHONY: release
release: pre-ui generate ui build-release
@@ -348,6 +353,11 @@ endif
ui: ui-env
cd ui/v2.5 && yarn build
.PHONY: zip-ui
zip-ui:
rm -f dist/stash-ui.zip
cd ui/v2.5/build && zip -r ../../../dist/stash-ui.zip .
.PHONY: ui-start
ui-start: ui-env
cd ui/v2.5 && yarn start --host
@@ -378,3 +388,16 @@ docker-build: build-info
.PHONY: docker-cuda-build
docker-cuda-build: build-info
docker build --build-arg GITHASH=$(GITHASH) --build-arg STASH_VERSION=$(STASH_VERSION) -t stash/cuda-build -f docker/build/x86_64/Dockerfile-CUDA .
# start the build container - for cross compilation
# this is adapted from the github actions build.yml file
.PHONY: start-compiler-container
start-compiler-container:
docker run -d --name build --mount type=bind,source="$(PWD)",target=/stash,consistency=delegated $(EXTRA_CONTAINER_ARGS) -w /stash $(COMPILER_IMAGE) tail -f /dev/null
# run the cross-compilation using
# docker exec -t build /bin/bash -c "make build-cc-<platform>"
.PHONY: remove-compiler-container
remove-compiler-container:
docker rm -f -v build

View File

@@ -24,9 +24,11 @@ For further information you can consult the [documentation](https://docs.stashap
# Installing Stash
<img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> MacOS| <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker
<img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS | <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker
:---:|:---:|:---:|:---:
[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 (Apple Silicon)](https://github.com/stashapp/stash/releases/latest/download/stash-macos-applesilicon) <br />[Latest Release (Intel)](https://github.com/stashapp/stash/releases/latest/download/stash-macos-intel) <br /><sup><sub>[Development Preview (Universal)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-macos)</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>
[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).
## First Run
@@ -46,9 +48,11 @@ Stash is a web-based application. Once the application is running, the interface
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/tree/develop/ui/v2.5/src/docs/en/Scraping.md), which integrate directly into Stash.
Many community-maintained scrapers are available for download from [CommunityScrapers repository](https://github.com/stashapp/CommunityScrapers). The community also maintains StashDB, a crowd-sourced repository of scene, studio, and performer information, that can automatically identify much of a typical media collection. Inquire in the Discord for details. Identifying an entire collection will typically require a mix of multiple sources.
Stash can pull metadata (performers, tags, descriptions, studios, and more) directly from many sites through the use of [scrapers](https://github.com/stashapp/stash/blob/develop/ui/v2.5/src/docs/en/Manual/Scraping.md), which integrate directly into Stash. Identifying an entire collection will typically require a mix of multiple sources:
- The 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/).
- 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).
- 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>

View File

@@ -4,6 +4,7 @@ package main
import (
"fmt"
"os"
"os/exec"
flag "github.com/spf13/pflag"
"github.com/stashapp/stash/pkg/ffmpeg"
@@ -45,6 +46,13 @@ func printPhash(ff *ffmpeg.FFMpeg, ffp ffmpeg.FFProbe, inputfile string, quiet *
return nil
}
func getPaths() (string, string) {
ffmpegPath, _ := exec.LookPath("ffmpeg")
ffprobePath, _ := exec.LookPath("ffprobe")
return ffmpegPath, ffprobePath
}
func main() {
flag.Usage = customUsage
quiet := flag.BoolP("quiet", "q", false, "print only the phash")
@@ -69,7 +77,7 @@ func main() {
fmt.Fprintf(os.Stderr, "Example: parallel %v ::: *.mp4\n", os.Args[0])
}
ffmpegPath, ffprobePath := ffmpeg.GetPaths(nil)
ffmpegPath, ffprobePath := getPaths()
encoder := ffmpeg.NewEncoder(ffmpegPath)
// don't need to InitHWSupport, phashing doesn't use hw acceleration
ffprobe := ffmpeg.FFProbe(ffprobePath)

View File

@@ -37,6 +37,8 @@ func main() {
defer recoverPanic()
initLogTemp()
helpFlag := false
pflag.BoolVarP(&helpFlag, "help", "h", false, "show this help text and exit")
@@ -104,6 +106,16 @@ func main() {
exitCode = <-exit
}
// initLogTemp initializes a temporary logger for use before the config is loaded.
// Logs only error level message to stderr.
func initLogTemp() *log.Logger {
l := log.NewLogger()
l.Init("", true, "Error")
logger.Logger = l
return l
}
func initLog(cfg *config.Config) *log.Logger {
l := log.NewLogger()
l.Init(cfg.GetLogFile(), cfg.GetLogOut(), cfg.GetLogLevel())

View File

@@ -11,9 +11,14 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \
FROM --platform=$TARGETPLATFORM alpine:latest AS app
COPY --from=binary /stash /usr/bin/
# vips version 8.15.0-r0 breaks thumbnail generation on arm32v6
# need to use 8.14.3-r0 from alpine 3.18 instead
RUN apk add --no-cache --virtual .build-deps gcc python3-dev musl-dev \
&& apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg vips-tools ruby tzdata \
&& pip install --user --break-system-packages mechanicalsoup cloudscraper bencoder.pyx \
&& apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg ruby tzdata \
&& apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.18/community vips=8.14.3-r0 vips-tools=8.14.3-r0 \
&& pip install --user --break-system-packages mechanicalsoup cloudscraper bencoder.pyx stashapp-tools \
&& gem install faraday \
&& apk del .build-deps
ENV STASH_CONFIG_FILE=/root/.stash/config.yml

View File

@@ -9,11 +9,11 @@ https://docs.docker.com/engine/install/
### Get the docker-compose.yml file
Now you can either navigate to the [docker-compose.yml](https://raw.githubusercontent.com/stashapp/stash/master/docker/production/docker-compose.yml) in the repository, or if you have curl, you can make your Linux console do it for you:
Now you can either navigate to the [docker-compose.yml](https://raw.githubusercontent.com/stashapp/stash/develop/docker/production/docker-compose.yml) in the repository, or if you have curl, you can make your Linux console do it for you:
```
mkdir stashapp && cd stashapp
curl -o docker-compose.yml https://raw.githubusercontent.com/stashapp/stash/master/docker/production/docker-compose.yml
curl -o docker-compose.yml https://raw.githubusercontent.com/stashapp/stash/develop/docker/production/docker-compose.yml
```
Once you have that file where you want it, modify the settings as you please, and then run:

23
go.mod
View File

@@ -8,11 +8,12 @@ require (
github.com/Yamashou/gqlgenc v0.0.6
github.com/anacrolix/dms v1.2.2
github.com/antchfx/htmlquery v1.3.0
github.com/asticode/go-astisub v0.26.0
github.com/asticode/go-astisub v0.25.1
github.com/chromedp/cdproto v0.0.0-20231007061347-18b01cd81617
github.com/chromedp/chromedp v0.9.2
github.com/corona10/goimagehash v1.1.0
github.com/disintegration/imaging v1.6.2
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d
github.com/doug-martin/goqu/v9 v9.18.0
github.com/go-chi/chi/v5 v5.0.10
github.com/go-chi/cors v1.2.1
@@ -30,17 +31,16 @@ require (
github.com/json-iterator/go v1.1.12
github.com/kermieisinthehouse/gosx-notifier v0.1.2
github.com/kermieisinthehouse/systray v1.2.4
github.com/knadh/koanf v1.5.0
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/mattn/go-sqlite3 v1.14.17
github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8
github.com/remeh/sizedwaitgroup v1.0.0
github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cast v1.5.1
github.com/spf13/pflag v1.0.5
github.com/spf13/viper v1.16.0
github.com/stretchr/testify v1.8.4
github.com/tidwall/gjson v1.16.0
github.com/vearutop/statigz v1.4.0
@@ -49,12 +49,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.14.0
golang.org/x/crypto v0.21.0
golang.org/x/image v0.12.0
golang.org/x/net v0.17.0
golang.org/x/sys v0.13.0
golang.org/x/term v0.13.0
golang.org/x/text v0.13.0
golang.org/x/net v0.23.0
golang.org/x/sys v0.18.0
golang.org/x/term v0.18.0
golang.org/x/text v0.14.0
gopkg.in/guregu/null.v4 v4.0.0
gopkg.in/yaml.v2 v2.4.0
)
@@ -67,11 +67,14 @@ require (
github.com/chromedp/sysutil v1.0.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.7.0 // indirect
github.com/fsnotify/fsnotify v1.6.0 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.3.0 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/hashicorp/golang-lru v0.5.4 // indirect
@@ -83,8 +86,10 @@ require (
github.com/matryer/moq v0.2.3 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.19 // indirect
github.com/mitchellh/copystructure v1.2.0 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
@@ -97,6 +102,7 @@ require (
github.com/spf13/afero v1.9.5 // indirect
github.com/spf13/cobra v1.7.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/spf13/viper v1.16.0 // indirect
github.com/stretchr/objx v0.5.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
@@ -107,6 +113,5 @@ require (
golang.org/x/mod v0.12.0 // indirect
golang.org/x/tools v0.13.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/sourcemap.v1 v1.0.5 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

142
go.sum
View File

@@ -70,6 +70,7 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy
github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0=
github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho=
github.com/anacrolix/dms v1.2.2 h1:0mk2/DXNqa5KDDbaLgFPf3oMV6VCGdFNh3d/gt4oafM=
github.com/anacrolix/dms v1.2.2/go.mod h1:msPKAoppoNRfrYplJqx63FZ+VipDZ4Xsj3KzIQxyU7k=
github.com/anacrolix/envpprof v0.0.0-20180404065416-323002cec2fa/go.mod h1:KgHhUaQMc8cC0+cEflSgCFNFbKwi5h54gqtVn8yhP7c=
@@ -94,10 +95,20 @@ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj
github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
github.com/asticode/go-astikit v0.20.0 h1:+7N+J4E4lWx2QOkRdOf6DafWJMv6O4RRfgClwQokrH8=
github.com/asticode/go-astikit v0.20.0/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0=
github.com/asticode/go-astisub v0.26.0 h1:Ka1oUyWzo/lIx7RX97GI1QdbClqYVxI0ExKuZRN/cDk=
github.com/asticode/go-astisub v0.26.0/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
github.com/asticode/go-astisub v0.25.1 h1:RZMGfZPp7CXOkI6g+zCU7DRLuciGPGup921uKZnMXPI=
github.com/asticode/go-astisub v0.25.1/go.mod h1:WTkuSzFB+Bp7wezuSf2Oxulj5A8zu2zLRVFf6bIFQK8=
github.com/asticode/go-astits v1.8.0 h1:rf6aiiGn/QhlFjNON1n5plqF3Fs025XLUwiQ0NB6oZg=
github.com/asticode/go-astits v1.8.0/go.mod h1:DkOWmBNQpnr9mv24KfZjq4JawCFX1FCqjLVGvO0DygQ=
github.com/aws/aws-sdk-go-v2 v1.9.2/go.mod h1:cK/D0BBs0b/oWPIcX/Z/obahJK1TT7IPVjy53i/mX/4=
github.com/aws/aws-sdk-go-v2/config v1.8.3/go.mod h1:4AEiLtAb8kLs7vgw2ZV3p2VZ1+hBavOc84hqxVNpCyw=
github.com/aws/aws-sdk-go-v2/credentials v1.4.3/go.mod h1:FNNC6nQZQUuyhq5aE5c7ata8o9e4ECGmS4lAXC7o1mQ=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.6.0/go.mod h1:gqlclDEZp4aqJOancXK6TN24aKhT0W0Ae9MHk3wzTMM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.2.4/go.mod h1:ZcBrrI3zBKlhGFNYWvju0I3TR93I7YIgAfy82Fh4lcQ=
github.com/aws/aws-sdk-go-v2/service/appconfig v1.4.2/go.mod h1:FZ3HkCe+b10uFZZkFdvf98LHW21k49W8o8J366lqVKY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.3.2/go.mod h1:72HRZDLMtmVQiLG2tLfQcaWLCssELvGl+Zf2WVxMmR8=
github.com/aws/aws-sdk-go-v2/service/sso v1.4.2/go.mod h1:NBvT9R1MEF+Ud6ApJKM0G+IkPchKS7p7c2YPKwHmBOk=
github.com/aws/aws-sdk-go-v2/service/sts v1.7.2/go.mod h1:8EzeIqfWt2wWT4rJVu3f21TfrhJ8AEMzVybRNSb/b4g=
github.com/aws/smithy-go v1.8.0/go.mod h1:SObp3lf9smib00L/v3U2eAKG8FyQ7iLrJnQiAmR5n+E=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
@@ -118,8 +129,11 @@ github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmt
github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic=
github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/logex v1.2.0/go.mod h1:9+9sk7u7pGNWYMkh0hdiL++6OeibzJccyQU4p4MedaY=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/readline v1.5.0/go.mod h1:x22KAscuvRqlLoK9CsoYsmxoXZMMFVyOl86cAH8qUic=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/chzyer/test v0.0.0-20210722231415-061457976a23/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/circonus-labs/circonus-gometrics v2.3.1+incompatible/go.mod h1:nmEj6Dob7S7YxXgwXpfOuvO54S+tGdZdw9fuRZt25Ag=
github.com/circonus-labs/circonusllhist v0.1.3/go.mod h1:kMXHVDlOchFAehlya5ePtbp5jckzBHf4XRpQvBOLI+I=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
@@ -142,6 +156,7 @@ github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:ma
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -150,10 +165,19 @@ github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48 h1:fRzb/w+pyskVMQ+
github.com/dgryski/trifles v0.0.0-20200323201526-dd97f9abfb48/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/dlclark/regexp2 v1.4.1-0.20201116162257-a2a8dda75c91/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.7.0 h1:7lJfhqlPssTb1WQx4yvTHN0uElPEv52sbaECrAQxjAo=
github.com/dlclark/regexp2 v1.7.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/docopt/docopt-go v0.0.0-20180111231733-ee0de3bc6815/go.mod h1:WwZ+bS3ebgob9U8Nd0kOddGdZWjyMGR8Wziv+TBNwSE=
github.com/dop251/goja v0.0.0-20211022113120-dc8c55024d06/go.mod h1:R9ET47fwRVRPZnOGvHxxhuZcbrMCuiqOz3Rlrh4KSnk=
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d h1:wi6jN5LVt/ljaBG4ue79Ekzb12QfJ52L9Q98tl8SWhw=
github.com/dop251/goja v0.0.0-20231027120936-b396bb4c349d/go.mod h1:QMWlm50DNe14hD7t24KEqZuUdC9sOTy8W6XbCU1mlw4=
github.com/dop251/goja_nodejs v0.0.0-20210225215109-d91c329300e7/go.mod h1:hn7BA7c8pLvoGndExHudxTDKZ84Pyvv+90pbBjbTz0Y=
github.com/dop251/goja_nodejs v0.0.0-20211022123610-8dd9abb0616d/go.mod h1:DngW8aVqWbuLRMHItjPUyqdj+HWPvnQe8V8y1nDpIbM=
github.com/doug-martin/goqu/v9 v9.18.0 h1:/6bcuEtAe6nsSMVK/M+fOiXUNfyFF3yYtE07DBPFMYY=
github.com/doug-martin/goqu/v9 v9.18.0/go.mod h1:nf0Wc2/hV3gYK9LiyqIrzBEVGlI8qW3GuDCEobC4wBQ=
github.com/dustin/go-humanize v0.0.0-20180421182945-02af3965c54e/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@@ -168,7 +192,9 @@ github.com/envoyproxy/protoc-gen-validate v0.6.2/go.mod h1:2t7qjJNvHPx8IjnBOzl9E
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fatih/color v1.9.0/go.mod h1:eQcE1qtQxscV5RaZvpXrrb8Drkc3/DdQ+uUYCNjL+zU=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.5.1/go.mod h1:T3375wBYaZdLLcVNkcVbzGHY7f1l/uK5T5Ai1i3InKU=
github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY=
github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw=
@@ -187,11 +213,17 @@ github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY=
github.com/go-ldap/ldap v3.0.2+incompatible/go.mod h1:qfd9rJvER9Q0/D/Sqn1DfHRoBp40uXYvFoEVrNEPqRc=
github.com/go-logfmt/logfmt v0.3.0/go.mod h1:Qt1PoO58o5twSAckw1HlFXLmHsOX5/0LbT9GBnD5lWE=
github.com/go-logfmt/logfmt v0.4.0/go.mod h1:3RMwSq7FuexP4Kalkev3ejPJsZTpXXBr9+V4qmtdjCk=
github.com/go-logfmt/logfmt v0.5.0/go.mod h1:wCYkCAKZfumFQihp8CzCvQ3paCTfi41vtzG1KdI/P7A=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/go-test/deep v1.0.2-0.20181118220953-042da051cf31/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE=
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
@@ -244,6 +276,7 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
github.com/golang/protobuf v1.5.1/go.mod h1:DopwsBzvsk0Fs44TXzsVbJyPhcCPeIwnvohx4u74HPM=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/snappy v0.0.0-20180518054509-2e65f85255db/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v0.0.0-20180124185431-e89373fe6b4a/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
@@ -260,6 +293,7 @@ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
@@ -281,6 +315,8 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
@@ -296,9 +332,11 @@ github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/z
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
github.com/hashicorp/consul/api v1.11.0/go.mod h1:XjsvQN+RJGWI2TWy1/kqaE16HrR2J/FWgkYjdZQsX9M=
github.com/hashicorp/consul/api v1.12.0/go.mod h1:6pVBMo0ebnYdt2S3H87XhekM/HHrUoTD2XXb/VrZVy0=
github.com/hashicorp/consul/api v1.13.0/go.mod h1:ZlVrynguJKcYr54zGaDbaL3fOvKC9m72FhPvA8T35KQ=
github.com/hashicorp/consul/sdk v0.8.0/go.mod h1:GBvyrGALthsZObzUGsfgHZQDXjg4lOjagTIwIR1vPms=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
@@ -306,6 +344,8 @@ github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brv
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.1/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.0.0-20180709165350-ff2cf002a8dd/go.mod h1:9bjs9uLqI8l75knNv3lV1kA55veR+WUPSiKIWcQHudI=
github.com/hashicorp/go-hclog v0.8.0/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
github.com/hashicorp/go-hclog v0.12.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-hclog v1.0.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
github.com/hashicorp/go-immutable-radix v1.0.0/go.mod h1:0y9vanUI8NX6FsYoO3zeMjhV/C5i9g4Q3DwcSNZ4P60=
@@ -315,12 +355,17 @@ github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHh
github.com/hashicorp/go-multierror v1.1.0/go.mod h1:spPvp8C1qA32ftKqdAHm4hHTbPw+vmowP0z+KUhOZdA=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-plugin v1.0.1/go.mod h1:++UyYGoz3o5w9ZzAdZxtQKrWWP+iqPBn3cQptSMzBuY=
github.com/hashicorp/go-retryablehttp v0.5.3/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-retryablehttp v0.5.4/go.mod h1:9B5zBasrRhHXnJnui7y6sL7es7NDiJgTc6Er0maI1Xs=
github.com/hashicorp/go-rootcerts v1.0.1/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8=
github.com/hashicorp/go-sockaddr v1.0.0/go.mod h1:7Xibr9yA9JjQq1JpNB2Vw7kxv8xerXegt+ozgdvDeDU=
github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A=
github.com/hashicorp/go-syslog v1.0.0/go.mod h1:qPfqrKkXGihmCqbJM2mZgkZGvKG1dFdvsLplgctolz4=
github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.1/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-version v1.1.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
@@ -336,21 +381,34 @@ github.com/hashicorp/memberlist v0.2.2/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOn
github.com/hashicorp/memberlist v0.3.0/go.mod h1:MS2lj3INKhZjWNqd3N0m3J+Jxf3DAOnAH9VT3Sh9MUE=
github.com/hashicorp/serf v0.9.5/go.mod h1:UWDWwZeL5cuWDJdl0C6wrvrUwEqtQ4ZKBKKENpqIUyk=
github.com/hashicorp/serf v0.9.6/go.mod h1:TXZNMjZQijwlDvp+r0b63xZ45H7JmCmgg4gpTwn9UV4=
github.com/hashicorp/vault/api v1.0.4/go.mod h1:gDcqh3WGcR1cpF5AJz/B1UFheUEneMoIospckxBxk6Q=
github.com/hashicorp/vault/sdk v0.1.13/go.mod h1:B+hVj7TpuQY1Y/GPbCpffmgd+tSEwvhkWnjtSYCaS2M=
github.com/hashicorp/yamux v0.0.0-20180604194846-3520598351bb/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
github.com/hashicorp/yamux v0.0.0-20181012175058-2f1d1f20f75d/go.mod h1:+NfK9FKeTrX5uv1uIXGdwYDTeHna2qgaIlx54MXqjAM=
github.com/hjson/hjson-go/v4 v4.0.0 h1:wlm6IYYqHjOdXH1gHev4VoXCaW20HdQAGCxdOEEg2cs=
github.com/hjson/hjson-go/v4 v4.0.0/go.mod h1:KaYt3bTw3zhBjYqnXkYywcYctk0A2nxeEFTse3rH13E=
github.com/huandu/xstrings v1.0.0/go.mod h1:4qWG/gcEcfX4z/mBDHJ++3ReCw9ibxbsNJbcucJdbSo=
github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20220319035150-800ac71e25c2/go.mod h1:aYm2/VgdVmcIU8iMfdMvDMsRAQjcfZSKFby6HOFvi/w=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo=
github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U=
github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g=
github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ=
github.com/joho/godotenv v1.3.0 h1:Zjp+RcGpHhGlrMbJzXTrZZPrWj+1vfm90La1wgB6Bhc=
github.com/joho/godotenv v1.3.0/go.mod h1:7hK45KPybAkOC6peb+G5yklZfMxEjkZhHbwpqxOKXbg=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
github.com/json-iterator/go v1.1.9/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.11/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
@@ -358,6 +416,7 @@ github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/jtolds/gls v4.2.1+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM=
github.com/kermieisinthehouse/gosx-notifier v0.1.2 h1:KV0KBeKK2B24kIHY7iK0jgS64Q05f4oB+hUZmsPodxQ=
github.com/kermieisinthehouse/gosx-notifier v0.1.2/go.mod h1:xyWT07azFtUOcHl96qMVvKhvKzsMcS7rKTHQyv8WTho=
github.com/kermieisinthehouse/systray v1.2.4 h1:pdH5vnl+KKjRrVCRU4g/2W1/0HVzuuJ6WXHlPPHYY6s=
@@ -365,15 +424,21 @@ github.com/kermieisinthehouse/systray v1.2.4/go.mod h1:axh6C/jNuSyC0QGtidZJURc9h
github.com/kevinmbeaulieu/eq-go v1.0.0/go.mod h1:G3S8ajA56gKBZm4UB9AOyoOS37JO3roToPzKNM8dtdM=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/knadh/koanf v1.5.0 h1:q2TSd/3Pyc/5yP9ldIrSdIz26MCcyNQzW0pEAugLPNs=
github.com/knadh/koanf v1.5.0/go.mod h1:Hgyjp4y8v44hpZtPzs7JZfRAW5AhN7KfZcwv1RYggDs=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.0/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
@@ -414,16 +479,25 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5
github.com/miekg/dns v1.0.14/go.mod h1:W1PPwlIAgtquWBMBEV9nkV9Cazfe8ScdGz/Lj7v3Nrg=
github.com/miekg/dns v1.1.26/go.mod h1:bPDLeHnStXmXAq1m/Ch/hvfNHr14JKNPMBo3VZKjuso=
github.com/miekg/dns v1.1.41/go.mod h1:p6aan82bvRIyn+zDIv9xYNUpwa73JcSh9BKwknJysuI=
github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc=
github.com/mitchellh/cli v1.1.0/go.mod h1:xcISNoH86gajksDmfB23e/pu+B+GeFRMYmoHXxx3xhI=
github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw=
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/go-testing-interface v0.0.0-20171004221916-a61a99592b77/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/go-testing-interface v1.0.0/go.mod h1:kRemZodwjscx+RGhAo8eIhFbs2+BFgRtFPeD/KE+zxI=
github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/mitchellh/mapstructure v1.2.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/reflectwalk v1.0.0/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
@@ -433,20 +507,26 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/mschoch/smat v0.0.0-20160514031455-90eadee771ae/go.mod h1:qAyveg+e4CE+eKJXWVjKXM4ck2QobLqTDytGJbLLhJg=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007 h1:Ohgj9L0EYOgXxkDp+bczlMBiulwmqYzQpvQNUdtt3oc=
github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007/go.mod h1:wKCOWMb6iNlvKiOToY2cNuaovSXvIiv1zDi9QDR7aGQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ=
github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U=
github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml v1.7.0/go.mod h1:vwGMzjaWMwyfHwgIBhI2YUM4fB6nL6lVAvS1LBMMhTE=
github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM=
github.com/pelletier/go-toml v1.9.4/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pelletier/go-toml/v2 v2.1.0 h1:FnwAJ4oYMvbT/34k9zzHuZNrhlz48GB3/s6at6/MHO4=
github.com/pelletier/go-toml/v2 v2.1.0/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/philhofer/fwd v1.0.0/go.mod h1:gk3iGcWd9+svBvR0sR+KPcfE+RNWozjowpeBVG3ZVNU=
github.com/pierrec/lz4 v2.0.5+incompatible/go.mod h1:pdkljMzZIN41W+lC3N2tnIh5sFi+IEE17M5jbnwPHcY=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8 h1:KoWmjvw+nsYOo29YJK9vDA65RGE3NrOnUtO7a+RF9HU=
github.com/pkg/browser v0.0.0-20210911075715-681adbf594b8/go.mod h1:HKlIX3XHQyzLZPlr7++PzdhaXEj94dEiJgZDTsxEqUI=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -463,21 +543,27 @@ github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSg
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0=
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
github.com/prometheus/common v0.9.1/go.mod h1:yhUN8i9wzaXS3w1O07YhxHEBxD+W35wd8bs7vj7HSQ4=
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc=
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
github.com/prometheus/procfs v0.0.8/go.mod h1:7Qr8sr6344vo1JqZ6HhLceV9o3AJ1Ff+GxbHq6oeK9A=
github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU=
github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA=
github.com/remeh/sizedwaitgroup v1.0.0 h1:VNGGFwNo/R5+MJBf6yrsr110p0m4/OX4S3DCy7Kyl5E=
github.com/remeh/sizedwaitgroup v1.0.0/go.mod h1:3j2R4OIe/SeS6YDhICBy22RWjJC5eNCJ1V+9+NVNYlo=
github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac h1:kYPjbEN6YPYWWHI6ky1J813KzIq/8+Wg4TO4xU7A/KU=
github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac/go.mod h1:xvqspoSXJTIpemEonrMDFq6XzwHYYgToXWj5eRX1OtY=
github.com/rhnvrm/simples3 v0.6.1/go.mod h1:Y+3vYm2V7Y4VijFoJHHTrja6OgPrJ2cBti8dPGkC3sA=
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
@@ -490,6 +576,8 @@ github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQD
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/ryszard/goskiplist v0.0.0-20150312221310-2dfbae5fcf46/go.mod h1:uAQ5PCi+MFsC7HjREoAz1BU+Mq60+05gifQSsHSDG/8=
github.com/sagikazarmark/crypt v0.3.0/go.mod h1:uD/D+6UF4SrIR1uGEv7bBNkNqLGqUr43MRiaGWX1Nig=
github.com/sagikazarmark/crypt v0.4.0/go.mod h1:ALv2SRj7GxYV4HO9elxH9nS6M9gW+xDNxqmyJ6RfDFM=
@@ -501,6 +589,7 @@ github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5K
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
@@ -582,8 +671,11 @@ github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5t
github.com/zencoder/go-dash/v3 v3.0.2 h1:oP1+dOh+Gp57PkvdCyMfbHtrHaxfl3w4kR3KBBbuqQE=
github.com/zencoder/go-dash/v3 v3.0.2/go.mod h1:30R5bKy1aUYY45yesjtZ9l8trNc2TwNqbS17WVQmCzk=
go.etcd.io/etcd/api/v3 v3.5.1/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
go.etcd.io/etcd/api/v3 v3.5.4/go.mod h1:5GB2vv4A4AOn3yk7MftYGHkUfGtDHnEraIjym4dYz5A=
go.etcd.io/etcd/client/pkg/v3 v3.5.1/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/pkg/v3 v3.5.4/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.1/go.mod h1:pMEacxZW7o8pg4CrFE7pquyCJJzZvkvdD2RibOCCCGs=
go.etcd.io/etcd/client/v3 v3.5.4/go.mod h1:ZaRkVgBZC+L+dLCjTcF1hRXpgZXQPOvnA/Ak/gq3kiY=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
@@ -614,8 +706,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.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
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=
@@ -711,8 +803,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
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=
@@ -749,9 +841,11 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181026203630-95b1ffbd15a5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190403152447-81d4e9dc473e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190415145633-3fd5a3612ccd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -763,10 +857,12 @@ golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20190922100055-0a153f010e69/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190924154521-2837fb4f24fe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191008105621-543471e840be/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200106162015-b016eb3dc98e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -781,6 +877,8 @@ golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200615200032-f1bc736245b1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -788,6 +886,7 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201201145000-ef89a241ccb3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210104204734-6f8348627aad/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210220050731-9a76102bfb43/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210225134936-a50acf3fe073/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -800,6 +899,7 @@ golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423185535-09eb48e85fd7/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210603125802-9665404d3644/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616045830-e2b7044e8c71/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -817,6 +917,7 @@ golang.org/x/sys v0.0.0-20211124211545-fe61309f8881/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20211205182925-97ca703d548d/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211210111614-af8b64212486/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220114195835-da31bd327af9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -827,27 +928,30 @@ golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
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=
golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
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=
@@ -962,6 +1066,7 @@ google.golang.org/appengine v1.6.6/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCID
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190404172233-64821d5d2107/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
@@ -1025,9 +1130,11 @@ google.golang.org/genproto v0.0.0-20211129164237-f09f9a12af12/go.mod h1:5CzLGKJ6
google.golang.org/genproto v0.0.0-20211203200212-54befc351ae9/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211206160659-862468c7d6e0/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/genproto v0.0.0-20211208223120-3a66f561d7aa/go.mod h1:5CzLGKJ67TSI2B9POpiiyGha0AjJvZIUgRMt1dSmuhc=
google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.22.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
@@ -1068,10 +1175,12 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/guregu/null.v4 v4.0.0 h1:1Wm3S1WEA2I26Kq+6vcW+w0gcDo44YKYD7YIEJNHDjg=
gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI=
@@ -1079,14 +1188,14 @@ gopkg.in/ini.v1 v1.66.2/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.66.3/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
gopkg.in/square/go-jose.v2 v2.3.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.3/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.5/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
@@ -1103,3 +1212,4 @@ honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
sigs.k8s.io/yaml v1.2.0/go.mod h1:yfXDCHCao9+ENCvLSE62v9VSji2MKu5jeNfTrofGhJc=

View File

@@ -36,6 +36,8 @@ models:
model: github.com/stashapp/stash/internal/api.Timestamp
BoolMap:
model: github.com/stashapp/stash/internal/api.BoolMap
PluginConfigMap:
model: github.com/stashapp/stash/internal/api.PluginConfigMap
# define to force resolvers
Image:
model: github.com/stashapp/stash/pkg/models.Image
@@ -68,6 +70,8 @@ models:
model: github.com/stashapp/stash/internal/manager/config.ConfigDisableDropdownCreate
ScanMetadataOptions:
model: github.com/stashapp/stash/internal/manager/config.ScanMetadataOptions
CleanGeneratedInput:
model: github.com/stashapp/stash/internal/manager/task.CleanGeneratedOptions
AutoTagMetadataOptions:
model: github.com/stashapp/stash/internal/manager/config.AutoTagMetadataOptions
SystemStatus:

View File

@@ -1,6 +0,0 @@
fragment SlimMovieData on Movie {
id
name
front_image_path
rating100
}

View File

@@ -12,7 +12,8 @@ type Query {
"A function which queries Scene objects"
findScenes(
scene_filter: SceneFilterType
scene_ids: [Int!]
scene_ids: [Int!] @deprecated(reason: "use ids")
ids: [ID!]
filter: FindFilterType
): FindScenesResultType!
@@ -50,7 +51,8 @@ type Query {
"A function which queries Scene objects"
findImages(
image_filter: ImageFilterType
image_ids: [Int!]
image_ids: [Int!] @deprecated(reason: "use ids")
ids: [ID!]
filter: FindFilterType
): FindImagesResultType!
@@ -60,7 +62,8 @@ type Query {
findPerformers(
performer_filter: PerformerFilterType
filter: FindFilterType
performer_ids: [Int!]
performer_ids: [Int!] @deprecated(reason: "use ids")
ids: [ID!]
): FindPerformersResultType!
"Find a studio by ID"
@@ -69,6 +72,7 @@ type Query {
findStudios(
studio_filter: StudioFilterType
filter: FindFilterType
ids: [ID!]
): FindStudiosResultType!
"Find a movie by ID"
@@ -77,18 +81,21 @@ type Query {
findMovies(
movie_filter: MovieFilterType
filter: FindFilterType
ids: [ID!]
): FindMoviesResultType!
findGallery(id: ID!): Gallery
findGalleries(
gallery_filter: GalleryFilterType
filter: FindFilterType
ids: [ID!]
): FindGalleriesResultType!
findTag(id: ID!): Tag
findTags(
tag_filter: TagFilterType
filter: FindFilterType
ids: [ID!]
): FindTagsResultType!
"Retrieve random scene markers for the wall"
@@ -197,15 +204,16 @@ type Query {
# Get everything
allScenes: [Scene!]!
allScenes: [Scene!]! @deprecated(reason: "Use findScenes instead")
allSceneMarkers: [SceneMarker!]!
allImages: [Image!]!
allGalleries: [Gallery!]!
allStudios: [Studio!]!
allMovies: [Movie!]!
allTags: [Tag!]!
@deprecated(reason: "Use findSceneMarkers instead")
allImages: [Image!]! @deprecated(reason: "Use findImages instead")
allGalleries: [Gallery!]! @deprecated(reason: "Use findGalleries instead")
allPerformers: [Performer!]! @deprecated(reason: "Use findPerformers instead")
allPerformers: [Performer!]!
allTags: [Tag!]! @deprecated(reason: "Use findTags instead")
allStudios: [Studio!]! @deprecated(reason: "Use findStudios instead")
allMovies: [Movie!]! @deprecated(reason: "Use findMovies instead")
# Get everything with minimal metadata
@@ -218,7 +226,12 @@ type Query {
type Mutation {
setup(input: SetupInput!): Boolean!
migrate(input: MigrateInput!): Boolean!
"Migrates the schema to the required version. Returns the job ID"
migrate(input: MigrateInput!): ID!
"Downloads and installs ffmpeg and ffprobe binaries into the configuration directory. Returns the job ID."
downloadFFMpeg: ID!
sceneCreate(input: SceneCreateInput!): Scene
sceneUpdate(input: SceneUpdateInput!): Scene
@@ -229,9 +242,15 @@ type Mutation {
scenesUpdate(input: [SceneUpdateInput!]!): [Scene]
"Increments the o-counter for a scene. Returns the new value"
sceneIncrementO(id: ID!): Int!
sceneIncrementO(id: ID!): Int! @deprecated(reason: "Use sceneAddO instead")
"Decrements the o-counter for a scene. Returns the new value"
sceneDecrementO(id: ID!): Int!
sceneDecrementO(id: ID!): Int! @deprecated(reason: "Use sceneRemoveO instead")
"Increments the o-counter for a scene. Uses the current time if none provided."
sceneAddO(id: ID!, times: [Timestamp!]): HistoryMutationResult!
"Decrements the o-counter for a scene, removing the last recorded time if specific time not provided. Returns the new value"
sceneDeleteO(id: ID!, times: [Timestamp!]): HistoryMutationResult!
"Resets the o-counter for a scene to 0. Returns the new value"
sceneResetO(id: ID!): Int!
@@ -240,6 +259,14 @@ type Mutation {
"Increments the play count for the scene. Returns the new play count value."
sceneIncrementPlayCount(id: ID!): Int!
@deprecated(reason: "Use sceneAddPlay instead")
"Increments the play count for the scene. Uses the current time if none provided."
sceneAddPlay(id: ID!, times: [Timestamp!]): HistoryMutationResult!
"Decrements the play count for the scene, removing the specific times or the last recorded time if not provided."
sceneDeletePlay(id: ID!, times: [Timestamp!]): HistoryMutationResult!
"Resets the play count for a scene to 0. Returns the new play count value."
sceneResetPlayCount(id: ID!): Int!
"Generates screenshot at specified time in seconds. Leave empty to generate default screenshot"
sceneGenerateScreenshot(id: ID!, at: Float): String!
@@ -327,12 +354,19 @@ type Mutation {
input: ConfigDefaultSettingsInput!
): ConfigDefaultSettingsResult!
# overwrites the entire plugin configuration for the given plugin
"overwrites the entire plugin configuration for the given plugin"
configurePlugin(plugin_id: ID!, input: Map!): Map!
# overwrites the entire UI configuration
configureUI(input: Map!): Map!
# sets a single UI key value
"""
overwrites the UI configuration
if input is provided, then the entire UI configuration is replaced
if partial is provided, then the partial UI configuration is merged into the existing UI configuration
"""
configureUI(input: Map, partial: Map): Map!
"""
sets a single UI key value
key is a dot separated path to the value
"""
configureUISetting(key: String!, value: Any): Map!
"Generate and set (or clear) API key"
@@ -356,6 +390,8 @@ type Mutation {
metadataAutoTag(input: AutoTagMetadataInput!): ID!
"Clean metadata. Returns the job ID"
metadataClean(input: CleanMetadataInput!): ID!
"Clean generated files. Returns the job ID"
metadataCleanGenerated(input: CleanGeneratedInput!): ID!
"Identifies scenes using scrapers. Returns the job ID"
metadataIdentify(input: IdentifyMetadataInput!): ID!
@@ -381,12 +417,29 @@ type Mutation {
"""
setPluginsEnabled(enabledMap: BoolMap!): Boolean!
"Run plugin task. Returns the job ID"
"""
Run a plugin task.
If task_name is provided, then the task must exist in the plugin config and the tasks configuration
will be used to run the plugin.
If no task_name is provided, then the plugin will be executed with the arguments provided only.
Returns the job ID
"""
runPluginTask(
plugin_id: ID!
task_name: String!
args: [PluginArgInput!]
"if provided, then the default args will be applied"
task_name: String
"displayed in the task queue"
description: String
args: [PluginArgInput!] @deprecated(reason: "Use args_map instead")
args_map: Map
): ID!
"""
Runs a plugin operation. The operation is run immediately and does not use the job queue.
Returns a map of the result.
"""
runPluginOperation(plugin_id: ID!, args: Map): Any
reloadPlugins: Boolean!
"""

View File

@@ -81,6 +81,10 @@ input ConfigGeneralInput {
blobsPath: String
"Where to store blobs"
blobsStorage: BlobsStorageType
"Path to the ffmpeg binary. If empty, stash will attempt to find it in the path or config directory"
ffmpegPath: String
"Path to the ffprobe binary. If empty, stash will attempt to find it in the path or config directory"
ffprobePath: String
"Whether to calculate MD5 checksums for scene video files"
calculateMD5: Boolean
"Hash algorithm to use for generated file naming"
@@ -199,6 +203,10 @@ type ConfigGeneralResult {
blobsPath: String!
"Where to store blobs"
blobsStorage: BlobsStorageType!
"Path to the ffmpeg binary. If empty, stash will attempt to find it in the path or config directory"
ffmpegPath: String!
"Path to the ffprobe binary. If empty, stash will attempt to find it in the path or config directory"
ffprobePath: String!
"Whether to calculate MD5 checksums for scene video files"
calculateMD5: Boolean!
"Hash algorithm to use for generated file naming"
@@ -461,6 +469,8 @@ input ConfigDLNAInput {
serverName: String
"True if DLNA service should be enabled by default"
enabled: Boolean
"Defaults to 1338"
port: Int
"List of IPs whitelisted for DLNA service"
whitelistedIPs: [String!]
"List of interfaces to run DLNA on. Empty for all"
@@ -473,6 +483,8 @@ type ConfigDLNAResult {
serverName: String!
"True if DLNA service should be enabled by default"
enabled: Boolean!
"Defaults to 1338"
port: Int!
"List of IPs whitelisted for DLNA service"
whitelistedIPs: [String!]!
"List of interfaces to run DLNA on. Empty for all"
@@ -535,7 +547,7 @@ type ConfigResult {
scraping: ConfigScrapingResult!
defaults: ConfigDefaultSettingsResult!
ui: Map!
plugins(include: [String!]): Map!
plugins(include: [ID!]): PluginConfigMap!
}
"Directory structure of a path"

View File

@@ -8,6 +8,7 @@ input FindFilterType {
page: Int
"use per_page = -1 to indicate all results. Defaults to 25."
per_page: Int
# TODO - this should be refactored to not use a string
sort: String
direction: SortDirectionEnum
}
@@ -61,6 +62,19 @@ input ResolutionCriterionInput {
modifier: CriterionModifier!
}
enum OrientationEnum {
"Landscape"
LANDSCAPE
"Portrait"
PORTRAIT
"Square"
SQUARE
}
input OrientationCriterionInput {
value: [OrientationEnum!]!
}
input PHashDuplicationCriterionInput {
duplicated: Boolean
"Currently unimplemented"
@@ -130,6 +144,8 @@ input PerformerFilterType {
image_count: IntCriterionInput
"Filter by gallery count"
gallery_count: IntCriterionInput
"Filter by play count"
play_count: IntCriterionInput
"Filter by o count"
o_counter: IntCriterionInput
"Filter by StashID"
@@ -212,8 +228,12 @@ input SceneFilterType {
duplicated: PHashDuplicationCriterionInput
"Filter by resolution"
resolution: ResolutionCriterionInput
"Filter by orientation"
orientation: OrientationCriterionInput
"Filter by frame rate"
framerate: IntCriterionInput
"Filter by bit rate"
bitrate: IntCriterionInput
"Filter by video codec"
video_codec: StringCriterionInput
"Filter by audio codec"
@@ -228,6 +248,8 @@ input SceneFilterType {
studios: HierarchicalMultiCriterionInput
"Filter to only include scenes with this movie"
movies: MultiCriterionInput
"Filter to only include scenes with this gallery"
galleries: MultiCriterionInput
"Filter to only include scenes with these tags"
tags: HierarchicalMultiCriterionInput
"Filter by tag count"
@@ -258,6 +280,8 @@ input SceneFilterType {
play_count: IntCriterionInput
"Filter by play duration (in seconds)"
play_duration: IntCriterionInput
"Filter by scene last played time"
last_played_at: TimestampCriterionInput
"Filter by date"
date: DateCriterionInput
"Filter by creation time"
@@ -306,6 +330,8 @@ input StudioFilterType {
is_missing: String
# rating expressed as 1-100
rating100: IntCriterionInput
"Filter by favorite"
favorite: Boolean
"Filter by scene count"
scene_count: IntCriterionInput
"Filter by image count"
@@ -316,6 +342,8 @@ input StudioFilterType {
url: StringCriterionInput
"Filter by studio aliases"
aliases: StringCriterionInput
"Filter by subsidiary studio count"
child_count: IntCriterionInput
"Filter by autotag ignore value"
ignore_auto_tag: Boolean
"Filter by creation time"
@@ -351,6 +379,8 @@ input GalleryFilterType {
average_resolution: ResolutionCriterionInput
"Filter to only include galleries that have chapters. `true` or `false`"
has_chapters: String
"Filter to only include galleries with these scenes"
scenes: MultiCriterionInput
"Filter to only include galleries with this studio"
studios: HierarchicalMultiCriterionInput
"Filter to only include galleries with these tags"
@@ -394,6 +424,9 @@ input TagFilterType {
"Filter by tag aliases"
aliases: StringCriterionInput
"Filter by favorite"
favorite: Boolean
"Filter by tag description"
description: StringCriterionInput
@@ -465,6 +498,8 @@ input ImageFilterType {
o_counter: IntCriterionInput
"Filter by resolution"
resolution: ResolutionCriterionInput
"Filter by orientation"
orientation: OrientationCriterionInput
"Filter to only include images missing this property"
is_missing: String
"Filter to only include images with this studio"
@@ -481,6 +516,8 @@ input ImageFilterType {
performer_count: IntCriterionInput
"Filter images that have performers that have been favorited"
performer_favorite: Boolean
"Filter images by performer age at time of image"
performer_age: IntCriterionInput
"Filter to only include images with these galleries"
galleries: MultiCriterionInput
"Filter by creation time"
@@ -545,6 +582,7 @@ input MultiCriterionInput {
input GenderCriterionInput {
value: GenderEnum
value_list: [GenderEnum!]
modifier: CriterionModifier!
}

View File

@@ -4,6 +4,7 @@ enum JobStatus {
FINISHED
STOPPING
CANCELLED
FAILED
}
type Job {
@@ -15,6 +16,7 @@ type Job {
startTime: Time
endTime: Time
addTime: Time!
error: String
}
input FindJobInput {

View File

@@ -1,6 +1,3 @@
"Log entries"
scalar Time
enum LogLevel {
Trace
Debug

View File

@@ -1,5 +1,3 @@
scalar Upload
input GenerateMetadataInput {
covers: Boolean
sprites: Boolean
@@ -14,6 +12,7 @@ input GenerateMetadataInput {
forceTranscodes: Boolean
phashes: Boolean
interactiveHeatmapsSpeeds: Boolean
imageThumbnails: Boolean
clipPreviews: Boolean
"scene ids to generate for"
@@ -50,6 +49,7 @@ type GenerateMetadataOptions {
transcodes: Boolean
phashes: Boolean
interactiveHeatmapsSpeeds: Boolean
imageThumbnails: Boolean
clipPreviews: Boolean
}
@@ -75,6 +75,8 @@ input ScanMetaDataFilterInput {
input ScanMetadataInput {
paths: [String!]
"Forces a rescan on files even if modification time is unchanged"
rescan: Boolean
"Generate covers during scan"
scanGenerateCovers: Boolean
"Generate previews during scan"
@@ -95,6 +97,8 @@ input ScanMetadataInput {
}
type ScanMetadataOptions {
"Forces a rescan on files even if modification time is unchanged"
rescan: Boolean!
"Generate covers during scan"
scanGenerateCovers: Boolean!
"Generate previews during scan"
@@ -118,6 +122,26 @@ input CleanMetadataInput {
dryRun: Boolean!
}
input CleanGeneratedInput {
"Clean blob files without blob entries"
blobFiles: Boolean
"Clean sprite and vtt files without scene entries"
sprites: Boolean
"Clean preview files without scene entries"
screenshots: Boolean
"Clean scene transcodes without scene entries"
transcodes: Boolean
"Clean marker files without marker entries"
markers: Boolean
"Clean image thumbnails/clips without image entries"
imageThumbnails: Boolean
"Do a dry run. Don't delete any files"
dryRun: Boolean
}
input AutoTagMetadataInput {
"Paths to tag, null for all files"
paths: [String!]
@@ -306,6 +330,8 @@ type SystemStatus {
os: String!
workingDir: String!
homeDir: String!
ffmpegPath: String
ffprobePath: String
}
input MigrateInput {

View File

@@ -1,3 +1,6 @@
"An RFC3339 timestamp"
scalar Time
"""
Timestamp is a point in time. It is always output as RFC3339-compatible time points.
It can be input as a RFC3339 string, or as "<4h" for "4 hours in the past" or ">5m"
@@ -5,12 +8,18 @@ for "5 minutes in the future"
"""
scalar Timestamp
# generic JSON object
"A String -> Any map"
scalar Map
# string, boolean map
"A String -> Boolean map"
scalar BoolMap
"A plugin ID -> Map (String -> Any map) map"
scalar PluginConfigMap
scalar Any
scalar Int64
"A multipart file upload"
scalar Upload

View File

@@ -58,6 +58,11 @@ type Scene {
"The number ot times a scene has been played"
play_count: Int
"Times a scene was played"
play_history: [Time!]!
"Times the o counter was incremented"
o_history: [Time!]!
files: [VideoFile!]!
paths: ScenePathsType! # Resolver
scene_markers: [SceneMarker!]!
@@ -118,6 +123,7 @@ input SceneUpdateInput {
# rating expressed as 1-100
rating100: Int
o_counter: Int
@deprecated(reason: "Unsupported - Use sceneIncrementO/sceneDecrementO")
organized: Boolean
studio_id: ID
gallery_ids: [ID!]
@@ -134,6 +140,9 @@ input SceneUpdateInput {
play_duration: Float
"The number ot times a scene has been played"
play_count: Int
@deprecated(
reason: "Unsupported - Use sceneIncrementPlayCount/sceneDecrementPlayCount"
)
primary_file_id: ID
}
@@ -251,4 +260,13 @@ input SceneMergeInput {
destination: ID!
# values defined here will override values in the destination
values: SceneUpdateInput
# if true, the source history will be combined with the destination
play_history: Boolean
o_history: Boolean
}
type HistoryMutationResult {
count: Int!
history: [Time!]!
}

View File

@@ -16,6 +16,7 @@ type Studio {
stash_ids: [StashID!]!
# rating expressed as 1-100
rating100: Int
favorite: Boolean!
details: String
created_at: Time!
updated_at: Time!
@@ -31,6 +32,7 @@ input StudioCreateInput {
stash_ids: [StashIDInput!]
# rating expressed as 1-100
rating100: Int
favorite: Boolean
details: String
aliases: [String!]
ignore_auto_tag: Boolean
@@ -46,6 +48,7 @@ input StudioUpdateInput {
stash_ids: [StashIDInput!]
# rating expressed as 1-100
rating100: Int
favorite: Boolean
details: String
aliases: [String!]
ignore_auto_tag: Boolean

View File

@@ -6,7 +6,7 @@ type Tag {
ignore_auto_tag: Boolean!
created_at: Time!
updated_at: Time!
favorite: Boolean!
image_path: String # Resolver
scene_count(depth: Int): Int! # Resolver
scene_marker_count(depth: Int): Int! # Resolver
@@ -25,7 +25,7 @@ input TagCreateInput {
description: String
aliases: [String!]
ignore_auto_tag: Boolean
favorite: Boolean
"This should be a URL or a base64 encoded data URL"
image: String
@@ -39,7 +39,7 @@ input TagUpdateInput {
description: String
aliases: [String!]
ignore_auto_tag: Boolean
favorite: Boolean
"This should be a URL or a base64 encoded data URL"
image: String

View File

@@ -42,7 +42,7 @@ func authenticateHandler() func(http.Handler) http.Handler {
userID, err := manager.GetInstance().SessionStore.Authenticate(w, r)
if err != nil {
if errors.Is(err, session.ErrUnauthorized) {
if !errors.Is(err, session.ErrUnauthorized) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}

View File

@@ -9,7 +9,11 @@
//go:generate go run github.com/vektah/dataloaden SceneFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
//go:generate go run github.com/vektah/dataloaden ImageFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
//go:generate go run github.com/vektah/dataloaden GalleryFileIDsLoader int []github.com/stashapp/stash/pkg/models.FileID
//go:generate go run github.com/vektah/dataloaden SceneOCountLoader int int
//go:generate go run github.com/vektah/dataloaden ScenePlayCountLoader int int
//go:generate go run github.com/vektah/dataloaden SceneOHistoryLoader int []time.Time
//go:generate go run github.com/vektah/dataloaden ScenePlayHistoryLoader int []time.Time
//go:generate go run github.com/vektah/dataloaden SceneLastPlayedLoader int *time.Time
package loaders
import (
@@ -32,8 +36,14 @@ const (
)
type Loaders struct {
SceneByID *SceneLoader
SceneFiles *SceneFileIDsLoader
SceneByID *SceneLoader
SceneFiles *SceneFileIDsLoader
ScenePlayCount *ScenePlayCountLoader
SceneOCount *SceneOCountLoader
ScenePlayHistory *ScenePlayHistoryLoader
SceneOHistory *SceneOHistoryLoader
SceneLastPlayed *SceneLastPlayedLoader
ImageFiles *ImageFileIDsLoader
GalleryFiles *GalleryFileIDsLoader
@@ -109,6 +119,31 @@ func (m Middleware) Middleware(next http.Handler) http.Handler {
maxBatch: maxBatch,
fetch: m.fetchGalleriesFileIDs(ctx),
},
ScenePlayCount: &ScenePlayCountLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchScenesPlayCount(ctx),
},
SceneOCount: &SceneOCountLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchScenesOCount(ctx),
},
ScenePlayHistory: &ScenePlayHistoryLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchScenesPlayHistory(ctx),
},
SceneLastPlayed: &SceneLastPlayedLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchScenesLastPlayed(ctx),
},
SceneOHistory: &SceneOHistoryLoader{
wait: wait,
maxBatch: maxBatch,
fetch: m.fetchScenesOHistory(ctx),
},
}
newCtx := context.WithValue(r.Context(), loadersCtxKey, ldrs)
@@ -251,3 +286,58 @@ func (m Middleware) fetchGalleriesFileIDs(ctx context.Context) func(keys []int)
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchScenesOCount(ctx context.Context) func(keys []int) ([]int, []error) {
return func(keys []int) (ret []int, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Scene.GetManyOCount(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchScenesPlayCount(ctx context.Context) func(keys []int) ([]int, []error) {
return func(keys []int) (ret []int, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Scene.GetManyViewCount(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchScenesOHistory(ctx context.Context) func(keys []int) ([][]time.Time, []error) {
return func(keys []int) (ret [][]time.Time, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Scene.GetManyODates(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchScenesPlayHistory(ctx context.Context) func(keys []int) ([][]time.Time, []error) {
return func(keys []int) (ret [][]time.Time, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Scene.GetManyViewDates(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}
func (m Middleware) fetchScenesLastPlayed(ctx context.Context) func(keys []int) ([]*time.Time, []error) {
return func(keys []int) (ret []*time.Time, errs []error) {
err := m.Repository.WithDB(ctx, func(ctx context.Context) error {
var err error
ret, err = m.Repository.Scene.GetManyLastViewed(ctx, keys)
return err
})
return ret, toErrorSlice(err)
}
}

View File

@@ -0,0 +1,222 @@
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
package loaders
import (
"sync"
"time"
)
// SceneLastPlayedLoaderConfig captures the config to create a new SceneLastPlayedLoader
type SceneLastPlayedLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []int) ([]*time.Time, []error)
// Wait is how long wait before sending a batch
Wait time.Duration
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
MaxBatch int
}
// NewSceneLastPlayedLoader creates a new SceneLastPlayedLoader given a fetch, wait, and maxBatch
func NewSceneLastPlayedLoader(config SceneLastPlayedLoaderConfig) *SceneLastPlayedLoader {
return &SceneLastPlayedLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// SceneLastPlayedLoader batches and caches requests
type SceneLastPlayedLoader struct {
// this method provides the data for the loader
fetch func(keys []int) ([]*time.Time, []error)
// how long to done before sending a batch
wait time.Duration
// this will limit the maximum number of keys to send in one batch, 0 = no limit
maxBatch int
// INTERNAL
// lazily created cache
cache map[int]*time.Time
// the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners
batch *sceneLastPlayedLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type sceneLastPlayedLoaderBatch struct {
keys []int
data []*time.Time
error []error
closing bool
done chan struct{}
}
// Load a Time by key, batching and caching will be applied automatically
func (l *SceneLastPlayedLoader) Load(key int) (*time.Time, error) {
return l.LoadThunk(key)()
}
// LoadThunk returns a function that when called will block waiting for a Time.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *SceneLastPlayedLoader) LoadThunk(key int) func() (*time.Time, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() (*time.Time, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &sceneLastPlayedLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() (*time.Time, error) {
<-batch.done
var data *time.Time
if pos < len(batch.data) {
data = batch.data[pos]
}
var err error
// its convenient to be able to return a single error for everything
if len(batch.error) == 1 {
err = batch.error[0]
} else if batch.error != nil {
err = batch.error[pos]
}
if err == nil {
l.mu.Lock()
l.unsafeSet(key, data)
l.mu.Unlock()
}
return data, err
}
}
// LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured
func (l *SceneLastPlayedLoader) LoadAll(keys []int) ([]*time.Time, []error) {
results := make([]func() (*time.Time, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
times := make([]*time.Time, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
times[i], errors[i] = thunk()
}
return times, errors
}
// LoadAllThunk returns a function that when called will block waiting for a Times.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *SceneLastPlayedLoader) LoadAllThunk(keys []int) func() ([]*time.Time, []error) {
results := make([]func() (*time.Time, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([]*time.Time, []error) {
times := make([]*time.Time, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
times[i], errors[i] = thunk()
}
return times, errors
}
}
// Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *SceneLastPlayedLoader) Prime(key int, value *time.Time) bool {
l.mu.Lock()
var found bool
if _, found = l.cache[key]; !found {
// make a copy when writing to the cache, its easy to pass a pointer in from a loop var
// and end up with the whole cache pointing to the same value.
cpy := *value
l.unsafeSet(key, &cpy)
}
l.mu.Unlock()
return !found
}
// Clear the value at key from the cache, if it exists
func (l *SceneLastPlayedLoader) Clear(key int) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *SceneLastPlayedLoader) unsafeSet(key int, value *time.Time) {
if l.cache == nil {
l.cache = map[int]*time.Time{}
}
l.cache[key] = value
}
// keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch
func (b *sceneLastPlayedLoaderBatch) keyIndex(l *SceneLastPlayedLoader, key int) int {
for i, existingKey := range b.keys {
if key == existingKey {
return i
}
}
pos := len(b.keys)
b.keys = append(b.keys, key)
if pos == 0 {
go b.startTimer(l)
}
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
if !b.closing {
b.closing = true
l.batch = nil
go b.end(l)
}
}
return pos
}
func (b *sceneLastPlayedLoaderBatch) startTimer(l *SceneLastPlayedLoader) {
time.Sleep(l.wait)
l.mu.Lock()
// we must have hit a batch limit and are already finalizing this batch
if b.closing {
l.mu.Unlock()
return
}
l.batch = nil
l.mu.Unlock()
b.end(l)
}
func (b *sceneLastPlayedLoaderBatch) end(l *SceneLastPlayedLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -0,0 +1,219 @@
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
package loaders
import (
"sync"
"time"
)
// SceneOCountLoaderConfig captures the config to create a new SceneOCountLoader
type SceneOCountLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []int) ([]int, []error)
// Wait is how long wait before sending a batch
Wait time.Duration
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
MaxBatch int
}
// NewSceneOCountLoader creates a new SceneOCountLoader given a fetch, wait, and maxBatch
func NewSceneOCountLoader(config SceneOCountLoaderConfig) *SceneOCountLoader {
return &SceneOCountLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// SceneOCountLoader batches and caches requests
type SceneOCountLoader struct {
// this method provides the data for the loader
fetch func(keys []int) ([]int, []error)
// how long to done before sending a batch
wait time.Duration
// this will limit the maximum number of keys to send in one batch, 0 = no limit
maxBatch int
// INTERNAL
// lazily created cache
cache map[int]int
// the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners
batch *sceneOCountLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type sceneOCountLoaderBatch struct {
keys []int
data []int
error []error
closing bool
done chan struct{}
}
// Load a int by key, batching and caching will be applied automatically
func (l *SceneOCountLoader) Load(key int) (int, error) {
return l.LoadThunk(key)()
}
// LoadThunk returns a function that when called will block waiting for a int.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *SceneOCountLoader) LoadThunk(key int) func() (int, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() (int, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &sceneOCountLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() (int, error) {
<-batch.done
var data int
if pos < len(batch.data) {
data = batch.data[pos]
}
var err error
// its convenient to be able to return a single error for everything
if len(batch.error) == 1 {
err = batch.error[0]
} else if batch.error != nil {
err = batch.error[pos]
}
if err == nil {
l.mu.Lock()
l.unsafeSet(key, data)
l.mu.Unlock()
}
return data, err
}
}
// LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured
func (l *SceneOCountLoader) LoadAll(keys []int) ([]int, []error) {
results := make([]func() (int, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
ints := make([]int, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
ints[i], errors[i] = thunk()
}
return ints, errors
}
// LoadAllThunk returns a function that when called will block waiting for a ints.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *SceneOCountLoader) LoadAllThunk(keys []int) func() ([]int, []error) {
results := make([]func() (int, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([]int, []error) {
ints := make([]int, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
ints[i], errors[i] = thunk()
}
return ints, errors
}
}
// Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *SceneOCountLoader) Prime(key int, value int) bool {
l.mu.Lock()
var found bool
if _, found = l.cache[key]; !found {
l.unsafeSet(key, value)
}
l.mu.Unlock()
return !found
}
// Clear the value at key from the cache, if it exists
func (l *SceneOCountLoader) Clear(key int) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *SceneOCountLoader) unsafeSet(key int, value int) {
if l.cache == nil {
l.cache = map[int]int{}
}
l.cache[key] = value
}
// keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch
func (b *sceneOCountLoaderBatch) keyIndex(l *SceneOCountLoader, key int) int {
for i, existingKey := range b.keys {
if key == existingKey {
return i
}
}
pos := len(b.keys)
b.keys = append(b.keys, key)
if pos == 0 {
go b.startTimer(l)
}
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
if !b.closing {
b.closing = true
l.batch = nil
go b.end(l)
}
}
return pos
}
func (b *sceneOCountLoaderBatch) startTimer(l *SceneOCountLoader) {
time.Sleep(l.wait)
l.mu.Lock()
// we must have hit a batch limit and are already finalizing this batch
if b.closing {
l.mu.Unlock()
return
}
l.batch = nil
l.mu.Unlock()
b.end(l)
}
func (b *sceneOCountLoaderBatch) end(l *SceneOCountLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -0,0 +1,223 @@
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
package loaders
import (
"sync"
"time"
)
// SceneOHistoryLoaderConfig captures the config to create a new SceneOHistoryLoader
type SceneOHistoryLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []int) ([][]time.Time, []error)
// Wait is how long wait before sending a batch
Wait time.Duration
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
MaxBatch int
}
// NewSceneOHistoryLoader creates a new SceneOHistoryLoader given a fetch, wait, and maxBatch
func NewSceneOHistoryLoader(config SceneOHistoryLoaderConfig) *SceneOHistoryLoader {
return &SceneOHistoryLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// SceneOHistoryLoader batches and caches requests
type SceneOHistoryLoader struct {
// this method provides the data for the loader
fetch func(keys []int) ([][]time.Time, []error)
// how long to done before sending a batch
wait time.Duration
// this will limit the maximum number of keys to send in one batch, 0 = no limit
maxBatch int
// INTERNAL
// lazily created cache
cache map[int][]time.Time
// the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners
batch *sceneOHistoryLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type sceneOHistoryLoaderBatch struct {
keys []int
data [][]time.Time
error []error
closing bool
done chan struct{}
}
// Load a Time by key, batching and caching will be applied automatically
func (l *SceneOHistoryLoader) Load(key int) ([]time.Time, error) {
return l.LoadThunk(key)()
}
// LoadThunk returns a function that when called will block waiting for a Time.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *SceneOHistoryLoader) LoadThunk(key int) func() ([]time.Time, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() ([]time.Time, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &sceneOHistoryLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() ([]time.Time, error) {
<-batch.done
var data []time.Time
if pos < len(batch.data) {
data = batch.data[pos]
}
var err error
// its convenient to be able to return a single error for everything
if len(batch.error) == 1 {
err = batch.error[0]
} else if batch.error != nil {
err = batch.error[pos]
}
if err == nil {
l.mu.Lock()
l.unsafeSet(key, data)
l.mu.Unlock()
}
return data, err
}
}
// LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured
func (l *SceneOHistoryLoader) LoadAll(keys []int) ([][]time.Time, []error) {
results := make([]func() ([]time.Time, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
times := make([][]time.Time, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
times[i], errors[i] = thunk()
}
return times, errors
}
// LoadAllThunk returns a function that when called will block waiting for a Times.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *SceneOHistoryLoader) LoadAllThunk(keys []int) func() ([][]time.Time, []error) {
results := make([]func() ([]time.Time, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([][]time.Time, []error) {
times := make([][]time.Time, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
times[i], errors[i] = thunk()
}
return times, errors
}
}
// Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *SceneOHistoryLoader) Prime(key int, value []time.Time) bool {
l.mu.Lock()
var found bool
if _, found = l.cache[key]; !found {
// make a copy when writing to the cache, its easy to pass a pointer in from a loop var
// and end up with the whole cache pointing to the same value.
cpy := make([]time.Time, len(value))
copy(cpy, value)
l.unsafeSet(key, cpy)
}
l.mu.Unlock()
return !found
}
// Clear the value at key from the cache, if it exists
func (l *SceneOHistoryLoader) Clear(key int) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *SceneOHistoryLoader) unsafeSet(key int, value []time.Time) {
if l.cache == nil {
l.cache = map[int][]time.Time{}
}
l.cache[key] = value
}
// keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch
func (b *sceneOHistoryLoaderBatch) keyIndex(l *SceneOHistoryLoader, key int) int {
for i, existingKey := range b.keys {
if key == existingKey {
return i
}
}
pos := len(b.keys)
b.keys = append(b.keys, key)
if pos == 0 {
go b.startTimer(l)
}
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
if !b.closing {
b.closing = true
l.batch = nil
go b.end(l)
}
}
return pos
}
func (b *sceneOHistoryLoaderBatch) startTimer(l *SceneOHistoryLoader) {
time.Sleep(l.wait)
l.mu.Lock()
// we must have hit a batch limit and are already finalizing this batch
if b.closing {
l.mu.Unlock()
return
}
l.batch = nil
l.mu.Unlock()
b.end(l)
}
func (b *sceneOHistoryLoaderBatch) end(l *SceneOHistoryLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -0,0 +1,219 @@
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
package loaders
import (
"sync"
"time"
)
// ScenePlayCountLoaderConfig captures the config to create a new ScenePlayCountLoader
type ScenePlayCountLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []int) ([]int, []error)
// Wait is how long wait before sending a batch
Wait time.Duration
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
MaxBatch int
}
// NewScenePlayCountLoader creates a new ScenePlayCountLoader given a fetch, wait, and maxBatch
func NewScenePlayCountLoader(config ScenePlayCountLoaderConfig) *ScenePlayCountLoader {
return &ScenePlayCountLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// ScenePlayCountLoader batches and caches requests
type ScenePlayCountLoader struct {
// this method provides the data for the loader
fetch func(keys []int) ([]int, []error)
// how long to done before sending a batch
wait time.Duration
// this will limit the maximum number of keys to send in one batch, 0 = no limit
maxBatch int
// INTERNAL
// lazily created cache
cache map[int]int
// the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners
batch *scenePlayCountLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type scenePlayCountLoaderBatch struct {
keys []int
data []int
error []error
closing bool
done chan struct{}
}
// Load a int by key, batching and caching will be applied automatically
func (l *ScenePlayCountLoader) Load(key int) (int, error) {
return l.LoadThunk(key)()
}
// LoadThunk returns a function that when called will block waiting for a int.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *ScenePlayCountLoader) LoadThunk(key int) func() (int, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() (int, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &scenePlayCountLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() (int, error) {
<-batch.done
var data int
if pos < len(batch.data) {
data = batch.data[pos]
}
var err error
// its convenient to be able to return a single error for everything
if len(batch.error) == 1 {
err = batch.error[0]
} else if batch.error != nil {
err = batch.error[pos]
}
if err == nil {
l.mu.Lock()
l.unsafeSet(key, data)
l.mu.Unlock()
}
return data, err
}
}
// LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured
func (l *ScenePlayCountLoader) LoadAll(keys []int) ([]int, []error) {
results := make([]func() (int, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
ints := make([]int, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
ints[i], errors[i] = thunk()
}
return ints, errors
}
// LoadAllThunk returns a function that when called will block waiting for a ints.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *ScenePlayCountLoader) LoadAllThunk(keys []int) func() ([]int, []error) {
results := make([]func() (int, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([]int, []error) {
ints := make([]int, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
ints[i], errors[i] = thunk()
}
return ints, errors
}
}
// Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *ScenePlayCountLoader) Prime(key int, value int) bool {
l.mu.Lock()
var found bool
if _, found = l.cache[key]; !found {
l.unsafeSet(key, value)
}
l.mu.Unlock()
return !found
}
// Clear the value at key from the cache, if it exists
func (l *ScenePlayCountLoader) Clear(key int) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *ScenePlayCountLoader) unsafeSet(key int, value int) {
if l.cache == nil {
l.cache = map[int]int{}
}
l.cache[key] = value
}
// keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch
func (b *scenePlayCountLoaderBatch) keyIndex(l *ScenePlayCountLoader, key int) int {
for i, existingKey := range b.keys {
if key == existingKey {
return i
}
}
pos := len(b.keys)
b.keys = append(b.keys, key)
if pos == 0 {
go b.startTimer(l)
}
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
if !b.closing {
b.closing = true
l.batch = nil
go b.end(l)
}
}
return pos
}
func (b *scenePlayCountLoaderBatch) startTimer(l *ScenePlayCountLoader) {
time.Sleep(l.wait)
l.mu.Lock()
// we must have hit a batch limit and are already finalizing this batch
if b.closing {
l.mu.Unlock()
return
}
l.batch = nil
l.mu.Unlock()
b.end(l)
}
func (b *scenePlayCountLoaderBatch) end(l *ScenePlayCountLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -0,0 +1,223 @@
// Code generated by github.com/vektah/dataloaden, DO NOT EDIT.
package loaders
import (
"sync"
"time"
)
// ScenePlayHistoryLoaderConfig captures the config to create a new ScenePlayHistoryLoader
type ScenePlayHistoryLoaderConfig struct {
// Fetch is a method that provides the data for the loader
Fetch func(keys []int) ([][]time.Time, []error)
// Wait is how long wait before sending a batch
Wait time.Duration
// MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit
MaxBatch int
}
// NewScenePlayHistoryLoader creates a new ScenePlayHistoryLoader given a fetch, wait, and maxBatch
func NewScenePlayHistoryLoader(config ScenePlayHistoryLoaderConfig) *ScenePlayHistoryLoader {
return &ScenePlayHistoryLoader{
fetch: config.Fetch,
wait: config.Wait,
maxBatch: config.MaxBatch,
}
}
// ScenePlayHistoryLoader batches and caches requests
type ScenePlayHistoryLoader struct {
// this method provides the data for the loader
fetch func(keys []int) ([][]time.Time, []error)
// how long to done before sending a batch
wait time.Duration
// this will limit the maximum number of keys to send in one batch, 0 = no limit
maxBatch int
// INTERNAL
// lazily created cache
cache map[int][]time.Time
// the current batch. keys will continue to be collected until timeout is hit,
// then everything will be sent to the fetch method and out to the listeners
batch *scenePlayHistoryLoaderBatch
// mutex to prevent races
mu sync.Mutex
}
type scenePlayHistoryLoaderBatch struct {
keys []int
data [][]time.Time
error []error
closing bool
done chan struct{}
}
// Load a Time by key, batching and caching will be applied automatically
func (l *ScenePlayHistoryLoader) Load(key int) ([]time.Time, error) {
return l.LoadThunk(key)()
}
// LoadThunk returns a function that when called will block waiting for a Time.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *ScenePlayHistoryLoader) LoadThunk(key int) func() ([]time.Time, error) {
l.mu.Lock()
if it, ok := l.cache[key]; ok {
l.mu.Unlock()
return func() ([]time.Time, error) {
return it, nil
}
}
if l.batch == nil {
l.batch = &scenePlayHistoryLoaderBatch{done: make(chan struct{})}
}
batch := l.batch
pos := batch.keyIndex(l, key)
l.mu.Unlock()
return func() ([]time.Time, error) {
<-batch.done
var data []time.Time
if pos < len(batch.data) {
data = batch.data[pos]
}
var err error
// its convenient to be able to return a single error for everything
if len(batch.error) == 1 {
err = batch.error[0]
} else if batch.error != nil {
err = batch.error[pos]
}
if err == nil {
l.mu.Lock()
l.unsafeSet(key, data)
l.mu.Unlock()
}
return data, err
}
}
// LoadAll fetches many keys at once. It will be broken into appropriate sized
// sub batches depending on how the loader is configured
func (l *ScenePlayHistoryLoader) LoadAll(keys []int) ([][]time.Time, []error) {
results := make([]func() ([]time.Time, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
times := make([][]time.Time, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
times[i], errors[i] = thunk()
}
return times, errors
}
// LoadAllThunk returns a function that when called will block waiting for a Times.
// This method should be used if you want one goroutine to make requests to many
// different data loaders without blocking until the thunk is called.
func (l *ScenePlayHistoryLoader) LoadAllThunk(keys []int) func() ([][]time.Time, []error) {
results := make([]func() ([]time.Time, error), len(keys))
for i, key := range keys {
results[i] = l.LoadThunk(key)
}
return func() ([][]time.Time, []error) {
times := make([][]time.Time, len(keys))
errors := make([]error, len(keys))
for i, thunk := range results {
times[i], errors[i] = thunk()
}
return times, errors
}
}
// Prime the cache with the provided key and value. If the key already exists, no change is made
// and false is returned.
// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).)
func (l *ScenePlayHistoryLoader) Prime(key int, value []time.Time) bool {
l.mu.Lock()
var found bool
if _, found = l.cache[key]; !found {
// make a copy when writing to the cache, its easy to pass a pointer in from a loop var
// and end up with the whole cache pointing to the same value.
cpy := make([]time.Time, len(value))
copy(cpy, value)
l.unsafeSet(key, cpy)
}
l.mu.Unlock()
return !found
}
// Clear the value at key from the cache, if it exists
func (l *ScenePlayHistoryLoader) Clear(key int) {
l.mu.Lock()
delete(l.cache, key)
l.mu.Unlock()
}
func (l *ScenePlayHistoryLoader) unsafeSet(key int, value []time.Time) {
if l.cache == nil {
l.cache = map[int][]time.Time{}
}
l.cache[key] = value
}
// keyIndex will return the location of the key in the batch, if its not found
// it will add the key to the batch
func (b *scenePlayHistoryLoaderBatch) keyIndex(l *ScenePlayHistoryLoader, key int) int {
for i, existingKey := range b.keys {
if key == existingKey {
return i
}
}
pos := len(b.keys)
b.keys = append(b.keys, key)
if pos == 0 {
go b.startTimer(l)
}
if l.maxBatch != 0 && pos >= l.maxBatch-1 {
if !b.closing {
b.closing = true
l.batch = nil
go b.end(l)
}
}
return pos
}
func (b *scenePlayHistoryLoaderBatch) startTimer(l *ScenePlayHistoryLoader) {
time.Sleep(l.wait)
l.mu.Lock()
// we must have hit a batch limit and are already finalizing this batch
if b.closing {
l.mu.Unlock()
return
}
l.batch = nil
l.mu.Unlock()
b.end(l)
}
func (b *scenePlayHistoryLoaderBatch) end(l *ScenePlayHistoryLoader) {
b.data, b.error = l.fetch(b.keys)
close(b.done)
}

View File

@@ -0,0 +1,37 @@
package api
import (
"encoding/json"
"fmt"
"io"
"github.com/99designs/gqlgen/graphql"
)
func MarshalPluginConfigMap(val map[string]map[string]interface{}) graphql.Marshaler {
return graphql.WriterFunc(func(w io.Writer) {
err := json.NewEncoder(w).Encode(val)
if err != nil {
panic(err)
}
})
}
func UnmarshalPluginConfigMap(v interface{}) (map[string]map[string]interface{}, error) {
m, ok := v.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("%T is not a plugin config map", v)
}
result := make(map[string]map[string]interface{})
for k, v := range m {
val, ok := v.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("key %s (%T) is not a map", k, v)
}
result[k] = val
}
return result, nil
}

View File

@@ -11,7 +11,7 @@ import (
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/scraper"
"github.com/stashapp/stash/pkg/scraper/stashbox"
)
@@ -29,7 +29,7 @@ var (
)
type hookExecutor interface {
ExecutePostHooks(ctx context.Context, id int, hookType plugin.HookTriggerEnum, input interface{}, inputFields []string)
ExecutePostHooks(ctx context.Context, id int, hookType hook.TriggerEnum, input interface{}, inputFields []string)
}
type Resolver struct {
@@ -228,7 +228,7 @@ func (r *queryResolver) Stats(ctx context.Context) (*StatsResultType, error) {
return err
}
scenesTotalOCount, err := sceneQB.OCount(ctx)
scenesTotalOCount, err := sceneQB.GetAllOCount(ctx)
if err != nil {
return err
}
@@ -243,12 +243,12 @@ func (r *queryResolver) Stats(ctx context.Context) (*StatsResultType, error) {
return err
}
totalPlayCount, err := sceneQB.PlayCount(ctx)
totalPlayCount, err := sceneQB.CountAllViews(ctx)
if err != nil {
return err
}
uniqueScenePlayCount, err := sceneQB.UniqueScenePlayCount(ctx)
uniqueScenePlayCount, err := sceneQB.CountUniqueViews(ctx)
if err != nil {
return err
}

View File

@@ -6,13 +6,13 @@ import (
"github.com/stashapp/stash/internal/manager/config"
)
func (r *configResultResolver) Plugins(ctx context.Context, obj *ConfigResult, include []string) (map[string]interface{}, error) {
func (r *configResultResolver) Plugins(ctx context.Context, obj *ConfigResult, include []string) (map[string]map[string]interface{}, error) {
if len(include) == 0 {
ret := config.GetInstance().GetAllPluginConfiguration()
return ret, nil
}
ret := make(map[string]interface{})
ret := make(map[string]map[string]interface{})
for _, plugin := range include {
c := config.GetInstance().GetPluginConfiguration(plugin)

View File

@@ -3,6 +3,7 @@ package api
import (
"context"
"fmt"
"time"
"github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/internal/api/urlbuilders"
@@ -319,3 +320,62 @@ func (r *sceneResolver) Urls(ctx context.Context, obj *models.Scene) ([]string,
return obj.URLs.List(), nil
}
func (r *sceneResolver) OCounter(ctx context.Context, obj *models.Scene) (*int, error) {
ret, err := loaders.From(ctx).SceneOCount.Load(obj.ID)
if err != nil {
return nil, err
}
return &ret, nil
}
func (r *sceneResolver) LastPlayedAt(ctx context.Context, obj *models.Scene) (*time.Time, error) {
ret, err := loaders.From(ctx).SceneLastPlayed.Load(obj.ID)
if err != nil {
return nil, err
}
return ret, nil
}
func (r *sceneResolver) PlayCount(ctx context.Context, obj *models.Scene) (*int, error) {
ret, err := loaders.From(ctx).ScenePlayCount.Load(obj.ID)
if err != nil {
return nil, err
}
return &ret, nil
}
func (r *sceneResolver) PlayHistory(ctx context.Context, obj *models.Scene) ([]*time.Time, error) {
ret, err := loaders.From(ctx).ScenePlayHistory.Load(obj.ID)
if err != nil {
return nil, err
}
// convert to pointer slice
ptrRet := make([]*time.Time, len(ret))
for i, t := range ret {
tt := t
ptrRet[i] = &tt
}
return ptrRet, nil
}
func (r *sceneResolver) OHistory(ctx context.Context, obj *models.Scene) ([]*time.Time, error) {
ret, err := loaders.From(ctx).SceneOHistory.Load(obj.ID)
if err != nil {
return nil, err
}
// convert to pointer slice
ptrRet := make([]*time.Time, len(ret))
for i, t := range ret {
tt := t
ptrRet[i] = &tt
}
return ptrRet, nil
}

View File

@@ -6,12 +6,16 @@ import (
"fmt"
"path/filepath"
"regexp"
"strconv"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/internal/manager/task"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
)
var ErrOverriddenConfig = errors.New("cannot set overridden value")
@@ -21,9 +25,60 @@ func (r *mutationResolver) Setup(ctx context.Context, input manager.SetupInput)
return err == nil, err
}
func (r *mutationResolver) Migrate(ctx context.Context, input manager.MigrateInput) (bool, error) {
err := manager.GetInstance().Migrate(ctx, input)
return err == nil, err
func (r *mutationResolver) DownloadFFMpeg(ctx context.Context) (string, error) {
mgr := manager.GetInstance()
configDir := mgr.Config.GetConfigPathAbs()
// don't run if ffmpeg is already installed
ffmpegPath := ffmpeg.FindFFMpeg(configDir)
ffprobePath := ffmpeg.FindFFProbe(configDir)
if ffmpegPath != "" && ffprobePath != "" {
return "", fmt.Errorf("ffmpeg and ffprobe already installed at %s and %s", ffmpegPath, ffprobePath)
}
t := &task.DownloadFFmpegJob{
ConfigDirectory: configDir,
OnComplete: func(ctx context.Context) {
// clear the ffmpeg and ffprobe paths
logger.Infof("Clearing ffmpeg and ffprobe config paths so they are resolved from the config directory")
mgr.Config.SetString(config.FFMpegPath, "")
mgr.Config.SetString(config.FFProbePath, "")
mgr.RefreshFFMpeg(ctx)
mgr.RefreshStreamManager()
},
}
jobID := mgr.JobManager.Add(ctx, "Downloading ffmpeg...", t)
return strconv.Itoa(jobID), nil
}
func (r *mutationResolver) setConfigString(key string, value *string) {
c := config.GetInstance()
if value != nil {
c.SetString(key, *value)
}
}
func (r *mutationResolver) setConfigBool(key string, value *bool) {
c := config.GetInstance()
if value != nil {
c.SetBool(key, *value)
}
}
func (r *mutationResolver) setConfigInt(key string, value *int) {
c := config.GetInstance()
if value != nil {
c.SetInt(key, *value)
}
}
func (r *mutationResolver) setConfigFloat(key string, value *float64) {
c := config.GetInstance()
if value != nil {
c.SetFloat(key, *value)
}
}
func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGeneralInput) (*ConfigGeneralResult, error) {
@@ -47,7 +102,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
}
}
}
c.Set(config.Stash, input.Stashes)
c.SetInterface(config.Stash, input.Stashes)
}
checkConfigOverride := func(key string) error {
@@ -82,7 +137,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
if ext != ".db" && ext != ".sqlite" && ext != ".sqlite3" {
return makeConfigGeneralResult(), fmt.Errorf("invalid database path, use extension db, sqlite, or sqlite3")
}
c.Set(config.Database, input.DatabasePath)
c.SetString(config.Database, *input.DatabasePath)
}
existingBackupDirectoryPath := c.GetBackupDirectoryPath()
@@ -91,7 +146,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), err
}
c.Set(config.BackupDirectoryPath, input.BackupDirectoryPath)
c.SetString(config.BackupDirectoryPath, *input.BackupDirectoryPath)
}
existingGeneratedPath := c.GetGeneratedPath()
@@ -100,7 +155,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), err
}
c.Set(config.Generated, input.GeneratedPath)
c.SetString(config.Generated, *input.GeneratedPath)
}
refreshScraperCache := false
@@ -113,7 +168,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
refreshScraperCache = true
refreshScraperSource = true
c.Set(config.ScrapersPath, input.ScrapersPath)
c.SetString(config.ScrapersPath, *input.ScrapersPath)
}
refreshPluginCache := false
@@ -126,7 +181,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
refreshPluginCache = true
refreshPluginSource = true
c.Set(config.PluginsPath, input.PluginsPath)
c.SetString(config.PluginsPath, *input.PluginsPath)
}
existingMetadataPath := c.GetMetadataPath()
@@ -135,7 +190,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), err
}
c.Set(config.Metadata, input.MetadataPath)
c.SetString(config.Metadata, *input.MetadataPath)
}
refreshStreamManager := false
@@ -145,7 +200,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), err
}
c.Set(config.Cache, input.CachePath)
c.SetString(config.Cache, *input.CachePath)
refreshStreamManager = true
}
@@ -156,7 +211,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), err
}
c.Set(config.BlobsPath, input.BlobsPath)
c.SetString(config.BlobsPath, *input.BlobsPath)
refreshBlobStorage = true
}
@@ -165,12 +220,34 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), fmt.Errorf("blobs path must be set when using filesystem storage")
}
// TODO - migrate between systems
c.Set(config.BlobsStorage, input.BlobsStorage)
c.SetInterface(config.BlobsStorage, *input.BlobsStorage)
refreshBlobStorage = true
}
refreshFfmpeg := false
if input.FfmpegPath != nil && *input.FfmpegPath != c.GetFFMpegPath() {
if *input.FfmpegPath != "" {
if err := ffmpeg.ValidateFFMpeg(*input.FfmpegPath); err != nil {
return makeConfigGeneralResult(), fmt.Errorf("invalid ffmpeg path: %w", err)
}
}
c.SetString(config.FFMpegPath, *input.FfmpegPath)
refreshFfmpeg = true
}
if input.FfprobePath != nil && *input.FfprobePath != c.GetFFProbePath() {
if *input.FfprobePath != "" {
if err := ffmpeg.ValidateFFProbe(*input.FfprobePath); err != nil {
return makeConfigGeneralResult(), fmt.Errorf("invalid ffprobe path: %w", err)
}
}
c.SetString(config.FFProbePath, *input.FfprobePath)
refreshFfmpeg = true
}
if input.VideoFileNamingAlgorithm != nil && *input.VideoFileNamingAlgorithm != c.GetVideoFileNamingAlgorithm() {
calculateMD5 := c.IsCalculateMD5()
if input.CalculateMd5 != nil {
@@ -187,68 +264,42 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), err
}
c.Set(config.VideoFileNamingAlgorithm, *input.VideoFileNamingAlgorithm)
c.SetInterface(config.VideoFileNamingAlgorithm, *input.VideoFileNamingAlgorithm)
}
if input.CalculateMd5 != nil {
c.Set(config.CalculateMD5, *input.CalculateMd5)
}
if input.ParallelTasks != nil {
c.Set(config.ParallelTasks, *input.ParallelTasks)
}
if input.PreviewAudio != nil {
c.Set(config.PreviewAudio, *input.PreviewAudio)
}
if input.PreviewSegments != nil {
c.Set(config.PreviewSegments, *input.PreviewSegments)
}
if input.PreviewSegmentDuration != nil {
c.Set(config.PreviewSegmentDuration, *input.PreviewSegmentDuration)
}
if input.PreviewExcludeStart != nil {
c.Set(config.PreviewExcludeStart, *input.PreviewExcludeStart)
}
if input.PreviewExcludeEnd != nil {
c.Set(config.PreviewExcludeEnd, *input.PreviewExcludeEnd)
}
r.setConfigBool(config.CalculateMD5, input.CalculateMd5)
r.setConfigInt(config.ParallelTasks, input.ParallelTasks)
r.setConfigBool(config.PreviewAudio, input.PreviewAudio)
r.setConfigInt(config.PreviewSegments, input.PreviewSegments)
r.setConfigFloat(config.PreviewSegmentDuration, input.PreviewSegmentDuration)
r.setConfigString(config.PreviewExcludeStart, input.PreviewExcludeStart)
r.setConfigString(config.PreviewExcludeEnd, input.PreviewExcludeEnd)
if input.PreviewPreset != nil {
c.Set(config.PreviewPreset, input.PreviewPreset.String())
c.SetString(config.PreviewPreset, input.PreviewPreset.String())
}
if input.TranscodeHardwareAcceleration != nil {
c.Set(config.TranscodeHardwareAcceleration, *input.TranscodeHardwareAcceleration)
}
r.setConfigBool(config.TranscodeHardwareAcceleration, input.TranscodeHardwareAcceleration)
if input.MaxTranscodeSize != nil {
c.Set(config.MaxTranscodeSize, input.MaxTranscodeSize.String())
c.SetString(config.MaxTranscodeSize, input.MaxTranscodeSize.String())
}
if input.MaxStreamingTranscodeSize != nil {
c.Set(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String())
}
if input.WriteImageThumbnails != nil {
c.Set(config.WriteImageThumbnails, *input.WriteImageThumbnails)
}
if input.CreateImageClipsFromVideos != nil {
c.Set(config.CreateImageClipsFromVideos, *input.CreateImageClipsFromVideos)
c.SetString(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String())
}
r.setConfigBool(config.WriteImageThumbnails, input.WriteImageThumbnails)
r.setConfigBool(config.CreateImageClipsFromVideos, input.CreateImageClipsFromVideos)
if input.GalleryCoverRegex != nil {
_, err := regexp.Compile(*input.GalleryCoverRegex)
if err != nil {
return makeConfigGeneralResult(), fmt.Errorf("Gallery cover regex '%v' invalid, '%v'", *input.GalleryCoverRegex, err.Error())
}
c.Set(config.GalleryCoverRegex, *input.GalleryCoverRegex)
c.SetString(config.GalleryCoverRegex, *input.GalleryCoverRegex)
}
if input.Username != nil && *input.Username != c.GetUsername() {
c.Set(config.Username, input.Username)
c.SetString(config.Username, *input.Username)
if *input.Password == "" {
logger.Info("Username cleared")
} else {
@@ -271,24 +322,13 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
}
}
if input.MaxSessionAge != nil {
c.Set(config.MaxSessionAge, *input.MaxSessionAge)
}
if input.LogFile != nil {
c.Set(config.LogFile, input.LogFile)
}
if input.LogOut != nil {
c.Set(config.LogOut, *input.LogOut)
}
if input.LogAccess != nil {
c.Set(config.LogAccess, *input.LogAccess)
}
r.setConfigInt(config.MaxSessionAge, input.MaxSessionAge)
r.setConfigString(config.LogFile, input.LogFile)
r.setConfigBool(config.LogOut, input.LogOut)
r.setConfigBool(config.LogAccess, input.LogAccess)
if input.LogLevel != nil && *input.LogLevel != c.GetLogLevel() {
c.Set(config.LogLevel, input.LogLevel)
c.SetString(config.LogLevel, *input.LogLevel)
logger := manager.GetInstance().Logger
logger.SetLogLevel(*input.LogLevel)
}
@@ -300,7 +340,7 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), fmt.Errorf("video exclusion pattern '%v' invalid: %w", r, err)
}
}
c.Set(config.Exclude, input.Excludes)
c.SetInterface(config.Exclude, input.Excludes)
}
if input.ImageExcludes != nil {
@@ -310,27 +350,25 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
return makeConfigGeneralResult(), fmt.Errorf("image/gallery exclusion pattern '%v' invalid: %w", r, err)
}
}
c.Set(config.ImageExclude, input.ImageExcludes)
c.SetInterface(config.ImageExclude, input.ImageExcludes)
}
if input.VideoExtensions != nil {
c.Set(config.VideoExtensions, input.VideoExtensions)
c.SetInterface(config.VideoExtensions, input.VideoExtensions)
}
if input.ImageExtensions != nil {
c.Set(config.ImageExtensions, input.ImageExtensions)
c.SetInterface(config.ImageExtensions, input.ImageExtensions)
}
if input.GalleryExtensions != nil {
c.Set(config.GalleryExtensions, input.GalleryExtensions)
c.SetInterface(config.GalleryExtensions, input.GalleryExtensions)
}
if input.CreateGalleriesFromFolders != nil {
c.Set(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders)
}
r.setConfigBool(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders)
if input.CustomPerformerImageLocation != nil {
c.Set(config.CustomPerformerImageLocation, *input.CustomPerformerImageLocation)
c.SetString(config.CustomPerformerImageLocation, *input.CustomPerformerImageLocation)
initCustomPerformerImages(*input.CustomPerformerImageLocation)
}
@@ -338,37 +376,35 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
if err := c.ValidateStashBoxes(input.StashBoxes); err != nil {
return nil, err
}
c.Set(config.StashBoxes, input.StashBoxes)
c.SetInterface(config.StashBoxes, input.StashBoxes)
}
if input.PythonPath != nil {
c.Set(config.PythonPath, input.PythonPath)
r.setConfigString(config.PythonPath, input.PythonPath)
}
if input.TranscodeInputArgs != nil {
c.Set(config.TranscodeInputArgs, input.TranscodeInputArgs)
c.SetInterface(config.TranscodeInputArgs, input.TranscodeInputArgs)
}
if input.TranscodeOutputArgs != nil {
c.Set(config.TranscodeOutputArgs, input.TranscodeOutputArgs)
c.SetInterface(config.TranscodeOutputArgs, input.TranscodeOutputArgs)
}
if input.LiveTranscodeInputArgs != nil {
c.Set(config.LiveTranscodeInputArgs, input.LiveTranscodeInputArgs)
c.SetInterface(config.LiveTranscodeInputArgs, input.LiveTranscodeInputArgs)
}
if input.LiveTranscodeOutputArgs != nil {
c.Set(config.LiveTranscodeOutputArgs, input.LiveTranscodeOutputArgs)
c.SetInterface(config.LiveTranscodeOutputArgs, input.LiveTranscodeOutputArgs)
}
if input.DrawFunscriptHeatmapRange != nil {
c.Set(config.DrawFunscriptHeatmapRange, input.DrawFunscriptHeatmapRange)
}
r.setConfigBool(config.DrawFunscriptHeatmapRange, input.DrawFunscriptHeatmapRange)
if input.ScraperPackageSources != nil {
c.Set(config.ScraperPackageSources, input.ScraperPackageSources)
c.SetInterface(config.ScraperPackageSources, input.ScraperPackageSources)
refreshScraperSource = true
}
if input.PluginPackageSources != nil {
c.Set(config.PluginPackageSources, input.PluginPackageSources)
c.SetInterface(config.PluginPackageSources, input.PluginPackageSources)
refreshPluginSource = true
}
@@ -383,6 +419,12 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
if refreshPluginCache {
manager.GetInstance().RefreshPluginCache()
}
if refreshFfmpeg {
manager.GetInstance().RefreshFFMpeg(ctx)
// refresh stream manager is required since ffmpeg changed
refreshStreamManager = true
}
if refreshStreamManager {
manager.GetInstance().RefreshStreamManager()
}
@@ -402,102 +444,70 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigInterfaceInput) (*ConfigInterfaceResult, error) {
c := config.GetInstance()
setBool := func(key string, v *bool) {
if v != nil {
c.Set(key, *v)
}
}
setString := func(key string, v *string) {
if v != nil {
c.Set(key, *v)
}
}
if input.MenuItems != nil {
c.Set(config.MenuItems, input.MenuItems)
c.SetInterface(config.MenuItems, input.MenuItems)
}
setBool(config.SoundOnPreview, input.SoundOnPreview)
setBool(config.WallShowTitle, input.WallShowTitle)
r.setConfigBool(config.SoundOnPreview, input.SoundOnPreview)
r.setConfigBool(config.WallShowTitle, input.WallShowTitle)
setBool(config.NoBrowser, input.NoBrowser)
r.setConfigBool(config.NoBrowser, input.NoBrowser)
setBool(config.NotificationsEnabled, input.NotificationsEnabled)
r.setConfigBool(config.NotificationsEnabled, input.NotificationsEnabled)
setBool(config.ShowScrubber, input.ShowScrubber)
r.setConfigBool(config.ShowScrubber, input.ShowScrubber)
if input.WallPlayback != nil {
c.Set(config.WallPlayback, *input.WallPlayback)
}
r.setConfigString(config.WallPlayback, input.WallPlayback)
r.setConfigInt(config.MaximumLoopDuration, input.MaximumLoopDuration)
r.setConfigBool(config.AutostartVideo, input.AutostartVideo)
r.setConfigBool(config.ShowStudioAsText, input.ShowStudioAsText)
r.setConfigBool(config.AutostartVideoOnPlaySelected, input.AutostartVideoOnPlaySelected)
r.setConfigBool(config.ContinuePlaylistDefault, input.ContinuePlaylistDefault)
if input.MaximumLoopDuration != nil {
c.Set(config.MaximumLoopDuration, *input.MaximumLoopDuration)
}
setBool(config.AutostartVideo, input.AutostartVideo)
setBool(config.ShowStudioAsText, input.ShowStudioAsText)
setBool(config.AutostartVideoOnPlaySelected, input.AutostartVideoOnPlaySelected)
setBool(config.ContinuePlaylistDefault, input.ContinuePlaylistDefault)
if input.Language != nil {
c.Set(config.Language, *input.Language)
}
r.setConfigString(config.Language, input.Language)
if input.ImageLightbox != nil {
options := input.ImageLightbox
if options.SlideshowDelay != nil {
c.Set(config.ImageLightboxSlideshowDelay, *options.SlideshowDelay)
}
r.setConfigInt(config.ImageLightboxSlideshowDelay, options.SlideshowDelay)
setString(config.ImageLightboxDisplayModeKey, (*string)(options.DisplayMode))
setBool(config.ImageLightboxScaleUp, options.ScaleUp)
setBool(config.ImageLightboxResetZoomOnNav, options.ResetZoomOnNav)
setString(config.ImageLightboxScrollModeKey, (*string)(options.ScrollMode))
r.setConfigString(config.ImageLightboxDisplayModeKey, (*string)(options.DisplayMode))
r.setConfigBool(config.ImageLightboxScaleUp, options.ScaleUp)
r.setConfigBool(config.ImageLightboxResetZoomOnNav, options.ResetZoomOnNav)
r.setConfigString(config.ImageLightboxScrollModeKey, (*string)(options.ScrollMode))
if options.ScrollAttemptsBeforeChange != nil {
c.Set(config.ImageLightboxScrollAttemptsBeforeChange, *options.ScrollAttemptsBeforeChange)
}
r.setConfigInt(config.ImageLightboxScrollAttemptsBeforeChange, options.ScrollAttemptsBeforeChange)
}
if input.CSS != nil {
c.SetCSS(*input.CSS)
}
setBool(config.CSSEnabled, input.CSSEnabled)
r.setConfigBool(config.CSSEnabled, input.CSSEnabled)
if input.Javascript != nil {
c.SetJavascript(*input.Javascript)
}
setBool(config.JavascriptEnabled, input.JavascriptEnabled)
r.setConfigBool(config.JavascriptEnabled, input.JavascriptEnabled)
if input.CustomLocales != nil {
c.SetCustomLocales(*input.CustomLocales)
}
setBool(config.CustomLocalesEnabled, input.CustomLocalesEnabled)
r.setConfigBool(config.CustomLocalesEnabled, input.CustomLocalesEnabled)
if input.DisableDropdownCreate != nil {
ddc := input.DisableDropdownCreate
setBool(config.DisableDropdownCreatePerformer, ddc.Performer)
setBool(config.DisableDropdownCreateStudio, ddc.Studio)
setBool(config.DisableDropdownCreateTag, ddc.Tag)
setBool(config.DisableDropdownCreateMovie, ddc.Movie)
r.setConfigBool(config.DisableDropdownCreatePerformer, ddc.Performer)
r.setConfigBool(config.DisableDropdownCreateStudio, ddc.Studio)
r.setConfigBool(config.DisableDropdownCreateTag, ddc.Tag)
r.setConfigBool(config.DisableDropdownCreateMovie, ddc.Movie)
}
if input.HandyKey != nil {
c.Set(config.HandyKey, *input.HandyKey)
}
if input.FunscriptOffset != nil {
c.Set(config.FunscriptOffset, *input.FunscriptOffset)
}
if input.UseStashHostedFunscript != nil {
c.Set(config.UseStashHostedFunscript, *input.UseStashHostedFunscript)
}
r.setConfigString(config.HandyKey, input.HandyKey)
r.setConfigInt(config.FunscriptOffset, input.FunscriptOffset)
r.setConfigBool(config.UseStashHostedFunscript, input.UseStashHostedFunscript)
if err := c.Write(); err != nil {
return makeConfigInterfaceResult(), err
@@ -509,26 +519,23 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI
func (r *mutationResolver) ConfigureDlna(ctx context.Context, input ConfigDLNAInput) (*ConfigDLNAResult, error) {
c := config.GetInstance()
if input.ServerName != nil {
c.Set(config.DLNAServerName, *input.ServerName)
}
r.setConfigString(config.DLNAServerName, input.ServerName)
if input.WhitelistedIPs != nil {
c.Set(config.DLNADefaultIPWhitelist, input.WhitelistedIPs)
c.SetInterface(config.DLNADefaultIPWhitelist, input.WhitelistedIPs)
}
if input.VideoSortOrder != nil {
c.Set(config.DLNAVideoSortOrder, input.VideoSortOrder)
}
r.setConfigString(config.DLNAVideoSortOrder, input.VideoSortOrder)
r.setConfigInt(config.DLNAPort, input.Port)
refresh := false
if input.Enabled != nil {
c.Set(config.DLNADefaultEnabled, *input.Enabled)
c.SetBool(config.DLNADefaultEnabled, *input.Enabled)
refresh = true
}
if input.Interfaces != nil {
c.Set(config.DLNAInterfaces, input.Interfaces)
c.SetInterface(config.DLNAInterfaces, input.Interfaces)
}
if err := c.Write(); err != nil {
@@ -547,12 +554,12 @@ func (r *mutationResolver) ConfigureScraping(ctx context.Context, input ConfigSc
refreshScraperCache := false
if input.ScraperUserAgent != nil {
c.Set(config.ScraperUserAgent, input.ScraperUserAgent)
c.SetString(config.ScraperUserAgent, *input.ScraperUserAgent)
refreshScraperCache = true
}
if input.ScraperCDPPath != nil {
c.Set(config.ScraperCDPPath, input.ScraperCDPPath)
c.SetString(config.ScraperCDPPath, *input.ScraperCDPPath)
refreshScraperCache = true
}
@@ -563,12 +570,10 @@ func (r *mutationResolver) ConfigureScraping(ctx context.Context, input ConfigSc
return makeConfigScrapingResult(), fmt.Errorf("tag exclusion pattern '%v' invalid: %w", r, err)
}
}
c.Set(config.ScraperExcludeTagPatterns, input.ExcludeTagPatterns)
c.SetInterface(config.ScraperExcludeTagPatterns, input.ExcludeTagPatterns)
}
if input.ScraperCertCheck != nil {
c.Set(config.ScraperCertCheck, input.ScraperCertCheck)
}
r.setConfigBool(config.ScraperCertCheck, input.ScraperCertCheck)
if refreshScraperCache {
manager.GetInstance().RefreshScraperCache()
@@ -584,30 +589,25 @@ func (r *mutationResolver) ConfigureDefaults(ctx context.Context, input ConfigDe
c := config.GetInstance()
if input.Identify != nil {
c.Set(config.DefaultIdentifySettings, input.Identify)
c.SetInterface(config.DefaultIdentifySettings, input.Identify)
}
if input.Scan != nil {
// if input.Scan is used then ScanMetadataOptions is included in the config file
// this causes the values to not be read correctly
c.Set(config.DefaultScanSettings, input.Scan.ScanMetadataOptions)
c.SetInterface(config.DefaultScanSettings, input.Scan.ScanMetadataOptions)
}
if input.AutoTag != nil {
c.Set(config.DefaultAutoTagSettings, input.AutoTag)
c.SetInterface(config.DefaultAutoTagSettings, input.AutoTag)
}
if input.Generate != nil {
c.Set(config.DefaultGenerateSettings, input.Generate)
c.SetInterface(config.DefaultGenerateSettings, input.Generate)
}
if input.DeleteFile != nil {
c.Set(config.DeleteFileDefault, *input.DeleteFile)
}
if input.DeleteGenerated != nil {
c.Set(config.DeleteGeneratedDefault, *input.DeleteGenerated)
}
r.setConfigBool(config.DeleteFileDefault, input.DeleteFile)
r.setConfigBool(config.DeleteGeneratedDefault, input.DeleteGenerated)
if err := c.Write(); err != nil {
return makeConfigDefaultsResult(), err
@@ -631,7 +631,7 @@ func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input GenerateAPI
}
}
c.Set(config.ApiKey, newAPIKey)
c.SetString(config.ApiKey, newAPIKey)
if err := c.Write(); err != nil {
return newAPIKey, err
}
@@ -639,9 +639,19 @@ func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input GenerateAPI
return newAPIKey, nil
}
func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]interface{}) (map[string]interface{}, error) {
func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]interface{}, partial map[string]interface{}) (map[string]interface{}, error) {
c := config.GetInstance()
c.SetUIConfiguration(input)
if input != nil {
c.SetUIConfiguration(input)
}
if partial != nil {
// merge partial into existing config
existing := c.GetUIConfiguration()
utils.MergeMaps(existing, partial)
c.SetUIConfiguration(existing)
}
if err := c.Write(); err != nil {
return c.GetUIConfiguration(), err
@@ -653,10 +663,10 @@ func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]int
func (r *mutationResolver) ConfigureUISetting(ctx context.Context, key string, value interface{}) (map[string]interface{}, error) {
c := config.GetInstance()
cfg := c.GetUIConfiguration()
cfg[key] = value
cfg := utils.NestedMap(c.GetUIConfiguration())
cfg.Set(key, value)
return r.ConfigureUI(ctx, cfg)
return r.ConfigureUI(ctx, cfg, nil)
}
func (r *mutationResolver) ConfigurePlugin(ctx context.Context, pluginID string, input map[string]interface{}) (map[string]interface{}, error) {

View File

@@ -13,6 +13,7 @@ import (
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/utils"
)
@@ -90,7 +91,7 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat
return nil, err
}
r.hookExecutor.ExecutePostHooks(ctx, newGallery.ID, plugin.GalleryCreatePost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, newGallery.ID, hook.GalleryCreatePost, input, nil)
return r.getGallery(ctx, newGallery.ID)
}
@@ -108,7 +109,7 @@ func (r *mutationResolver) GalleryUpdate(ctx context.Context, input models.Galle
}
// execute post hooks outside txn
r.hookExecutor.ExecutePostHooks(ctx, ret.ID, plugin.GalleryUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, ret.ID, hook.GalleryUpdatePost, input, translator.getFields())
return r.getGallery(ctx, ret.ID)
}
@@ -142,7 +143,7 @@ func (r *mutationResolver) GalleriesUpdate(ctx context.Context, input []*models.
inputMap: inputMaps[i],
}
r.hookExecutor.ExecutePostHooks(ctx, gallery.ID, plugin.GalleryUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, gallery.ID, hook.GalleryUpdatePost, input, translator.getFields())
gallery, err = r.getGallery(ctx, gallery.ID)
if err != nil {
@@ -313,7 +314,7 @@ func (r *mutationResolver) BulkGalleryUpdate(ctx context.Context, input BulkGall
// execute post hooks outside of txn
var newRet []*models.Gallery
for _, gallery := range ret {
r.hookExecutor.ExecutePostHooks(ctx, gallery.ID, plugin.GalleryUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, gallery.ID, hook.GalleryUpdatePost, input, translator.getFields())
gallery, err := r.getGallery(ctx, gallery.ID)
if err != nil {
@@ -386,9 +387,9 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall
}
}
// call post hook after performing the other actions
// call post hook after performing the other actionsa
for _, gallery := range galleries {
r.hookExecutor.ExecutePostHooks(ctx, gallery.ID, plugin.GalleryDestroyPost, plugin.GalleryDestroyInput{
r.hookExecutor.ExecutePostHooks(ctx, gallery.ID, hook.GalleryDestroyPost, plugin.GalleryDestroyInput{
GalleryDestroyInput: input,
Checksum: gallery.PrimaryChecksum(),
Path: gallery.Path,
@@ -397,7 +398,7 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall
// call image destroy post hook as well
for _, img := range imgsDestroyed {
r.hookExecutor.ExecutePostHooks(ctx, img.ID, plugin.ImageDestroyPost, plugin.ImageDestroyInput{
r.hookExecutor.ExecutePostHooks(ctx, img.ID, hook.ImageDestroyPost, plugin.ImageDestroyInput{
Checksum: img.Checksum,
Path: img.Path,
}, nil)
@@ -518,7 +519,7 @@ func (r *mutationResolver) GalleryChapterCreate(ctx context.Context, input Galle
return nil, err
}
r.hookExecutor.ExecutePostHooks(ctx, newChapter.ID, plugin.GalleryChapterCreatePost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, newChapter.ID, hook.GalleryChapterCreatePost, input, nil)
return r.getGalleryChapter(ctx, newChapter.ID)
}
@@ -584,7 +585,7 @@ func (r *mutationResolver) GalleryChapterUpdate(ctx context.Context, input Galle
return nil, err
}
r.hookExecutor.ExecutePostHooks(ctx, chapterID, plugin.GalleryChapterUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, chapterID, hook.GalleryChapterUpdatePost, input, translator.getFields())
return r.getGalleryChapter(ctx, chapterID)
}
@@ -612,7 +613,7 @@ func (r *mutationResolver) GalleryChapterDestroy(ctx context.Context, id string)
return false, err
}
r.hookExecutor.ExecutePostHooks(ctx, chapterID, plugin.GalleryChapterDestroyPost, id, nil)
r.hookExecutor.ExecutePostHooks(ctx, chapterID, hook.GalleryChapterDestroyPost, id, nil)
return true, nil
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/utils"
@@ -41,7 +42,7 @@ func (r *mutationResolver) ImageUpdate(ctx context.Context, input ImageUpdateInp
}
// execute post hooks outside txn
r.hookExecutor.ExecutePostHooks(ctx, ret.ID, plugin.ImageUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, ret.ID, hook.ImageUpdatePost, input, translator.getFields())
return r.getImage(ctx, ret.ID)
}
@@ -75,7 +76,7 @@ func (r *mutationResolver) ImagesUpdate(ctx context.Context, input []*ImageUpdat
inputMap: inputMaps[i],
}
r.hookExecutor.ExecutePostHooks(ctx, image.ID, plugin.ImageUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, image.ID, hook.ImageUpdatePost, input, translator.getFields())
image, err = r.getImage(ctx, image.ID)
if err != nil {
@@ -288,7 +289,7 @@ func (r *mutationResolver) BulkImageUpdate(ctx context.Context, input BulkImageU
// execute post hooks outside of txn
var newRet []*models.Image
for _, image := range ret {
r.hookExecutor.ExecutePostHooks(ctx, image.ID, plugin.ImageUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, image.ID, hook.ImageUpdatePost, input, translator.getFields())
image, err = r.getImage(ctx, image.ID)
if err != nil {
@@ -332,7 +333,7 @@ func (r *mutationResolver) ImageDestroy(ctx context.Context, input models.ImageD
fileDeleter.Commit()
// call post hook after performing the other actions
r.hookExecutor.ExecutePostHooks(ctx, i.ID, plugin.ImageDestroyPost, plugin.ImageDestroyInput{
r.hookExecutor.ExecutePostHooks(ctx, i.ID, hook.ImageDestroyPost, plugin.ImageDestroyInput{
ImageDestroyInput: input,
Checksum: i.Checksum,
Path: i.Path,
@@ -383,7 +384,7 @@ func (r *mutationResolver) ImagesDestroy(ctx context.Context, input models.Image
for _, image := range images {
// call post hook after performing the other actions
r.hookExecutor.ExecutePostHooks(ctx, image.ID, plugin.ImageDestroyPost, plugin.ImagesDestroyInput{
r.hookExecutor.ExecutePostHooks(ctx, image.ID, hook.ImageDestroyPost, plugin.ImagesDestroyInput{
ImagesDestroyInput: input,
Checksum: image.Checksum,
Path: image.Path,

View File

@@ -10,6 +10,7 @@ import (
"github.com/stashapp/stash/internal/identify"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/internal/manager/task"
"github.com/stashapp/stash/pkg/logger"
)
@@ -98,6 +99,21 @@ func (r *mutationResolver) MetadataClean(ctx context.Context, input manager.Clea
return strconv.Itoa(jobID), nil
}
func (r *mutationResolver) MetadataCleanGenerated(ctx context.Context, input task.CleanGeneratedOptions) (string, error) {
mgr := manager.GetInstance()
t := &task.CleanGeneratedJob{
Options: input,
Paths: mgr.Paths,
BlobsStorageType: mgr.Config.GetBlobsStorage(),
VideoFileNamingAlgorithm: mgr.Config.GetVideoFileNamingAlgorithm(),
Repository: mgr.Repository,
BlobCleaner: mgr.Repository.Blob,
}
jobID := mgr.JobManager.Add(ctx, "Cleaning generated files...", t)
return strconv.Itoa(jobID), nil
}
func (r *mutationResolver) MigrateHashNaming(ctx context.Context) (string, error) {
jobID := manager.GetInstance().MigrateHash(ctx)
return strconv.Itoa(jobID), nil

View File

@@ -38,3 +38,16 @@ func (r *mutationResolver) MigrateBlobs(ctx context.Context, input MigrateBlobsI
return strconv.Itoa(jobID), nil
}
func (r *mutationResolver) Migrate(ctx context.Context, input manager.MigrateInput) (string, error) {
mgr := manager.GetInstance()
t := &task.MigrateJob{
BackupPath: input.BackupPath,
Config: mgr.Config,
Database: mgr.Database,
}
jobID := mgr.JobManager.Add(ctx, "Migrating database...", t)
return strconv.Itoa(jobID), nil
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/stashapp/stash/internal/static"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/utils"
)
@@ -102,7 +102,7 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp
return nil, err
}
r.hookExecutor.ExecutePostHooks(ctx, newMovie.ID, plugin.MovieCreatePost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, newMovie.ID, hook.MovieCreatePost, input, nil)
return r.getMovie(ctx, newMovie.ID)
}
@@ -181,7 +181,7 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp
return nil, err
}
r.hookExecutor.ExecutePostHooks(ctx, movie.ID, plugin.MovieUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.MovieUpdatePost, input, translator.getFields())
return r.getMovie(ctx, movie.ID)
}
@@ -227,7 +227,7 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU
var newRet []*models.Movie
for _, movie := range ret {
r.hookExecutor.ExecutePostHooks(ctx, movie.ID, plugin.MovieUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, movie.ID, hook.MovieUpdatePost, input, translator.getFields())
movie, err = r.getMovie(ctx, movie.ID)
if err != nil {
@@ -252,7 +252,7 @@ func (r *mutationResolver) MovieDestroy(ctx context.Context, input MovieDestroyI
return false, err
}
r.hookExecutor.ExecutePostHooks(ctx, id, plugin.MovieDestroyPost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, input, nil)
return true, nil
}
@@ -277,7 +277,7 @@ func (r *mutationResolver) MoviesDestroy(ctx context.Context, movieIDs []string)
}
for _, id := range ids {
r.hookExecutor.ExecutePostHooks(ctx, id, plugin.MovieDestroyPost, movieIDs, nil)
r.hookExecutor.ExecutePostHooks(ctx, id, hook.MovieDestroyPost, movieIDs, nil)
}
return true, nil

View File

@@ -7,7 +7,7 @@ import (
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/utils"
)
@@ -108,7 +108,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
return nil, err
}
r.hookExecutor.ExecutePostHooks(ctx, newPerformer.ID, plugin.PerformerCreatePost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, newPerformer.ID, hook.PerformerCreatePost, input, nil)
return r.getPerformer(ctx, newPerformer.ID)
}
@@ -207,7 +207,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
return nil, err
}
r.hookExecutor.ExecutePostHooks(ctx, performerID, plugin.PerformerUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, performerID, hook.PerformerUpdatePost, input, translator.getFields())
return r.getPerformer(ctx, performerID)
}
@@ -297,7 +297,7 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe
// execute post hooks outside of txn
var newRet []*models.Performer
for _, performer := range ret {
r.hookExecutor.ExecutePostHooks(ctx, performer.ID, plugin.PerformerUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, performer.ID, hook.PerformerUpdatePost, input, translator.getFields())
performer, err = r.getPerformer(ctx, performer.ID)
if err != nil {
@@ -322,7 +322,7 @@ func (r *mutationResolver) PerformerDestroy(ctx context.Context, input Performer
return false, err
}
r.hookExecutor.ExecutePostHooks(ctx, id, plugin.PerformerDestroyPost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, id, hook.PerformerDestroyPost, input, nil)
return true, nil
}
@@ -347,7 +347,7 @@ func (r *mutationResolver) PerformersDestroy(ctx context.Context, performerIDs [
}
for _, id := range ids {
r.hookExecutor.ExecutePostHooks(ctx, id, plugin.PerformerDestroyPost, performerIDs, nil)
r.hookExecutor.ExecutePostHooks(ctx, id, hook.PerformerDestroyPost, performerIDs, nil)
}
return true, nil

View File

@@ -2,6 +2,7 @@ package api
import (
"context"
"strconv"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
@@ -9,10 +10,72 @@ import (
"github.com/stashapp/stash/pkg/sliceutil"
)
func (r *mutationResolver) RunPluginTask(ctx context.Context, pluginID string, taskName string, args []*plugin.PluginArgInput) (string, error) {
func toPluginArgs(args []*plugin.PluginArgInput) plugin.OperationInput {
ret := make(plugin.OperationInput)
for _, a := range args {
ret[a.Key] = toPluginArgValue(a.Value)
}
return ret
}
func toPluginArgValue(arg *plugin.PluginValueInput) interface{} {
if arg == nil {
return nil
}
switch {
case arg.Str != nil:
return *arg.Str
case arg.I != nil:
return *arg.I
case arg.B != nil:
return *arg.B
case arg.F != nil:
return *arg.F
case arg.O != nil:
return toPluginArgs(arg.O)
case arg.A != nil:
var ret []interface{}
for _, v := range arg.A {
ret = append(ret, toPluginArgValue(v))
}
return ret
}
return nil
}
func (r *mutationResolver) RunPluginTask(
ctx context.Context,
pluginID string,
taskName *string,
description *string,
args []*plugin.PluginArgInput,
argsMap map[string]interface{},
) (string, error) {
if argsMap == nil {
// convert args to map
// otherwise ignore args in favour of args map
argsMap = toPluginArgs(args)
}
m := manager.GetInstance()
m.RunPluginTask(ctx, pluginID, taskName, args)
return "todo", nil
jobID := m.RunPluginTask(ctx, pluginID, taskName, description, argsMap)
return strconv.Itoa(jobID), nil
}
func (r *mutationResolver) RunPluginOperation(
ctx context.Context,
pluginID string,
args map[string]interface{},
) (interface{}, error) {
if args == nil {
args = make(map[string]interface{})
}
m := manager.GetInstance()
return m.PluginCache.RunPlugin(ctx, pluginID, args)
}
func (r *mutationResolver) ReloadPlugins(ctx context.Context) (bool, error) {
@@ -40,7 +103,7 @@ func (r *mutationResolver) SetPluginsEnabled(ctx context.Context, enabledMap map
}
}
c.Set(config.DisabledPlugins, newDisabled)
c.SetInterface(config.DisabledPlugins, newDisabled)
if err := c.Write(); err != nil {
return false, err

View File

@@ -5,11 +5,14 @@ import (
"errors"
"fmt"
"strconv"
"time"
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
@@ -114,7 +117,7 @@ func (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUp
return nil, err
}
r.hookExecutor.ExecutePostHooks(ctx, ret.ID, plugin.SceneUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, ret.ID, hook.SceneUpdatePost, input, translator.getFields())
return r.getScene(ctx, ret.ID)
}
@@ -148,7 +151,7 @@ func (r *mutationResolver) ScenesUpdate(ctx context.Context, input []*models.Sce
inputMap: inputMaps[i],
}
r.hookExecutor.ExecutePostHooks(ctx, scene.ID, plugin.SceneUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, scene.ID, hook.SceneUpdatePost, input, translator.getFields())
scene, err = r.getScene(ctx, scene.ID)
if err != nil {
@@ -169,8 +172,15 @@ func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTr
updatedScene.Details = translator.optionalString(input.Details, "details")
updatedScene.Director = translator.optionalString(input.Director, "director")
updatedScene.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedScene.OCounter = translator.optionalInt(input.OCounter, "o_counter")
updatedScene.PlayCount = translator.optionalInt(input.PlayCount, "play_count")
if input.OCounter != nil {
logger.Warnf("o_counter is deprecated and no longer supported, use sceneIncrementO/sceneDecrementO instead")
}
if input.PlayCount != nil {
logger.Warnf("play_count is deprecated and no longer supported, use sceneIncrementPlayCount/sceneDecrementPlayCount instead")
}
updatedScene.PlayDuration = translator.optionalFloat64(input.PlayDuration, "play_duration")
updatedScene.Organized = translator.optionalBool(input.Organized, "organized")
updatedScene.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids")
@@ -376,7 +386,7 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU
// execute post hooks outside of txn
var newRet []*models.Scene
for _, scene := range ret {
r.hookExecutor.ExecutePostHooks(ctx, scene.ID, plugin.SceneUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, scene.ID, hook.SceneUpdatePost, input, translator.getFields())
scene, err = r.getScene(ctx, scene.ID)
if err != nil {
@@ -432,7 +442,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
fileDeleter.Commit()
// call post hook after performing the other actions
r.hookExecutor.ExecutePostHooks(ctx, s.ID, plugin.SceneDestroyPost, plugin.SceneDestroyInput{
r.hookExecutor.ExecutePostHooks(ctx, s.ID, hook.SceneDestroyPost, plugin.SceneDestroyInput{
SceneDestroyInput: input,
Checksum: s.Checksum,
OSHash: s.OSHash,
@@ -493,7 +503,7 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
for _, scene := range scenes {
// call post hook after performing the other actions
r.hookExecutor.ExecutePostHooks(ctx, scene.ID, plugin.SceneDestroyPost, plugin.ScenesDestroyInput{
r.hookExecutor.ExecutePostHooks(ctx, scene.ID, hook.SceneDestroyPost, plugin.ScenesDestroyInput{
ScenesDestroyInput: input,
Checksum: scene.Checksum,
OSHash: scene.OSHash,
@@ -560,9 +570,20 @@ func (r *mutationResolver) SceneMerge(ctx context.Context, input SceneMergeInput
values = &v
}
mgr := manager.GetInstance()
fileDeleter := &scene.FileDeleter{
Deleter: file.NewDeleter(),
FileNamingAlgo: mgr.Config.GetVideoFileNamingAlgorithm(),
Paths: mgr.Paths,
}
var ret *models.Scene
if err := r.withTxn(ctx, func(ctx context.Context) error {
if err := r.Resolver.sceneService.Merge(ctx, srcIDs, destID, *values); err != nil {
if err := r.Resolver.sceneService.Merge(ctx, srcIDs, destID, fileDeleter, scene.MergeOptions{
ScenePartial: *values,
IncludePlayHistory: utils.IsTrue(input.PlayHistory),
IncludeOHistory: utils.IsTrue(input.OHistory),
}); err != nil {
return err
}
@@ -633,7 +654,7 @@ func (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input SceneMar
return nil, err
}
r.hookExecutor.ExecutePostHooks(ctx, newMarker.ID, plugin.SceneMarkerCreatePost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, newMarker.ID, hook.SceneMarkerCreatePost, input, nil)
return r.getSceneMarker(ctx, newMarker.ID)
}
@@ -731,7 +752,7 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar
// perform the post-commit actions
fileDeleter.Commit()
r.hookExecutor.ExecutePostHooks(ctx, markerID, plugin.SceneMarkerUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, markerID, hook.SceneMarkerUpdatePost, input, translator.getFields())
return r.getSceneMarker(ctx, markerID)
}
@@ -781,7 +802,7 @@ func (r *mutationResolver) SceneMarkerDestroy(ctx context.Context, id string) (b
// perform the post-commit actions
fileDeleter.Commit()
r.hookExecutor.ExecutePostHooks(ctx, markerID, plugin.SceneMarkerDestroyPost, id, nil)
r.hookExecutor.ExecutePostHooks(ctx, markerID, hook.SceneMarkerDestroyPost, id, nil)
return true, nil
}
@@ -804,16 +825,96 @@ func (r *mutationResolver) SceneSaveActivity(ctx context.Context, id string, res
return ret, nil
}
// deprecated
func (r *mutationResolver) SceneIncrementPlayCount(ctx context.Context, id string) (ret int, err error) {
sceneID, err := strconv.Atoi(id)
if err != nil {
return 0, fmt.Errorf("converting id: %w", err)
}
var updatedTimes []time.Time
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Scene
ret, err = qb.IncrementWatchCount(ctx, sceneID)
updatedTimes, err = qb.AddViews(ctx, sceneID, nil)
return err
}); err != nil {
return 0, err
}
return len(updatedTimes), nil
}
func (r *mutationResolver) SceneAddPlay(ctx context.Context, id string, t []*time.Time) (*HistoryMutationResult, error) {
sceneID, err := strconv.Atoi(id)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)
}
var times []time.Time
// convert time to local time, so that sorting is consistent
for _, tt := range t {
times = append(times, tt.Local())
}
var updatedTimes []time.Time
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Scene
updatedTimes, err = qb.AddViews(ctx, sceneID, times)
return err
}); err != nil {
return nil, err
}
return &HistoryMutationResult{
Count: len(updatedTimes),
History: sliceutil.ValuesToPtrs(updatedTimes),
}, nil
}
func (r *mutationResolver) SceneDeletePlay(ctx context.Context, id string, t []*time.Time) (*HistoryMutationResult, error) {
sceneID, err := strconv.Atoi(id)
if err != nil {
return nil, err
}
var times []time.Time
for _, tt := range t {
times = append(times, *tt)
}
var updatedTimes []time.Time
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Scene
updatedTimes, err = qb.DeleteViews(ctx, sceneID, times)
return err
}); err != nil {
return nil, err
}
return &HistoryMutationResult{
Count: len(updatedTimes),
History: sliceutil.ValuesToPtrs(updatedTimes),
}, nil
}
func (r *mutationResolver) SceneResetPlayCount(ctx context.Context, id string) (ret int, err error) {
sceneID, err := strconv.Atoi(id)
if err != nil {
return 0, err
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Scene
ret, err = qb.DeleteAllViews(ctx, sceneID)
return err
}); err != nil {
return 0, err
@@ -822,40 +923,46 @@ func (r *mutationResolver) SceneIncrementPlayCount(ctx context.Context, id strin
return ret, nil
}
// deprecated
func (r *mutationResolver) SceneIncrementO(ctx context.Context, id string) (ret int, err error) {
sceneID, err := strconv.Atoi(id)
if err != nil {
return 0, fmt.Errorf("converting id: %w", err)
}
var updatedTimes []time.Time
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Scene
ret, err = qb.IncrementOCounter(ctx, sceneID)
updatedTimes, err = qb.AddO(ctx, sceneID, nil)
return err
}); err != nil {
return 0, err
}
return ret, nil
return len(updatedTimes), nil
}
// deprecated
func (r *mutationResolver) SceneDecrementO(ctx context.Context, id string) (ret int, err error) {
sceneID, err := strconv.Atoi(id)
if err != nil {
return 0, fmt.Errorf("converting id: %w", err)
}
var updatedTimes []time.Time
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Scene
ret, err = qb.DecrementOCounter(ctx, sceneID)
updatedTimes, err = qb.DeleteO(ctx, sceneID, nil)
return err
}); err != nil {
return 0, err
}
return ret, nil
return len(updatedTimes), nil
}
func (r *mutationResolver) SceneResetO(ctx context.Context, id string) (ret int, err error) {
@@ -867,7 +974,7 @@ func (r *mutationResolver) SceneResetO(ctx context.Context, id string) (ret int,
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Scene
ret, err = qb.ResetOCounter(ctx, sceneID)
ret, err = qb.ResetO(ctx, sceneID)
return err
}); err != nil {
return 0, err
@@ -876,6 +983,65 @@ func (r *mutationResolver) SceneResetO(ctx context.Context, id string) (ret int,
return ret, nil
}
func (r *mutationResolver) SceneAddO(ctx context.Context, id string, t []*time.Time) (*HistoryMutationResult, error) {
sceneID, err := strconv.Atoi(id)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)
}
var times []time.Time
// convert time to local time, so that sorting is consistent
for _, tt := range t {
times = append(times, tt.Local())
}
var updatedTimes []time.Time
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Scene
updatedTimes, err = qb.AddO(ctx, sceneID, times)
return err
}); err != nil {
return nil, err
}
return &HistoryMutationResult{
Count: len(updatedTimes),
History: sliceutil.ValuesToPtrs(updatedTimes),
}, nil
}
func (r *mutationResolver) SceneDeleteO(ctx context.Context, id string, t []*time.Time) (*HistoryMutationResult, error) {
sceneID, err := strconv.Atoi(id)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)
}
var times []time.Time
for _, tt := range t {
times = append(times, *tt)
}
var updatedTimes []time.Time
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Scene
updatedTimes, err = qb.DeleteO(ctx, sceneID, times)
return err
}); err != nil {
return nil, err
}
return &HistoryMutationResult{
Count: len(updatedTimes),
History: sliceutil.ValuesToPtrs(updatedTimes),
}, nil
}
func (r *mutationResolver) SceneGenerateScreenshot(ctx context.Context, id string, at *float64) (string, error) {
if at != nil {
manager.GetInstance().GenerateScreenshot(ctx, id, *at)

View File

@@ -6,7 +6,7 @@ import (
"strconv"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/studio"
"github.com/stashapp/stash/pkg/utils"
@@ -35,6 +35,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
newStudio.Name = input.Name
newStudio.URL = translator.string(input.URL)
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)
@@ -61,16 +62,10 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Studio
if err := studio.EnsureStudioNameUnique(ctx, 0, newStudio.Name, qb); err != nil {
if err := studio.ValidateCreate(ctx, newStudio, qb); err != nil {
return err
}
if len(input.Aliases) > 0 {
if err := studio.EnsureAliasesUnique(ctx, 0, input.Aliases, qb); err != nil {
return err
}
}
err = qb.Create(ctx, &newStudio)
if err != nil {
return err
@@ -87,7 +82,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
return nil, err
}
r.hookExecutor.ExecutePostHooks(ctx, newStudio.ID, plugin.StudioCreatePost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, newStudio.ID, hook.StudioCreatePost, input, nil)
return r.getStudio(ctx, newStudio.ID)
}
@@ -109,6 +104,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
updatedStudio.URL = translator.optionalString(input.URL, "url")
updatedStudio.Details = translator.optionalString(input.Details, "details")
updatedStudio.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedStudio.Favorite = translator.optionalBool(input.Favorite, "favorite")
updatedStudio.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
updatedStudio.Aliases = translator.updateStrings(input.Aliases, "aliases")
updatedStudio.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids")
@@ -153,7 +149,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
return nil, err
}
r.hookExecutor.ExecutePostHooks(ctx, studioID, plugin.StudioUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, studioID, hook.StudioUpdatePost, input, translator.getFields())
return r.getStudio(ctx, studioID)
}
@@ -169,7 +165,7 @@ func (r *mutationResolver) StudioDestroy(ctx context.Context, input StudioDestro
return false, err
}
r.hookExecutor.ExecutePostHooks(ctx, id, plugin.StudioDestroyPost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, id, hook.StudioDestroyPost, input, nil)
return true, nil
}
@@ -194,7 +190,7 @@ func (r *mutationResolver) StudiosDestroy(ctx context.Context, studioIDs []strin
}
for _, id := range ids {
r.hookExecutor.ExecutePostHooks(ctx, id, plugin.StudioDestroyPost, studioIDs, nil)
r.hookExecutor.ExecutePostHooks(ctx, id, hook.StudioDestroyPost, studioIDs, nil)
}
return true, nil

View File

@@ -7,7 +7,7 @@ import (
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/tag"
"github.com/stashapp/stash/pkg/utils"
@@ -33,6 +33,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
newTag := models.NewTag()
newTag.Name = input.Name
newTag.Favorite = translator.bool(input.Favorite)
newTag.Description = translator.string(input.Description)
newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag)
@@ -119,7 +120,7 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
return nil, err
}
r.hookExecutor.ExecutePostHooks(ctx, newTag.ID, plugin.TagCreatePost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, newTag.ID, hook.TagCreatePost, input, nil)
return r.getTag(ctx, newTag.ID)
}
@@ -136,6 +137,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
// Populate tag from the input
updatedTag := models.NewTagPartial()
updatedTag.Favorite = translator.optionalBool(input.Favorite, "favorite")
updatedTag.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag")
updatedTag.Description = translator.optionalString(input.Description, "description")
@@ -235,7 +237,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput)
return nil, err
}
r.hookExecutor.ExecutePostHooks(ctx, t.ID, plugin.TagUpdatePost, input, translator.getFields())
r.hookExecutor.ExecutePostHooks(ctx, t.ID, hook.TagUpdatePost, input, translator.getFields())
return r.getTag(ctx, t.ID)
}
@@ -251,7 +253,7 @@ func (r *mutationResolver) TagDestroy(ctx context.Context, input TagDestroyInput
return false, err
}
r.hookExecutor.ExecutePostHooks(ctx, tagID, plugin.TagDestroyPost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, tagID, hook.TagDestroyPost, input, nil)
return true, nil
}
@@ -276,7 +278,7 @@ func (r *mutationResolver) TagsDestroy(ctx context.Context, tagIDs []string) (bo
}
for _, id := range ids {
r.hookExecutor.ExecutePostHooks(ctx, id, plugin.TagDestroyPost, tagIDs, nil)
r.hookExecutor.ExecutePostHooks(ctx, id, hook.TagDestroyPost, tagIDs, nil)
}
return true, nil
@@ -340,7 +342,7 @@ func (r *mutationResolver) TagsMerge(ctx context.Context, input TagsMergeInput)
return nil, err
}
r.hookExecutor.ExecutePostHooks(ctx, t.ID, plugin.TagMergePost, input, nil)
r.hookExecutor.ExecutePostHooks(ctx, t.ID, hook.TagMergePost, input, nil)
return t, nil
}

View File

@@ -7,7 +7,7 @@ import (
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
@@ -35,7 +35,7 @@ var testCtx = context.Background()
type mockHookExecutor struct{}
func (*mockHookExecutor) ExecutePostHooks(ctx context.Context, id int, hookType plugin.HookTriggerEnum, input interface{}, inputFields []string) {
func (*mockHookExecutor) ExecutePostHooks(ctx context.Context, id int, hookType hook.TriggerEnum, input interface{}, inputFields []string) {
}
func TestTagCreate(t *testing.T) {

View File

@@ -91,6 +91,8 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
CachePath: config.GetCachePath(),
BlobsPath: config.GetBlobsPath(),
BlobsStorage: config.GetBlobsStorage(),
FfmpegPath: config.GetFFMpegPath(),
FfprobePath: config.GetFFProbePath(),
CalculateMd5: config.IsCalculateMD5(),
VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),
ParallelTasks: config.GetParallelTasks(),
@@ -197,6 +199,7 @@ func makeConfigDLNAResult() *ConfigDLNAResult {
return &ConfigDLNAResult{
ServerName: config.GetDLNAServerName(),
Enabled: config.GetDLNADefaultEnabled(),
Port: config.GetDLNAPort(),
WhitelistedIPs: config.GetDLNADefaultIPWhitelist(),
Interfaces: config.GetDLNAInterfaces(),
VideoSortOrder: config.GetVideoSortOrder(),

View File

@@ -5,6 +5,7 @@ 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) {
@@ -23,9 +24,24 @@ func (r *queryResolver) FindGallery(ctx context.Context, id string) (ret *models
return ret, nil
}
func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType) (ret *FindGalleriesResultType, err error) {
func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType, ids []string) (ret *FindGalleriesResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids)
if err != nil {
return nil, err
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
galleries, total, err := r.repository.Gallery.Query(ctx, galleryFilter, filter)
var galleries []*models.Gallery
var err error
var total int
if len(idInts) > 0 {
galleries, err = r.repository.Gallery.FindMany(ctx, idInts)
total = len(galleries)
} else {
galleries, total, err = r.repository.Gallery.Query(ctx, galleryFilter, filter)
}
if err != nil {
return err
}

View File

@@ -7,6 +7,7 @@ import (
"github.com/99designs/gqlgen/graphql"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *string) (*models.Image, error) {
@@ -46,26 +47,65 @@ func (r *queryResolver) FindImage(ctx context.Context, id *string, checksum *str
return image, nil
}
func (r *queryResolver) FindImages(ctx context.Context, imageFilter *models.ImageFilterType, imageIds []int, filter *models.FindFilterType) (ret *FindImagesResultType, err error) {
func (r *queryResolver) FindImages(
ctx context.Context,
imageFilter *models.ImageFilterType,
imageIds []int,
ids []string,
filter *models.FindFilterType,
) (ret *FindImagesResultType, err error) {
if len(ids) > 0 {
imageIds, err = stringslice.StringSliceToIntSlice(ids)
if err != nil {
return nil, err
}
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Image
var images []*models.Image
fields := graphql.CollectAllFields(ctx)
result := &models.ImageQueryResult{}
result, err := qb.Query(ctx, models.ImageQueryOptions{
QueryOptions: models.QueryOptions{
FindFilter: filter,
Count: sliceutil.Contains(fields, "count"),
},
ImageFilter: imageFilter,
Megapixels: sliceutil.Contains(fields, "megapixels"),
TotalSize: sliceutil.Contains(fields, "filesize"),
})
if err != nil {
return err
if len(imageIds) > 0 {
images, err = r.repository.Image.FindMany(ctx, imageIds)
if err == nil {
result.Count = len(images)
for _, s := range images {
if err = s.LoadPrimaryFile(ctx, r.repository.File); err != nil {
break
}
f := s.Files.Primary()
if f == nil {
continue
}
imageFile, ok := f.(*models.ImageFile)
if !ok {
continue
}
result.Megapixels += float64(imageFile.Width*imageFile.Height) / float64(1000000)
result.TotalSize += float64(f.Base().Size)
}
}
} else {
result, err = qb.Query(ctx, models.ImageQueryOptions{
QueryOptions: models.QueryOptions{
FindFilter: filter,
Count: sliceutil.Contains(fields, "count"),
},
ImageFilter: imageFilter,
Megapixels: sliceutil.Contains(fields, "megapixels"),
TotalSize: sliceutil.Contains(fields, "filesize"),
})
if err == nil {
images, err = result.Resolve(ctx)
}
}
images, err := result.Resolve(ctx)
if err != nil {
return err
}

View File

@@ -5,6 +5,7 @@ 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.Movie, err error) {
@@ -23,9 +24,24 @@ func (r *queryResolver) FindMovie(ctx context.Context, id string) (ret *models.M
return ret, nil
}
func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.MovieFilterType, filter *models.FindFilterType) (ret *FindMoviesResultType, err error) {
func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.MovieFilterType, filter *models.FindFilterType, ids []string) (ret *FindMoviesResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids)
if err != nil {
return nil, err
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
movies, total, err := r.repository.Movie.Query(ctx, movieFilter, filter)
var movies []*models.Movie
var err error
var total int
if len(idInts) > 0 {
movies, err = r.repository.Movie.FindMany(ctx, idInts)
total = len(movies)
} else {
movies, total, err = r.repository.Movie.Query(ctx, movieFilter, filter)
}
if err != nil {
return err
}

View File

@@ -5,6 +5,7 @@ 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) {
@@ -23,7 +24,14 @@ func (r *queryResolver) FindPerformer(ctx context.Context, id string) (ret *mode
return ret, nil
}
func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType, performerIDs []int) (ret *FindPerformersResultType, err error) {
func (r *queryResolver) FindPerformers(ctx context.Context, performerFilter *models.PerformerFilterType, filter *models.FindFilterType, performerIDs []int, ids []string) (ret *FindPerformersResultType, err error) {
if len(ids) > 0 {
performerIDs, err = stringslice.StringSliceToIntSlice(ids)
if err != nil {
return nil, err
}
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var performers []*models.Performer
var err error

View File

@@ -9,6 +9,7 @@ import (
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *queryResolver) FindScene(ctx context.Context, id *string, checksum *string) (*models.Scene, error) {
@@ -74,7 +75,20 @@ func (r *queryResolver) FindSceneByHash(ctx context.Context, input SceneHashInpu
return scene, nil
}
func (r *queryResolver) FindScenes(ctx context.Context, sceneFilter *models.SceneFilterType, sceneIDs []int, filter *models.FindFilterType) (ret *FindScenesResultType, err error) {
func (r *queryResolver) FindScenes(
ctx context.Context,
sceneFilter *models.SceneFilterType,
sceneIDs []int,
ids []string,
filter *models.FindFilterType,
) (ret *FindScenesResultType, err error) {
if len(ids) > 0 {
sceneIDs, err = stringslice.StringSliceToIntSlice(ids)
if err != nil {
return nil, err
}
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
var scenes []*models.Scene
var err error

View File

@@ -5,6 +5,7 @@ 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) {
@@ -24,9 +25,23 @@ func (r *queryResolver) FindStudio(ctx context.Context, id string) (ret *models.
return ret, nil
}
func (r *queryResolver) FindStudios(ctx context.Context, studioFilter *models.StudioFilterType, filter *models.FindFilterType) (ret *FindStudiosResultType, err error) {
func (r *queryResolver) FindStudios(ctx context.Context, studioFilter *models.StudioFilterType, filter *models.FindFilterType, ids []string) (ret *FindStudiosResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids)
if err != nil {
return nil, err
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
studios, total, err := r.repository.Studio.Query(ctx, studioFilter, filter)
var studios []*models.Studio
var err error
var total int
if len(idInts) > 0 {
studios, err = r.repository.Studio.FindMany(ctx, idInts)
total = len(studios)
} else {
studios, total, err = r.repository.Studio.Query(ctx, studioFilter, filter)
}
if err != nil {
return err
}

View File

@@ -5,6 +5,7 @@ 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) {
@@ -23,9 +24,24 @@ func (r *queryResolver) FindTag(ctx context.Context, id string) (ret *models.Tag
return ret, nil
}
func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType) (ret *FindTagsResultType, err error) {
func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilterType, filter *models.FindFilterType, ids []string) (ret *FindTagsResultType, err error) {
idInts, err := stringslice.StringSliceToIntSlice(ids)
if err != nil {
return nil, err
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
tags, total, err := r.repository.Tag.Query(ctx, tagFilter, filter)
var tags []*models.Tag
var err error
var total int
if len(idInts) > 0 {
tags, err = r.repository.Tag.FindMany(ctx, idInts)
total = len(tags)
} else {
tags, total, err = r.repository.Tag.Query(ctx, tagFilter, filter)
}
if err != nil {
return err
}

View File

@@ -41,6 +41,7 @@ func jobToJobModel(j job.Job) *Job {
StartTime: j.StartTime,
EndTime: j.EndTime,
AddTime: j.AddTime,
Error: j.Error,
}
if j.Progress != -1 {

View File

@@ -54,9 +54,8 @@ func (r *queryResolver) ScrapeSceneQuery(ctx context.Context, scraperID string,
return ret, nil
}
// filterSceneTags removes tags matching excluded tag patterns from the provided scraped scenes
func filterSceneTags(scenes []*scraper.ScrapedScene) {
excludePatterns := manager.GetInstance().Config.GetScraperExcludeTagPatterns()
func compileRegexps(patterns []string) []*regexp.Regexp {
excludePatterns := patterns
var excludeRegexps []*regexp.Regexp
for _, excludePattern := range excludePatterns {
@@ -68,30 +67,77 @@ func filterSceneTags(scenes []*scraper.ScrapedScene) {
}
}
return excludeRegexps
}
// filterSceneTags removes tags matching excluded tag patterns from the provided scraped scenes
func filterTags(excludeRegexps []*regexp.Regexp, tags []*models.ScrapedTag) (newTags []*models.ScrapedTag, ignoredTags []string) {
if len(excludeRegexps) == 0 {
return
return tags, nil
}
for _, t := range tags {
ignore := false
for _, reg := range excludeRegexps {
if reg.MatchString(strings.ToLower(t.Name)) {
ignore = true
ignoredTags = sliceutil.AppendUnique(ignoredTags, t.Name)
break
}
}
if !ignore {
newTags = append(newTags, t)
}
}
return
}
// filterSceneTags removes tags matching excluded tag patterns from the provided scraped scenes
func filterSceneTags(scenes []*scraper.ScrapedScene) {
excludeRegexps := compileRegexps(manager.GetInstance().Config.GetScraperExcludeTagPatterns())
var ignoredTags []string
for _, s := range scenes {
var newTags []*models.ScrapedTag
for _, t := range s.Tags {
ignore := false
for _, reg := range excludeRegexps {
if reg.MatchString(strings.ToLower(t.Name)) {
ignore = true
ignoredTags = sliceutil.AppendUnique(ignoredTags, t.Name)
break
}
}
var ignored []string
s.Tags, ignored = filterTags(excludeRegexps, s.Tags)
ignoredTags = sliceutil.AppendUniques(ignoredTags, ignored)
}
if !ignore {
newTags = append(newTags, t)
}
}
if len(ignoredTags) > 0 {
logger.Debugf("Scraping ignored tags: %s", strings.Join(ignoredTags, ", "))
}
}
s.Tags = newTags
// filterGalleryTags removes tags matching excluded tag patterns from the provided scraped galleries
func filterGalleryTags(g []*scraper.ScrapedGallery) {
excludeRegexps := compileRegexps(manager.GetInstance().Config.GetScraperExcludeTagPatterns())
var ignoredTags []string
for _, s := range g {
var ignored []string
s.Tags, ignored = filterTags(excludeRegexps, s.Tags)
ignoredTags = sliceutil.AppendUniques(ignoredTags, ignored)
}
if len(ignoredTags) > 0 {
logger.Debugf("Scraping ignored tags: %s", strings.Join(ignoredTags, ", "))
}
}
// filterGalleryTags removes tags matching excluded tag patterns from the provided scraped galleries
func filterPerformerTags(p []*models.ScrapedPerformer) {
excludeRegexps := compileRegexps(manager.GetInstance().Config.GetScraperExcludeTagPatterns())
var ignoredTags []string
for _, s := range p {
var ignored []string
s.Tags, ignored = filterTags(excludeRegexps, s.Tags)
ignoredTags = sliceutil.AppendUniques(ignoredTags, ignored)
}
if len(ignoredTags) > 0 {
@@ -123,7 +169,16 @@ func (r *queryResolver) ScrapeGalleryURL(ctx context.Context, url string) (*scra
return nil, err
}
return marshalScrapedGallery(content)
ret, err := marshalScrapedGallery(content)
if err != nil {
return nil, err
}
if ret != nil {
filterGalleryTags([]*scraper.ScrapedGallery{ret})
}
return ret, nil
}
func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models.ScrapedMovie, error) {
@@ -264,39 +319,46 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S
}
func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scraper.Source, input ScrapeSinglePerformerInput) ([]*models.ScrapedPerformer, error) {
if source.ScraperID != nil {
if input.PerformerInput != nil {
var ret []*models.ScrapedPerformer
switch {
case source.ScraperID != nil:
switch {
case input.PerformerInput != nil:
performer, err := r.scraperCache().ScrapeFragment(ctx, *source.ScraperID, scraper.Input{Performer: input.PerformerInput})
if err != nil {
return nil, err
}
return marshalScrapedPerformers([]scraper.ScrapedContent{performer})
}
if input.Query != nil {
ret, err = marshalScrapedPerformers([]scraper.ScrapedContent{performer})
if err != nil {
return nil, err
}
case input.Query != nil:
content, err := r.scraperCache().ScrapeName(ctx, *source.ScraperID, *input.Query, scraper.ScrapeContentTypePerformer)
if err != nil {
return nil, err
}
return marshalScrapedPerformers(content)
ret, err = marshalScrapedPerformers(content)
if err != nil {
return nil, err
}
default:
return nil, ErrNotImplemented
}
return nil, ErrNotImplemented
// FIXME - we're relying on a deprecated field and not processing the endpoint input
} else if source.StashBoxIndex != nil {
case source.StashBoxIndex != nil:
client, err := r.getStashBoxClient(*source.StashBoxIndex)
if err != nil {
return nil, err
}
var ret []*stashbox.StashBoxPerformerQueryResult
var res []*stashbox.StashBoxPerformerQueryResult
switch {
case input.PerformerID != nil:
ret, err = client.FindStashBoxPerformersByNames(ctx, []string{*input.PerformerID})
res, err = client.FindStashBoxPerformersByNames(ctx, []string{*input.PerformerID})
case input.Query != nil:
ret, err = client.QueryStashBoxPerformer(ctx, *input.Query)
res, err = client.QueryStashBoxPerformer(ctx, *input.Query)
default:
return nil, ErrNotImplemented
}
@@ -305,14 +367,16 @@ func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scrape
return nil, err
}
if len(ret) > 0 {
return ret[0].Results, nil
if len(res) > 0 {
ret = res[0].Results
}
return nil, nil
default:
return nil, errors.New("scraper_id or stash_box_index must be set")
}
return nil, errors.New("scraper_id or stash_box_index must be set")
filterPerformerTags(ret)
return ret, nil
}
func (r *queryResolver) ScrapeMultiPerformers(ctx context.Context, source scraper.Source, input ScrapeMultiPerformersInput) ([][]*models.ScrapedPerformer, error) {
@@ -331,6 +395,8 @@ func (r *queryResolver) ScrapeMultiPerformers(ctx context.Context, source scrape
}
func (r *queryResolver) ScrapeSingleGallery(ctx context.Context, source scraper.Source, input ScrapeSingleGalleryInput) ([]*scraper.ScrapedGallery, error) {
var ret []*scraper.ScrapedGallery
if source.StashBoxIndex != nil {
return nil, ErrNotSupported
}
@@ -351,16 +417,25 @@ func (r *queryResolver) ScrapeSingleGallery(ctx context.Context, source scraper.
if err != nil {
return nil, err
}
return marshalScrapedGalleries([]scraper.ScrapedContent{c})
ret, err = marshalScrapedGalleries([]scraper.ScrapedContent{c})
if err != nil {
return nil, err
}
case input.GalleryInput != nil:
c, err := r.scraperCache().ScrapeFragment(ctx, *source.ScraperID, scraper.Input{Gallery: input.GalleryInput})
if err != nil {
return nil, err
}
return marshalScrapedGalleries([]scraper.ScrapedContent{c})
ret, err = marshalScrapedGalleries([]scraper.ScrapedContent{c})
if err != nil {
return nil, err
}
default:
return nil, ErrNotImplemented
}
filterGalleryTags(ret)
return ret, nil
}
func (r *queryResolver) ScrapeSingleMovie(ctx context.Context, source scraper.Source, input ScrapeSingleMovieInput) ([]*models.ScrapedMovie, error) {

View File

@@ -46,8 +46,9 @@ func (rs imageRoutes) Routes() chi.Router {
}
func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
mgr := manager.GetInstance()
img := r.Context().Value(imageKey).(*models.Image)
filepath := manager.GetInstance().Paths.Generated.GetThumbnailPath(img.Checksum, models.DefaultGthumbWidth)
filepath := mgr.Paths.Generated.GetThumbnailPath(img.Checksum, models.DefaultGthumbWidth)
// if the thumbnail doesn't exist, encode on the fly
exists, _ := fsutil.FileExists(filepath)
@@ -62,6 +63,11 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
return
}
// use the image thumbnail generate wait group to limit the number of concurrent thumbnail generation tasks
wg := &mgr.ImageThumbnailGenerateWaitGroup
wg.Add()
defer wg.Done()
clipPreviewOptions := image.ClipPreviewOptions{
InputArgs: manager.GetInstance().Config.GetTranscodeInputArgs(),
OutputArgs: manager.GetInstance().Config.GetTranscodeOutputArgs(),

View File

@@ -50,6 +50,7 @@ func (rs pluginRoutes) Assets(w http.ResponseWriter, r *http.Request) {
r.URL.Path, dir = p.UI.Assets.GetFilesystemLocation(r.URL.Path)
if dir == "" {
http.NotFound(w, r)
return
}
dir = filepath.Join(pluginDir, filepath.FromSlash(dir))

View File

@@ -53,6 +53,28 @@ type Server struct {
manager *manager.Manager
}
// TODO - os.DirFS doesn't implement ReadDir, so re-implement it here
// This can be removed when we upgrade go
type osFS string
func (dir osFS) ReadDir(name string) ([]os.DirEntry, error) {
fullname := string(dir) + "/" + name
entries, err := os.ReadDir(fullname)
if err != nil {
var e *os.PathError
if errors.As(err, &e) {
// See comment in dirFS.Open.
e.Path = name
}
return nil, err
}
return entries, nil
}
func (dir osFS) Open(name string) (fs.File, error) {
return os.DirFS(string(dir)).Open(name)
}
// Called at startup
func Initialize() (*Server, error) {
mgr := manager.GetInstance()
@@ -213,25 +235,31 @@ func Initialize() (*Server, error) {
r.Mount("/custom", getCustomRoutes(customServedFolders))
}
customUILocation := cfg.GetCustomUILocation()
staticUI := statigz.FileServer(ui.UIBox.(fs.ReadDirFS))
var uiFS fs.FS
var staticUI *statigz.Server
customUILocation := cfg.GetUILocation()
if customUILocation != "" {
logger.Debugf("Serving UI from %s", customUILocation)
uiFS = osFS(customUILocation)
staticUI = statigz.FileServer(uiFS.(fs.ReadDirFS))
} else {
logger.Debug("Serving embedded UI")
uiFS = ui.UIBox
staticUI = statigz.FileServer(ui.UIBox.(fs.ReadDirFS))
}
// Serve the web app
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
ext := path.Ext(r.URL.Path)
if customUILocation != "" {
if r.URL.Path == "index.html" || ext == "" {
r.URL.Path = "/"
}
http.FileServer(http.Dir(customUILocation)).ServeHTTP(w, r)
return
if ext == ".html" || ext == "" {
w.Header().Set("Content-Type", "text/html")
setPageSecurityHeaders(w, r, pluginCache.ListPlugins())
}
if ext == ".html" || ext == "" {
if ext == "" || r.URL.Path == "/" || r.URL.Path == "/index.html" {
themeColor := cfg.GetThemeColor()
data, err := fs.ReadFile(ui.UIBox, "index.html")
data, err := fs.ReadFile(uiFS, "index.html")
if err != nil {
panic(err)
}
@@ -241,9 +269,6 @@ func Initialize() (*Server, error) {
indexHtml = strings.ReplaceAll(indexHtml, "%COLOR%", themeColor)
indexHtml = strings.Replace(indexHtml, `<base href="/"`, fmt.Sprintf(`<base href="%s/"`, prefix), 1)
w.Header().Set("Content-Type", "text/html")
setPageSecurityHeaders(w, r, pluginCache.ListPlugins())
utils.ServeStaticContent(w, r, []byte(indexHtml))
} else {
isStatic, _ := path.Match("/assets/*", r.URL.Path)

View File

@@ -2,14 +2,9 @@ package api
import (
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil"
)
func stashIDsSliceToPtrSlice(v []models.StashID) []*models.StashID {
ret := make([]*models.StashID, len(v))
for i, vv := range v {
c := vv
ret[i] = &c
}
return ret
return sliceutil.ValuesToPtrs(v)
}

View File

@@ -21,7 +21,7 @@ func startSystray(exit chan int, faviconProvider FaviconProvider) {
c := config.GetInstance()
if c.GetShowOneTimeMovedNotification() {
SendNotification("Stash has moved!", "Stash now runs in your tray, instead of a terminal window.")
c.Set(config.ShowOneTimeMovedNotification, false)
c.SetBool(config.ShowOneTimeMovedNotification, false)
if err := c.Write(); err != nil {
logger.Errorf("Error while writing configuration file: %v", err)
}

View File

@@ -85,7 +85,6 @@ func sceneToContainer(scene *models.Scene, parent string, host string) interface
Path: iconPath,
RawQuery: url.Values{
"scene": {strconv.Itoa(scene.ID)},
"c": {"jpeg"},
}.Encode(),
}).String()

View File

@@ -542,7 +542,7 @@ func (me *Server) contentDirectoryEventSubHandler(w http.ResponseWriter, r *http
case r.Method == "SUBSCRIBE" && r.Header.Get("SID") == "":
urls := upnp.ParseCallbackURLs(r.Header.Get("CALLBACK"))
var timeout int
fmt.Sscanf(r.Header.Get("TIMEOUT"), "Second-%d", &timeout)
_, _ = fmt.Sscanf(r.Header.Get("TIMEOUT"), "Second-%d", &timeout)
sid, timeout, _ := service.Subscribe(urls, timeout)
w.Header()["SID"] = []string{sid}
w.Header()["TIMEOUT"] = []string{fmt.Sprintf("Second-%d", timeout)}
@@ -595,6 +595,8 @@ func (me *Server) initMux(mux *http.ServeMux) {
return
}
w.Header().Set("transferMode.dlna.org", "Streaming")
w.Header().Set("contentFeatures.dlna.org", "DLNA.ORG_OP=01;DLNA.ORG_CI=0;DLNA.ORG_FLAGS=01500000000000000000000000000000")
me.sceneServer.StreamSceneDirect(scene, w, r)
})
mux.HandleFunc(rootDescPath, func(w http.ResponseWriter, r *http.Request) {

View File

@@ -76,6 +76,7 @@ type Config interface {
GetDLNAServerName() string
GetDLNADefaultIPWhitelist() []string
GetVideoSortOrder() string
GetDLNAPortAsString() string
}
type Service struct {
@@ -138,7 +139,7 @@ func (s *Service) init() error {
var dmsConfig = &dmsConfig{
Path: "",
IfNames: s.config.GetDLNADefaultIPWhitelist(),
Http: ":1338",
Http: s.config.GetDLNAPortAsString(),
FriendlyName: friendlyName,
LogHeaders: false,
NotifyInterval: 30 * time.Second,
@@ -241,7 +242,7 @@ func (s *Service) Start(duration *time.Duration) error {
}
go func() {
logger.Info("Starting DLNA")
logger.Info("Starting DLNA " + s.server.HTTPConn.Addr().String())
if err := s.server.Serve(); err != nil {
logger.Error(err)
}

View File

@@ -252,7 +252,8 @@ func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene,
}
}
if utils.IsTrue(options.SetCoverImage) {
// SetCoverImage defaults to true if unset
if options.SetCoverImage == nil || *options.SetCoverImage {
ret.CoverImage, err = rel.cover(ctx)
if err != nil {
return nil, err
@@ -392,7 +393,7 @@ func getScenePartial(scene *models.Scene, scraped *scraper.ScrapedScene, fieldOp
switch getFieldStrategy(fieldOptions["url"]) {
case FieldStrategyOverwrite:
// only overwrite if not equal
if len(sliceutil.Exclude(scene.URLs.List(), scraped.URLs)) != 0 {
if len(sliceutil.Exclude(scraped.URLs, scene.URLs.List())) != 0 {
partial.URLs = &models.UpdateStrings{
Values: scraped.URLs,
Mode: models.RelationshipUpdateModeSet,

View File

@@ -46,17 +46,17 @@ func createMissingStudio(ctx context.Context, endpoint string, w models.StudioRe
return nil, err
}
studioPartial := s.Parent.ToPartial(s.Parent.StoredID, endpoint, nil, existingStashIDs)
studioPartial := s.Parent.ToPartial(*s.Parent.StoredID, endpoint, nil, existingStashIDs)
parentImage, err := s.Parent.GetImage(ctx, nil)
if err != nil {
return nil, err
}
if err := studio.ValidateModify(ctx, *studioPartial, w); err != nil {
if err := studio.ValidateModify(ctx, studioPartial, w); err != nil {
return nil, err
}
_, err = w.UpdatePartial(ctx, *studioPartial)
_, err = w.UpdatePartial(ctx, studioPartial)
if err != nil {
return nil, err
}

View File

@@ -4,8 +4,10 @@ import (
"fmt"
"os"
"path/filepath"
"reflect"
"regexp"
"runtime"
"strconv"
"strings"
"sync"
@@ -13,7 +15,9 @@ import (
"golang.org/x/crypto/bcrypt"
"github.com/spf13/viper"
"github.com/knadh/koanf"
"github.com/knadh/koanf/parsers/yaml"
"github.com/knadh/koanf/providers/file"
"github.com/stashapp/stash/internal/identify"
"github.com/stashapp/stash/pkg/fsutil"
@@ -38,6 +42,9 @@ const (
Password = "password"
MaxSessionAge = "max_session_age"
FFMpegPath = "ffmpeg_path"
FFProbePath = "ffprobe_path"
BlobsStorage = "blobs_storage"
DefaultMaxSessionAge = 60 * 60 * 1 // 1 hours
@@ -156,7 +163,10 @@ const (
// UI directory. Overrides to serve the UI from a specific location
// rather than use the embedded UI.
CustomUILocation = "custom_ui_location"
UILocation = "ui_location"
// backwards compatible name
LegacyCustomUILocation = "custom_ui_location"
// Gallery Cover Regex
GalleryCoverRegex = "gallery_cover_regex"
@@ -177,9 +187,9 @@ const (
autostartVideoOnPlaySelectedDefault = true
ContinuePlaylistDefault = "continue_playlist_default"
ShowStudioAsText = "show_studio_as_text"
CSSEnabled = "cssEnabled"
JavascriptEnabled = "javascriptEnabled"
CustomLocalesEnabled = "customLocalesEnabled"
CSSEnabled = "cssenabled"
JavascriptEnabled = "javascriptenabled"
CustomLocalesEnabled = "customlocalesenabled"
ShowScrubber = "show_scrubber"
showScrubberDefault = true
@@ -231,13 +241,16 @@ const (
DLNAVideoSortOrder = "dlna.video_sort_order"
dlnaVideoSortOrderDefault = "title"
DLNAPort = "dlna.port"
DLNAPortDefault = 1338
// Logging options
LogFile = "logFile"
LogOut = "logOut"
LogFile = "logfile"
LogOut = "logout"
defaultLogOut = true
LogLevel = "logLevel"
LogLevel = "loglevel"
defaultLogLevel = "Info"
LogAccess = "logAccess"
LogAccess = "logaccess"
defaultLogAccess = true
// Default settings
@@ -251,7 +264,7 @@ const (
deleteGeneratedDefaultDefault = true
// Desktop Integration Options
NoBrowser = "noBrowser"
NoBrowser = "nobrowser"
NoBrowserDefault = false
NotificationsEnabled = "notifications_enabled"
NotificationsEnabledDefault = true
@@ -260,6 +273,9 @@ const (
// File upload options
MaxUploadSize = "max_upload_size"
// Developer options
ExtraBlobsPaths = "developer_options.extra_blob_paths"
)
// slice default values
@@ -290,12 +306,13 @@ func (s *StashBoxError) Error() string {
type Config struct {
// main instance - backed by config file
main *viper.Viper
main *koanf.Koanf
// override instance - populated from flags/environment
// not written to config file
overrides *viper.Viper
overrides *koanf.Koanf
filePath string
isNewSystem bool
// configUpdates chan int
certFile string
@@ -313,6 +330,15 @@ func GetInstance() *Config {
return instance
}
func (i *Config) load(f string) error {
if err := i.main.Load(file.Provider(f), yaml.Parser()); err != nil {
return err
}
i.filePath = f
return nil
}
func (i *Config) IsNewSystem() bool {
return i.isNewSystem
}
@@ -320,7 +346,7 @@ func (i *Config) IsNewSystem() bool {
func (i *Config) SetConfigFile(fn string) {
i.Lock()
defer i.Unlock()
i.main.SetConfigFile(fn)
i.filePath = fn
}
func (i *Config) InitTLS() {
@@ -351,10 +377,6 @@ func (i *Config) GetNotificationsEnabled() bool {
return i.getBool(NotificationsEnabled)
}
// func (i *Instance) GetConfigUpdatesChannel() chan int {
// return i.configUpdates
// }
// GetShowOneTimeMovedNotification shows whether a small notification to inform the user that Stash
// will no longer show a terminal window, and instead will be available in the tray, should be shown.
// It is true when an existing system is started after upgrading, and set to false forever after it is shown.
@@ -362,34 +384,93 @@ func (i *Config) GetShowOneTimeMovedNotification() bool {
return i.getBool(ShowOneTimeMovedNotification)
}
func (i *Config) Set(key string, value interface{}) {
// if key == MenuItems {
// i.configUpdates <- 0
// }
// these methods are intended to ensure type safety (ie no primitive pointers)
func (i *Config) SetBool(key string, value bool) {
i.SetInterface(key, value)
}
func (i *Config) SetString(key string, value string) {
i.SetInterface(key, value)
}
func (i *Config) SetInt(key string, value int) {
i.SetInterface(key, value)
}
func (i *Config) SetFloat(key string, value float64) {
i.SetInterface(key, value)
}
func (i *Config) SetInterface(key string, value interface{}) {
i.Lock()
defer i.Unlock()
i.main.Set(key, value)
i.set(key, value)
}
func (i *Config) set(key string, value interface{}) {
// assumes lock held
// default behaviour for Set is to merge the value
// we want to replace it
i.main.Delete(key)
if value == nil {
return
}
// test for nil interface as well
refVal := reflect.ValueOf(value)
if refVal.Kind() == reflect.Ptr && refVal.IsNil() {
return
}
_ = i.main.Set(key, value)
}
func (i *Config) SetDefault(key string, value interface{}) {
i.Lock()
defer i.Unlock()
i.main.SetDefault(key, value)
i.setDefault(key, value)
}
func (i *Config) setDefault(key string, value interface{}) {
if !i.main.Exists(key) {
i.set(key, value)
}
}
func (i *Config) SetPassword(value string) {
// if blank, don't bother hashing; we want it to be blank
if value == "" {
i.Set(Password, "")
i.SetString(Password, "")
} else {
i.Set(Password, hashPassword(value))
i.SetString(Password, hashPassword(value))
}
}
func (i *Config) Write() error {
i.Lock()
defer i.Unlock()
return i.main.WriteConfig()
data, err := i.marshal()
if err != nil {
return err
}
return os.WriteFile(i.filePath, data, 0640)
}
func (i *Config) Marshal() ([]byte, error) {
i.RLock()
defer i.RUnlock()
return i.marshal()
}
func (i *Config) marshal() ([]byte, error) {
return i.main.Marshal(yaml.Parser())
}
// FileEnvSet returns true if the configuration file environment parameter
@@ -402,7 +483,7 @@ func FileEnvSet() bool {
func (i *Config) GetConfigFile() string {
i.RLock()
defer i.RUnlock()
return i.main.ConfigFileUsed()
return i.filePath
}
// GetConfigPath returns the path of the directory containing the used
@@ -411,18 +492,32 @@ func (i *Config) GetConfigPath() string {
return filepath.Dir(i.GetConfigFile())
}
// GetConfigPathAbs returns the path of the directory containing the used
// configuration file, resolved to an absolute path. Returns the return value
// of GetConfigPath if the path cannot be made into an absolute path.
func (i *Config) GetConfigPathAbs() string {
p := filepath.Dir(i.GetConfigFile())
ret, _ := filepath.Abs(p)
if ret == "" {
return p
}
return ret
}
// GetDefaultDatabaseFilePath returns the default database filename,
// which is located in the same directory as the config file.
func (i *Config) GetDefaultDatabaseFilePath() string {
return filepath.Join(i.GetConfigPath(), "stash-go.sqlite")
}
// viper returns the viper instance that should be used to get the provided
// forKey returns the Koanf instance that should be used to get the provided
// key. Returns the overrides instance if the key exists there, otherwise it
// returns the main instance. Assumes read lock held.
func (i *Config) viper(key string) *viper.Viper {
func (i *Config) forKey(key string) *koanf.Koanf {
v := i.main
if i.overrides.IsSet(key) {
if i.overrides.Exists(key) {
v = i.overrides
}
@@ -431,10 +526,10 @@ func (i *Config) viper(key string) *viper.Viper {
// viper returns the viper instance that has the key set. Returns nil
// if no instance has the key. Assumes read lock held.
func (i *Config) viperWith(key string) *viper.Viper {
v := i.viper(key)
func (i *Config) with(key string) *koanf.Koanf {
v := i.forKey(key)
if v.IsSet(key) {
if v.Exists(key) {
return v
}
@@ -445,7 +540,7 @@ func (i *Config) HasOverride(key string) bool {
i.RLock()
defer i.RUnlock()
return i.overrides.IsSet(key)
return i.overrides.Exists(key)
}
// These functions wrap the equivalent viper functions, checking the override
@@ -455,28 +550,28 @@ func (i *Config) unmarshalKey(key string, rawVal interface{}) error {
i.RLock()
defer i.RUnlock()
return i.viper(key).UnmarshalKey(key, rawVal)
return i.forKey(key).Unmarshal(key, rawVal)
}
func (i *Config) getStringSlice(key string) []string {
i.RLock()
defer i.RUnlock()
return i.viper(key).GetStringSlice(key)
return i.forKey(key).Strings(key)
}
func (i *Config) getString(key string) string {
i.RLock()
defer i.RUnlock()
return i.viper(key).GetString(key)
return i.forKey(key).String(key)
}
func (i *Config) getBool(key string) bool {
i.RLock()
defer i.RUnlock()
return i.viper(key).GetBool(key)
return i.forKey(key).Bool(key)
}
func (i *Config) getBoolDefault(key string, def bool) bool {
@@ -484,9 +579,9 @@ func (i *Config) getBoolDefault(key string, def bool) bool {
defer i.RUnlock()
ret := def
v := i.viper(key)
if v.IsSet(key) {
ret = v.GetBool(key)
v := i.forKey(key)
if v.Exists(key) {
ret = v.Bool(key)
}
return ret
}
@@ -495,21 +590,21 @@ func (i *Config) getInt(key string) int {
i.RLock()
defer i.RUnlock()
return i.viper(key).GetInt(key)
return i.forKey(key).Int(key)
}
func (i *Config) getFloat64(key string) float64 {
i.RLock()
defer i.RUnlock()
return i.viper(key).GetFloat64(key)
return i.forKey(key).Float64(key)
}
func (i *Config) getStringMapString(key string) map[string]string {
i.RLock()
defer i.RUnlock()
ret := i.viper(key).GetStringMapString(key)
ret := i.forKey(key).StringMap(key)
// GetStringMapString returns an empty map regardless of whether the
// key exists or not.
@@ -530,13 +625,13 @@ func (i *Config) GetStashPaths() StashConfigs {
var ret StashConfigs
v := i.main
if !v.IsSet(Stash) {
if !v.Exists(Stash) {
v = i.overrides
}
if err := v.UnmarshalKey(Stash, &ret); err != nil || len(ret) == 0 {
if err := v.Unmarshal(Stash, &ret); err != nil || len(ret) == 0 {
// fallback to legacy format
ss := v.GetStringSlice(Stash)
ss := v.Strings(Stash)
ret = nil
for _, path := range ss {
toAdd := &StashConfig{
@@ -561,6 +656,12 @@ func (i *Config) GetBlobsPath() string {
return i.getString(BlobsPath)
}
// GetExtraBlobsPaths returns extra blobs paths.
// For developer/advanced use only.
func (i *Config) GetExtraBlobsPaths() []string {
return i.getStringSlice(ExtraBlobsPaths)
}
func (i *Config) GetBlobsStorage() BlobsStorageType {
ret := BlobsStorageType(i.getString(BlobsStorage))
@@ -594,6 +695,18 @@ func (i *Config) GetBackupDirectoryPathOrDefault() string {
return ret
}
// GetFFMpegPath returns the path to the FFMpeg executable.
// If empty, stash will attempt to resolve it from the path.
func (i *Config) GetFFMpegPath() string {
return i.getString(FFMpegPath)
}
// GetFFProbePath returns the path to the FFProbe executable.
// If empty, stash will attempt to resolve it from the path.
func (i *Config) GetFFProbePath() string {
return i.getString(FFProbePath)
}
func (i *Config) GetJWTSignKey() []byte {
return []byte(i.getString(JWTSignKey))
}
@@ -619,7 +732,7 @@ func (i *Config) GetImageExcludes() []string {
func (i *Config) GetVideoExtensions() []string {
ret := i.getStringSlice(VideoExtensions)
if ret == nil {
if len(ret) == 0 {
ret = defaultVideoExtensions
}
return ret
@@ -627,7 +740,7 @@ func (i *Config) GetVideoExtensions() []string {
func (i *Config) GetImageExtensions() []string {
ret := i.getStringSlice(ImageExtensions)
if ret == nil {
if len(ret) == 0 {
ret = defaultImageExtensions
}
return ret
@@ -635,7 +748,7 @@ func (i *Config) GetImageExtensions() []string {
func (i *Config) GetGalleryExtensions() []string {
ret := i.getStringSlice(GalleryExtensions)
if ret == nil {
if len(ret) == 0 {
ret = defaultGalleryExtensions
}
return ret
@@ -735,22 +848,21 @@ func (i *Config) GetPluginsPath() string {
return i.getString(PluginsPath)
}
func (i *Config) GetAllPluginConfiguration() map[string]interface{} {
func (i *Config) GetAllPluginConfiguration() map[string]map[string]interface{} {
i.RLock()
defer i.RUnlock()
ret := make(map[string]interface{})
ret := make(map[string]map[string]interface{})
sub := i.viper(PluginsSetting).GetStringMap(PluginsSetting)
v := i.forKey(PluginsSetting)
sub := v.Cut(PluginsSetting)
if sub == nil {
return ret
}
for plugin := range sub {
// HACK: viper changes map keys to case insensitive values, so the workaround is to
// convert map keys to snake case for storage
name := fromSnakeCase(plugin)
ret[name] = fromSnakeCaseMap(i.viper(PluginsSetting).GetStringMap(PluginsSettingPrefix + plugin))
for plugin := range sub.Raw() {
ret[plugin] = sub.Cut(plugin).Raw()
}
return ret
@@ -760,26 +872,20 @@ func (i *Config) GetPluginConfiguration(pluginID string) map[string]interface{}
i.RLock()
defer i.RUnlock()
key := PluginsSettingPrefix + toSnakeCase(pluginID)
key := PluginsSettingPrefix + pluginID
// HACK: viper changes map keys to case insensitive values, so the workaround is to
// convert map keys to snake case for storage
v := i.viper(key).GetStringMap(key)
return fromSnakeCaseMap(v)
return i.forKey(key).Cut(key).Raw()
}
// SetPluginConfiguration sets the configuration for a plugin.
// It will overwrite any existing configuration.
func (i *Config) SetPluginConfiguration(pluginID string, v map[string]interface{}) {
i.Lock()
defer i.Unlock()
pluginID = toSnakeCase(pluginID)
key := PluginsSettingPrefix + pluginID
// HACK: viper changes map keys to case insensitive values, so the workaround is to
// convert map keys to snake case for storage
i.viper(key).Set(key, toSnakeCaseMap(v))
i.set(key, v)
}
func (i *Config) GetDisabledPlugins() []string {
@@ -1019,9 +1125,9 @@ func (i *Config) GetMaxSessionAge() int {
defer i.RUnlock()
ret := DefaultMaxSessionAge
v := i.viper(MaxSessionAge)
if v.IsSet(MaxSessionAge) {
ret = v.GetInt(MaxSessionAge)
v := i.forKey(MaxSessionAge)
if v.Exists(MaxSessionAge) {
ret = v.Int(MaxSessionAge)
}
return ret
@@ -1033,17 +1139,21 @@ func (i *Config) GetCustomServedFolders() utils.URLMap {
return i.getStringMapString(CustomServedFolders)
}
func (i *Config) GetCustomUILocation() string {
return i.getString(CustomUILocation)
func (i *Config) GetUILocation() string {
if ret := i.getString(UILocation); ret != "" {
return ret
}
return i.getString(LegacyCustomUILocation)
}
// Interface options
func (i *Config) GetMenuItems() []string {
i.RLock()
defer i.RUnlock()
v := i.viper(MenuItems)
if v.IsSet(MenuItems) {
return v.GetStringSlice(MenuItems)
v := i.forKey(MenuItems)
if v.Exists(MenuItems) {
return v.Strings(MenuItems)
}
return defaultMenuItems
}
@@ -1057,9 +1167,9 @@ func (i *Config) GetWallShowTitle() bool {
defer i.RUnlock()
ret := defaultWallShowTitle
v := i.viper(WallShowTitle)
if v.IsSet(WallShowTitle) {
ret = v.GetBool(WallShowTitle)
v := i.forKey(WallShowTitle)
if v.Exists(WallShowTitle) {
ret = v.Bool(WallShowTitle)
}
return ret
}
@@ -1073,9 +1183,9 @@ func (i *Config) GetWallPlayback() string {
defer i.RUnlock()
ret := defaultWallPlayback
v := i.viper(WallPlayback)
if v.IsSet(WallPlayback) {
ret = v.GetString(WallPlayback)
v := i.forKey(WallPlayback)
if v.Exists(WallPlayback) {
ret = v.String(WallPlayback)
}
return ret
@@ -1109,14 +1219,14 @@ func (i *Config) getSlideshowDelay() int {
// assume have lock
ret := defaultImageLightboxSlideshowDelay
v := i.viper(ImageLightboxSlideshowDelay)
if v.IsSet(ImageLightboxSlideshowDelay) {
ret = v.GetInt(ImageLightboxSlideshowDelay)
v := i.forKey(ImageLightboxSlideshowDelay)
if v.Exists(ImageLightboxSlideshowDelay) {
ret = v.Int(ImageLightboxSlideshowDelay)
} else {
// fallback to old location
v := i.viper(legacyImageLightboxSlideshowDelay)
if v.IsSet(legacyImageLightboxSlideshowDelay) {
ret = v.GetInt(legacyImageLightboxSlideshowDelay)
v := i.forKey(legacyImageLightboxSlideshowDelay)
if v.Exists(legacyImageLightboxSlideshowDelay) {
ret = v.Int(legacyImageLightboxSlideshowDelay)
}
}
@@ -1133,24 +1243,24 @@ func (i *Config) GetImageLightboxOptions() ConfigImageLightboxResult {
SlideshowDelay: &delay,
}
if v := i.viperWith(ImageLightboxDisplayModeKey); v != nil {
mode := ImageLightboxDisplayMode(v.GetString(ImageLightboxDisplayModeKey))
if v := i.with(ImageLightboxDisplayModeKey); v != nil {
mode := ImageLightboxDisplayMode(v.String(ImageLightboxDisplayModeKey))
ret.DisplayMode = &mode
}
if v := i.viperWith(ImageLightboxScaleUp); v != nil {
value := v.GetBool(ImageLightboxScaleUp)
if v := i.with(ImageLightboxScaleUp); v != nil {
value := v.Bool(ImageLightboxScaleUp)
ret.ScaleUp = &value
}
if v := i.viperWith(ImageLightboxResetZoomOnNav); v != nil {
value := v.GetBool(ImageLightboxResetZoomOnNav)
if v := i.with(ImageLightboxResetZoomOnNav); v != nil {
value := v.Bool(ImageLightboxResetZoomOnNav)
ret.ResetZoomOnNav = &value
}
if v := i.viperWith(ImageLightboxScrollModeKey); v != nil {
mode := ImageLightboxScrollMode(v.GetString(ImageLightboxScrollModeKey))
if v := i.with(ImageLightboxScrollModeKey); v != nil {
mode := ImageLightboxScrollMode(v.String(ImageLightboxScrollModeKey))
ret.ScrollMode = &mode
}
if v := i.viperWith(ImageLightboxScrollAttemptsBeforeChange); v != nil {
ret.ScrollAttemptsBeforeChange = v.GetInt(ImageLightboxScrollAttemptsBeforeChange)
if v := i.with(ImageLightboxScrollAttemptsBeforeChange); v != nil {
ret.ScrollAttemptsBeforeChange = v.Int(ImageLightboxScrollAttemptsBeforeChange)
}
return ret
@@ -1169,20 +1279,14 @@ func (i *Config) GetUIConfiguration() map[string]interface{} {
i.RLock()
defer i.RUnlock()
// HACK: viper changes map keys to case insensitive values, so the workaround is to
// convert map keys to snake case for storage
v := i.viper(UI).GetStringMap(UI)
return fromSnakeCaseMap(v)
return i.forKey(UI).Cut(UI).Raw()
}
func (i *Config) SetUIConfiguration(v map[string]interface{}) {
i.Lock()
defer i.Unlock()
// HACK: viper changes map keys to case insensitive values, so the workaround is to
// convert map keys to snake case for storage
i.viper(UI).Set(UI, toSnakeCaseMap(v))
i.set(UI, v)
}
func (i *Config) GetCSSPath() string {
@@ -1340,11 +1444,12 @@ func (i *Config) GetDeleteGeneratedDefault() bool {
func (i *Config) GetDefaultIdentifySettings() *identify.Options {
i.RLock()
defer i.RUnlock()
v := i.viper(DefaultIdentifySettings)
v := i.forKey(DefaultIdentifySettings)
if v.IsSet(DefaultIdentifySettings) {
if v.Exists(DefaultIdentifySettings) && v.Get(DefaultIdentifySettings) != nil {
var ret identify.Options
if err := v.UnmarshalKey(DefaultIdentifySettings, &ret); err != nil {
if err := v.Unmarshal(DefaultIdentifySettings, &ret); err != nil {
return nil
}
return &ret
@@ -1359,11 +1464,11 @@ func (i *Config) GetDefaultIdentifySettings() *identify.Options {
func (i *Config) GetDefaultScanSettings() *ScanMetadataOptions {
i.RLock()
defer i.RUnlock()
v := i.viper(DefaultScanSettings)
v := i.forKey(DefaultScanSettings)
if v.IsSet(DefaultScanSettings) {
if v.Exists(DefaultScanSettings) && v.Get(DefaultScanSettings) != nil {
var ret ScanMetadataOptions
if err := v.UnmarshalKey(DefaultScanSettings, &ret); err != nil {
if err := v.Unmarshal(DefaultScanSettings, &ret); err != nil {
return nil
}
return &ret
@@ -1378,11 +1483,11 @@ func (i *Config) GetDefaultScanSettings() *ScanMetadataOptions {
func (i *Config) GetDefaultAutoTagSettings() *AutoTagMetadataOptions {
i.RLock()
defer i.RUnlock()
v := i.viper(DefaultAutoTagSettings)
v := i.forKey(DefaultAutoTagSettings)
if v.IsSet(DefaultAutoTagSettings) {
if v.Exists(DefaultAutoTagSettings) {
var ret AutoTagMetadataOptions
if err := v.UnmarshalKey(DefaultAutoTagSettings, &ret); err != nil {
if err := v.Unmarshal(DefaultAutoTagSettings, &ret); err != nil {
return nil
}
return &ret
@@ -1397,11 +1502,11 @@ func (i *Config) GetDefaultAutoTagSettings() *AutoTagMetadataOptions {
func (i *Config) GetDefaultGenerateSettings() *models.GenerateMetadataOptions {
i.RLock()
defer i.RUnlock()
v := i.viper(DefaultGenerateSettings)
v := i.forKey(DefaultGenerateSettings)
if v.IsSet(DefaultGenerateSettings) {
if v.Exists(DefaultGenerateSettings) {
var ret models.GenerateMetadataOptions
if err := v.UnmarshalKey(DefaultGenerateSettings, &ret); err != nil {
if err := v.Unmarshal(DefaultGenerateSettings, &ret); err != nil {
return nil
}
return &ret
@@ -1446,6 +1551,21 @@ func (i *Config) GetDLNAInterfaces() []string {
return i.getStringSlice(DLNAInterfaces)
}
// GetDLNAPort returns the port to run the DLNA server on. If empty, 1338
// will be used.
func (i *Config) GetDLNAPort() int {
ret := i.getInt(DLNAPort)
if ret == 0 {
ret = DLNAPortDefault
}
return ret
}
// GetDLNAPortAsString returns the port to run the DLNA server on as a string.
func (i *Config) GetDLNAPortAsString() string {
return ":" + strconv.Itoa(i.GetDLNAPort())
}
// GetVideoSortOrder returns the sort order to display videos. If
// empty, videos will be sorted by titles.
func (i *Config) GetVideoSortOrder() string {
@@ -1493,9 +1613,9 @@ func (i *Config) GetMaxUploadSize() int64 {
defer i.RUnlock()
ret := int64(1024)
v := i.viper(MaxUploadSize)
if v.IsSet(MaxUploadSize) {
ret = v.GetInt64(MaxUploadSize)
v := i.forKey(MaxUploadSize)
if v.Exists(MaxUploadSize) {
ret = v.Int64(MaxUploadSize)
}
return ret << 20
}
@@ -1525,7 +1645,7 @@ func (i *Config) GetNoProxy() string {
// config field to the provided IP address to indicate that stash has been accessed
// from this public IP without authentication.
func (i *Config) ActivatePublicAccessTripwire(requestIP string) error {
i.Set(SecurityTripwireAccessedFromPublicInternet, requestIP)
i.SetString(SecurityTripwireAccessedFromPublicInternet, requestIP)
return i.Write()
}
@@ -1595,7 +1715,7 @@ func (i *Config) Validate() error {
var missingFields []string
for _, p := range mandatoryPaths {
if !i.viper(p).IsSet(p) || i.viper(p).GetString(p) == "" {
if !i.forKey(p).Exists(p) || i.forKey(p).String(p) == "" {
missingFields = append(missingFields, p)
}
}
@@ -1606,7 +1726,7 @@ func (i *Config) Validate() error {
}
}
if i.GetBlobsStorage() == BlobStorageTypeFilesystem && i.viper(BlobsPath).GetString(BlobsPath) == "" {
if i.GetBlobsStorage() == BlobStorageTypeFilesystem && i.forKey(BlobsPath).String(BlobsPath) == "" {
return MissingConfigError{
missingFields: []string{BlobsPath},
}
@@ -1626,52 +1746,52 @@ func (i *Config) setDefaultValues() {
// set the default host and port so that these are written to the config
// file
i.main.SetDefault(Host, hostDefault)
i.main.SetDefault(Port, portDefault)
i.setDefault(Host, hostDefault)
i.setDefault(Port, portDefault)
i.main.SetDefault(ParallelTasks, parallelTasksDefault)
i.main.SetDefault(SequentialScanning, SequentialScanningDefault)
i.main.SetDefault(PreviewSegmentDuration, previewSegmentDurationDefault)
i.main.SetDefault(PreviewSegments, previewSegmentsDefault)
i.main.SetDefault(PreviewExcludeStart, previewExcludeStartDefault)
i.main.SetDefault(PreviewExcludeEnd, previewExcludeEndDefault)
i.main.SetDefault(PreviewAudio, previewAudioDefault)
i.main.SetDefault(SoundOnPreview, false)
i.setDefault(ParallelTasks, parallelTasksDefault)
i.setDefault(SequentialScanning, SequentialScanningDefault)
i.setDefault(PreviewSegmentDuration, previewSegmentDurationDefault)
i.setDefault(PreviewSegments, previewSegmentsDefault)
i.setDefault(PreviewExcludeStart, previewExcludeStartDefault)
i.setDefault(PreviewExcludeEnd, previewExcludeEndDefault)
i.setDefault(PreviewAudio, previewAudioDefault)
i.setDefault(SoundOnPreview, false)
i.main.SetDefault(ThemeColor, DefaultThemeColor)
i.setDefault(ThemeColor, DefaultThemeColor)
i.main.SetDefault(WriteImageThumbnails, writeImageThumbnailsDefault)
i.main.SetDefault(CreateImageClipsFromVideos, createImageClipsFromVideosDefault)
i.setDefault(WriteImageThumbnails, writeImageThumbnailsDefault)
i.setDefault(CreateImageClipsFromVideos, createImageClipsFromVideosDefault)
i.main.SetDefault(Database, defaultDatabaseFilePath)
i.setDefault(Database, defaultDatabaseFilePath)
i.main.SetDefault(dangerousAllowPublicWithoutAuth, dangerousAllowPublicWithoutAuthDefault)
i.main.SetDefault(SecurityTripwireAccessedFromPublicInternet, securityTripwireAccessedFromPublicInternetDefault)
i.setDefault(dangerousAllowPublicWithoutAuth, dangerousAllowPublicWithoutAuthDefault)
i.setDefault(SecurityTripwireAccessedFromPublicInternet, securityTripwireAccessedFromPublicInternetDefault)
// Set generated to the metadata path for backwards compat
i.main.SetDefault(Generated, i.main.GetString(Metadata))
i.setDefault(Generated, i.main.String(Metadata))
i.main.SetDefault(NoBrowser, NoBrowserDefault)
i.main.SetDefault(NotificationsEnabled, NotificationsEnabledDefault)
i.main.SetDefault(ShowOneTimeMovedNotification, ShowOneTimeMovedNotificationDefault)
i.setDefault(NoBrowser, NoBrowserDefault)
i.setDefault(NotificationsEnabled, NotificationsEnabledDefault)
i.setDefault(ShowOneTimeMovedNotification, ShowOneTimeMovedNotificationDefault)
// Set default scrapers and plugins paths
i.main.SetDefault(ScrapersPath, defaultScrapersPath)
i.main.SetDefault(PluginsPath, defaultPluginsPath)
i.setDefault(ScrapersPath, defaultScrapersPath)
i.setDefault(PluginsPath, defaultPluginsPath)
// Set default gallery cover regex
i.main.SetDefault(GalleryCoverRegex, galleryCoverRegexDefault)
i.setDefault(GalleryCoverRegex, galleryCoverRegexDefault)
// Set NoProxy default
i.main.SetDefault(NoProxy, noProxyDefault)
i.setDefault(NoProxy, noProxyDefault)
// set default package sources
i.main.SetDefault(PluginPackageSources, []map[string]string{{
i.setDefault(PluginPackageSources, []map[string]string{{
"name": sourceDefaultName,
"url": pluginPackageSourcesDefault,
"localpath": sourceDefaultPath,
}})
i.main.SetDefault(ScraperPackageSources, []map[string]string{{
i.setDefault(ScraperPackageSources, []map[string]string{{
"name": sourceDefaultName,
"url": scraperPackageSourcesDefault,
"localpath": sourceDefaultPath,
@@ -1687,13 +1807,13 @@ func (i *Config) setExistingSystemDefaults() {
if !i.isNewSystem {
// Existing systems as of the introduction of auto-browser open should retain existing
// behavior and not start the browser automatically.
if !i.main.InConfig(NoBrowser) {
i.main.Set(NoBrowser, true)
if !i.main.Exists(NoBrowser) {
i.set(NoBrowser, true)
}
// Existing systems as of the introduction of the taskbar should inform users.
if !i.main.InConfig(ShowOneTimeMovedNotification) {
i.main.Set(ShowOneTimeMovedNotification, true)
if !i.main.Exists(ShowOneTimeMovedNotification) {
i.set(ShowOneTimeMovedNotification, true)
}
}
}
@@ -1708,7 +1828,7 @@ func (i *Config) SetInitialConfig() error {
if err != nil {
return fmt.Errorf("error generating JWTSignKey: %w", err)
}
i.Set(JWTSignKey, signKey)
i.SetString(JWTSignKey, signKey)
}
if string(i.GetSessionStoreKey()) == "" {
@@ -1716,7 +1836,7 @@ func (i *Config) SetInitialConfig() error {
if err != nil {
return fmt.Errorf("error generating session store key: %w", err)
}
i.Set(SessionStoreKey, sessionStoreKey)
i.SetString(SessionStoreKey, sessionStoreKey)
}
i.setDefaultValues()

View File

@@ -3,6 +3,7 @@ package config
import (
"sync"
"testing"
"time"
)
// should be run with -race
@@ -16,6 +17,7 @@ func TestConcurrentConfigAccess(t *testing.T) {
wg.Add(1)
go func(wk int) {
for l := 0; l < loops; l++ {
start := time.Now()
if err := i.SetInitialConfig(); err != nil {
t.Errorf("Failure setting initial configuration in worker %v iteration %v: %v", wk, l, err)
}
@@ -25,96 +27,102 @@ func TestConcurrentConfigAccess(t *testing.T) {
i.GetConfigFile()
i.GetConfigPath()
i.GetDefaultDatabaseFilePath()
i.Set(BackupDirectoryPath, i.GetBackupDirectoryPath())
i.SetInterface(BackupDirectoryPath, i.GetBackupDirectoryPath())
i.GetStashPaths()
_ = i.ValidateStashBoxes(nil)
_ = i.Validate()
_ = i.ActivatePublicAccessTripwire("")
i.Set(Cache, i.GetCachePath())
i.Set(Generated, i.GetGeneratedPath())
i.Set(Metadata, i.GetMetadataPath())
i.Set(Database, i.GetDatabasePath())
i.Set(JWTSignKey, i.GetJWTSignKey())
i.Set(SessionStoreKey, i.GetSessionStoreKey())
i.SetInterface(Cache, i.GetCachePath())
i.SetInterface(Generated, i.GetGeneratedPath())
i.SetInterface(Metadata, i.GetMetadataPath())
i.SetInterface(Database, i.GetDatabasePath())
// these must be set as strings since the original values are also strings
// setting them as []byte will cause the returned string to be corrupted
i.SetInterface(JWTSignKey, string(i.GetJWTSignKey()))
i.SetInterface(SessionStoreKey, string(i.GetSessionStoreKey()))
i.GetDefaultScrapersPath()
i.Set(Exclude, i.GetExcludes())
i.Set(ImageExclude, i.GetImageExcludes())
i.Set(VideoExtensions, i.GetVideoExtensions())
i.Set(ImageExtensions, i.GetImageExtensions())
i.Set(GalleryExtensions, i.GetGalleryExtensions())
i.Set(CreateGalleriesFromFolders, i.GetCreateGalleriesFromFolders())
i.Set(Language, i.GetLanguage())
i.Set(VideoFileNamingAlgorithm, i.GetVideoFileNamingAlgorithm())
i.Set(ScrapersPath, i.GetScrapersPath())
i.Set(ScraperUserAgent, i.GetScraperUserAgent())
i.Set(ScraperCDPPath, i.GetScraperCDPPath())
i.Set(ScraperCertCheck, i.GetScraperCertCheck())
i.Set(ScraperExcludeTagPatterns, i.GetScraperExcludeTagPatterns())
i.Set(StashBoxes, i.GetStashBoxes())
i.SetInterface(Exclude, i.GetExcludes())
i.SetInterface(ImageExclude, i.GetImageExcludes())
i.SetInterface(VideoExtensions, i.GetVideoExtensions())
i.SetInterface(ImageExtensions, i.GetImageExtensions())
i.SetInterface(GalleryExtensions, i.GetGalleryExtensions())
i.SetInterface(CreateGalleriesFromFolders, i.GetCreateGalleriesFromFolders())
i.SetInterface(Language, i.GetLanguage())
i.SetInterface(VideoFileNamingAlgorithm, i.GetVideoFileNamingAlgorithm())
i.SetInterface(ScrapersPath, i.GetScrapersPath())
i.SetInterface(ScraperUserAgent, i.GetScraperUserAgent())
i.SetInterface(ScraperCDPPath, i.GetScraperCDPPath())
i.SetInterface(ScraperCertCheck, i.GetScraperCertCheck())
i.SetInterface(ScraperExcludeTagPatterns, i.GetScraperExcludeTagPatterns())
i.SetInterface(StashBoxes, i.GetStashBoxes())
i.GetDefaultPluginsPath()
i.Set(PluginsPath, i.GetPluginsPath())
i.Set(Host, i.GetHost())
i.Set(Port, i.GetPort())
i.Set(ExternalHost, i.GetExternalHost())
i.Set(PreviewSegmentDuration, i.GetPreviewSegmentDuration())
i.Set(ParallelTasks, i.GetParallelTasks())
i.Set(ParallelTasks, i.GetParallelTasksWithAutoDetection())
i.Set(PreviewAudio, i.GetPreviewAudio())
i.Set(PreviewSegments, i.GetPreviewSegments())
i.Set(PreviewExcludeStart, i.GetPreviewExcludeStart())
i.Set(PreviewExcludeEnd, i.GetPreviewExcludeEnd())
i.Set(PreviewPreset, i.GetPreviewPreset())
i.Set(MaxTranscodeSize, i.GetMaxTranscodeSize())
i.Set(MaxStreamingTranscodeSize, i.GetMaxStreamingTranscodeSize())
i.Set(ApiKey, i.GetAPIKey())
i.Set(Username, i.GetUsername())
i.Set(Password, i.GetPasswordHash())
i.SetInterface(PluginsPath, i.GetPluginsPath())
i.SetInterface(Host, i.GetHost())
i.SetInterface(Port, i.GetPort())
i.SetInterface(ExternalHost, i.GetExternalHost())
i.SetInterface(PreviewSegmentDuration, i.GetPreviewSegmentDuration())
i.SetInterface(ParallelTasks, i.GetParallelTasks())
i.SetInterface(ParallelTasks, i.GetParallelTasksWithAutoDetection())
i.SetInterface(PreviewAudio, i.GetPreviewAudio())
i.SetInterface(PreviewSegments, i.GetPreviewSegments())
i.SetInterface(PreviewExcludeStart, i.GetPreviewExcludeStart())
i.SetInterface(PreviewExcludeEnd, i.GetPreviewExcludeEnd())
i.SetInterface(PreviewPreset, i.GetPreviewPreset())
i.SetInterface(MaxTranscodeSize, i.GetMaxTranscodeSize())
i.SetInterface(MaxStreamingTranscodeSize, i.GetMaxStreamingTranscodeSize())
i.SetInterface(ApiKey, i.GetAPIKey())
i.SetInterface(Username, i.GetUsername())
i.SetInterface(Password, i.GetPasswordHash())
i.GetCredentials()
i.Set(MaxSessionAge, i.GetMaxSessionAge())
i.Set(CustomServedFolders, i.GetCustomServedFolders())
i.Set(CustomUILocation, i.GetCustomUILocation())
i.Set(MenuItems, i.GetMenuItems())
i.Set(SoundOnPreview, i.GetSoundOnPreview())
i.Set(WallShowTitle, i.GetWallShowTitle())
i.Set(CustomPerformerImageLocation, i.GetCustomPerformerImageLocation())
i.Set(WallPlayback, i.GetWallPlayback())
i.Set(MaximumLoopDuration, i.GetMaximumLoopDuration())
i.Set(AutostartVideo, i.GetAutostartVideo())
i.Set(ShowStudioAsText, i.GetShowStudioAsText())
i.Set(legacyImageLightboxSlideshowDelay, *i.GetImageLightboxOptions().SlideshowDelay)
i.Set(ImageLightboxSlideshowDelay, *i.GetImageLightboxOptions().SlideshowDelay)
i.SetInterface(MaxSessionAge, i.GetMaxSessionAge())
i.SetInterface(CustomServedFolders, i.GetCustomServedFolders())
i.SetInterface(LegacyCustomUILocation, i.GetUILocation())
i.SetInterface(MenuItems, i.GetMenuItems())
i.SetInterface(SoundOnPreview, i.GetSoundOnPreview())
i.SetInterface(WallShowTitle, i.GetWallShowTitle())
i.SetInterface(CustomPerformerImageLocation, i.GetCustomPerformerImageLocation())
i.SetInterface(WallPlayback, i.GetWallPlayback())
i.SetInterface(MaximumLoopDuration, i.GetMaximumLoopDuration())
i.SetInterface(AutostartVideo, i.GetAutostartVideo())
i.SetInterface(ShowStudioAsText, i.GetShowStudioAsText())
i.SetInterface(legacyImageLightboxSlideshowDelay, *i.GetImageLightboxOptions().SlideshowDelay)
i.SetInterface(ImageLightboxSlideshowDelay, *i.GetImageLightboxOptions().SlideshowDelay)
i.GetCSSPath()
i.GetCSS()
i.GetJavascriptPath()
i.GetJavascript()
i.GetCustomLocalesPath()
i.GetCustomLocales()
i.Set(CSSEnabled, i.GetCSSEnabled())
i.Set(CSSEnabled, i.GetCustomLocalesEnabled())
i.Set(HandyKey, i.GetHandyKey())
i.Set(UseStashHostedFunscript, i.GetUseStashHostedFunscript())
i.Set(DLNAServerName, i.GetDLNAServerName())
i.Set(DLNADefaultEnabled, i.GetDLNADefaultEnabled())
i.Set(DLNADefaultIPWhitelist, i.GetDLNADefaultIPWhitelist())
i.Set(DLNAInterfaces, i.GetDLNAInterfaces())
i.Set(LogFile, i.GetLogFile())
i.Set(LogOut, i.GetLogOut())
i.Set(LogLevel, i.GetLogLevel())
i.Set(LogAccess, i.GetLogAccess())
i.Set(MaxUploadSize, i.GetMaxUploadSize())
i.Set(FunscriptOffset, i.GetFunscriptOffset())
i.Set(DefaultIdentifySettings, i.GetDefaultIdentifySettings())
i.Set(DeleteGeneratedDefault, i.GetDeleteGeneratedDefault())
i.Set(DeleteFileDefault, i.GetDeleteFileDefault())
i.Set(dangerousAllowPublicWithoutAuth, i.GetDangerousAllowPublicWithoutAuth())
i.Set(SecurityTripwireAccessedFromPublicInternet, i.GetSecurityTripwireAccessedFromPublicInternet())
i.Set(DisableDropdownCreatePerformer, i.GetDisableDropdownCreate().Performer)
i.Set(DisableDropdownCreateStudio, i.GetDisableDropdownCreate().Studio)
i.Set(DisableDropdownCreateTag, i.GetDisableDropdownCreate().Tag)
i.Set(DisableDropdownCreateMovie, i.GetDisableDropdownCreate().Movie)
i.Set(AutostartVideoOnPlaySelected, i.GetAutostartVideoOnPlaySelected())
i.Set(ContinuePlaylistDefault, i.GetContinuePlaylistDefault())
i.Set(PythonPath, i.GetPythonPath())
i.SetInterface(CSSEnabled, i.GetCSSEnabled())
i.SetInterface(CSSEnabled, i.GetCustomLocalesEnabled())
i.SetInterface(HandyKey, i.GetHandyKey())
i.SetInterface(UseStashHostedFunscript, i.GetUseStashHostedFunscript())
i.SetInterface(DLNAServerName, i.GetDLNAServerName())
i.SetInterface(DLNADefaultEnabled, i.GetDLNADefaultEnabled())
i.SetInterface(DLNADefaultIPWhitelist, i.GetDLNADefaultIPWhitelist())
i.SetInterface(DLNAInterfaces, i.GetDLNAInterfaces())
i.SetInterface(DLNAPort, i.GetDLNAPort())
i.SetInterface(LogFile, i.GetLogFile())
i.SetInterface(LogOut, i.GetLogOut())
i.SetInterface(LogLevel, i.GetLogLevel())
i.SetInterface(LogAccess, i.GetLogAccess())
i.SetInterface(MaxUploadSize, i.GetMaxUploadSize())
i.SetInterface(FunscriptOffset, i.GetFunscriptOffset())
i.SetInterface(DefaultIdentifySettings, i.GetDefaultIdentifySettings())
i.SetInterface(DeleteGeneratedDefault, i.GetDeleteGeneratedDefault())
i.SetInterface(DeleteFileDefault, i.GetDeleteFileDefault())
i.SetInterface(dangerousAllowPublicWithoutAuth, i.GetDangerousAllowPublicWithoutAuth())
i.SetInterface(SecurityTripwireAccessedFromPublicInternet, i.GetSecurityTripwireAccessedFromPublicInternet())
i.SetInterface(DisableDropdownCreatePerformer, i.GetDisableDropdownCreate().Performer)
i.SetInterface(DisableDropdownCreateStudio, i.GetDisableDropdownCreate().Studio)
i.SetInterface(DisableDropdownCreateTag, i.GetDisableDropdownCreate().Tag)
i.SetInterface(DisableDropdownCreateMovie, i.GetDisableDropdownCreate().Movie)
i.SetInterface(AutostartVideoOnPlaySelected, i.GetAutostartVideoOnPlaySelected())
i.SetInterface(ContinuePlaylistDefault, i.GetContinuePlaylistDefault())
i.SetInterface(PythonPath, i.GetPythonPath())
t.Logf("Worker %v iteration %v took %v", wk, l, time.Since(start))
}
wg.Done()
}(k)

View File

@@ -0,0 +1,34 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestConfig_GetAllPluginConfiguration(t *testing.T) {
i := InitializeEmpty()
assert.Equal(t, i.GetAllPluginConfiguration(), map[string]map[string]interface{}{})
i.SetPluginConfiguration("plugin1", map[string]interface{}{"key1": "value1"})
assert.Equal(t, map[string]map[string]interface{}{
"plugin1": {"key1": "value1"},
}, i.GetAllPluginConfiguration())
i.SetPluginConfiguration("plugin2", map[string]interface{}{"key2": "value2"})
assert.Equal(t, map[string]map[string]interface{}{
"plugin1": {"key1": "value1"},
"plugin2": {"key2": "value2"},
}, i.GetAllPluginConfiguration())
// ensure SetPluginConfiguration overwrites existing configuration
i.SetPluginConfiguration("plugin2", map[string]interface{}{"key3": "value3"})
assert.Equal(t, map[string]map[string]interface{}{
"plugin1": {"key1": "value1"},
"plugin2": {"key3": "value3"},
}, i.GetAllPluginConfiguration())
}

View File

@@ -6,9 +6,12 @@ import (
"net"
"os"
"path/filepath"
"strings"
"github.com/knadh/koanf"
"github.com/knadh/koanf/providers/env"
"github.com/knadh/koanf/providers/posflag"
"github.com/spf13/pflag"
"github.com/spf13/viper"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
@@ -19,20 +22,44 @@ type flagStruct struct {
nobrowser bool
}
var flags flagStruct
var (
flags flagStruct
homeDir, _ = os.UserHomeDir()
defaultConfigLocations = []string{
"config.yml",
filepath.Join(homeDir, ".stash", "config.yml"),
}
// map of env vars to config keys
envBinds = map[string]string{
"host": Host,
"port": Port,
"external_host": ExternalHost,
"generated": Generated,
"metadata": Metadata,
"cache": Cache,
"stash": Stash,
"ui": UILocation,
}
)
var errConfigNotFound = errors.New("config file not found")
func init() {
pflag.IP("host", net.IPv4(0, 0, 0, 0), "ip address for the host")
pflag.Int("port", 9999, "port to serve from")
pflag.StringVarP(&flags.configFilePath, "config", "c", "", "config file to use")
pflag.BoolVar(&flags.nobrowser, "nobrowser", false, "Don't open a browser window after launch")
pflag.StringP("ui-location", "u", "", "path to the webui")
}
// Called at startup
func Initialize() (*Config, error) {
cfg := &Config{
main: viper.New(),
overrides: viper.New(),
main: koanf.New("."),
overrides: koanf.New("."),
}
cfg.initOverrides()
@@ -75,45 +102,49 @@ func Initialize() (*Config, error) {
// Called by tests to initialize an empty config
func InitializeEmpty() *Config {
cfg := &Config{
main: viper.New(),
overrides: viper.New(),
main: koanf.New("."),
overrides: koanf.New("."),
}
instance = cfg
return instance
}
func bindEnv(v *viper.Viper, key string) {
if err := v.BindEnv(key); err != nil {
panic(fmt.Sprintf("unable to set environment key (%v): %v", key, err))
func (i *Config) loadFromCommandLine() {
v := i.overrides
if err := v.Load(posflag.ProviderWithFlag(pflag.CommandLine, ".", v, func(f *pflag.Flag) (string, interface{}) {
// ignore flags that have not been changed
if !f.Changed {
return "", nil
}
return f.Name, posflag.FlagVal(pflag.CommandLine, f)
}), nil); err != nil {
logger.Errorf("failed to load flags: %v", err)
}
}
func (i *Config) loadFromEnv() {
v := i.overrides
if err := v.Load(env.ProviderWithValue("STASH_", ".", func(key, value string) (string, interface{}) {
key = strings.ToLower(strings.TrimPrefix(key, "STASH_"))
if newKey, ok := envBinds[key]; ok {
return newKey, value
}
return "", nil
}), nil); err != nil {
logger.Errorf("failed to load envs: %v", err)
}
}
func (i *Config) initOverrides() {
v := i.overrides
if err := v.BindPFlags(pflag.CommandLine); err != nil {
logger.Infof("failed to bind flags: %v", err)
}
v.SetEnvPrefix("stash") // will be uppercased automatically
bindEnv(v, "host") // STASH_HOST
bindEnv(v, "port") // STASH_PORT
bindEnv(v, "external_host") // STASH_EXTERNAL_HOST
bindEnv(v, "generated") // STASH_GENERATED
bindEnv(v, "metadata") // STASH_METADATA
bindEnv(v, "cache") // STASH_CACHE
bindEnv(v, "stash") // STASH_STASH
i.loadFromCommandLine()
i.loadFromEnv()
}
func (i *Config) initConfig() error {
v := i.main
// The config file is called config. Leave off the file extension.
v.SetConfigName("config")
v.AddConfigPath(".") // Look for config in the working directory
v.AddConfigPath(filepath.FromSlash("$HOME/.stash")) // Look for the config in the home directory
configFile := ""
envConfigFile := os.Getenv("STASH_CONFIG_FILE")
@@ -124,11 +155,10 @@ func (i *Config) initConfig() error {
}
if configFile != "" {
v.SetConfigFile(configFile)
// if file does not exist, assume it is a new system
if exists, _ := fsutil.FileExists(configFile); !exists {
i.isNewSystem = true
i.SetConfigFile(configFile)
// ensure we can write to the file
if err := fsutil.Touch(configFile); err != nil {
@@ -139,18 +169,33 @@ func (i *Config) initConfig() error {
}
return nil
} else {
// load from provided config file
if err := i.loadFirstFromFiles([]string{configFile}); err != nil {
return err
}
}
}
} else {
// load from default locations
if err := i.loadFirstFromFiles(defaultConfigLocations); err != nil {
if errors.Is(err, errConfigNotFound) {
i.isNewSystem = true
return nil
}
err := v.ReadInConfig() // Find and read the config file
// if not found, assume its a new system
var notFoundErr viper.ConfigFileNotFoundError
if errors.As(err, &notFoundErr) {
i.isNewSystem = true
return nil
} else if err != nil {
return err
return err
}
}
return nil
}
func (i *Config) loadFirstFromFiles(f []string) error {
for _, ff := range f {
if exists, _ := fsutil.FileExists(ff); exists {
return i.load(ff)
}
}
return errConfigNotFound
}

View File

@@ -1,117 +0,0 @@
package config
import (
"bytes"
"unicode"
"github.com/spf13/cast"
)
// HACK: viper changes map keys to case insensitive values, so the workaround is to
// convert the map to use snake-case keys
// toSnakeCase converts a string from camelCase to snake_case
// NOTE: a double capital will be converted in a way that will yield a different result
// when converted back to camel case.
// For example: someIDs => some_ids => someIds
func toSnakeCase(v string) string {
var buf bytes.Buffer
underscored := false
for i, c := range v {
if !underscored && unicode.IsUpper(c) && i > 0 {
buf.WriteByte('_')
underscored = true
} else {
underscored = false
}
buf.WriteRune(unicode.ToLower(c))
}
return buf.String()
}
// fromSnakeCase converts a string from snake_case to camelCase
func fromSnakeCase(v string) string {
var buf bytes.Buffer
leadingUnderscore := true
capvar := false
for i, c := range v {
switch {
case c == '_' && !leadingUnderscore && i > 0:
capvar = true
case c == '_' && leadingUnderscore:
buf.WriteRune(c)
case capvar:
buf.WriteRune(unicode.ToUpper(c))
capvar = false
default:
leadingUnderscore = false
buf.WriteRune(c)
}
}
return buf.String()
}
// fromSnakeCaseMap recursively converts a map using snake_case keys to camelCase keys
func fromSnakeCaseMap(m map[string]interface{}) map[string]interface{} {
return fromSnakeCaseValue(m).(map[string]interface{})
}
func fromSnakeCaseValue(val interface{}) interface{} {
switch v := val.(type) {
case map[interface{}]interface{}:
ret := cast.ToStringMap(v)
for k, vv := range ret {
adjKey := fromSnakeCase(k)
ret[adjKey] = fromSnakeCaseValue(vv)
}
return ret
case map[string]interface{}:
ret := make(map[string]interface{})
for k, vv := range v {
adjKey := fromSnakeCase(k)
ret[adjKey] = fromSnakeCaseValue(vv)
}
return ret
case []interface{}:
ret := make([]interface{}, len(v))
for i, vv := range v {
ret[i] = fromSnakeCaseValue(vv)
}
return ret
default:
return v
}
}
// toSnakeCaseMap recursively converts a map using camelCase keys to snake_case keys
func toSnakeCaseMap(m map[string]interface{}) map[string]interface{} {
return toSnakeCaseValue(m).(map[string]interface{})
}
func toSnakeCaseValue(val interface{}) interface{} {
switch v := val.(type) {
case map[interface{}]interface{}:
ret := cast.ToStringMap(v)
for k, vv := range ret {
adjKey := toSnakeCase(k)
ret[adjKey] = toSnakeCaseValue(vv)
}
return ret
case map[string]interface{}:
ret := make(map[string]interface{})
for k, vv := range v {
adjKey := toSnakeCase(k)
ret[adjKey] = toSnakeCaseValue(vv)
}
return ret
case []interface{}:
ret := make([]interface{}, len(v))
for i, vv := range v {
ret[i] = toSnakeCaseValue(vv)
}
return ret
default:
return v
}
}

View File

@@ -1,82 +0,0 @@
package config
import (
"testing"
)
func Test_toSnakeCase(t *testing.T) {
tests := []struct {
name string
v string
want string
}{
{
"basic",
"basic",
"basic",
},
{
"two words",
"twoWords",
"two_words",
},
{
"three word value",
"threeWordValue",
"three_word_value",
},
{
"snake case",
"snake_case",
"snake_case",
},
{
"double capital",
"doubleCApital",
"double_capital",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := toSnakeCase(tt.v); got != tt.want {
t.Errorf("toSnakeCase() = %v, want %v", got, tt.want)
}
})
}
}
func Test_fromSnakeCase(t *testing.T) {
tests := []struct {
name string
v string
want string
}{
{
"basic",
"basic",
"basic",
},
{
"two words",
"two_words",
"twoWords",
},
{
"three word value",
"three_word_value",
"threeWordValue",
},
{
"camel case",
"camelCase",
"camelCase",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := fromSnakeCase(tt.v); got != tt.want {
t.Errorf("fromSnakeCase() = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -1,6 +1,8 @@
package config
type ScanMetadataOptions struct {
// Forces a rescan on files even if they have not changed
Rescan bool `json:"rescan"`
// Generate scene covers during scan
ScanGenerateCovers bool `json:"scanGenerateCovers"`
// Generate previews during scan

View File

@@ -44,9 +44,7 @@ type Action struct {
// Pos is the place in percent to move to.
Pos int `json:"pos"`
Slope float64
Intensity int64
Speed float64
Speed float64
}
type GradientTable []struct {
@@ -136,8 +134,7 @@ func (funscript *Script) UpdateIntensityAndSpeed() {
var t1, t2 int64
var p1, p2 int
var slope float64
var intensity int64
var intensity float64
for i := range funscript.Actions {
if i == 0 {
continue
@@ -147,13 +144,10 @@ func (funscript *Script) UpdateIntensityAndSpeed() {
p1 = funscript.Actions[i].Pos
p2 = funscript.Actions[i-1].Pos
slope = math.Min(math.Max(1/(2*float64(t1-t2)/1000), 0), 20)
intensity = int64(slope * math.Abs((float64)(p1-p2)))
speed := math.Abs(float64(p1-p2)) / float64(t1-t2) * 1000
speed := math.Abs(float64(p1 - p2))
intensity = float64(speed/float64(t1-t2)) * 1000
funscript.Actions[i].Slope = slope
funscript.Actions[i].Intensity = intensity
funscript.Actions[i].Speed = speed
funscript.Actions[i].Speed = intensity
}
}
@@ -294,7 +288,7 @@ func (funscript Script) getGradientTable(numSegments int, sceneDurationMilli int
}
segments[segment].at = a.At
segments[segment].count++
segments[segment].intensity += int(a.Intensity)
segments[segment].intensity += int(a.Speed)
segments[segment].yRange[0] = averageTop
segments[segment].yRange[1] = averageBottom
}
@@ -303,7 +297,7 @@ func (funscript Script) getGradientTable(numSegments int, sceneDurationMilli int
// Fill in gaps in segments
for i := 0; i < numSegments; i++ {
segmentTS := int64(float64(i) / float64(numSegments))
segmentTS := (maxts / int64(numSegments)) * int64(i)
// Empty segment - fill it with the previous up to backfillThreshold ms
if segments[i].count == 0 {
@@ -340,12 +334,12 @@ func getSegmentColor(intensity float64) colorful.Color {
colorBlack, _ := colorful.Hex("#0f001e")
colorBackground, _ := colorful.Hex("#30404d") // Same as GridCard bg
var stepSize = 60.0
var stepSize = 125.0
var f float64
var c colorful.Color
switch {
case intensity <= 0.001:
case intensity <= 25:
c = colorBackground
case intensity <= 1*stepSize:
f = (intensity - 0*stepSize) / stepSize

View File

@@ -9,6 +9,7 @@ import (
"strings"
"time"
"github.com/remeh/sizedwaitgroup"
"github.com/stashapp/stash/internal/desktop"
"github.com/stashapp/stash/internal/dlna"
"github.com/stashapp/stash/internal/log"
@@ -80,6 +81,8 @@ func Initialize(cfg *config.Config, l *log.Logger) (*Manager, error) {
Paths: mgrPaths,
ImageThumbnailGenerateWaitGroup: sizedwaitgroup.New(1),
JobManager: initJobManager(cfg),
ReadLockManager: fsutil.NewReadLockManager(),
@@ -189,7 +192,6 @@ func (s *Manager) postInit(ctx context.Context) error {
s.RefreshScraperCache()
s.RefreshScraperSourceManager()
s.RefreshStreamManager()
s.RefreshDLNA()
s.SetBlobStoreOptions()
@@ -236,9 +238,8 @@ func (s *Manager) postInit(ctx context.Context) error {
logger.Info("Using HTTP proxy")
}
if err := s.initFFmpeg(ctx); err != nil {
return fmt.Errorf("error initializing FFmpeg subsystem: %v", err)
}
s.RefreshFFMpeg(ctx)
s.RefreshStreamManager()
return nil
}
@@ -257,41 +258,55 @@ func (s *Manager) writeStashIcon() {
}
}
func (s *Manager) initFFmpeg(ctx context.Context) error {
func (s *Manager) RefreshFFMpeg(ctx context.Context) {
// use same directory as config path
configDirectory := s.Config.GetConfigPath()
paths := []string{
configDirectory,
paths.GetStashHomeDirectory(),
}
ffmpegPath, ffprobePath := ffmpeg.GetPaths(paths)
// executing binaries requires directory to be included
// https://pkg.go.dev/os/exec#hdr-Executables_in_the_current_directory
configDirectory := s.Config.GetConfigPathAbs()
stashHomeDir := paths.GetStashHomeDirectory()
if ffmpegPath == "" || ffprobePath == "" {
logger.Infof("couldn't find FFmpeg, attempting to download it")
if err := ffmpeg.Download(ctx, configDirectory); err != nil {
path, absErr := filepath.Abs(configDirectory)
if absErr != nil {
path = configDirectory
}
msg := `Unable to automatically download FFmpeg
// prefer the configured paths
ffmpegPath := s.Config.GetFFMpegPath()
ffprobePath := s.Config.GetFFProbePath()
Check the readme for download links.
The ffmpeg and ffprobe binaries should be placed in %s.
`
logger.Errorf(msg, path)
return err
} else {
// After download get new paths for ffmpeg and ffprobe
ffmpegPath, ffprobePath = ffmpeg.GetPaths(paths)
// ensure the paths are valid
if ffmpegPath != "" {
// path was set explicitly
if err := ffmpeg.ValidateFFMpeg(ffmpegPath); err != nil {
logger.Errorf("invalid ffmpeg path: %v", err)
return
}
if err := ffmpeg.ValidateFFMpegCodecSupport(ffmpegPath); err != nil {
logger.Warn(err)
}
} else {
ffmpegPath = ffmpeg.ResolveFFMpeg(configDirectory, stashHomeDir)
}
s.FFMpeg = ffmpeg.NewEncoder(ffmpegPath)
s.FFProbe = ffmpeg.FFProbe(ffprobePath)
if ffprobePath != "" {
if err := ffmpeg.ValidateFFProbe(ffmpegPath); err != nil {
logger.Errorf("invalid ffprobe path: %v", err)
return
}
} else {
ffprobePath = ffmpeg.ResolveFFProbe(configDirectory, stashHomeDir)
}
s.FFMpeg.InitHWSupport(ctx)
s.RefreshStreamManager()
if ffmpegPath == "" {
logger.Warn("Couldn't find FFmpeg")
}
if ffprobePath == "" {
logger.Warn("Couldn't find FFProbe")
}
return nil
if ffmpegPath != "" && ffprobePath != "" {
logger.Debugf("using ffmpeg: %s", ffmpegPath)
logger.Debugf("using ffprobe: %s", ffprobePath)
s.FFMpeg = ffmpeg.NewEncoder(ffmpegPath)
s.FFProbe = ffmpeg.FFProbe(ffprobePath)
s.FFMpeg.InitHWSupport(ctx)
}
}

View File

@@ -10,6 +10,7 @@ import (
"runtime"
"time"
"github.com/remeh/sizedwaitgroup"
"github.com/stashapp/stash/internal/dlna"
"github.com/stashapp/stash/internal/log"
"github.com/stashapp/stash/internal/manager/config"
@@ -33,6 +34,10 @@ type Manager struct {
Config *config.Config
Logger *log.Logger
// ImageThumbnailGenerateWaitGroup is the global wait group image thumbnail generation
// It uses the parallel tasks setting from the configuration.
ImageThumbnailGenerateWaitGroup sizedwaitgroup.SizedWaitGroup
Paths *paths.Paths
FFMpeg *ffmpeg.FFMpeg
@@ -75,11 +80,13 @@ func GetInstance() *Manager {
func (s *Manager) SetBlobStoreOptions() {
storageType := s.Config.GetBlobsStorage()
blobsPath := s.Config.GetBlobsPath()
extraBlobsPaths := s.Config.GetExtraBlobsPaths()
s.Database.SetBlobStoreOptions(sqlite.BlobStoreOptions{
UseFilesystem: storageType == config.BlobStorageTypeFilesystem,
UseDatabase: storageType == config.BlobStorageTypeDatabase,
Path: blobsPath,
UseFilesystem: storageType == config.BlobStorageTypeFilesystem,
UseDatabase: storageType == config.BlobStorageTypeDatabase,
Path: blobsPath,
SupplementaryPaths: extraBlobsPaths,
})
}
@@ -105,6 +112,8 @@ func (s *Manager) RefreshConfig() {
if err := fsutil.EnsureDir(s.Paths.Generated.InteractiveHeatmap); err != nil {
logger.Warnf("could not create interactive heatmaps directory: %v", err)
}
s.ImageThumbnailGenerateWaitGroup.Size = cfg.GetParallelTasksWithAutoDetection()
}
}
@@ -236,7 +245,7 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error {
}
}
s.Config.Set(config.Generated, input.GeneratedLocation)
s.Config.SetString(config.Generated, input.GeneratedLocation)
}
// create the cache directory if it does not exist
@@ -247,11 +256,11 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error {
}
}
cfg.Set(config.Cache, input.CacheLocation)
cfg.SetString(config.Cache, input.CacheLocation)
}
if input.StoreBlobsInDatabase {
cfg.Set(config.BlobsStorage, config.BlobStorageTypeDatabase)
cfg.SetInterface(config.BlobsStorage, config.BlobStorageTypeDatabase)
} else {
if !cfg.HasOverride(config.BlobsPath) {
if exists, _ := fsutil.DirExists(input.BlobsLocation); !exists {
@@ -260,18 +269,18 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error {
}
}
cfg.Set(config.BlobsPath, input.BlobsLocation)
cfg.SetString(config.BlobsPath, input.BlobsLocation)
}
cfg.Set(config.BlobsStorage, config.BlobStorageTypeFilesystem)
cfg.SetInterface(config.BlobsStorage, config.BlobStorageTypeFilesystem)
}
// set the configuration
if !cfg.HasOverride(config.Database) {
cfg.Set(config.Database, input.DatabaseFile)
cfg.SetString(config.Database, input.DatabaseFile)
}
cfg.Set(config.Stash, input.Stashes)
cfg.SetInterface(config.Stash, input.Stashes)
if err := cfg.Write(); err != nil {
return fmt.Errorf("error writing configuration file: %v", err)
@@ -294,52 +303,6 @@ func (s *Manager) validateFFmpeg() error {
return nil
}
func (s *Manager) Migrate(ctx context.Context, input MigrateInput) error {
database := s.Database
// always backup so that we can roll back to the previous version if
// migration fails
backupPath := input.BackupPath
if backupPath == "" {
backupPath = database.DatabaseBackupPath(s.Config.GetBackupDirectoryPath())
} else {
// check if backup path is a filename or path
// filename goes into backup directory, path is kept as is
filename := filepath.Base(backupPath)
if backupPath == filename {
backupPath = filepath.Join(s.Config.GetBackupDirectoryPathOrDefault(), filename)
}
}
// perform database backup
if err := database.Backup(backupPath); err != nil {
return fmt.Errorf("error backing up database: %s", err)
}
if err := database.RunMigrations(); err != nil {
errStr := fmt.Sprintf("error performing migration: %s", err)
// roll back to the backed up version
restoreErr := database.RestoreFromBackup(backupPath)
if restoreErr != nil {
errStr = fmt.Sprintf("ERROR: unable to restore database from backup after migration failure: %s\n%s", restoreErr.Error(), errStr)
} else {
errStr = "An error occurred migrating the database to the latest schema version. The backup database file was automatically renamed to restore the database.\n" + errStr
}
return errors.New(errStr)
}
// if no backup path was provided, then delete the created backup
if input.BackupPath == "" {
if err := os.Remove(backupPath); err != nil {
logger.Warnf("error removing unwanted database backup (%s): %s", backupPath, err.Error())
}
}
return nil
}
func (s *Manager) BackupDatabase(download bool) (string, string, error) {
var backupPath string
var backupName string
@@ -428,6 +391,16 @@ func (s *Manager) GetSystemStatus() *SystemStatus {
configFile := s.Config.GetConfigFile()
ffmpegPath := ""
if s.FFMpeg != nil {
ffmpegPath = s.FFMpeg.Path()
}
ffprobePath := ""
if s.FFProbe != "" {
ffprobePath = s.FFProbe.Path()
}
return &SystemStatus{
Os: runtime.GOOS,
WorkingDir: workingDir,
@@ -437,6 +410,8 @@ func (s *Manager) GetSystemStatus() *SystemStatus {
AppSchema: appSchema,
Status: status,
ConfigPath: &configFile,
FfmpegPath: &ffmpegPath,
FfprobePath: &ffprobePath,
}
}

View File

@@ -19,14 +19,17 @@ import (
)
func useAsVideo(pathname string) bool {
if instance.Config.IsCreateImageClipsFromVideos() && config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname).ExcludeVideo {
stash := config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname)
if instance.Config.IsCreateImageClipsFromVideos() && stash != nil && stash.ExcludeVideo {
return false
}
return isVideo(pathname)
}
func useAsImage(pathname string) bool {
if instance.Config.IsCreateImageClipsFromVideos() && config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname).ExcludeVideo {
stash := config.StashConfigs.GetStashFromDirPath(instance.Config.GetStashPaths(), pathname)
if instance.Config.IsCreateImageClipsFromVideos() && stash != nil && stash.ExcludeVideo {
return isImage(pathname) || isVideo(pathname)
}
return isImage(pathname)
@@ -133,7 +136,7 @@ func (s *Manager) Import(ctx context.Context) (int, error) {
return 0, errors.New("metadata path must be set in config")
}
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {
task := ImportTask{
repository: s.Repository,
resetter: s.Database,
@@ -144,6 +147,9 @@ func (s *Manager) Import(ctx context.Context) (int, error) {
fileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),
}
task.Start(ctx)
// TODO - return error from task
return nil
})
return s.JobManager.Add(ctx, "Importing...", j), nil
@@ -156,7 +162,7 @@ func (s *Manager) Export(ctx context.Context) (int, error) {
return 0, errors.New("metadata path must be set in config")
}
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {
var wg sync.WaitGroup
wg.Add(1)
task := ExportTask{
@@ -165,6 +171,8 @@ func (s *Manager) Export(ctx context.Context) (int, error) {
fileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),
}
task.Start(ctx, &wg)
// TODO - return error from task
return nil
})
return s.JobManager.Add(ctx, "Exporting...", j), nil
@@ -174,9 +182,11 @@ func (s *Manager) RunSingleTask(ctx context.Context, t Task) int {
var wg sync.WaitGroup
wg.Add(1)
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {
t.Start(ctx)
wg.Done()
defer wg.Done()
// TODO - return error from task
return nil
})
return s.JobManager.Add(ctx, t.GetDescription(), j)
@@ -212,11 +222,10 @@ func (s *Manager) generateScreenshot(ctx context.Context, sceneId string, at *fl
logger.Warnf("failure generating screenshot: %v", err)
}
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {
sceneIdInt, err := strconv.Atoi(sceneId)
if err != nil {
logger.Errorf("Error parsing scene id %s: %v", sceneId, err)
return
return fmt.Errorf("error parsing scene id %s: %w", sceneId, err)
}
var scene *models.Scene
@@ -231,8 +240,7 @@ func (s *Manager) generateScreenshot(ctx context.Context, sceneId string, at *fl
return scene.LoadPrimaryFile(ctx, s.Repository.File)
}); err != nil {
logger.Errorf("error finding scene for screenshot generation: %v", err)
return
return fmt.Errorf("error finding scene for screenshot generation: %w", err)
}
task := GenerateCoverTask{
@@ -245,6 +253,9 @@ func (s *Manager) generateScreenshot(ctx context.Context, sceneId string, at *fl
task.Start(ctx)
logger.Infof("Generate screenshot finished")
// TODO - return error from task
return nil
})
return s.JobManager.Add(ctx, fmt.Sprintf("Generating screenshot for scene id %s", sceneId), j)
@@ -306,7 +317,7 @@ func (s *Manager) OptimiseDatabase(ctx context.Context) int {
}
func (s *Manager) MigrateHash(ctx context.Context) int {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {
fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm()
logger.Infof("Migrating generated files for %s naming hash", fileNamingAlgo.String())
@@ -316,8 +327,7 @@ func (s *Manager) MigrateHash(ctx context.Context) int {
scenes, err = s.Repository.Scene.All(ctx)
return err
}); err != nil {
logger.Errorf("failed to fetch list of scenes for migration: %s", err.Error())
return
return fmt.Errorf("failed to fetch list of scenes for migration: %w", err)
}
var wg sync.WaitGroup
@@ -328,7 +338,7 @@ func (s *Manager) MigrateHash(ctx context.Context) int {
progress.Increment()
if job.IsCancelled(ctx) {
logger.Info("Stopping due to user request")
return
return nil
}
if scene == nil {
@@ -348,6 +358,7 @@ func (s *Manager) MigrateHash(ctx context.Context) int {
}
logger.Info("Finished migrating")
return nil
})
return s.JobManager.Add(ctx, "Migrating scene hashes...", j)
@@ -378,13 +389,12 @@ type StashBoxBatchTagInput struct {
}
func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxBatchTagInput) int {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {
logger.Infof("Initiating stash-box batch performer tag")
boxes := config.GetInstance().GetStashBoxes()
if input.Endpoint < 0 || input.Endpoint >= len(boxes) {
logger.Error(fmt.Errorf("invalid stash_box_index %d", input.Endpoint))
return
return fmt.Errorf("invalid stash_box_index %d", input.Endpoint)
}
box := boxes[input.Endpoint]
@@ -432,7 +442,7 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB
}
return nil
}); err != nil {
logger.Error(err.Error())
return err
}
} else if len(input.Names) > 0 || len(input.PerformerNames) > 0 {
// The user is batch adding performers
@@ -490,13 +500,12 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB
}
return nil
}); err != nil {
logger.Error(err.Error())
return
return err
}
}
if len(tasks) == 0 {
return
return nil
}
progress.SetTotal(len(tasks))
@@ -510,19 +519,20 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB
progress.Increment()
}
return nil
})
return s.JobManager.Add(ctx, "Batch stash-box performer tag...", j)
}
func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, input StashBoxBatchTagInput) int {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {
logger.Infof("Initiating stash-box batch studio tag")
boxes := config.GetInstance().GetStashBoxes()
if input.Endpoint < 0 || input.Endpoint >= len(boxes) {
logger.Error(fmt.Errorf("invalid stash_box_index %d", input.Endpoint))
return
return fmt.Errorf("invalid stash_box_index %d", input.Endpoint)
}
box := boxes[input.Endpoint]
@@ -617,13 +627,12 @@ func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, input StashBoxBatc
}
return nil
}); err != nil {
logger.Error(err.Error())
return
return err
}
}
if len(tasks) == 0 {
return
return nil
}
progress.SetTotal(len(tasks))
@@ -637,6 +646,8 @@ func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, input StashBoxBatc
progress.Increment()
}
return nil
})
return s.JobManager.Add(ctx, "Batch stash-box studio tag...", j)

View File

@@ -13,6 +13,8 @@ type SystemStatus struct {
Os string `json:"os"`
WorkingDir string `json:"working_dir"`
HomeDir string `json:"home_dir"`
FfmpegPath *string `json:"ffmpegPath"`
FfprobePath *string `json:"ffprobePath"`
}
type SetupInput struct {

View File

@@ -11,7 +11,7 @@ import (
type SceneService interface {
Create(ctx context.Context, input *models.Scene, fileIDs []models.FileID, coverImage []byte) (*models.Scene, error)
AssignFile(ctx context.Context, sceneID int, fileID models.FileID) error
Merge(ctx context.Context, sourceIDs []int, destinationID int, values models.ScenePartial) error
Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *scene.FileDeleter, options scene.MergeOptions) error
Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile bool) error
}

View File

@@ -59,8 +59,9 @@ func (s *SceneServer) StreamSceneDirect(scene *models.Scene, w http.ResponseWrit
func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter, r *http.Request) {
var cover []byte
readTxnErr := txn.WithReadTxn(r.Context(), s.TxnManager, func(ctx context.Context) error {
cover, _ = s.SceneCoverGetter.GetCover(ctx, scene.ID)
return nil
var err error
cover, err = s.SceneCoverGetter.GetCover(ctx, scene.ID)
return err
})
if errors.Is(readTxnErr, context.Canceled) {
return

View File

@@ -0,0 +1,730 @@
package task
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/paths"
)
type CleanGeneratedOptions struct {
BlobFiles bool `json:"blobs"`
Sprites bool `json:"sprites"`
Screenshots bool `json:"screenshots"`
Transcodes bool `json:"transcodes"`
Markers bool `json:"markers"`
ImageThumbnails bool `json:"imageThumbnails"`
DryRun bool `json:"dryRun"`
}
type BlobCleaner interface {
EntryExists(ctx context.Context, checksum string) (bool, error)
}
type CleanGeneratedJob struct {
Options CleanGeneratedOptions
Paths *paths.Paths
BlobsStorageType config.BlobsStorageType
VideoFileNamingAlgorithm models.HashAlgorithm
BlobCleaner BlobCleaner
Repository models.Repository
dryRunPrefix string
totalTasks int
tasksComplete int
}
func (j *CleanGeneratedJob) deleteFile(path string) {
if j.Options.DryRun {
logger.Debugf("would delete file: %s", path)
return
}
if err := os.Remove(path); err != nil {
logger.Errorf("error deleting file %s: %v", path, err)
}
}
func (j *CleanGeneratedJob) deleteDir(path string) {
if j.Options.DryRun {
logger.Debugf("would delete file: %s", path)
return
}
if err := os.RemoveAll(path); err != nil {
logger.Errorf("error deleting directory %s: %v", path, err)
}
}
func (j *CleanGeneratedJob) countTasks() int {
tasks := 0
if j.Options.BlobFiles {
tasks++
}
if j.Options.Sprites {
tasks++
}
if j.Options.Screenshots {
tasks++
}
if j.Options.Transcodes {
tasks++
}
if j.Options.Markers {
tasks++
}
if j.Options.ImageThumbnails {
tasks++
}
return tasks
}
func (j *CleanGeneratedJob) taskComplete(progress *job.Progress) {
j.tasksComplete++
progress.SetPercent(float64(j.tasksComplete) / float64(j.totalTasks))
}
func (j *CleanGeneratedJob) logError(err error) {
if !errors.Is(err, context.Canceled) {
logger.Error(err)
}
}
func (j *CleanGeneratedJob) Execute(ctx context.Context, progress *job.Progress) error {
j.tasksComplete = 0
if !j.BlobsStorageType.IsValid() {
return fmt.Errorf("invalid blobs storage type: %s", j.BlobsStorageType)
}
if !j.VideoFileNamingAlgorithm.IsValid() {
return fmt.Errorf("invalid video file naming algorithm: %s", j.VideoFileNamingAlgorithm)
}
if j.Options.DryRun {
j.dryRunPrefix = "[dry run] "
}
logger.Infof("Cleaning generated files %s", j.dryRunPrefix)
j.totalTasks = j.countTasks()
if j.Options.BlobFiles {
progress.ExecuteTask("Cleaning blob files", func() {
if err := j.cleanBlobFiles(ctx, progress); err != nil {
j.logError(fmt.Errorf("error cleaning blob files: %w", err))
}
})
j.taskComplete(progress)
}
if j.Options.Sprites {
progress.ExecuteTask("Cleaning sprite files", func() {
if err := j.cleanSpriteFiles(ctx, progress); err != nil {
j.logError(fmt.Errorf("error cleaning sprite files: %w", err))
}
})
j.taskComplete(progress)
}
if j.Options.Screenshots {
progress.ExecuteTask("Cleaning screenshot files", func() {
if err := j.cleanScreenshotFiles(ctx, progress); err != nil {
j.logError(fmt.Errorf("error cleaning screenshot files: %w", err))
}
})
j.taskComplete(progress)
}
if j.Options.Transcodes {
progress.ExecuteTask("Cleaning transcode files", func() {
if err := j.cleanTranscodeFiles(ctx, progress); err != nil {
j.logError(fmt.Errorf("error cleaning transcode files: %w", err))
}
})
j.taskComplete(progress)
}
if j.Options.Markers {
progress.ExecuteTask("Cleaning marker files", func() {
if err := j.cleanMarkerFiles(ctx, progress); err != nil {
j.logError(fmt.Errorf("error cleaning marker files: %w", err))
}
})
j.taskComplete(progress)
}
if j.Options.ImageThumbnails {
progress.ExecuteTask("Cleaning thumbnail files", func() {
if err := j.cleanThumbnailFiles(ctx, progress); err != nil {
j.logError(fmt.Errorf("error cleaning thumbnail files: %w", err))
}
})
j.taskComplete(progress)
}
if job.IsCancelled(ctx) {
logger.Info("Stopping due to user request")
return nil
}
logger.Infof("Finished cleaning generated files")
return nil
}
func (j *CleanGeneratedJob) setTaskProgress(taskProgress float64, progress *job.Progress) {
progress.SetPercent((float64(j.tasksComplete) + taskProgress) / float64(j.totalTasks))
}
func (j *CleanGeneratedJob) logDelete(format string, args ...interface{}) {
logger.Infof(j.dryRunPrefix+format, args...)
}
// estimates the progress by the hash prefix - first two characters
// this is a rough estimate, but it's better than nothing
// the prefix ranges from 00 to ff
func (j *CleanGeneratedJob) estimateProgress(hashPrefix string) (float64, error) {
toInt, err := strconv.ParseInt(hashPrefix, 16, 64)
if err != nil {
return 0, err
}
const total = 256 // ff
return float64(toInt) / total, nil
}
func (j *CleanGeneratedJob) setProgressFromFilename(prefix string, progress *job.Progress) {
p, err := j.estimateProgress(prefix)
if err != nil {
logger.Errorf("error estimating progress: %v", err)
return
}
j.setTaskProgress(p, progress)
}
func (j *CleanGeneratedJob) getIntraFolderPrefix(basename string) (string, error) {
var hash string
_, err := fmt.Sscanf(basename, "%2x", &hash)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", hash), nil
}
func (j *CleanGeneratedJob) getBlobFileHash(basename string) (string, error) {
var hash string
_, err := fmt.Sscanf(basename, "%32x", &hash)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", hash), nil
}
func (j *CleanGeneratedJob) cleanBlobFiles(ctx context.Context, progress *job.Progress) error {
if job.IsCancelled(ctx) {
return nil
}
if j.BlobsStorageType != config.BlobStorageTypeFilesystem {
logger.Debugf("skipping blob file cleanup, storage type is not filesystem")
return nil
}
logger.Infof("Cleaning blob files")
// walk through the blob directory
if err := filepath.Walk(j.Paths.Blobs, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if err = ctx.Err(); err != nil {
return err
}
if info.IsDir() {
if path == j.Paths.Blobs {
return nil
}
// ignore any directory that isn't a two character hash prefix
_, err := j.getIntraFolderPrefix(info.Name())
if err != nil {
logger.Warnf("Ignoring unknown directory: %s", path)
return fs.SkipDir
}
// estimate progress by the hash prefix
if filepath.Dir(path) == j.Paths.Blobs {
hashPrefix := filepath.Base(path)
j.setProgressFromFilename(hashPrefix, progress)
}
return nil
}
blobname := info.Name()
// ignore any files that aren't a 32 character hash
_, err = j.getBlobFileHash(blobname)
if err != nil {
logger.Warnf("ignoring unknown blob file: %s", blobname)
return nil
}
// if blob entry does not exist, delete the file
if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error {
exists, err := j.BlobCleaner.EntryExists(ctx, blobname)
if err != nil {
return err
}
if !exists {
j.logDelete("deleting unused blob file: %s", blobname)
j.deleteFile(path)
}
return nil
}); err != nil {
logger.Errorf("error checking blob entry: %v", err)
return nil
}
return nil
}); err != nil {
return err
}
return nil
}
func (j *CleanGeneratedJob) getScenesWithHash(ctx context.Context, hash string) ([]*models.Scene, error) {
fp := models.Fingerprint{
Fingerprint: hash,
}
if j.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 {
fp.Type = models.FingerprintTypeMD5
} else {
fp.Type = models.FingerprintTypeOshash
}
return j.Repository.Scene.FindByFingerprints(ctx, []models.Fingerprint{fp})
}
const (
md5Length = 32
oshashLength = 16
)
func (j *CleanGeneratedJob) hashPatternPrefix() string {
hashLen := oshashLength
if j.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 {
hashLen = md5Length
}
return fmt.Sprintf("%%%dx", hashLen)
}
func (j *CleanGeneratedJob) getSpriteFileHash(basename string) (string, error) {
patternPrefix := j.hashPatternPrefix()
spritePattern := patternPrefix + "_sprite.jpg"
var hash string
_, err := fmt.Sscanf(basename, spritePattern, &hash)
if err != nil {
// also try thumbs
thumbPattern := patternPrefix + "_thumbs.vtt"
_, err = fmt.Sscanf(basename, thumbPattern, &hash)
if err != nil {
return "", err
}
}
return fmt.Sprintf("%x", hash), nil
}
func (j *CleanGeneratedJob) cleanSpriteFiles(ctx context.Context, progress *job.Progress) error {
if job.IsCancelled(ctx) {
return nil
}
logger.Infof("Cleaning sprite files")
// walk through the sprite directory
if err := filepath.Walk(j.Paths.Generated.Vtt, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if err = ctx.Err(); err != nil {
return err
}
if info.IsDir() {
return nil
}
filename := info.Name()
hash, err := j.getSpriteFileHash(filename)
if err != nil {
logger.Warnf("Ignoring unknown sprite file: %s", filename)
return nil
}
j.setProgressFromFilename(hash[0:2], progress)
var exists []*models.Scene
if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error {
exists, err = j.getScenesWithHash(ctx, hash)
return err
}); err != nil {
logger.Errorf("error checking scene entry for sprite: %v", err)
return nil
}
if len(exists) == 0 {
j.logDelete("deleting unused sprite file: %s", filename)
j.deleteFile(path)
}
return nil
}); err != nil {
return err
}
return nil
}
func (j *CleanGeneratedJob) cleanSceneFiles(ctx context.Context, path string, typ string, getSceneFileHash func(filename string) (string, error), progress *job.Progress) error {
if job.IsCancelled(ctx) {
return nil
}
logger.Infof("Cleaning %s files", typ)
// walk through the sprite directory
if err := filepath.Walk(path, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if err = ctx.Err(); err != nil {
return err
}
filename := info.Name()
hash, err := getSceneFileHash(filename)
if err != nil {
logger.Warnf("Ignoring unknown %s file: %s", typ, filename)
return nil
}
j.setProgressFromFilename(hash[0:2], progress)
var exists []*models.Scene
if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error {
exists, err = j.getScenesWithHash(ctx, hash)
return err
}); err != nil {
logger.Errorf("error checking scene entry: %v", err)
return nil
}
if len(exists) == 0 {
j.logDelete("deleting unused %s file: %s", typ, filename)
j.deleteFile(path)
}
return nil
}); err != nil {
return err
}
return nil
}
func (j *CleanGeneratedJob) getScreenshotFileHash(basename string) (string, error) {
var hash string
var ext string
// include the extension - which could be mp4/jpg/webp
_, err := fmt.Sscanf(basename, j.hashPatternPrefix()+".%s", &hash, &ext)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", hash), nil
}
func (j *CleanGeneratedJob) cleanScreenshotFiles(ctx context.Context, progress *job.Progress) error {
return j.cleanSceneFiles(ctx, j.Paths.Generated.Screenshots, "screenshot", j.getScreenshotFileHash, progress)
}
func (j *CleanGeneratedJob) getTranscodeFileHash(basename string) (string, error) {
var hash string
_, err := fmt.Sscanf(basename, j.hashPatternPrefix()+".mp4", &hash)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", hash), nil
}
func (j *CleanGeneratedJob) cleanTranscodeFiles(ctx context.Context, progress *job.Progress) error {
return j.cleanSceneFiles(ctx, j.Paths.Generated.Transcodes, "transcode", j.getTranscodeFileHash, progress)
}
func (j *CleanGeneratedJob) getMarkerSceneFileHash(basename string) (string, error) {
var hash string
_, err := fmt.Sscanf(basename, j.hashPatternPrefix(), &hash)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", hash), nil
}
func (j *CleanGeneratedJob) getMarkerFileSeconds(basename string) (int, error) {
var ret int
var ext string
// include the extension - which could be mp4/jpg/webp
_, err := fmt.Sscanf(basename, "%d.%s", &ret, &ext)
if err != nil {
return 0, err
}
return ret, nil
}
func (j *CleanGeneratedJob) cleanMarkerFiles(ctx context.Context, progress *job.Progress) error {
if job.IsCancelled(ctx) {
return nil
}
logger.Infof("Cleaning marker files")
var scenes []*models.Scene
var sceneHash string
var markers []*models.SceneMarker
// walk through the markers directory
if err := filepath.Walk(j.Paths.Generated.Markers, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if err = ctx.Err(); err != nil {
return err
}
if info.IsDir() {
// ignore markers directory
if path == j.Paths.Generated.Markers {
return nil
}
markers = nil
if filepath.Dir(path) != j.Paths.Generated.Markers {
logger.Warnf("Ignoring unknown marker directory: %s", path)
return nil
}
sceneHash, err = j.getMarkerSceneFileHash(info.Name())
if err != nil {
logger.Warnf("Ignoring unknown marker directory: %s", path)
return nil
}
j.setProgressFromFilename(sceneHash[0:2], progress)
// check if the scene exists
if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error {
var err error
scenes, err = j.getScenesWithHash(ctx, sceneHash)
if err != nil {
return fmt.Errorf("error checking scene entry: %v", err)
}
if len(scenes) == 0 {
j.logDelete("deleting unused marker directory: %s", sceneHash)
j.deleteDir(path)
} else {
// get the markers now
for _, scene := range scenes {
thisMarkers, err := j.Repository.SceneMarker.FindBySceneID(ctx, scene.ID)
if err != nil {
return fmt.Errorf("error getting markers for scene: %v", err)
}
markers = append(markers, thisMarkers...)
}
}
return nil
}); err != nil {
logger.Error(err.Error())
}
return nil
}
filename := info.Name()
seconds, err := j.getMarkerFileSeconds(filename)
if err != nil {
logger.Warnf("Ignoring unknown marker file: %s", filename)
return nil
}
// scenes should be set by the directory walk
hash := filepath.Base(filepath.Dir(path))
if hash != sceneHash {
logger.Errorf("internal error: scene hash mismatch: %s != %s", hash, sceneHash)
return nil
}
if len(scenes) == 0 {
logger.Errorf("no scenes found for marker file: %s", filename)
return nil
}
// find the marker
var marker *models.SceneMarker
for _, m := range markers {
if int(m.Seconds) == seconds {
marker = m
break
}
}
if marker == nil {
// not found, delete the file
j.logDelete("deleting unused marker file: %s", filename)
j.deleteFile(path)
}
return nil
}); err != nil {
return err
}
return nil
}
func (j *CleanGeneratedJob) getImagesWithHash(ctx context.Context, checksum string) ([]*models.Image, error) {
var exists []*models.Image
if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error {
// if scene entry does not exist, delete the file
var err error
exists, err = j.Repository.Image.FindByChecksum(ctx, checksum)
return err
}); err != nil {
return nil, err
}
return exists, nil
}
func (j *CleanGeneratedJob) getThumbnailFileHash(basename string) (string, error) {
var (
hash string
width int
ext string
)
// include the extension - which could be jpg/webp
_, err := fmt.Sscanf(basename, "%32x_%d.%s", &hash, &width, &ext)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", hash), nil
}
func (j *CleanGeneratedJob) cleanThumbnailFiles(ctx context.Context, progress *job.Progress) error {
if job.IsCancelled(ctx) {
return nil
}
logger.Infof("Cleaning image thumbnail files")
// walk through the sprite directory
if err := filepath.Walk(j.Paths.Generated.Thumbnails, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if err = ctx.Err(); err != nil {
return err
}
if info.IsDir() {
if path == j.Paths.Generated.Thumbnails {
return nil
}
// ensure the directory is a hash prefix
_, err := j.getIntraFolderPrefix(info.Name())
if err != nil {
logger.Warnf("Ignoring unknown thumbnail directory: %s", path)
return nil
}
// estimate progress by the hash prefix
if filepath.Dir(path) == j.Paths.Generated.Thumbnails {
hashPrefix := filepath.Base(path)
j.setProgressFromFilename(hashPrefix, progress)
}
return nil
}
filename := info.Name()
checksum, err := j.getThumbnailFileHash(filename)
if err != nil {
logger.Warnf("Ignoring unknown thumbnail file: %s", filename)
return nil
}
exists, err := j.getImagesWithHash(ctx, checksum)
if err != nil {
logger.Errorf("error checking image entry: %v", err)
return nil
}
if len(exists) == 0 {
j.logDelete("deleting unused thumbnail file: %s", filename)
j.deleteFile(path)
}
return nil
}); err != nil {
return err
}
return nil
}

View File

@@ -0,0 +1,241 @@
package task
import (
"archive/zip"
"context"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"runtime"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger"
)
type DownloadFFmpegJob struct {
ConfigDirectory string
OnComplete func(ctx context.Context)
urls []string
downloaded int
}
func (s *DownloadFFmpegJob) Execute(ctx context.Context, progress *job.Progress) error {
if err := s.download(ctx, progress); err != nil {
if job.IsCancelled(ctx) {
return nil
}
return err
}
if s.OnComplete != nil {
s.OnComplete(ctx)
}
return nil
}
func (s *DownloadFFmpegJob) setTaskProgress(taskProgress float64, progress *job.Progress) {
progress.SetPercent((float64(s.downloaded) + taskProgress) / float64(len(s.urls)))
}
func (s *DownloadFFmpegJob) download(ctx context.Context, progress *job.Progress) error {
s.urls = ffmpeg.GetFFmpegURL()
// set steps based on the number of URLs
for _, url := range s.urls {
err := s.downloadSingle(ctx, url, progress)
if err != nil {
return err
}
s.downloaded++
}
// validate that the urls contained what we needed
executables := []string{fsutil.GetExeName("ffmpeg"), fsutil.GetExeName("ffprobe")}
for _, executable := range executables {
_, err := os.Stat(filepath.Join(s.ConfigDirectory, executable))
if err != nil {
return err
}
}
return nil
}
type downloadProgressReader struct {
io.Reader
setProgress func(taskProgress float64)
bytesRead int64
total int64
}
func (r *downloadProgressReader) Read(p []byte) (int, error) {
read, err := r.Reader.Read(p)
if err == nil {
r.bytesRead += int64(read)
if r.total > 0 {
progress := float64(r.bytesRead) / float64(r.total)
r.setProgress(progress)
}
}
return read, err
}
func (s *DownloadFFmpegJob) downloadSingle(ctx context.Context, url string, progress *job.Progress) error {
if url == "" {
return fmt.Errorf("no ffmpeg url for this platform")
}
configDirectory := s.ConfigDirectory
// Configure where we want to download the archive
urlBase := path.Base(url)
archivePath := filepath.Join(configDirectory, urlBase)
_ = os.Remove(archivePath) // remove archive if it already exists
out, err := os.Create(archivePath)
if err != nil {
return err
}
defer out.Close()
logger.Infof("Downloading %s...", url)
progress.ExecuteTask(fmt.Sprintf("Downloading %s", url), func() {
err = s.downloadFile(ctx, url, out, progress)
})
if err != nil {
return fmt.Errorf("failed to download ffmpeg from %s: %w", url, err)
}
logger.Info("Downloading complete")
logger.Infof("Unzipping %s...", archivePath)
progress.ExecuteTask(fmt.Sprintf("Unzipping %s", archivePath), func() {
err = s.unzip(archivePath)
})
if err != nil {
return fmt.Errorf("failed to unzip ffmpeg archive: %w", err)
}
// On OSX or Linux set downloaded files permissions
if runtime.GOOS == "darwin" || runtime.GOOS == "linux" {
_, err = os.Stat(filepath.Join(configDirectory, "ffmpeg"))
if !os.IsNotExist(err) {
if err = os.Chmod(filepath.Join(configDirectory, "ffmpeg"), 0755); err != nil {
return err
}
}
_, err = os.Stat(filepath.Join(configDirectory, "ffprobe"))
if !os.IsNotExist(err) {
if err := os.Chmod(filepath.Join(configDirectory, "ffprobe"), 0755); err != nil {
return err
}
}
// TODO: In future possible clear xattr to allow running on osx without user intervention
// TODO: this however may not be required.
// xattr -c /path/to/binary -- xattr.Remove(path, "com.apple.quarantine")
}
return nil
}
func (s *DownloadFFmpegJob) downloadFile(ctx context.Context, url string, out *os.File, progress *job.Progress) error {
// Make the HTTP request
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
transport := &http.Transport{Proxy: http.ProxyFromEnvironment}
client := &http.Client{
Transport: transport,
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// Check server response
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status)
}
reader := &downloadProgressReader{
Reader: resp.Body,
total: resp.ContentLength,
setProgress: func(taskProgress float64) {
s.setTaskProgress(taskProgress, progress)
},
}
// Write the response to the archive file location
if _, err := io.Copy(out, reader); err != nil {
return err
}
mime := resp.Header.Get("Content-Type")
if mime != "application/zip" { // try detecting MIME type since some servers don't return the correct one
data := make([]byte, 500) // http.DetectContentType only reads up to 500 bytes
_, _ = out.ReadAt(data, 0)
mime = http.DetectContentType(data)
}
if mime != "application/zip" {
return fmt.Errorf("downloaded file is not a zip archive")
}
return nil
}
func (s *DownloadFFmpegJob) unzip(src string) error {
zipReader, err := zip.OpenReader(src)
if err != nil {
return err
}
defer zipReader.Close()
for _, f := range zipReader.File {
if f.FileInfo().IsDir() {
continue
}
filename := f.FileInfo().Name()
if filename != "ffprobe" && filename != "ffmpeg" && filename != "ffprobe.exe" && filename != "ffmpeg.exe" {
continue
}
rc, err := f.Open()
if err != nil {
return err
}
unzippedPath := filepath.Join(s.ConfigDirectory, filename)
unzippedOutput, err := os.Create(unzippedPath)
if err != nil {
return err
}
_, err = io.Copy(unzippedOutput, rc)
if err != nil {
return err
}
if err := unzippedOutput.Close(); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,153 @@
package task
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/sqlite"
)
type migrateJobConfig interface {
GetBackupDirectoryPath() string
GetBackupDirectoryPathOrDefault() string
}
type MigrateJob struct {
BackupPath string
Config migrateJobConfig
Database *sqlite.Database
}
func (s *MigrateJob) Execute(ctx context.Context, progress *job.Progress) error {
required, err := s.required()
if err != nil {
return err
}
if required == 0 {
logger.Infof("database is already at the latest schema version")
return nil
}
// set the number of tasks = required steps + optimise
progress.SetTotal(int(required + 1))
database := s.Database
// always backup so that we can roll back to the previous version if
// migration fails
backupPath := s.BackupPath
if backupPath == "" {
backupPath = database.DatabaseBackupPath(s.Config.GetBackupDirectoryPath())
} else {
// check if backup path is a filename or path
// filename goes into backup directory, path is kept as is
filename := filepath.Base(backupPath)
if backupPath == filename {
backupPath = filepath.Join(s.Config.GetBackupDirectoryPathOrDefault(), filename)
}
}
// perform database backup
if err := database.Backup(backupPath); err != nil {
return fmt.Errorf("error backing up database: %s", err)
}
if err := s.runMigrations(ctx, progress); err != nil {
errStr := fmt.Sprintf("error performing migration: %s", err)
// roll back to the backed up version
restoreErr := database.RestoreFromBackup(backupPath)
if restoreErr != nil {
errStr = fmt.Sprintf("ERROR: unable to restore database from backup after migration failure: %s\n%s", restoreErr.Error(), errStr)
} else {
errStr = "An error occurred migrating the database to the latest schema version. The backup database file was automatically renamed to restore the database.\n" + errStr
}
return errors.New(errStr)
}
// if no backup path was provided, then delete the created backup
if s.BackupPath == "" {
if err := os.Remove(backupPath); err != nil {
logger.Warnf("error removing unwanted database backup (%s): %s", backupPath, err.Error())
}
}
return nil
}
func (s *MigrateJob) required() (uint, error) {
database := s.Database
m, err := sqlite.NewMigrator(database)
if err != nil {
return 0, err
}
defer m.Close()
currentSchemaVersion := m.CurrentSchemaVersion()
targetSchemaVersion := m.RequiredSchemaVersion()
if targetSchemaVersion < currentSchemaVersion {
// shouldn't happen
return 0, nil
}
return targetSchemaVersion - currentSchemaVersion, nil
}
func (s *MigrateJob) runMigrations(ctx context.Context, progress *job.Progress) error {
database := s.Database
m, err := sqlite.NewMigrator(database)
if err != nil {
return err
}
defer m.Close()
for {
currentSchemaVersion := m.CurrentSchemaVersion()
targetSchemaVersion := m.RequiredSchemaVersion()
if currentSchemaVersion >= targetSchemaVersion {
break
}
var err error
progress.ExecuteTask(fmt.Sprintf("Migrating database to schema version %d", currentSchemaVersion+1), func() {
err = m.RunMigration(ctx, currentSchemaVersion+1)
})
if err != nil {
return fmt.Errorf("error running migration for schema %d: %s", currentSchemaVersion+1, err)
}
progress.Increment()
}
// reinitialise the database
if err := database.ReInitialise(); err != nil {
return fmt.Errorf("error reinitialising database: %s", err)
}
// optimise the database
progress.ExecuteTask("Optimising database", func() {
err = database.Optimise(ctx)
})
if err != nil {
return fmt.Errorf("error optimising database: %s", err)
}
progress.Increment()
return nil
}

View File

@@ -26,7 +26,7 @@ type MigrateBlobsJob struct {
DeleteOld bool
}
func (j *MigrateBlobsJob) Execute(ctx context.Context, progress *job.Progress) {
func (j *MigrateBlobsJob) Execute(ctx context.Context, progress *job.Progress) error {
var (
count int
err error
@@ -37,13 +37,12 @@ func (j *MigrateBlobsJob) Execute(ctx context.Context, progress *job.Progress) {
})
if err != nil {
logger.Errorf("Error counting blobs: %s", err.Error())
return
return fmt.Errorf("error counting blobs: %w", err)
}
if count == 0 {
logger.Infof("No blobs to migrate")
return
return nil
}
logger.Infof("Migrating %d blobs", count)
@@ -54,12 +53,11 @@ func (j *MigrateBlobsJob) Execute(ctx context.Context, progress *job.Progress) {
if job.IsCancelled(ctx) {
logger.Info("Cancelled migrating blobs")
return
return nil
}
if err != nil {
logger.Errorf("Error migrating blobs: %v", err)
return
return fmt.Errorf("error migrating blobs: %w", err)
}
// run a vacuum to reclaim space
@@ -71,6 +69,7 @@ func (j *MigrateBlobsJob) Execute(ctx context.Context, progress *job.Progress) {
})
logger.Infof("Finished migrating blobs")
return nil
}
func (j *MigrateBlobsJob) countBlobs(ctx context.Context) (int, error) {

View File

@@ -3,6 +3,7 @@ package task
import (
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
@@ -21,7 +22,7 @@ type MigrateSceneScreenshotsJob struct {
TxnManager txn.Manager
}
func (j *MigrateSceneScreenshotsJob) Execute(ctx context.Context, progress *job.Progress) {
func (j *MigrateSceneScreenshotsJob) Execute(ctx context.Context, progress *job.Progress) error {
var err error
progress.ExecuteTask("Counting files", func() {
var count int
@@ -30,8 +31,7 @@ func (j *MigrateSceneScreenshotsJob) Execute(ctx context.Context, progress *job.
})
if err != nil {
logger.Errorf("Error counting files: %s", err.Error())
return
return fmt.Errorf("error counting files: %w", err)
}
progress.ExecuteTask("Migrating files", func() {
@@ -40,15 +40,15 @@ func (j *MigrateSceneScreenshotsJob) Execute(ctx context.Context, progress *job.
if job.IsCancelled(ctx) {
logger.Info("Cancelled migrating scene screenshots")
return
return nil
}
if err != nil {
logger.Errorf("Error migrating scene screenshots: %v", err)
return
return fmt.Errorf("error migrating scene screenshots: %w", err)
}
logger.Infof("Finished migrating scene screenshots")
return nil
}
func (j *MigrateSceneScreenshotsJob) countFiles(ctx context.Context) (int, error) {

View File

@@ -30,13 +30,13 @@ type InstallPackagesJob struct {
Packages []*models.PackageSpecInput
}
func (j *InstallPackagesJob) Execute(ctx context.Context, progress *job.Progress) {
func (j *InstallPackagesJob) Execute(ctx context.Context, progress *job.Progress) error {
progress.SetTotal(len(j.Packages))
for _, p := range j.Packages {
if job.IsCancelled(ctx) {
logger.Info("Cancelled installing packages")
return
return nil
}
logger.Infof("Installing package %s", p.ID)
@@ -53,6 +53,7 @@ func (j *InstallPackagesJob) Execute(ctx context.Context, progress *job.Progress
}
logger.Infof("Finished installing packages")
return nil
}
type UpdatePackagesJob struct {
@@ -60,13 +61,12 @@ type UpdatePackagesJob struct {
Packages []*models.PackageSpecInput
}
func (j *UpdatePackagesJob) Execute(ctx context.Context, progress *job.Progress) {
func (j *UpdatePackagesJob) Execute(ctx context.Context, progress *job.Progress) error {
// if no packages are specified, update all
if len(j.Packages) == 0 {
installed, err := j.PackageManager.InstalledStatus(ctx)
if err != nil {
logger.Errorf("Error getting installed packages: %v", err)
return
return fmt.Errorf("error getting installed packages: %w", err)
}
for _, p := range installed {
@@ -84,7 +84,7 @@ func (j *UpdatePackagesJob) Execute(ctx context.Context, progress *job.Progress)
for _, p := range j.Packages {
if job.IsCancelled(ctx) {
logger.Info("Cancelled updating packages")
return
return nil
}
logger.Infof("Updating package %s", p.ID)
@@ -101,6 +101,7 @@ func (j *UpdatePackagesJob) Execute(ctx context.Context, progress *job.Progress)
}
logger.Infof("Finished updating packages")
return nil
}
type UninstallPackagesJob struct {
@@ -108,13 +109,13 @@ type UninstallPackagesJob struct {
Packages []*models.PackageSpecInput
}
func (j *UninstallPackagesJob) Execute(ctx context.Context, progress *job.Progress) {
func (j *UninstallPackagesJob) Execute(ctx context.Context, progress *job.Progress) error {
progress.SetTotal(len(j.Packages))
for _, p := range j.Packages {
if job.IsCancelled(ctx) {
logger.Info("Cancelled installing packages")
return
return nil
}
logger.Infof("Uninstalling package %s", p.ID)
@@ -131,4 +132,5 @@ func (j *UninstallPackagesJob) Execute(ctx context.Context, progress *job.Progre
}
logger.Infof("Finished uninstalling packages")
return nil
}

View File

@@ -25,7 +25,7 @@ type autoTagJob struct {
cache match.Cache
}
func (j *autoTagJob) Execute(ctx context.Context, progress *job.Progress) {
func (j *autoTagJob) Execute(ctx context.Context, progress *job.Progress) error {
begin := time.Now()
input := j.input
@@ -38,6 +38,7 @@ func (j *autoTagJob) Execute(ctx context.Context, progress *job.Progress) {
}
logger.Infof("Finished auto-tag after %s", time.Since(begin).String())
return nil
}
func (j *autoTagJob) isFileBasedAutoTag(input AutoTagMetadataInput) bool {
@@ -162,6 +163,11 @@ func (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progre
return fmt.Errorf("performer with id %s not found", performerId)
}
if performer.IgnoreAutoTag {
logger.Infof("Skipping performer %s because auto-tag is disabled", performer.Name)
return nil
}
if err := performer.LoadAliases(ctx, r.Performer); err != nil {
return fmt.Errorf("loading aliases for performer %d: %w", performer.ID, err)
}
@@ -253,6 +259,11 @@ func (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress,
return fmt.Errorf("studio with id %s not found", studioId)
}
if studio.IgnoreAutoTag {
logger.Infof("Skipping studio %s because auto-tag is disabled", studio.Name)
return nil
}
studios = append(studios, studio)
}
@@ -345,6 +356,11 @@ func (j *autoTagJob) autoTagTags(ctx context.Context, progress *job.Progress, pa
return fmt.Errorf("tag with id %s not found", tagId)
}
if tag.IgnoreAutoTag {
logger.Infof("Skipping tag %s because auto-tag is disabled", tag.Name)
return nil
}
tags = append(tags, tag)
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/scene"
)
@@ -31,7 +32,7 @@ type cleanJob struct {
scanSubs *subscriptionManager
}
func (j *cleanJob) Execute(ctx context.Context, progress *job.Progress) {
func (j *cleanJob) Execute(ctx context.Context, progress *job.Progress) error {
logger.Infof("Starting cleaning of tracked files")
start := time.Now()
if j.input.DryRun {
@@ -46,7 +47,7 @@ func (j *cleanJob) Execute(ctx context.Context, progress *job.Progress) {
if job.IsCancelled(ctx) {
logger.Info("Stopping due to user request")
return
return nil
}
j.cleanEmptyGalleries(ctx)
@@ -54,6 +55,7 @@ func (j *cleanJob) Execute(ctx context.Context, progress *job.Progress) {
j.scanSubs.notify()
elapsed := time.Since(start)
logger.Info(fmt.Sprintf("Finished Cleaning (%s)", elapsed))
return nil
}
func (j *cleanJob) cleanEmptyGalleries(ctx context.Context) {
@@ -129,7 +131,7 @@ func (j *cleanJob) deleteGallery(ctx context.Context, id int) {
return err
}
pluginCache.RegisterPostHooks(ctx, id, plugin.GalleryDestroyPost, plugin.GalleryDestroyInput{
pluginCache.RegisterPostHooks(ctx, id, hook.GalleryDestroyPost, plugin.GalleryDestroyInput{
Checksum: g.PrimaryChecksum(),
Path: g.Path,
}, nil)
@@ -302,7 +304,7 @@ func (h *cleanHandler) handleRelatedScenes(ctx context.Context, fileDeleter *fil
return err
}
mgr.PluginCache.RegisterPostHooks(ctx, scene.ID, plugin.SceneDestroyPost, plugin.SceneDestroyInput{
mgr.PluginCache.RegisterPostHooks(ctx, scene.ID, hook.SceneDestroyPost, plugin.SceneDestroyInput{
Checksum: scene.Checksum,
OSHash: scene.OSHash,
Path: scene.Path,
@@ -349,7 +351,7 @@ func (h *cleanHandler) handleRelatedGalleries(ctx context.Context, fileID models
return err
}
mgr.PluginCache.RegisterPostHooks(ctx, g.ID, plugin.GalleryDestroyPost, plugin.GalleryDestroyInput{
mgr.PluginCache.RegisterPostHooks(ctx, g.ID, hook.GalleryDestroyPost, plugin.GalleryDestroyInput{
Checksum: g.PrimaryChecksum(),
Path: g.Path,
}, nil)
@@ -389,7 +391,7 @@ func (h *cleanHandler) deleteRelatedFolderGalleries(ctx context.Context, folderI
return err
}
mgr.PluginCache.RegisterPostHooks(ctx, g.ID, plugin.GalleryDestroyPost, plugin.GalleryDestroyInput{
mgr.PluginCache.RegisterPostHooks(ctx, g.ID, hook.GalleryDestroyPost, plugin.GalleryDestroyInput{
// No checksum for folders
// Checksum: g.Checksum(),
Path: g.Path,
@@ -423,7 +425,7 @@ func (h *cleanHandler) handleRelatedImages(ctx context.Context, fileDeleter *fil
return err
}
mgr.PluginCache.RegisterPostHooks(ctx, i.ID, plugin.ImageDestroyPost, plugin.ImageDestroyInput{
mgr.PluginCache.RegisterPostHooks(ctx, i.ID, hook.ImageDestroyPost, plugin.ImageDestroyInput{
Checksum: i.Checksum,
Path: i.Path,
}, nil)

View File

@@ -527,7 +527,6 @@ func (t *ExportTask) exportScene(ctx context.Context, wg *sync.WaitGroup, jobCha
newSceneJSON.Galleries = gallery.GetRefs(galleries)
newSceneJSON.ResumeTime = s.ResumeTime
newSceneJSON.PlayCount = s.PlayCount
newSceneJSON.PlayDuration = s.PlayDuration
performers, err := performerReader.FindBySceneID(ctx, s.ID)

View File

@@ -31,6 +31,7 @@ type GenerateMetadataInput struct {
Phashes bool `json:"phashes"`
InteractiveHeatmapsSpeeds bool `json:"interactiveHeatmapsSpeeds"`
ClipPreviews bool `json:"clipPreviews"`
ImageThumbnails bool `json:"imageThumbnails"`
// scene ids to generate for
SceneIDs []string `json:"sceneIDs"`
// marker ids to generate for
@@ -60,6 +61,8 @@ type GenerateJob struct {
overwrite bool
fileNamingAlgo models.HashAlgorithm
totals totalsGenerate
}
type totalsGenerate struct {
@@ -72,11 +75,12 @@ type totalsGenerate struct {
phashes int64
interactiveHeatmapSpeeds int64
clipPreviews int64
imageThumbnails int64
tasks int
}
func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) error {
var scenes []*models.Scene
var err error
var markers []*models.SceneMarker
@@ -93,7 +97,6 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
go func() {
defer close(queue)
var totals totalsGenerate
sceneIDs, err := stringslice.StringSliceToIntSlice(j.input.SceneIDs)
if err != nil {
logger.Error(err.Error())
@@ -116,7 +119,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
qb := r.Scene
if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 {
totals = j.queueTasks(ctx, g, queue)
j.queueTasks(ctx, g, queue)
} else {
if len(j.input.SceneIDs) > 0 {
scenes, err = qb.FindMany(ctx, sceneIDs)
@@ -125,7 +128,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
return err
}
j.queueSceneJobs(ctx, g, s, queue, &totals)
j.queueSceneJobs(ctx, g, s, queue)
}
}
@@ -135,7 +138,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
return err
}
for _, m := range markers {
j.queueMarkerJob(g, m, queue, &totals)
j.queueMarkerJob(g, m, queue)
}
}
}
@@ -146,6 +149,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
return
}
totals := j.totals
logMsg := "Generating"
if j.input.Covers {
logMsg += fmt.Sprintf(" %d covers", totals.covers)
@@ -174,6 +178,9 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
if j.input.ClipPreviews {
logMsg += fmt.Sprintf(" %d Image Clip Previews", totals.clipPreviews)
}
if j.input.ImageThumbnails {
logMsg += fmt.Sprintf(" %d Image Thumbnails", totals.imageThumbnails)
}
if logMsg == "Generating" {
logMsg = "Nothing selected to generate"
}
@@ -216,16 +223,22 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
if job.IsCancelled(ctx) {
logger.Info("Stopping due to user request")
return
return nil
}
elapsed := time.Since(start)
logger.Info(fmt.Sprintf("Generate finished (%s)", elapsed))
return nil
}
func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) totalsGenerate {
var totals totalsGenerate
func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) {
j.totals = totalsGenerate{}
j.queueScenesTasks(ctx, g, queue)
j.queueImagesTasks(ctx, g, queue)
}
func (j *GenerateJob) queueScenesTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) {
const batchSize = 1000
findFilter := models.BatchFindFilter(batchSize)
@@ -234,26 +247,26 @@ func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, que
for more := true; more; {
if job.IsCancelled(ctx) {
return totals
return
}
scenes, err := scene.Query(ctx, r.Scene, nil, findFilter)
if err != nil {
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
return totals
return
}
for _, ss := range scenes {
if job.IsCancelled(ctx) {
return totals
return
}
if err := ss.LoadFiles(ctx, r.Scene); err != nil {
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
return totals
return
}
j.queueSceneJobs(ctx, g, ss, queue, &totals)
j.queueSceneJobs(ctx, g, ss, queue)
}
if len(scenes) != batchSize {
@@ -262,30 +275,37 @@ func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, que
*findFilter.Page++
}
}
}
*findFilter.Page = 1
for more := j.input.ClipPreviews; more; {
func (j *GenerateJob) queueImagesTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) {
const batchSize = 1000
findFilter := models.BatchFindFilter(batchSize)
r := j.repository
for more := j.input.ClipPreviews || j.input.ImageThumbnails; more; {
if job.IsCancelled(ctx) {
return totals
return
}
images, err := image.Query(ctx, r.Image, nil, findFilter)
if err != nil {
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
return totals
return
}
for _, ss := range images {
if job.IsCancelled(ctx) {
return totals
return
}
if err := ss.LoadFiles(ctx, r.Image); err != nil {
logger.Errorf("Error encountered queuing files to scan: %s", err.Error())
return totals
return
}
j.queueImageJob(g, ss, queue, &totals)
j.queueImageJob(g, ss, queue)
}
if len(images) != batchSize {
@@ -294,8 +314,6 @@ func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, que
*findFilter.Page++
}
}
return totals
}
func getGeneratePreviewOptions(optionsInput GeneratePreviewOptionsInput) generate.PreviewOptions {
@@ -333,7 +351,7 @@ func getGeneratePreviewOptions(optionsInput GeneratePreviewOptionsInput) generat
return ret
}
func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, scene *models.Scene, queue chan<- Task, totals *totalsGenerate) {
func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, scene *models.Scene, queue chan<- Task) {
r := j.repository
if j.input.Covers {
@@ -344,8 +362,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
}
if task.required(ctx) {
totals.covers++
totals.tasks++
j.totals.covers++
j.totals.tasks++
queue <- task
}
}
@@ -358,8 +376,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
}
if task.required() {
totals.sprites++
totals.tasks++
j.totals.sprites++
j.totals.tasks++
queue <- task
}
}
@@ -382,13 +400,13 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
if task.required() {
if task.videoPreviewRequired() {
totals.previews++
j.totals.previews++
}
if task.imagePreviewRequired() {
totals.imagePreviews++
j.totals.imagePreviews++
}
totals.tasks++
j.totals.tasks++
queue <- task
}
}
@@ -407,8 +425,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
markers := task.markersNeeded(ctx)
if markers > 0 {
totals.markers += int64(markers)
totals.tasks++
j.totals.markers += int64(markers)
j.totals.tasks++
queue <- task
}
@@ -424,8 +442,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
g: g,
}
if task.required() {
totals.transcodes++
totals.tasks++
j.totals.transcodes++
j.totals.tasks++
queue <- task
}
}
@@ -441,8 +459,8 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
}
if task.required() {
totals.phashes++
totals.tasks++
j.totals.phashes++
j.totals.tasks++
queue <- task
}
}
@@ -457,14 +475,14 @@ func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator,
}
if task.required() {
totals.interactiveHeatmapSpeeds++
totals.tasks++
j.totals.interactiveHeatmapSpeeds++
j.totals.tasks++
queue <- task
}
}
}
func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.SceneMarker, queue chan<- Task, totals *totalsGenerate) {
func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.SceneMarker, queue chan<- Task) {
task := &GenerateMarkersTask{
repository: j.repository,
Marker: marker,
@@ -472,20 +490,35 @@ func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.Scene
fileNamingAlgorithm: j.fileNamingAlgo,
generator: g,
}
totals.markers++
totals.tasks++
j.totals.markers++
j.totals.tasks++
queue <- task
}
func (j *GenerateJob) queueImageJob(g *generate.Generator, image *models.Image, queue chan<- Task, totals *totalsGenerate) {
task := &GenerateClipPreviewTask{
Image: *image,
Overwrite: j.overwrite,
func (j *GenerateJob) queueImageJob(g *generate.Generator, image *models.Image, queue chan<- Task) {
if j.input.ImageThumbnails {
task := &GenerateImageThumbnailTask{
Image: *image,
Overwrite: j.overwrite,
}
if task.required() {
j.totals.imageThumbnails++
j.totals.tasks++
queue <- task
}
}
if task.required() {
totals.clipPreviews++
totals.tasks++
queue <- task
if j.input.ClipPreviews {
task := &GenerateClipPreviewTask{
Image: *image,
Overwrite: j.overwrite,
}
if task.required() {
j.totals.clipPreviews++
j.totals.tasks++
queue <- task
}
}
}

View File

@@ -0,0 +1,79 @@
package manager
import (
"context"
"errors"
"fmt"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
type GenerateImageThumbnailTask struct {
Image models.Image
Overwrite bool
}
func (t *GenerateImageThumbnailTask) GetDescription() string {
return fmt.Sprintf("Generating Thumbnail for image %s", t.Image.Path)
}
func (t *GenerateImageThumbnailTask) Start(ctx context.Context) {
if !t.required() {
return
}
thumbPath := GetInstance().Paths.Generated.GetThumbnailPath(t.Image.Checksum, models.DefaultGthumbWidth)
f := t.Image.Files.Primary()
path := f.Base().Path
logger.Debugf("Generating thumbnail for %s", path)
mgr := GetInstance()
c := mgr.Config
clipPreviewOptions := image.ClipPreviewOptions{
InputArgs: c.GetTranscodeInputArgs(),
OutputArgs: c.GetTranscodeOutputArgs(),
Preset: c.GetPreviewPreset().String(),
}
encoder := image.NewThumbnailEncoder(mgr.FFMpeg, mgr.FFProbe, clipPreviewOptions)
data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth)
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)
}
return
}
err = fsutil.WriteFile(thumbPath, data)
if err != nil {
logger.Errorf("[generator] writing thumbnail for image %s: %w", path, err)
return
}
}
func (t *GenerateImageThumbnailTask) required() bool {
vf, ok := t.Image.Files.Primary().(models.VisualFile)
if !ok {
return false
}
if vf.GetHeight() <= models.DefaultGthumbWidth && vf.GetWidth() <= models.DefaultGthumbWidth {
return false
}
if t.Overwrite {
return true
}
thumbPath := GetInstance().Paths.Generated.GetThumbnailPath(t.Image.Checksum, models.DefaultGthumbWidth)
exists, _ := fsutil.FileExists(thumbPath)
return !exists
}

View File

@@ -25,19 +25,38 @@ func (t *GeneratePhashTask) Start(ctx context.Context) {
return
}
hash, err := videophash.Generate(instance.FFMpeg, t.File)
if err != nil {
logger.Errorf("error generating phash: %s", err.Error())
logErrorOutput(err)
return
var hash int64
set := false
// #4393 - if there is a file with the same oshash, we can use the same phash
// only use this if we're not overwriting
if !t.Overwrite {
existing, err := t.findExistingPhash(ctx)
if err != nil {
logger.Warnf("Error finding existing phash: %v", err)
} else if existing != nil {
logger.Infof("Using existing phash for %s", t.File.Path)
hash = existing.(int64)
set = true
}
}
if !set {
generated, err := videophash.Generate(instance.FFMpeg, t.File)
if err != nil {
logger.Errorf("Error generating phash: %v", err)
logErrorOutput(err)
return
}
hash = int64(*generated)
}
r := t.repository
if err := r.WithTxn(ctx, func(ctx context.Context) error {
hashValue := int64(*hash)
t.File.Fingerprints = t.File.Fingerprints.AppendUnique(models.Fingerprint{
Type: models.FingerprintTypePhash,
Fingerprint: hashValue,
Fingerprint: hash,
})
return r.File.Update(ctx, t.File)
@@ -46,6 +65,36 @@ func (t *GeneratePhashTask) Start(ctx context.Context) {
}
}
func (t *GeneratePhashTask) findExistingPhash(ctx context.Context) (interface{}, error) {
r := t.repository
var ret interface{}
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
oshash := t.File.Fingerprints.Get(models.FingerprintTypeOshash)
// find other files with the same oshash
files, err := r.File.FindByFingerprint(ctx, models.Fingerprint{
Type: models.FingerprintTypeOshash,
Fingerprint: oshash,
})
if err != nil {
return fmt.Errorf("finding files by oshash: %w", err)
}
// find the first file with a phash
for _, file := range files {
if phash := file.Base().Fingerprints.Get(models.FingerprintTypePhash); phash != nil {
ret = phash
return nil
}
}
return nil
}); err != nil {
return nil, err
}
return ret, nil
}
func (t *GeneratePhashTask) required() bool {
if t.Overwrite {
return true

View File

@@ -34,18 +34,17 @@ func CreateIdentifyJob(input identify.Options) *IdentifyJob {
}
}
func (j *IdentifyJob) Execute(ctx context.Context, progress *job.Progress) {
func (j *IdentifyJob) Execute(ctx context.Context, progress *job.Progress) error {
j.progress = progress
// if no sources provided - just return
if len(j.input.Sources) == 0 {
return
return nil
}
sources, err := j.getSources()
if err != nil {
logger.Error(err)
return
return err
}
// if scene ids provided, use those
@@ -84,8 +83,10 @@ func (j *IdentifyJob) Execute(ctx context.Context, progress *job.Progress) {
return nil
}); err != nil {
logger.Errorf("Error encountered while identifying scenes: %v", err)
return fmt.Errorf("error encountered while identifying scenes: %w", err)
}
return nil
}
func (j *IdentifyJob) identifyAllScenes(ctx context.Context, sources []identify.ScraperSource) error {

View File

@@ -2,6 +2,7 @@ package manager
import (
"context"
"fmt"
"time"
"github.com/stashapp/stash/pkg/job"
@@ -17,7 +18,7 @@ type OptimiseDatabaseJob struct {
Optimiser Optimiser
}
func (j *OptimiseDatabaseJob) Execute(ctx context.Context, progress *job.Progress) {
func (j *OptimiseDatabaseJob) Execute(ctx context.Context, progress *job.Progress) error {
logger.Info("Optimising database")
progress.SetTotal(2)
@@ -31,11 +32,10 @@ func (j *OptimiseDatabaseJob) Execute(ctx context.Context, progress *job.Progres
})
if job.IsCancelled(ctx) {
logger.Info("Stopping due to user request")
return
return nil
}
if err != nil {
logger.Errorf("Error analyzing database: %v", err)
return
return fmt.Errorf("Error analyzing database: %w", err)
}
progress.ExecuteTask("Vacuuming database", func() {
@@ -44,13 +44,13 @@ func (j *OptimiseDatabaseJob) Execute(ctx context.Context, progress *job.Progres
})
if job.IsCancelled(ctx) {
logger.Info("Stopping due to user request")
return
return nil
}
if err != nil {
logger.Errorf("Error vacuuming database: %v", err)
return
return fmt.Errorf("error vacuuming database: %w", err)
}
elapsed := time.Since(start)
logger.Infof("Finished optimising database after %s", elapsed)
return nil
}

View File

@@ -9,19 +9,23 @@ import (
"github.com/stashapp/stash/pkg/plugin"
)
func (s *Manager) RunPluginTask(ctx context.Context, pluginID string, taskName string, args []*plugin.PluginArgInput) int {
j := job.MakeJobExec(func(jobCtx context.Context, progress *job.Progress) {
func (s *Manager) RunPluginTask(
ctx context.Context,
pluginID string,
taskName *string,
description *string,
args plugin.OperationInput,
) int {
j := job.MakeJobExec(func(jobCtx context.Context, progress *job.Progress) error {
pluginProgress := make(chan float64)
task, err := s.PluginCache.CreateTask(ctx, pluginID, taskName, args, pluginProgress)
if err != nil {
logger.Errorf("Error creating plugin task: %s", err.Error())
return
return fmt.Errorf("Error creating plugin task: %w", err)
}
err = task.Start()
if err != nil {
logger.Errorf("Error running plugin task: %s", err.Error())
return
return fmt.Errorf("Error running plugin task: %w", err)
}
done := make(chan bool)
@@ -44,17 +48,24 @@ func (s *Manager) RunPluginTask(ctx context.Context, pluginID string, taskName s
for {
select {
case <-done:
return
return nil
case p := <-pluginProgress:
progress.SetPercent(p)
case <-jobCtx.Done():
if err := task.Stop(); err != nil {
logger.Errorf("Error stopping plugin operation: %s", err.Error())
}
return
return nil
}
}
})
return s.JobManager.Add(ctx, fmt.Sprintf("Running plugin task: %s", taskName), j)
displayName := pluginID
if taskName != nil {
displayName = *taskName
}
if description != nil {
displayName = *description
}
return s.JobManager.Add(ctx, fmt.Sprintf("Running plugin task: %s", displayName), j)
}

View File

@@ -2,7 +2,6 @@ package manager
import (
"context"
"errors"
"fmt"
"io/fs"
"path/filepath"
@@ -35,12 +34,13 @@ type ScanJob struct {
subscriptions *subscriptionManager
}
func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) {
func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) error {
cfg := config.GetInstance()
input := j.input
if job.IsCancelled(ctx) {
logger.Info("Stopping due to user request")
return
return nil
}
sp := getScanPaths(input.Paths)
@@ -56,7 +56,7 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) {
start := time.Now()
const taskQueueSize = 200000
taskQueue := job.NewTaskQueue(ctx, progress, taskQueueSize, c.GetParallelTasksWithAutoDetection())
taskQueue := job.NewTaskQueue(ctx, progress, taskQueueSize, cfg.GetParallelTasksWithAutoDetection())
var minModTime time.Time
if j.input.Filter != nil && j.input.Filter.MinModTime != nil {
@@ -66,22 +66,24 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) {
j.scanner.Scan(ctx, getScanHandlers(j.input, taskQueue, progress), file.ScanOptions{
Paths: paths,
ScanFilters: []file.PathFilter{newScanFilter(c, repo, minModTime)},
ZipFileExtensions: c.GetGalleryExtensions(),
ParallelTasks: c.GetParallelTasksWithAutoDetection(),
HandlerRequiredFilters: []file.Filter{newHandlerRequiredFilter(c, repo)},
ZipFileExtensions: cfg.GetGalleryExtensions(),
ParallelTasks: cfg.GetParallelTasksWithAutoDetection(),
HandlerRequiredFilters: []file.Filter{newHandlerRequiredFilter(cfg, repo)},
Rescan: j.input.Rescan,
}, progress)
taskQueue.Close()
if job.IsCancelled(ctx) {
logger.Info("Stopping due to user request")
return
return nil
}
elapsed := time.Since(start)
logger.Info(fmt.Sprintf("Scan finished (%s)", elapsed))
j.subscriptions.notify()
return nil
}
type extensionConfig struct {
@@ -177,7 +179,8 @@ func (f *handlerRequiredFilter) Accept(ctx context.Context, ff models.File) bool
// if create galleries from folder is enabled and the file is not in a zip
// file, then check if there is a folder-based gallery for the file's
// directory
if isImageFile && instance.Config.GetCreateGalleriesFromFolders() && ff.Base().ZipFileID == nil {
// #4611 - also check for .forcegallery
if isImageFile && ff.Base().ZipFileID == nil {
// only do this for the first time it encounters the folder
// the first instance should create the gallery
_, found := f.FolderCache.Get(ctx, ff.Base().ParentFolderID.String())
@@ -186,9 +189,23 @@ func (f *handlerRequiredFilter) Accept(ctx context.Context, ff models.File) bool
return false
}
g, _ := f.GalleryFinder.FindByFolderID(ctx, ff.Base().ParentFolderID)
f.FolderCache.Add(ctx, ff.Base().ParentFolderID.String(), true)
createGallery := instance.Config.GetCreateGalleriesFromFolders()
if !createGallery {
// check for presence of .forcegallery
forceGalleryPath := filepath.Join(filepath.Dir(path), ".forcegallery")
if exists, _ := fsutil.FileExists(forceGalleryPath); exists {
createGallery = true
}
}
if !createGallery {
return false
}
g, _ := f.GalleryFinder.FindByFolderID(ctx, ff.Base().ParentFolderID)
if len(g) == 0 {
// no folder gallery. Return true so that it creates one.
return true
@@ -264,6 +281,12 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo)
return false
}
s := f.stashPaths.GetStashFromDirPath(path)
if s == nil {
logger.Debugf("Skipping %s as it is not in the stash library", path)
return false
}
isVideoFile := useAsVideo(path)
isImageFile := useAsImage(path)
isZipFile := fsutil.MatchExtension(path, f.zipExt)
@@ -288,13 +311,6 @@ func (f *scanFilter) Accept(ctx context.Context, path string, info fs.FileInfo)
return false
}
s := f.stashPaths.GetStashFromDirPath(path)
if s == nil {
logger.Debugf("Skipping %s as it is not in the stash library", path)
return false
}
// shortcut: skip the directory entirely if it matches both exclusion patterns
// add a trailing separator so that it correctly matches against patterns like path/.*
pathExcludeTest := path + string(filepath.Separator)
@@ -411,21 +427,24 @@ func (g *imageGenerators) Generate(ctx context.Context, i *models.Image, f model
t := g.input
path := f.Base().Path
// this is a bit of a hack: the task requires files to be loaded, but
// we don't really need to since we already have the file
ii := *i
ii.Files = models.NewRelatedFiles([]models.File{f})
if t.ScanGenerateThumbnails {
// this should be quick, so always generate sequentially
if err := g.generateThumbnail(ctx, i, f); err != nil {
logger.Errorf("Error generating thumbnail for %s: %v", path, err)
taskThumbnail := GenerateImageThumbnailTask{
Image: ii,
Overwrite: overwrite,
}
taskThumbnail.Start(ctx)
}
// avoid adding a task if the file isn't a video file
_, isVideo := f.(*models.VideoFile)
if isVideo && t.ScanGenerateClipPreviews {
// this is a bit of a hack: the task requires files to be loaded, but
// we don't really need to since we already have the file
ii := *i
ii.Files = models.NewRelatedFiles([]models.File{f})
progress.AddTotal(1)
previewsFn := func(ctx context.Context) {
taskPreview := GenerateClipPreviewTask{
@@ -447,54 +466,6 @@ func (g *imageGenerators) Generate(ctx context.Context, i *models.Image, f model
return nil
}
func (g *imageGenerators) generateThumbnail(ctx context.Context, i *models.Image, f models.File) error {
thumbPath := g.paths.Generated.GetThumbnailPath(i.Checksum, models.DefaultGthumbWidth)
exists, _ := fsutil.FileExists(thumbPath)
if exists {
return nil
}
path := f.Base().Path
vf, ok := f.(models.VisualFile)
if !ok {
return fmt.Errorf("file %s is not a visual file", path)
}
if vf.GetHeight() <= models.DefaultGthumbWidth && vf.GetWidth() <= models.DefaultGthumbWidth {
return nil
}
logger.Debugf("Generating thumbnail for %s", path)
mgr := GetInstance()
c := mgr.Config
clipPreviewOptions := image.ClipPreviewOptions{
InputArgs: c.GetTranscodeInputArgs(),
OutputArgs: c.GetTranscodeOutputArgs(),
Preset: c.GetPreviewPreset().String(),
}
encoder := image.NewThumbnailEncoder(mgr.FFMpeg, mgr.FFProbe, clipPreviewOptions)
data, err := encoder.GetThumbnail(f, models.DefaultGthumbWidth)
if err != nil {
// don't log for animated images
if !errors.Is(err, image.ErrNotSupportedForThumbnail) {
return fmt.Errorf("getting thumbnail for image %s: %w", path, err)
}
return nil
}
err = fsutil.WriteFile(thumbPath, data)
if err != nil {
return fmt.Errorf("writing thumbnail for image %s: %w", path, err)
}
return nil
}
type sceneGenerators struct {
input ScanMetadataInput
taskQueue *job.TaskQueue

View File

@@ -7,6 +7,7 @@ import (
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/scraper/stashbox"
"github.com/stashapp/stash/pkg/studio"
)
@@ -155,6 +156,10 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m
partial := p.ToPartial(t.box.Endpoint, excluded, existingStashIDs)
if err := performer.ValidateUpdate(ctx, t.performer.ID, partial, qb); err != nil {
return err
}
if _, err := qb.UpdatePartial(ctx, t.performer.ID, partial); err != nil {
return err
}
@@ -185,6 +190,10 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m
err = r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Performer
if err := performer.ValidateCreate(ctx, *newPerformer, qb); err != nil {
return err
}
if err := qb.Create(ctx, newPerformer); err != nil {
return err
}
@@ -302,13 +311,13 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
return err
}
partial := s.ToPartial(s.StoredID, t.box.Endpoint, excluded, existingStashIDs)
partial := s.ToPartial(*s.StoredID, t.box.Endpoint, excluded, existingStashIDs)
if err := studio.ValidateModify(ctx, *partial, qb); err != nil {
if err := studio.ValidateModify(ctx, partial, qb); err != nil {
return err
}
if _, err := qb.UpdatePartial(ctx, *partial); err != nil {
if _, err := qb.UpdatePartial(ctx, partial); err != nil {
return err
}
@@ -346,6 +355,10 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
err = r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Studio
if err := studio.ValidateCreate(ctx, *newStudio, qb); err != nil {
return err
}
if err := qb.Create(ctx, newStudio); err != nil {
return err
}
@@ -422,13 +435,13 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *
return err
}
partial := parent.ToPartial(parent.StoredID, t.box.Endpoint, excluded, existingStashIDs)
partial := parent.ToPartial(*parent.StoredID, t.box.Endpoint, excluded, existingStashIDs)
if err := studio.ValidateModify(ctx, *partial, qb); err != nil {
if err := studio.ValidateModify(ctx, partial, qb); err != nil {
return err
}
if _, err := qb.UpdatePartial(ctx, *partial); err != nil {
if _, err := qb.UpdatePartial(ctx, partial); err != nil {
return err
}

View File

@@ -26,7 +26,7 @@ func (t *GenerateTranscodeTask) GetDescription() string {
return fmt.Sprintf("Generating transcode for %s", t.Scene.Path)
}
func (t *GenerateTranscodeTask) Start(ctc context.Context) {
func (t *GenerateTranscodeTask) Start(ctx context.Context) {
hasTranscode := HasTranscode(&t.Scene, t.fileNamingAlgorithm)
if !t.Overwrite && hasTranscode {
return
@@ -72,23 +72,26 @@ func (t *GenerateTranscodeTask) Start(ctc context.Context) {
w, h := videoFile.TranscodeScale(transcodeSize.GetMaxResolution())
options := generate.TranscodeOptions{
Width: w,
Height: h,
}
// if scale is being set, then we can't use stream copy
scaleSet := w == 0 && h == 0
if videoCodec == ffmpeg.H264 { // for non supported h264 files stream copy the video part
if scaleSet && videoCodec == ffmpeg.H264 { // for non supported h264 files stream copy the video part
if audioCodec == ffmpeg.MissingUnsupported {
err = t.g.TranscodeCopyVideo(context.TODO(), videoFile.Path, sceneHash, options)
err = t.g.TranscodeCopyVideo(ctx, videoFile.Path, sceneHash)
} else {
err = t.g.TranscodeAudio(context.TODO(), videoFile.Path, sceneHash, options)
err = t.g.TranscodeAudio(ctx, videoFile.Path, sceneHash)
}
} else {
options := generate.TranscodeOptions{
Width: w,
Height: h,
}
if audioCodec == ffmpeg.MissingUnsupported {
// ffmpeg fails if it tries to transcode an unsupported audio codec
err = t.g.TranscodeVideo(context.TODO(), videoFile.Path, sceneHash, options)
err = t.g.TranscodeVideo(ctx, videoFile.Path, sceneHash, options)
} else {
err = t.g.Transcode(context.TODO(), videoFile.Path, sceneHash, options)
err = t.g.Transcode(ctx, videoFile.Path, sceneHash, options)
}
}

View File

@@ -8,6 +8,7 @@ import (
"strings"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
var (
@@ -24,6 +25,8 @@ var (
VideoCodecVVPX VideoCodec = "vp8_vaapi"
)
const minHeight int = 256
// Tests all (given) hardware codec's
func (f *FFMpeg) InitHWSupport(ctx context.Context) {
var hwCodecSupport []VideoCodec
@@ -39,15 +42,13 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
var args Args
args = append(args, "-hide_banner")
args = args.LogLevel(LogLevelWarning)
args = f.hwDeviceInit(args, codec)
args = f.hwDeviceInit(args, codec, false)
args = args.Format("lavfi")
args = args.Input("color=c=red")
args = args.Input(fmt.Sprintf("color=c=red:s=%dx%d", 1280, 720))
args = args.Duration(0.1)
videoFilter := f.hwFilterInit(codec)
// Test scaling
videoFilter = videoFilter.ScaleDimensions(-2, 160)
videoFilter = f.hwCodecFilter(videoFilter, codec)
videoFilter := f.hwMaxResFilter(codec, 1280, 720, minHeight, false)
args = append(args, CodecInit(codec)...)
args = args.VideoFilter(videoFilter)
@@ -59,12 +60,7 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
logger.Debugf("[InitHWSupport] error starting command: %w", err)
continue
}
if err := cmd.Wait(); err != nil {
if err := cmd.Run(); err != nil {
errOutput := stderr.String()
if len(errOutput) == 0 {
@@ -77,7 +73,7 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
}
}
outstr := "[InitHWSupport] Supported HW codecs:\n"
outstr := fmt.Sprintf("[InitHWSupport] Supported HW codecs [%d]:\n", len(hwCodecSupport))
for _, codec := range hwCodecSupport {
outstr += fmt.Sprintf("\t%s\n", codec)
}
@@ -86,66 +82,157 @@ func (f *FFMpeg) InitHWSupport(ctx context.Context) {
f.hwCodecSupport = hwCodecSupport
}
func (f *FFMpeg) hwCanFullHWTranscode(ctx context.Context, codec VideoCodec, vf *models.VideoFile, reqHeight int) bool {
if codec == VideoCodecCopy {
return false
}
var args Args
args = append(args, "-hide_banner")
args = args.LogLevel(LogLevelWarning)
args = args.XError()
args = f.hwDeviceInit(args, codec, true)
args = args.Input(vf.Path)
args = args.Duration(0.1)
videoFilter := f.hwMaxResFilter(codec, vf.Width, vf.Height, reqHeight, true)
args = append(args, CodecInit(codec)...)
args = args.VideoFilter(videoFilter)
args = args.Format("null")
args = args.Output("-")
cmd := f.Command(ctx, args)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errOutput := stderr.String()
if len(errOutput) == 0 {
errOutput = err.Error()
}
logger.Debugf("[InitHWSupport] Full hardware transcode for file %s not supported. Error output:\n%s", vf.Basename, errOutput)
return false
}
return true
}
// Prepend input for hardware encoding only
func (f *FFMpeg) hwDeviceInit(args Args, codec VideoCodec) Args {
switch codec {
func (f *FFMpeg) hwDeviceInit(args Args, toCodec VideoCodec, fullhw bool) Args {
switch toCodec {
case VideoCodecN264:
args = append(args, "-hwaccel_device")
args = append(args, "0")
if fullhw {
args = append(args, "-hwaccel")
args = append(args, "cuda")
args = append(args, "-hwaccel_output_format")
args = append(args, "cuda")
args = append(args, "-extra_hw_frames")
args = append(args, "5")
}
case VideoCodecV264,
VideoCodecVVP9:
args = append(args, "-vaapi_device")
args = append(args, "/dev/dri/renderD128")
if fullhw {
args = append(args, "-hwaccel")
args = append(args, "vaapi")
args = append(args, "-hwaccel_output_format")
args = append(args, "vaapi")
}
case VideoCodecI264,
VideoCodecIVP9:
args = append(args, "-init_hw_device")
args = append(args, "qsv=hw")
args = append(args, "-filter_hw_device")
args = append(args, "hw")
if fullhw {
args = append(args, "-hwaccel")
args = append(args, "qsv")
args = append(args, "-hwaccel_output_format")
args = append(args, "qsv")
} else {
args = append(args, "-init_hw_device")
args = append(args, "qsv=hw")
args = append(args, "-filter_hw_device")
args = append(args, "hw")
}
}
return args
}
// Initialise a video filter for HW encoding
func (f *FFMpeg) hwFilterInit(codec VideoCodec) VideoFilter {
func (f *FFMpeg) hwFilterInit(toCodec VideoCodec, fullhw bool) VideoFilter {
var videoFilter VideoFilter
switch codec {
switch toCodec {
case VideoCodecV264,
VideoCodecVVP9:
videoFilter = videoFilter.Append("format=nv12")
videoFilter = videoFilter.Append("hwupload")
if !fullhw {
videoFilter = videoFilter.Append("format=nv12")
videoFilter = videoFilter.Append("hwupload")
}
case VideoCodecN264:
videoFilter = videoFilter.Append("format=nv12")
videoFilter = videoFilter.Append("hwupload_cuda")
if !fullhw {
videoFilter = videoFilter.Append("format=nv12")
videoFilter = videoFilter.Append("hwupload_cuda")
}
case VideoCodecI264,
VideoCodecIVP9:
videoFilter = videoFilter.Append("hwupload=extra_hw_frames=64")
videoFilter = videoFilter.Append("format=qsv")
if !fullhw {
videoFilter = videoFilter.Append("hwupload=extra_hw_frames=64")
videoFilter = videoFilter.Append("format=qsv")
}
}
return videoFilter
}
var scaler_re = regexp.MustCompile(`scale=(?P<value>[-\d]+:[-\d]+)`)
func templateReplaceScale(input string, template string, match []int, minusonehack bool) string {
result := []byte{}
res := string(scaler_re.ExpandString(result, template, input, match))
// BUG: [scale_qsv]: Size values less than -1 are not acceptable.
// Fix: Replace all instances of -2 with -1 in a scale operation
if minusonehack {
res = strings.ReplaceAll(res, "-2", "-1")
}
matchStart := match[0]
matchEnd := match[1]
return input[0:matchStart] + res + input[matchEnd:]
}
// Replace video filter scaling with hardware scaling for full hardware transcoding
func (f *FFMpeg) hwCodecFilter(args VideoFilter, codec VideoCodec) VideoFilter {
func (f *FFMpeg) hwCodecFilter(args VideoFilter, codec VideoCodec, fullhw bool) VideoFilter {
sargs := string(args)
if strings.Contains(sargs, "scale=") {
switch codec {
case VideoCodecN264:
args = VideoFilter(strings.Replace(sargs, "scale=", "scale_cuda=", 1))
case VideoCodecV264,
VideoCodecVVP9:
args = VideoFilter(strings.Replace(sargs, "scale=", "scale_vaapi=", 1))
case VideoCodecI264,
VideoCodecIVP9:
// BUG: [scale_qsv]: Size values less than -1 are not acceptable.
// Fix: Replace all instances of -2 with -1 in a scale operation
re := regexp.MustCompile(`(scale=)([\d:]*)(-2)(.*)`)
sargs = re.ReplaceAllString(sargs, "scale=$2-1$4")
args = VideoFilter(strings.Replace(sargs, "scale=", "scale_qsv=", 1))
match := scaler_re.FindStringSubmatchIndex(sargs)
if match == nil {
return args
}
switch codec {
case VideoCodecN264:
template := "scale_cuda=$value"
// In 10bit inputs you might get an error like "10 bit encode not supported"
if fullhw && f.version.major >= 5 {
template += ":format=nv12"
}
args = VideoFilter(templateReplaceScale(sargs, template, match, false))
case VideoCodecV264,
VideoCodecVVP9:
template := "scale_vaapi=$value"
args = VideoFilter(templateReplaceScale(sargs, template, match, false))
case VideoCodecI264,
VideoCodecIVP9:
template := "scale_qsv=$value"
args = VideoFilter(templateReplaceScale(sargs, template, match, true))
}
return args
@@ -153,7 +240,9 @@ func (f *FFMpeg) hwCodecFilter(args VideoFilter, codec VideoCodec) VideoFilter {
// Returns the max resolution for a given codec, or a default
func (f *FFMpeg) hwCodecMaxRes(codec VideoCodec, dW int, dH int) (int, int) {
if codec == VideoCodecN264 {
switch codec {
case VideoCodecN264,
VideoCodecI264:
return 4096, 4096
}
@@ -161,11 +250,14 @@ func (f *FFMpeg) hwCodecMaxRes(codec VideoCodec, dW int, dH int) (int, int) {
}
// Return a maxres filter
func (f *FFMpeg) hwMaxResFilter(codec VideoCodec, width int, height int, max int) VideoFilter {
videoFilter := f.hwFilterInit(codec)
maxWidth, maxHeight := f.hwCodecMaxRes(codec, width, height)
videoFilter = videoFilter.ScaleMaxLM(width, height, max, maxWidth, maxHeight)
return f.hwCodecFilter(videoFilter, codec)
func (f *FFMpeg) hwMaxResFilter(toCodec VideoCodec, width int, height int, reqHeight int, fullhw bool) VideoFilter {
if width == 0 || height == 0 {
return ""
}
videoFilter := f.hwFilterInit(toCodec, fullhw)
maxWidth, maxHeight := f.hwCodecMaxRes(toCodec, width, height)
videoFilter = videoFilter.ScaleMaxLM(width, height, reqHeight, maxWidth, maxHeight)
return f.hwCodecFilter(videoFilter, toCodec, fullhw)
}
// Return if a hardware accelerated for HLS is available

View File

@@ -1,179 +1,10 @@
package ffmpeg
import (
"archive/zip"
"context"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"path"
"path/filepath"
"runtime"
"strings"
stashExec "github.com/stashapp/stash/pkg/exec"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
)
func GetPaths(paths []string) (string, string) {
var ffmpegPath, ffprobePath string
// Check if ffmpeg exists in the PATH
if pathBinaryHasCorrectFlags() {
ffmpegPath, _ = exec.LookPath("ffmpeg")
ffprobePath, _ = exec.LookPath("ffprobe")
}
// Check if ffmpeg exists in the config directory
if ffmpegPath == "" {
ffmpegPath = fsutil.FindInPaths(paths, getFFMpegFilename())
}
if ffprobePath == "" {
ffprobePath = fsutil.FindInPaths(paths, getFFProbeFilename())
}
return ffmpegPath, ffprobePath
}
func Download(ctx context.Context, configDirectory string) error {
for _, url := range getFFmpegURL() {
err := downloadSingle(ctx, configDirectory, url)
if err != nil {
return err
}
}
// validate that the urls contained what we needed
executables := []string{getFFMpegFilename(), getFFProbeFilename()}
for _, executable := range executables {
_, err := os.Stat(filepath.Join(configDirectory, executable))
if err != nil {
return err
}
}
return nil
}
type progressReader struct {
io.Reader
lastProgress int64
bytesRead int64
total int64
}
func (r *progressReader) Read(p []byte) (int, error) {
read, err := r.Reader.Read(p)
if err == nil {
r.bytesRead += int64(read)
if r.total > 0 {
progress := int64(float64(r.bytesRead) / float64(r.total) * 100)
if progress/5 > r.lastProgress {
logger.Infof("%d%% downloaded...", progress)
r.lastProgress = progress / 5
}
}
}
return read, err
}
func downloadSingle(ctx context.Context, configDirectory, url string) error {
if url == "" {
return fmt.Errorf("no ffmpeg url for this platform")
}
// Configure where we want to download the archive
urlBase := path.Base(url)
archivePath := filepath.Join(configDirectory, urlBase)
_ = os.Remove(archivePath) // remove archive if it already exists
out, err := os.Create(archivePath)
if err != nil {
return err
}
defer out.Close()
logger.Infof("Downloading %s...", url)
// Make the HTTP request
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
transport := &http.Transport{Proxy: http.ProxyFromEnvironment}
client := &http.Client{
Transport: transport,
}
resp, err := client.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
// Check server response
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("bad status: %s", resp.Status)
}
reader := &progressReader{
Reader: resp.Body,
total: resp.ContentLength,
}
// Write the response to the archive file location
_, err = io.Copy(out, reader)
if err != nil {
return err
}
logger.Info("Downloading complete")
mime := resp.Header.Get("Content-Type")
if mime != "application/zip" { // try detecting MIME type since some servers don't return the correct one
data := make([]byte, 500) // http.DetectContentType only reads up to 500 bytes
_, _ = out.ReadAt(data, 0)
mime = http.DetectContentType(data)
}
if mime == "application/zip" {
logger.Infof("Unzipping %s...", archivePath)
if err := unzip(archivePath, configDirectory); err != nil {
return err
}
// On OSX or Linux set downloaded files permissions
if runtime.GOOS == "darwin" || runtime.GOOS == "linux" {
_, err = os.Stat(filepath.Join(configDirectory, "ffmpeg"))
if !os.IsNotExist(err) {
if err = os.Chmod(filepath.Join(configDirectory, "ffmpeg"), 0755); err != nil {
return err
}
}
_, err = os.Stat(filepath.Join(configDirectory, "ffprobe"))
if !os.IsNotExist(err) {
if err := os.Chmod(filepath.Join(configDirectory, "ffprobe"), 0755); err != nil {
return err
}
}
// TODO: In future possible clear xattr to allow running on osx without user intervention
// TODO: this however may not be required.
// xattr -c /path/to/binary -- xattr.Remove(path, "com.apple.quarantine")
}
} else {
return fmt.Errorf("ffmpeg was downloaded to %s", archivePath)
}
return nil
}
func getFFmpegURL() []string {
func GetFFmpegURL() []string {
var urls []string
switch runtime.GOOS {
case "darwin":
@@ -208,60 +39,3 @@ func getFFProbeFilename() string {
}
return "ffprobe"
}
// Checks if ffmpeg in the path has the correct flags
func pathBinaryHasCorrectFlags() bool {
ffmpegPath, err := exec.LookPath("ffmpeg")
if err != nil {
return false
}
cmd := stashExec.Command(ffmpegPath)
bytes, _ := cmd.CombinedOutput()
output := string(bytes)
hasOpus := strings.Contains(output, "--enable-libopus")
hasVpx := strings.Contains(output, "--enable-libvpx")
hasX264 := strings.Contains(output, "--enable-libx264")
hasX265 := strings.Contains(output, "--enable-libx265")
hasWebp := strings.Contains(output, "--enable-libwebp")
return hasOpus && hasVpx && hasX264 && hasX265 && hasWebp
}
func unzip(src, configDirectory string) error {
zipReader, err := zip.OpenReader(src)
if err != nil {
return err
}
defer zipReader.Close()
for _, f := range zipReader.File {
if f.FileInfo().IsDir() {
continue
}
filename := f.FileInfo().Name()
if filename != "ffprobe" && filename != "ffmpeg" && filename != "ffprobe.exe" && filename != "ffmpeg.exe" {
continue
}
rc, err := f.Open()
if err != nil {
return err
}
unzippedPath := filepath.Join(configDirectory, filename)
unzippedOutput, err := os.Create(unzippedPath)
if err != nil {
return err
}
_, err = io.Copy(unzippedOutput, rc)
if err != nil {
return err
}
if err := unzippedOutput.Close(); err != nil {
return err
}
}
return nil
}

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