Compare commits

..

90 Commits

Author SHA1 Message Date
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
300 changed files with 9722 additions and 3536 deletions

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

@@ -353,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

View File

@@ -48,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

@@ -18,7 +18,7 @@ COPY --from=binary /stash /usr/bin/
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 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 \
&& 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

21
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,11 +49,11 @@ 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.17.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.15.0
golang.org/x/term v0.15.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
)

139
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.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
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,23 +928,25 @@ 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.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.15.0 h1:y/Oo/a/q3IXu26lQgl04j/gjuBDOBlx7X6Om1j2CPW4=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
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/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
@@ -963,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=
@@ -1026,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=
@@ -1069,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=
@@ -1080,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=
@@ -1104,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

@@ -226,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
@@ -349,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"

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"

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
}
@@ -143,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"
@@ -229,6 +232,8 @@ input SceneFilterType {
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"
@@ -243,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"
@@ -273,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"
@@ -321,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"
@@ -368,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"
@@ -411,6 +424,9 @@ input TagFilterType {
"Filter by tag aliases"
aliases: StringCriterionInput
"Filter by favorite"
favorite: Boolean
"Filter by tag description"
description: StringCriterionInput

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

@@ -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"
@@ -326,6 +330,8 @@ type SystemStatus {
os: String!
workingDir: String!
homeDir: String!
ffmpegPath: String
ffprobePath: String
}
input MigrateInput {

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

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

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

@@ -103,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

@@ -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)
@@ -103,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")

View File

@@ -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)
@@ -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")

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

@@ -106,6 +106,10 @@ func (r *queryResolver) FindImages(
}
}
if err != nil {
return err
}
ret = &FindImagesResultType{
Count: result.Count,
Images: images,

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

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

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

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
@@ -293,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
@@ -316,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
}
@@ -323,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() {
@@ -354,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.
@@ -365,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
@@ -405,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
@@ -414,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
}
@@ -434,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
}
@@ -448,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
@@ -458,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 {
@@ -487,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
}
@@ -498,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.
@@ -533,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{
@@ -603,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))
}
@@ -628,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
@@ -636,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
@@ -644,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
@@ -750,16 +854,15 @@ func (i *Config) GetAllPluginConfiguration() map[string]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
@@ -769,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 {
@@ -1028,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
@@ -1042,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
}
@@ -1066,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
}
@@ -1082,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
@@ -1118,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)
}
}
@@ -1142,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
@@ -1178,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 {
@@ -1349,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
@@ -1368,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
@@ -1387,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
@@ -1406,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
@@ -1455,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 {
@@ -1502,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
}
@@ -1534,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()
}
@@ -1604,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)
}
}
@@ -1615,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},
}
@@ -1635,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,
@@ -1696,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)
}
}
}
@@ -1717,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()) == "" {
@@ -1725,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

@@ -192,7 +192,6 @@ func (s *Manager) postInit(ctx context.Context) error {
s.RefreshScraperCache()
s.RefreshScraperSourceManager()
s.RefreshStreamManager()
s.RefreshDLNA()
s.SetBlobStoreOptions()
@@ -239,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
}
@@ -260,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

@@ -245,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
@@ -256,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 {
@@ -269,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)
@@ -303,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
@@ -437,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,
@@ -446,6 +410,8 @@ func (s *Manager) GetSystemStatus() *SystemStatus {
AppSchema: appSchema,
Status: status,
ConfigPath: &configFile,
FfmpegPath: &ffmpegPath,
FfprobePath: &ffprobePath,
}
}

View File

@@ -136,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,
@@ -147,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
@@ -159,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{
@@ -168,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
@@ -177,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)
@@ -215,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
@@ -234,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{
@@ -248,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)
@@ -309,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())
@@ -319,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
@@ -331,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 {
@@ -351,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)
@@ -381,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]
@@ -435,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
@@ -493,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))
@@ -513,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]
@@ -620,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))
@@ -640,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

@@ -106,17 +106,15 @@ func (j *CleanGeneratedJob) logError(err error) {
}
}
func (j *CleanGeneratedJob) Execute(ctx context.Context, progress *job.Progress) {
func (j *CleanGeneratedJob) Execute(ctx context.Context, progress *job.Progress) error {
j.tasksComplete = 0
if !j.BlobsStorageType.IsValid() {
logger.Errorf("invalid blobs storage type: %s", j.BlobsStorageType)
return
return fmt.Errorf("invalid blobs storage type: %s", j.BlobsStorageType)
}
if !j.VideoFileNamingAlgorithm.IsValid() {
logger.Errorf("invalid video file naming algorithm: %s", j.VideoFileNamingAlgorithm)
return
return fmt.Errorf("invalid video file naming algorithm: %s", j.VideoFileNamingAlgorithm)
}
if j.Options.DryRun {
@@ -183,10 +181,11 @@ func (j *CleanGeneratedJob) Execute(ctx context.Context, progress *job.Progress)
if job.IsCancelled(ctx) {
logger.Info("Stopping due to user request")
return
return nil
}
logger.Infof("Finished cleaning generated files")
return nil
}
func (j *CleanGeneratedJob) setTaskProgress(taskProgress float64, progress *job.Progress) {
@@ -652,9 +651,13 @@ func (j *CleanGeneratedJob) getImagesWithHash(ctx context.Context, checksum stri
}
func (j *CleanGeneratedJob) getThumbnailFileHash(basename string) (string, error) {
var hash string
var width int
_, err := fmt.Sscanf(basename, "%32x_%d.jpg", &hash, &width)
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
}

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 {

View File

@@ -32,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 {
@@ -47,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)
@@ -55,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) {

View File

@@ -80,7 +80,7 @@ type totalsGenerate struct {
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
@@ -223,11 +223,12 @@ 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) {

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

@@ -16,18 +16,16 @@ func (s *Manager) RunPluginTask(
description *string,
args plugin.OperationInput,
) int {
j := job.MakeJobExec(func(jobCtx context.Context, progress *job.Progress) {
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)
@@ -50,14 +48,14 @@ func (s *Manager) RunPluginTask(
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
}
}
})

View File

@@ -34,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)
@@ -55,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 {
@@ -65,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 {

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
}

View File

@@ -2,15 +2,203 @@
package ffmpeg
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
stashExec "github.com/stashapp/stash/pkg/exec"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
)
func ffmpegHelp(ffmpegPath string) (string, error) {
cmd := stashExec.Command(ffmpegPath, "-h")
bytes, err := cmd.CombinedOutput()
output := string(bytes)
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return "", fmt.Errorf("error running ffmpeg: %v", output)
}
return "", fmt.Errorf("error running ffmpeg: %v", err)
}
return output, nil
}
func ValidateFFMpeg(ffmpegPath string) error {
_, err := ffmpegHelp(ffmpegPath)
return err
}
func ValidateFFMpegCodecSupport(ffmpegPath string) error {
output, err := ffmpegHelp(ffmpegPath)
if err != nil {
return err
}
var missingSupport []string
if !strings.Contains(output, "--enable-libopus") {
missingSupport = append(missingSupport, "libopus")
}
if !strings.Contains(output, "--enable-libvpx") {
missingSupport = append(missingSupport, "libvpx")
}
if !strings.Contains(output, "--enable-libx264") {
missingSupport = append(missingSupport, "libx264")
}
if !strings.Contains(output, "--enable-libx265") {
missingSupport = append(missingSupport, "libx265")
}
if !strings.Contains(output, "--enable-libwebp") {
missingSupport = append(missingSupport, "libwebp")
}
if len(missingSupport) > 0 {
return fmt.Errorf("ffmpeg missing codec support: %v", missingSupport)
}
return nil
}
func LookPathFFMpeg() string {
ret, _ := exec.LookPath(getFFMpegFilename())
if ret != "" {
// ensure ffmpeg has the correct flags
if err := ValidateFFMpeg(ret); err != nil {
logger.Warnf("ffmpeg found (%s), could not be executed: %v", ret, err)
ret = ""
}
}
return ret
}
func FindFFMpeg(path string) string {
ret := fsutil.FindInPaths([]string{path}, getFFMpegFilename())
if ret != "" {
// ensure ffmpeg has the correct flags
if err := ValidateFFMpeg(ret); err != nil {
logger.Warnf("ffmpeg found (%s), could not be executed: %v", ret, err)
ret = ""
}
}
return ret
}
// ResolveFFMpeg attempts to resolve the path to the ffmpeg executable.
// It first looks in the provided path, then resolves from the environment, and finally looks in the fallback path.
// It will prefer an ffmpeg binary that has the required codec support.
// Returns an empty string if a valid ffmpeg cannot be found.
func ResolveFFMpeg(path string, fallbackPath string) string {
var ret string
// look in the provided path first
pathFound := FindFFMpeg(path)
if pathFound != "" {
err := ValidateFFMpegCodecSupport(pathFound)
if err == nil {
return pathFound
}
logger.Warnf("ffmpeg found (%s), but it is missing required flags: %v", pathFound, err)
ret = pathFound
}
// then resolve from the environment
envFound := LookPathFFMpeg()
if envFound != "" {
err := ValidateFFMpegCodecSupport(envFound)
if err == nil {
return envFound
}
logger.Warnf("ffmpeg found (%s), but it is missing required flags: %v", envFound, err)
if ret == "" {
ret = envFound
}
}
// finally, look in the fallback path
fallbackFound := FindFFMpeg(fallbackPath)
if fallbackFound != "" {
err := ValidateFFMpegCodecSupport(fallbackFound)
if err == nil {
return fallbackFound
}
logger.Warnf("ffmpeg found (%s), but it is missing required flags: %v", fallbackFound, err)
if ret == "" {
ret = fallbackFound
}
}
return ret
}
func (f *FFMpeg) getVersion() error {
var args Args
args = append(args, "-version")
cmd := f.Command(context.Background(), args)
var stdout bytes.Buffer
cmd.Stdout = &stdout
var err error
if err = cmd.Run(); err != nil {
return err
}
version_re := regexp.MustCompile(`ffmpeg version ((\d+)\.(\d+)(?:\.(\d+))?)`)
stdoutStr := stdout.String()
match := version_re.FindStringSubmatchIndex(stdoutStr)
if match == nil {
return errors.New("version string malformed")
}
majorS := stdoutStr[match[4]:match[5]]
minorS := stdoutStr[match[6]:match[7]]
// patch is optional
var patchS string
if match[8] != -1 && match[9] != -1 {
patchS = stdoutStr[match[8]:match[9]]
}
if i, err := strconv.Atoi(majorS); err == nil {
f.version.major = i
}
if i, err := strconv.Atoi(minorS); err == nil {
f.version.minor = i
}
if i, err := strconv.Atoi(patchS); err == nil {
f.version.patch = i
}
logger.Debugf("FFMpeg version %d.%d.%d detected", f.version.major, f.version.minor, f.version.patch)
return nil
}
// FFMpeg version params
type FFMpegVersion struct {
major int
minor int
patch int
}
// FFMpeg provides an interface to ffmpeg.
type FFMpeg struct {
ffmpeg string
version FFMpegVersion
hwCodecSupport []VideoCodec
}
@@ -19,6 +207,9 @@ func NewEncoder(ffmpegPath string) *FFMpeg {
ret := &FFMpeg{
ffmpeg: ffmpegPath,
}
if err := ret.getVersion(); err != nil {
logger.Warnf("FFMpeg version not detected %v", err)
}
return ret
}
@@ -27,3 +218,7 @@ func NewEncoder(ffmpegPath string) *FFMpeg {
func (f *FFMpeg) Command(ctx context.Context, args []string) *exec.Cmd {
return stashExec.CommandContext(ctx, string(f.ffmpeg), args...)
}
func (f *FFMpeg) Path() string {
return f.ffmpeg
}

View File

@@ -2,17 +2,83 @@ package ffmpeg
import (
"encoding/json"
"errors"
"fmt"
"math"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/stashapp/stash/pkg/exec"
stashExec "github.com/stashapp/stash/pkg/exec"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
)
func ValidateFFProbe(ffprobePath string) error {
cmd := stashExec.Command(ffprobePath, "-h")
bytes, err := cmd.CombinedOutput()
output := string(bytes)
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return fmt.Errorf("error running ffprobe: %v", output)
}
return fmt.Errorf("error running ffprobe: %v", err)
}
return nil
}
func LookPathFFProbe() string {
ret, _ := exec.LookPath(getFFProbeFilename())
if ret != "" {
if err := ValidateFFProbe(ret); err != nil {
logger.Warnf("ffprobe found in PATH (%s), but it is missing required flags: %v", ret, err)
ret = ""
}
}
return ret
}
func FindFFProbe(path string) string {
ret := fsutil.FindInPaths([]string{path}, getFFProbeFilename())
if ret != "" {
if err := ValidateFFProbe(ret); err != nil {
logger.Warnf("ffprobe found (%s), but it is missing required flags: %v", ret, err)
ret = ""
}
}
return ret
}
// ResolveFFMpeg attempts to resolve the path to the ffmpeg executable.
// It first looks in the provided path, then resolves from the environment, and finally looks in the fallback path.
// Returns an empty string if a valid ffmpeg cannot be found.
func ResolveFFProbe(path string, fallbackPath string) string {
// look in the provided path first
ret := FindFFProbe(path)
if ret != "" {
return ret
}
// then resolve from the environment
ret = LookPathFFProbe()
if ret != "" {
return ret
}
// finally, look in the fallback path
ret = FindFFProbe(fallbackPath)
return ret
}
// VideoFile represents the ffprobe output for a video file.
type VideoFile struct {
JSON FFProbeJSON
@@ -75,10 +141,14 @@ func (v *VideoFile) TranscodeScale(maxSize int) (int, int) {
// FFProbe provides an interface to the ffprobe executable.
type FFProbe string
func (f *FFProbe) Path() string {
return string(*f)
}
// NewVideoFile runs ffprobe on the given path and returns a VideoFile.
func (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) {
args := []string{"-v", "quiet", "-print_format", "json", "-show_format", "-show_streams", "-show_error", videoPath}
cmd := exec.Command(string(*f), args...)
cmd := stashExec.Command(string(*f), args...)
out, err := cmd.Output()
if err != nil {
@@ -97,7 +167,7 @@ func (f *FFProbe) NewVideoFile(videoPath string) (*VideoFile, error) {
// Used when the frame count is missing or incorrect.
func (f *FFProbe) GetReadFrameCount(path string) (int64, error) {
args := []string{"-v", "quiet", "-print_format", "json", "-count_frames", "-show_format", "-show_streams", "-show_error", path}
out, err := exec.Command(string(*f), args...).Output()
out, err := stashExec.Command(string(*f), args...).Output()
if err != nil {
return 0, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", path, string(out), err.Error())

View File

@@ -81,6 +81,7 @@ var (
"-f", "hls",
"-start_number", fmt.Sprint(segment),
"-hls_time", fmt.Sprint(segmentLength),
"-hls_flags", "split_by_time",
"-hls_segment_type", "mpegts",
"-hls_playlist_type", "vod",
"-hls_segment_filename", filepath.Join(outputDir, ".%d.ts"),
@@ -110,6 +111,7 @@ var (
"-f", "hls",
"-start_number", fmt.Sprint(segment),
"-hls_time", fmt.Sprint(segmentLength),
"-hls_flags", "split_by_time",
"-hls_segment_type", "mpegts",
"-hls_playlist_type", "vod",
"-hls_segment_filename", filepath.Join(outputDir, ".%d.ts"),
@@ -328,7 +330,8 @@ func (s *runningStream) makeStreamArgs(sm *StreamManager, segment int) Args {
codec := HLSGetCodec(sm, s.streamType.Name)
args = sm.encoder.hwDeviceInit(args, codec)
fullhw := sm.config.GetTranscodeHardwareAcceleration() && sm.encoder.hwCanFullHWTranscode(sm.context, codec, s.vf, s.maxTranscodeSize)
args = sm.encoder.hwDeviceInit(args, codec, fullhw)
args = append(args, extraInputArgs...)
if segment > 0 {
@@ -339,7 +342,7 @@ func (s *runningStream) makeStreamArgs(sm *StreamManager, segment int) Args {
videoOnly := ProbeAudioCodec(s.vf.AudioCodec) == MissingUnsupported
videoFilter := sm.encoder.hwMaxResFilter(codec, s.vf.Width, s.vf.Height, s.maxTranscodeSize)
videoFilter := sm.encoder.hwMaxResFilter(codec, s.vf.Width, s.vf.Height, s.maxTranscodeSize, fullhw)
args = append(args, s.streamType.Args(codec, segment, videoFilter, videoOnly, s.outputDir)...)

View File

@@ -138,14 +138,30 @@ type TranscodeOptions struct {
StartTime float64
}
func FileGetCodec(sm *StreamManager, mimetype string) (codec VideoCodec) {
switch mimetype {
func (o TranscodeOptions) FileGetCodec(sm *StreamManager, maxTranscodeSize int) (codec VideoCodec) {
needsResize := false
if maxTranscodeSize != 0 {
if o.VideoFile.Width > o.VideoFile.Height {
needsResize = o.VideoFile.Width > maxTranscodeSize
} else {
needsResize = o.VideoFile.Height > maxTranscodeSize
}
}
switch o.StreamType.MimeType {
case MimeMp4Video:
if !needsResize && o.VideoFile.VideoCodec == H264 {
return VideoCodecCopy
}
codec = VideoCodecLibX264
if hwcodec := sm.encoder.hwCodecMP4Compatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() {
codec = *hwcodec
}
case MimeWebmVideo:
if !needsResize && (o.VideoFile.VideoCodec == Vp8 || o.VideoFile.VideoCodec == Vp9) {
return VideoCodecCopy
}
codec = VideoCodecVP9
if hwcodec := sm.encoder.hwCodecWEBMCompatible(); hwcodec != nil && sm.config.GetTranscodeHardwareAcceleration() {
codec = *hwcodec
@@ -168,9 +184,10 @@ func (o TranscodeOptions) makeStreamArgs(sm *StreamManager) Args {
args := Args{"-hide_banner"}
args = args.LogLevel(LogLevelError)
codec := FileGetCodec(sm, o.StreamType.MimeType)
codec := o.FileGetCodec(sm, maxTranscodeSize)
args = sm.encoder.hwDeviceInit(args, codec)
fullhw := sm.config.GetTranscodeHardwareAcceleration() && sm.encoder.hwCanFullHWTranscode(sm.context, codec, o.VideoFile, maxTranscodeSize)
args = sm.encoder.hwDeviceInit(args, codec, fullhw)
args = append(args, extraInputArgs...)
if o.StartTime != 0 {
@@ -181,7 +198,7 @@ func (o TranscodeOptions) makeStreamArgs(sm *StreamManager) Args {
videoOnly := ProbeAudioCodec(o.VideoFile.AudioCodec) == MissingUnsupported
videoFilter := sm.encoder.hwMaxResFilter(codec, o.VideoFile.Width, o.VideoFile.Height, maxTranscodeSize)
videoFilter := sm.encoder.hwMaxResFilter(codec, o.VideoFile.Width, o.VideoFile.Height, maxTranscodeSize, fullhw)
args = append(args, o.StreamType.Args(codec, videoFilter, videoOnly)...)

View File

@@ -28,7 +28,7 @@ type cleanJob struct {
options CleanOptions
}
// ScanOptions provides options for scanning files.
// CleanOptions provides options for scanning files.
type CleanOptions struct {
Paths []string

View File

@@ -134,6 +134,9 @@ type ScanOptions struct {
HandlerRequiredFilters []Filter
ParallelTasks int
// When true files in path will be rescanned even if they haven't changed
Rescan bool
}
// Scan starts the scanning process.
@@ -895,28 +898,35 @@ func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.F
// assume does not exist, update existing file
// it's possible that there may be multiple missing files.
// just use the first one to rename.
// #4775 - using the new file instance means that any changes made to the existing
// file will be lost. Update the existing file instead.
other := missing[0]
otherBase := other.Base()
updated := other.Clone()
updatedBase := updated.Base()
fBase := f.Base()
fBaseCopy := *(f.Base())
logger.Infof("%s moved to %s. Updating path...", otherBase.Path, fBase.Path)
fBase.ID = otherBase.ID
fBase.CreatedAt = otherBase.CreatedAt
fBase.Fingerprints = otherBase.Fingerprints
oldPath := updatedBase.Path
newPath := fBaseCopy.Path
logger.Infof("%s moved to %s. Updating path...", oldPath, newPath)
fBaseCopy.ID = updatedBase.ID
fBaseCopy.CreatedAt = updatedBase.CreatedAt
fBaseCopy.Fingerprints = updatedBase.Fingerprints
*updatedBase = fBaseCopy
if err := s.withTxn(ctx, func(ctx context.Context) error {
if err := s.Repository.File.Update(ctx, f); err != nil {
return fmt.Errorf("updating file for rename %q: %w", fBase.Path, err)
if err := s.Repository.File.Update(ctx, updated); err != nil {
return fmt.Errorf("updating file for rename %q: %w", newPath, err)
}
if s.isZipFile(fBase.Basename) {
if err := transferZipHierarchy(ctx, s.Repository.Folder, s.Repository.File, fBase.ID, otherBase.Path, fBase.Path); err != nil {
return fmt.Errorf("moving zip hierarchy for renamed zip file %q: %w", fBase.Path, err)
if s.isZipFile(updatedBase.Basename) {
if err := transferZipHierarchy(ctx, s.Repository.Folder, s.Repository.File, updatedBase.ID, oldPath, newPath); err != nil {
return fmt.Errorf("moving zip hierarchy for renamed zip file %q: %w", newPath, err)
}
}
if err := s.fireHandlers(ctx, f, other); err != nil {
if err := s.fireHandlers(ctx, updated, other); err != nil {
return err
}
@@ -925,7 +935,7 @@ func (s *scanJob) handleRename(ctx context.Context, f models.File, fp []models.F
return nil, err
}
return f, nil
return updated, nil
}
func (s *scanJob) isHandlerRequired(ctx context.Context, f models.File) bool {
@@ -1016,14 +1026,20 @@ func (s *scanJob) onExistingFile(ctx context.Context, f scanFile, existing model
fileModTime := f.ModTime
updated := !fileModTime.Equal(base.ModTime)
forceRescan := s.options.Rescan
if !updated {
if !updated && !forceRescan {
return s.onUnchangedFile(ctx, f, existing)
}
oldBase := *base
logger.Infof("%s has been updated: rescanning", path)
if !updated && forceRescan {
logger.Infof("rescanning %s", path)
} else {
logger.Infof("%s has been updated: rescanning", path)
}
base.ModTime = fileModTime
base.Size = f.Size
base.UpdatedAt = time.Now()

View File

@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"regexp"
"runtime"
"strings"
"github.com/stashapp/stash/pkg/logger"
@@ -163,3 +164,12 @@ func SanitiseBasename(v string) string {
return strings.TrimSpace(v)
}
// GetExeName returns the name of the given executable for the current platform.
// One windows it returns the name with the .exe extension.
func GetExeName(base string) string {
if runtime.GOOS == "windows" {
return base + ".exe"
}
return base
}

26
pkg/javascript/console.go Normal file
View File

@@ -0,0 +1,26 @@
package javascript
import "fmt"
type console struct {
Log
}
func (c *console) AddToVM(globalName string, vm *VM) error {
console := vm.NewObject()
if err := SetAll(console,
ObjectValueDef{"log", c.logInfo},
ObjectValueDef{"error", c.logError},
ObjectValueDef{"warn", c.logWarn},
ObjectValueDef{"info", c.logInfo},
ObjectValueDef{"debug", c.logDebug},
); err != nil {
return err
}
if err := vm.Set(globalName, console); err != nil {
return fmt.Errorf("unable to set console: %w", err)
}
return nil
}

106
pkg/javascript/gql.go Normal file
View File

@@ -0,0 +1,106 @@
package javascript
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/dop251/goja"
)
type responseWriter struct {
r strings.Builder
header http.Header
statusCode int
}
func (w *responseWriter) Header() http.Header {
return w.header
}
func (w *responseWriter) WriteHeader(statusCode int) {
w.statusCode = statusCode
}
func (w *responseWriter) Write(b []byte) (int, error) {
return w.r.Write(b)
}
type GQL struct {
Context context.Context
Cookie *http.Cookie
GQLHandler http.Handler
}
func (g *GQL) gqlRequestFunc(vm *VM) func(query string, variables map[string]interface{}) (goja.Value, error) {
return func(query string, variables map[string]interface{}) (goja.Value, error) {
in := struct {
Query string `json:"query"`
Variables map[string]interface{} `json:"variables,omitempty"`
}{
Query: query,
Variables: variables,
}
var body bytes.Buffer
err := json.NewEncoder(&body).Encode(in)
if err != nil {
return nil, err
}
r, err := http.NewRequestWithContext(g.Context, "POST", "/graphql", &body)
if err != nil {
return nil, fmt.Errorf("could not make request")
}
r.Header.Set("Content-Type", "application/json")
if g.Cookie != nil {
r.AddCookie(g.Cookie)
}
w := &responseWriter{
header: make(http.Header),
}
g.GQLHandler.ServeHTTP(w, r)
if w.statusCode != http.StatusOK && w.statusCode != 0 {
vm.Throw(fmt.Errorf("graphQL query failed: %d - %s. Query: %s. Variables: %v", w.statusCode, w.r.String(), in.Query, in.Variables))
}
output := w.r.String()
// convert to JSON
var obj map[string]interface{}
if err = json.Unmarshal([]byte(output), &obj); err != nil {
vm.Throw(fmt.Errorf("could not unmarshal object %s: %s", output, err.Error()))
}
retErr, hasErr := obj["errors"]
if hasErr {
errOut, _ := json.Marshal(retErr)
vm.Throw(fmt.Errorf("graphql error: %s", string(errOut)))
}
v := vm.ToValue(obj["data"])
return v, nil
}
}
func (g *GQL) AddToVM(globalName string, vm *VM) error {
gql := vm.NewObject()
if err := gql.Set("Do", g.gqlRequestFunc(vm)); err != nil {
return fmt.Errorf("unable to set GraphQL Do function: %w", err)
}
if err := vm.Set(globalName, gql); err != nil {
return fmt.Errorf("unable to set gql: %w", err)
}
return nil
}

90
pkg/javascript/log.go Normal file
View File

@@ -0,0 +1,90 @@
package javascript
import (
"encoding/json"
"fmt"
"math"
"reflect"
"github.com/dop251/goja"
"github.com/stashapp/stash/pkg/logger"
)
// Log provides log wrappers for usable from the JS VM.
type Log struct {
// Logger is the LoggerImpl to forward log messages to.
Logger logger.LoggerImpl
// Prefix is the prefix to prepend to log messages.
Prefix string
// ProgressChan is a channel that receives float64s indicating the current progress of an operation.
ProgressChan chan float64
}
func (l *Log) argToString(call goja.FunctionCall) string {
arg := call.Argument(0)
var o map[string]interface{}
if arg.ExportType() == reflect.TypeOf(o) {
ii := arg.Export()
o = ii.(map[string]interface{})
data, err := json.Marshal(o)
if err != nil {
logger.Warnf("Couldn't json encode object")
}
return string(data)
}
return arg.String()
}
func (l *Log) logTrace(call goja.FunctionCall) goja.Value {
l.Logger.Trace(l.Prefix, l.argToString(call))
return nil
}
func (l *Log) logDebug(call goja.FunctionCall) goja.Value {
l.Logger.Debug(l.Prefix, l.argToString(call))
return nil
}
func (l *Log) logInfo(call goja.FunctionCall) goja.Value {
l.Logger.Info(l.Prefix, l.argToString(call))
return nil
}
func (l *Log) logWarn(call goja.FunctionCall) goja.Value {
l.Logger.Warn(l.Prefix, l.argToString(call))
return nil
}
func (l *Log) logError(call goja.FunctionCall) goja.Value {
l.Logger.Error(l.Prefix, l.argToString(call))
return nil
}
// Progress logs the current progress value. The progress value should be
// between 0 and 1.0 inclusively, with 1 representing that the task is
// complete. Values outside of this range will be clamp to be within it.
func (l *Log) logProgress(value float64) {
value = math.Min(math.Max(0, value), 1)
l.ProgressChan <- value
}
func (l *Log) AddToVM(globalName string, vm *VM) error {
log := vm.NewObject()
if err := SetAll(log,
ObjectValueDef{"Trace", l.logTrace},
ObjectValueDef{"Debug", l.logDebug},
ObjectValueDef{"Info", l.logInfo},
ObjectValueDef{"Warn", l.logWarn},
ObjectValueDef{"Error", l.logError},
ObjectValueDef{"Progress", l.logProgress},
); err != nil {
return err
}
if err := vm.Set(globalName, log); err != nil {
return fmt.Errorf("unable to set log: %w", err)
}
return nil
}

25
pkg/javascript/util.go Normal file
View File

@@ -0,0 +1,25 @@
package javascript
import (
"fmt"
"time"
)
type Util struct{}
func (u *Util) sleepFunc(ms int64) {
time.Sleep(time.Millisecond * time.Duration(ms))
}
func (u *Util) AddToVM(globalName string, vm *VM) error {
util := vm.NewObject()
if err := util.Set("Sleep", u.sleepFunc); err != nil {
return fmt.Errorf("unable to set sleep func: %w", err)
}
if err := vm.Set(globalName, util); err != nil {
return fmt.Errorf("unable to set util: %w", err)
}
return nil
}

96
pkg/javascript/vm.go Normal file
View File

@@ -0,0 +1,96 @@
package javascript
import (
"fmt"
"net/http"
"os"
"reflect"
"github.com/dop251/goja"
"github.com/stashapp/stash/pkg/logger"
)
type VM struct {
*goja.Runtime
Progress chan float64
SessionCookie *http.Cookie
GQLHandler http.Handler
}
// optionalFieldNameMapper wraps a goja.FieldNameMapper and returns the field name if the wrapped mapper returns an empty string.
type optionalFieldNameMapper struct {
mapper goja.FieldNameMapper
}
func (tfm optionalFieldNameMapper) FieldName(t reflect.Type, f reflect.StructField) string {
if ret := tfm.mapper.FieldName(t, f); ret != "" {
return ret
}
return f.Name
}
func (tfm optionalFieldNameMapper) MethodName(t reflect.Type, m reflect.Method) string {
return tfm.mapper.MethodName(t, m)
}
func NewVM() *VM {
r := goja.New()
// enable console for backwards compatibility
c := console{
Log{
Logger: logger.Logger,
},
}
// there should not be any reason for this to fail
_ = c.AddToVM("console", &VM{Runtime: r})
r.SetFieldNameMapper(optionalFieldNameMapper{goja.TagFieldNameMapper("json", true)})
return &VM{Runtime: r}
}
type APIAdder interface {
AddToVM(globalName string, vm *VM) error
}
type ObjectValueDef struct {
Name string
Value interface{}
}
type setter interface {
Set(name string, value interface{}) error
}
func Compile(path string) (*goja.Program, error) {
js, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
return goja.Compile(path, string(js), true)
}
func CompileScript(name, script string) (*goja.Program, error) {
return goja.Compile(name, string(script), true)
}
func SetAll(s setter, defs ...ObjectValueDef) error {
for _, def := range defs {
if err := s.Set(def.Name, def.Value); err != nil {
return fmt.Errorf("failed to set %s: %w", def.Name, err)
}
}
return nil
}
func (v *VM) Throw(err error) {
e, newErr := v.New(v.Get("Error"), v.ToValue(err))
if newErr != nil {
panic(newErr)
}
panic(e)
}

View File

@@ -5,22 +5,24 @@ import (
"time"
)
type JobExecFn func(ctx context.Context, progress *Progress) error
// JobExec represents the implementation of a Job to be executed.
type JobExec interface {
Execute(ctx context.Context, progress *Progress)
Execute(ctx context.Context, progress *Progress) error
}
type jobExecImpl struct {
fn func(ctx context.Context, progress *Progress)
fn JobExecFn
}
func (j *jobExecImpl) Execute(ctx context.Context, progress *Progress) {
j.fn(ctx, progress)
func (j *jobExecImpl) Execute(ctx context.Context, progress *Progress) error {
return j.fn(ctx, progress)
}
// MakeJobExec returns a simple JobExec implementation using the provided
// function.
func MakeJobExec(fn func(ctx context.Context, progress *Progress)) JobExec {
func MakeJobExec(fn JobExecFn) JobExec {
return &jobExecImpl{
fn: fn,
}
@@ -56,6 +58,7 @@ type Job struct {
StartTime *time.Time
EndTime *time.Time
AddTime time.Time
Error *string
outerCtx context.Context
exec JobExec
@@ -87,6 +90,12 @@ func (j *Job) cancel() {
}
}
func (j *Job) error(err error) {
errStr := err.Error()
j.Error = &errStr
j.Status = StatusFailed
}
// IsCancelled returns true if cancel has been called on the context.
func IsCancelled(ctx context.Context) bool {
select {

View File

@@ -206,7 +206,10 @@ func (m *Manager) executeJob(ctx context.Context, j *Job, done chan struct{}) {
}()
progress := m.newProgress(j)
j.exec.Execute(ctx, progress)
if err := j.exec.Execute(ctx, progress); err != nil {
logger.Errorf("task failed due to error: %v", err)
j.error(err)
}
}
func (m *Manager) onJobFinish(job *Job) {

View File

@@ -24,7 +24,7 @@ func newTestExec(finish chan struct{}) *testExec {
}
}
func (e *testExec) Execute(ctx context.Context, p *Progress) {
func (e *testExec) Execute(ctx context.Context, p *Progress) error {
e.progress = p
close(e.started)
@@ -38,6 +38,8 @@ func (e *testExec) Execute(ctx context.Context, p *Progress) {
// fall through
}
}
return nil
}
func TestAdd(t *testing.T) {

View File

@@ -27,6 +27,8 @@ type GalleryFilterType struct {
AverageResolution *ResolutionCriterionInput `json:"average_resolution"`
// Filter to only include scenes which have chapters. `true` or `false`
HasChapters *string `json:"has_chapters"`
// Filter to only include galleries with these scenes
Scenes *MultiCriterionInput `json:"scenes"`
// Filter to only include galleries with this studio
Studios *HierarchicalMultiCriterionInput `json:"studios"`
// Filter to only include galleries with these tags

View File

@@ -18,6 +18,7 @@ type Studio struct {
CreatedAt json.JSONTime `json:"created_at,omitempty"`
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
Rating int `json:"rating,omitempty"`
Favorite bool `json:"favorite,omitempty"`
Details string `json:"details,omitempty"`
Aliases []string `json:"aliases,omitempty"`
StashIDs []models.StashID `json:"stash_ids,omitempty"`

View File

@@ -12,6 +12,7 @@ import (
type Tag struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
Favorite bool `json:"favorite,omitempty"`
Aliases []string `json:"aliases,omitempty"`
Image string `json:"image,omitempty"`
Parents []string `json:"parents,omitempty"`

View File

@@ -111,6 +111,7 @@ type File interface {
Base() *BaseFile
SetFingerprints(fp Fingerprints)
Open(fs FS) (io.ReadCloser, error)
Clone() File
}
// BaseFile represents a file in the file system.
@@ -173,6 +174,12 @@ func (f *BaseFile) Open(fs FS) (io.ReadCloser, error) {
return fs.Open(f.Path)
}
func (f *BaseFile) Clone() (ret File) {
clone := *f
ret = &clone
return
}
func (f *BaseFile) Info(fs FS) (fs.FileInfo, error) {
return f.info(fs, f.Path)
}
@@ -249,6 +256,13 @@ func (f ImageFile) GetFormat() string {
return f.Format
}
func (f ImageFile) Clone() (ret File) {
clone := f
clone.BaseFile = f.BaseFile.Clone().(*BaseFile)
ret = &clone
return
}
// VideoFile is an extension of BaseFile to represent video files.
type VideoFile struct {
*BaseFile
@@ -277,6 +291,13 @@ func (f VideoFile) GetFormat() string {
return f.Format
}
func (f VideoFile) Clone() (ret File) {
clone := f
clone.BaseFile = f.BaseFile.Clone().(*BaseFile)
ret = &clone
return
}
// #1572 - Inf and NaN values cause the JSON marshaller to fail
// Replace these values with 0 rather than erroring

View File

@@ -14,6 +14,7 @@ type Studio struct {
UpdatedAt time.Time `json:"updated_at"`
// Rating expressed in 1-100 scale
Rating *int `json:"rating"`
Favorite bool `json:"favorite"`
Details string `json:"details"`
IgnoreAutoTag bool `json:"ignore_auto_tag"`
@@ -37,6 +38,7 @@ type StudioPartial struct {
ParentID OptionalInt
// Rating expressed in 1-100 scale
Rating OptionalInt
Favorite OptionalBool
Details OptionalString
CreatedAt OptionalTime
UpdatedAt OptionalTime

View File

@@ -7,6 +7,7 @@ import (
type Tag struct {
ID int `json:"id"`
Name string `json:"name"`
Favorite bool `json:"favorite"`
Description string `json:"description"`
IgnoreAutoTag bool `json:"ignore_auto_tag"`
CreatedAt time.Time `json:"created_at"`
@@ -24,6 +25,7 @@ func NewTag() Tag {
type TagPartial struct {
Name OptionalString
Description OptionalString
Favorite OptionalBool
IgnoreAutoTag OptionalBool
CreatedAt OptionalTime
UpdatedAt OptionalTime

View File

@@ -160,6 +160,8 @@ type PerformerFilterType struct {
ImageCount *IntCriterionInput `json:"image_count"`
// Filter by gallery count
GalleryCount *IntCriterionInput `json:"gallery_count"`
// Filter by play count
PlayCount *IntCriterionInput `json:"play_count"`
// Filter by O count
OCounter *IntCriterionInput `json:"o_counter"`
// Filter by StashID

View File

@@ -2,7 +2,7 @@ package models
import "context"
// FileGetter provides methods to get files by ID.
// BlobReader provides methods to get files by ID.
type BlobReader interface {
EntryExists(ctx context.Context, checksum string) (bool, error)
}

View File

@@ -43,6 +43,8 @@ type SceneFilterType struct {
Orientation *OrientationCriterionInput `json:"orientation"`
// Filter by framerate
Framerate *IntCriterionInput `json:"framerate"`
// Filter by bitrate
Bitrate *IntCriterionInput `json:"bitrate"`
// Filter by video codec
VideoCodec *StringCriterionInput `json:"video_codec"`
// Filter by audio codec
@@ -57,6 +59,8 @@ type SceneFilterType struct {
Studios *HierarchicalMultiCriterionInput `json:"studios"`
// Filter to only include scenes with this movie
Movies *MultiCriterionInput `json:"movies"`
// Filter to only include scenes with this gallery
Galleries *MultiCriterionInput `json:"galleries"`
// Filter to only include scenes with these tags
Tags *HierarchicalMultiCriterionInput `json:"tags"`
// Filter by tag count
@@ -89,6 +93,8 @@ type SceneFilterType struct {
PlayCount *IntCriterionInput `json:"play_count"`
// Filter by play duration (in seconds)
PlayDuration *IntCriterionInput `json:"play_duration"`
// Filter by last played at
LastPlayedAt *TimestampCriterionInput `json:"last_played_at"`
// Filter by date
Date *DateCriterionInput `json:"date"`
// Filter by created at

View File

@@ -16,6 +16,8 @@ type StudioFilterType struct {
IsMissing *string `json:"is_missing"`
// Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"`
// Filter by favorite
Favorite *bool `json:"favorite"`
// Filter by scene count
SceneCount *IntCriterionInput `json:"scene_count"`
// Filter by image count
@@ -44,6 +46,7 @@ type StudioCreateInput struct {
Image *string `json:"image"`
StashIds []StashID `json:"stash_ids"`
Rating100 *int `json:"rating100"`
Favorite *bool `json:"favorite"`
Details *string `json:"details"`
Aliases []string `json:"aliases"`
IgnoreAutoTag *bool `json:"ignore_auto_tag"`
@@ -58,6 +61,7 @@ type StudioUpdateInput struct {
Image *string `json:"image"`
StashIds []StashID `json:"stash_ids"`
Rating100 *int `json:"rating100"`
Favorite *bool `json:"favorite"`
Details *string `json:"details"`
Aliases []string `json:"aliases"`
IgnoreAutoTag *bool `json:"ignore_auto_tag"`

View File

@@ -8,6 +8,8 @@ type TagFilterType struct {
Name *StringCriterionInput `json:"name"`
// Filter by tag aliases
Aliases *StringCriterionInput `json:"aliases"`
// Filter by tag favorites
Favorite *bool `json:"favorite"`
// Filter by tag description
Description *StringCriterionInput `json:"description"`
// Filter to only include tags missing this property

View File

@@ -177,7 +177,7 @@ func NewOptionalBoolPtr(v *bool) OptionalBool {
return OptionalBool{*v, false, true}
}
// OptionalBool represents an optional float64 argument that may be null. See OptionalString.
// OptionalFloat64 represents an optional float64 argument that may be null. See OptionalString.
type OptionalFloat64 struct {
Value float64
Null bool

View File

@@ -77,6 +77,14 @@ func (m ArgsMap) Float(key string) float64 {
return ret
}
func (m ArgsMap) ToMap() map[string]interface{} {
ret := make(map[string]interface{})
for k, v := range m {
ret[k] = v
}
return ret
}
// PluginInput is the data structure that is sent to plugin instances when they
// are spawned.
type PluginInput struct {

View File

@@ -10,6 +10,7 @@ import (
"strings"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/python"
"github.com/stashapp/stash/pkg/utils"
"gopkg.in/yaml.v2"
)
@@ -294,16 +295,18 @@ func (c Config) getConfigPath() string {
}
func (c Config) getExecCommand(task *OperationConfig) []string {
ret := c.Exec
// #4859 - don't modify the original exec command
ret := append([]string{}, c.Exec...)
if task != nil {
ret = append(ret, task.ExecArgs...)
}
if len(ret) > 0 {
// #4859 - don't use the plugin path in the exec command if it is a python command
if len(ret) > 0 && !python.IsPythonCommand(ret[0]) {
_, err := exec.LookPath(ret[0])
if err != nil {
// change command to use absolute path
// change command to run from the plugin path
pluginPath := filepath.Dir(c.path)
ret[0] = filepath.Join(pluginPath, ret[0])
}

View File

@@ -7,9 +7,10 @@ import (
"path/filepath"
"sync"
"github.com/robertkrimen/otto"
"github.com/dop251/goja"
"github.com/stashapp/stash/pkg/javascript"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/plugin/common"
"github.com/stashapp/stash/pkg/plugin/js"
)
var errStop = errors.New("stop")
@@ -27,7 +28,7 @@ type jsPluginTask struct {
started bool
waitGroup sync.WaitGroup
vm *otto.Otto
vm *javascript.VM
}
func (t *jsPluginTask) onError(err error) {
@@ -37,24 +38,73 @@ func (t *jsPluginTask) onError(err error) {
}
}
func (t *jsPluginTask) makeOutput(o otto.Value) {
func (t *jsPluginTask) makeOutput(o goja.Value) {
t.result = &common.PluginOutput{}
asObj := o.Object()
asObj := o.ToObject(t.vm.Runtime)
if asObj == nil {
return
}
output, _ := asObj.Get("Output")
t.result.Output, _ = output.Export()
err, _ := asObj.Get("Error")
if !err.IsUndefined() {
t.result.Output = asObj.Get("Output")
err := asObj.Get("Error")
if !goja.IsNull(err) && !goja.IsUndefined(err) {
errStr := err.String()
t.result.Error = &errStr
}
}
func (t *jsPluginTask) initVM() error {
// converting the Args field to map[string]interface{} is required, otherwise
// it gets converted to an empty object
// ideally this should have included json tags with the correct casing but changing
// it now will result in a breaking change
type pluginInput struct {
// Server details to connect to the stash server.
ServerConnection common.StashServerConnection
// Arguments to the plugin operation.
Args map[string]interface{}
}
input := pluginInput{
ServerConnection: t.input.ServerConnection,
Args: t.input.Args.ToMap(),
}
if err := t.vm.Set("input", input); err != nil {
return fmt.Errorf("error setting input: %w", err)
}
const pluginPrefix = "[Plugin / %s] "
log := &javascript.Log{
Logger: logger.Logger,
Prefix: fmt.Sprintf(pluginPrefix, t.plugin.Name),
ProgressChan: t.progress,
}
if err := log.AddToVM("log", t.vm); err != nil {
return fmt.Errorf("error adding log API: %w", err)
}
util := &javascript.Util{}
if err := util.AddToVM("util", t.vm); err != nil {
return fmt.Errorf("error adding util API: %w", err)
}
gql := &javascript.GQL{
Context: context.TODO(),
Cookie: t.input.ServerConnection.SessionCookie,
GQLHandler: t.gqlHandler,
}
if err := gql.AddToVM("gql", t.vm); err != nil {
return fmt.Errorf("error adding GraphQL API: %w", err)
}
return nil
}
func (t *jsPluginTask) Start() error {
if t.started {
return errors.New("task already started")
@@ -68,31 +118,17 @@ func (t *jsPluginTask) Start() error {
scriptFile := t.plugin.Exec[0]
t.vm = otto.New()
t.vm = javascript.NewVM()
pluginPath := t.plugin.getConfigPath()
script, err := t.vm.Compile(filepath.Join(pluginPath, scriptFile), nil)
script, err := javascript.Compile(filepath.Join(pluginPath, scriptFile))
if err != nil {
return err
}
if err := t.vm.Set("input", t.input); err != nil {
return fmt.Errorf("error setting input: %w", err)
if err := t.initVM(); err != nil {
return err
}
if err := js.AddLogAPI(t.vm, t.progress); err != nil {
return fmt.Errorf("error adding log API: %w", err)
}
if err := js.AddUtilAPI(t.vm); err != nil {
return fmt.Errorf("error adding util API: %w", err)
}
if err := js.AddGQLAPI(context.TODO(), t.vm, t.input.ServerConnection.SessionCookie, t.gqlHandler); err != nil {
return fmt.Errorf("error adding GraphQL API: %w", err)
}
t.vm.Interrupt = make(chan func(), 1)
t.waitGroup.Add(1)
go func() {
@@ -107,7 +143,7 @@ func (t *jsPluginTask) Start() error {
}
}()
output, err := t.vm.Run(script)
output, err := t.vm.RunProgram(script)
if err != nil {
t.onError(err)
@@ -124,9 +160,6 @@ func (t *jsPluginTask) Wait() {
}
func (t *jsPluginTask) Stop() error {
// TODO - need another way of doing this that doesn't require panic
t.vm.Interrupt <- func() {
panic(errStop)
}
t.vm.Interrupt(errStop)
return nil
}

View File

@@ -1,119 +0,0 @@
package js
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"github.com/robertkrimen/otto"
)
type responseWriter struct {
r strings.Builder
header http.Header
statusCode int
}
func (w *responseWriter) Header() http.Header {
return w.header
}
func (w *responseWriter) WriteHeader(statusCode int) {
w.statusCode = statusCode
}
func (w *responseWriter) Write(b []byte) (int, error) {
return w.r.Write(b)
}
func throw(vm *otto.Otto, str string) {
value, _ := vm.Call("new Error", nil, str)
panic(value)
}
func gqlRequestFunc(ctx context.Context, vm *otto.Otto, cookie *http.Cookie, gqlHandler http.Handler) func(call otto.FunctionCall) otto.Value {
return func(call otto.FunctionCall) otto.Value {
if len(call.ArgumentList) == 0 {
throw(vm, "missing argument")
}
query := call.Argument(0)
vars := call.Argument(1)
var variables map[string]interface{}
if !vars.IsUndefined() {
exported, _ := vars.Export()
variables, _ = exported.(map[string]interface{})
}
in := struct {
Query string `json:"query"`
Variables map[string]interface{} `json:"variables,omitempty"`
}{
Query: query.String(),
Variables: variables,
}
var body bytes.Buffer
err := json.NewEncoder(&body).Encode(in)
if err != nil {
throw(vm, err.Error())
}
r, err := http.NewRequestWithContext(ctx, "POST", "/graphql", &body)
if err != nil {
throw(vm, "could not make request")
}
r.Header.Set("Content-Type", "application/json")
if cookie != nil {
r.AddCookie(cookie)
}
w := &responseWriter{
header: make(http.Header),
}
gqlHandler.ServeHTTP(w, r)
if w.statusCode != http.StatusOK && w.statusCode != 0 {
throw(vm, fmt.Sprintf("graphQL query failed: %d - %s. Query: %s. Variables: %v", w.statusCode, w.r.String(), in.Query, in.Variables))
}
output := w.r.String()
// convert to JSON
var obj map[string]interface{}
if err = json.Unmarshal([]byte(output), &obj); err != nil {
throw(vm, fmt.Sprintf("could not unmarshal object %s: %s", output, err.Error()))
}
retErr, hasErr := obj["errors"]
if hasErr {
errOut, _ := json.Marshal(retErr)
throw(vm, fmt.Sprintf("graphql error: %s", string(errOut)))
}
v, err := vm.ToValue(obj["data"])
if err != nil {
throw(vm, fmt.Sprintf("could not create return value: %s", err.Error()))
}
return v
}
}
func AddGQLAPI(ctx context.Context, vm *otto.Otto, cookie *http.Cookie, gqlHandler http.Handler) error {
gql, _ := vm.Object("({})")
if err := gql.Set("Do", gqlRequestFunc(ctx, vm, cookie, gqlHandler)); err != nil {
return fmt.Errorf("unable to set GraphQL Do function: %w", err)
}
if err := vm.Set("gql", gql); err != nil {
return fmt.Errorf("unable to set gql: %w", err)
}
return nil
}

View File

@@ -1,96 +0,0 @@
package js
import (
"encoding/json"
"fmt"
"math"
"github.com/robertkrimen/otto"
"github.com/stashapp/stash/pkg/logger"
)
const pluginPrefix = "[Plugin] "
func argToString(call otto.FunctionCall) string {
arg := call.Argument(0)
if arg.IsObject() {
o, _ := arg.Export()
data, err := json.Marshal(o)
if err != nil {
logger.Warnf("Couldn't json encode object")
}
return string(data)
}
return arg.String()
}
func logTrace(call otto.FunctionCall) otto.Value {
logger.Trace(pluginPrefix + argToString(call))
return otto.UndefinedValue()
}
func logDebug(call otto.FunctionCall) otto.Value {
logger.Debug(pluginPrefix + argToString(call))
return otto.UndefinedValue()
}
func logInfo(call otto.FunctionCall) otto.Value {
logger.Info(pluginPrefix + argToString(call))
return otto.UndefinedValue()
}
func logWarn(call otto.FunctionCall) otto.Value {
logger.Warn(pluginPrefix + argToString(call))
return otto.UndefinedValue()
}
func logError(call otto.FunctionCall) otto.Value {
logger.Error(pluginPrefix + argToString(call))
return otto.UndefinedValue()
}
// Progress logs the current progress value. The progress value should be
// between 0 and 1.0 inclusively, with 1 representing that the task is
// complete. Values outside of this range will be clamp to be within it.
func logProgressFunc(c chan float64) func(call otto.FunctionCall) otto.Value {
return func(call otto.FunctionCall) otto.Value {
arg := call.Argument(0)
if !arg.IsNumber() {
return otto.UndefinedValue()
}
progress, _ := arg.ToFloat()
progress = math.Min(math.Max(0, progress), 1)
c <- progress
return otto.UndefinedValue()
}
}
func AddLogAPI(vm *otto.Otto, progress chan float64) error {
log, _ := vm.Object("({})")
if err := log.Set("Trace", logTrace); err != nil {
return fmt.Errorf("error setting Trace: %w", err)
}
if err := log.Set("Debug", logDebug); err != nil {
return fmt.Errorf("error setting Debug: %w", err)
}
if err := log.Set("Info", logInfo); err != nil {
return fmt.Errorf("error setting Info: %w", err)
}
if err := log.Set("Warn", logWarn); err != nil {
return fmt.Errorf("error setting Warn: %w", err)
}
if err := log.Set("Error", logError); err != nil {
return fmt.Errorf("error setting Error: %w", err)
}
if err := log.Set("Progress", logProgressFunc(progress)); err != nil {
return fmt.Errorf("error setting Progress: %v", err)
}
if err := vm.Set("log", log); err != nil {
return fmt.Errorf("unable to set log: %w", err)
}
return nil
}

View File

@@ -1,29 +0,0 @@
package js
import (
"fmt"
"time"
"github.com/robertkrimen/otto"
)
func sleepFunc(call otto.FunctionCall) otto.Value {
arg := call.Argument(0)
ms, _ := arg.ToInteger()
time.Sleep(time.Millisecond * time.Duration(ms))
return otto.UndefinedValue()
}
func AddUtilAPI(vm *otto.Otto) error {
util, _ := vm.Object("({})")
if err := util.Set("Sleep", sleepFunc); err != nil {
return fmt.Errorf("unable to set sleep func: %w", err)
}
if err := vm.Set("util", util); err != nil {
return fmt.Errorf("unable to set util: %w", err)
}
return nil
}

View File

@@ -91,7 +91,7 @@ type PluginSetting struct {
type ServerConfig interface {
GetHost() string
GetPort() int
GetConfigPath() string
GetConfigPathAbs() string
HasTLSConfig() bool
GetPluginsPath() string
GetDisabledPlugins() []string
@@ -249,7 +249,7 @@ func (c Cache) makeServerConnection(ctx context.Context) common.StashServerConne
Host: c.config.GetHost(),
Port: c.config.GetPort(),
SessionCookie: cookie,
Dir: c.config.GetConfigPath(),
Dir: c.config.GetConfigPathAbs(),
}
if c.config.HasTLSConfig() {

View File

@@ -35,7 +35,7 @@ func Resolve(configuredPythonPath string) (*Python, error) {
case err == nil && !isFile:
logger.Warnf("configured python path is not a file: %s", configuredPythonPath)
case err != nil:
logger.Warnf("unable to use configured python path: %s", err)
logger.Warnf("unable to use configured python path: %v", err)
}
}
@@ -44,7 +44,7 @@ func Resolve(configuredPythonPath string) (*Python, error) {
if err != nil {
python, err := exec.LookPath("python")
if err != nil {
return nil, fmt.Errorf("python executable not in PATH: %s", err)
return nil, fmt.Errorf("python executable not in PATH: %w", err)
}
ret := Python(python)
return &ret, nil

View File

@@ -32,19 +32,19 @@ func (g Generator) TranscodeVideo(ctx context.Context, input string, hash string
}
// TranscodeAudio will copy the video stream as is, and transcode audio.
func (g Generator) TranscodeAudio(ctx context.Context, input string, hash string, options TranscodeOptions) error {
func (g Generator) TranscodeAudio(ctx context.Context, input string, hash string) error {
lockCtx := g.LockManager.ReadLock(ctx, input)
defer lockCtx.Cancel()
return g.makeTranscode(lockCtx, hash, g.transcodeAudio(input, options))
return g.makeTranscode(lockCtx, hash, g.transcodeAudio(input))
}
// TranscodeCopyVideo will copy the video stream as is, and drop the audio stream.
func (g Generator) TranscodeCopyVideo(ctx context.Context, input string, hash string, options TranscodeOptions) error {
func (g Generator) TranscodeCopyVideo(ctx context.Context, input string, hash string) error {
lockCtx := g.LockManager.ReadLock(ctx, input)
defer lockCtx.Cancel()
return g.makeTranscode(lockCtx, hash, g.transcodeCopyVideo(input, options))
return g.makeTranscode(lockCtx, hash, g.transcodeCopyVideo(input))
}
func (g Generator) makeTranscode(lockCtx *fsutil.LockContext, hash string, generateFn generateFn) error {
@@ -129,19 +129,11 @@ func (g Generator) transcodeVideo(input string, options TranscodeOptions) genera
}
}
func (g Generator) transcodeAudio(input string, options TranscodeOptions) generateFn {
func (g Generator) transcodeAudio(input string) generateFn {
return func(lockCtx *fsutil.LockContext, tmpFn string) error {
var videoArgs ffmpeg.Args
if options.Width != 0 && options.Height != 0 {
var videoFilter ffmpeg.VideoFilter
videoFilter = videoFilter.ScaleDimensions(options.Width, options.Height)
videoArgs = videoArgs.VideoFilter(videoFilter)
}
args := transcoder.Transcode(input, transcoder.TranscodeOptions{
OutputPath: tmpFn,
VideoCodec: ffmpeg.VideoCodecCopy,
VideoArgs: videoArgs,
AudioCodec: ffmpeg.AudioCodecAAC,
})
@@ -149,14 +141,8 @@ func (g Generator) transcodeAudio(input string, options TranscodeOptions) genera
}
}
func (g Generator) transcodeCopyVideo(input string, options TranscodeOptions) generateFn {
func (g Generator) transcodeCopyVideo(input string) generateFn {
return func(lockCtx *fsutil.LockContext, tmpFn string) error {
var videoArgs ffmpeg.Args
if options.Width != 0 && options.Height != 0 {
var videoFilter ffmpeg.VideoFilter
videoFilter = videoFilter.ScaleDimensions(options.Width, options.Height)
videoArgs = videoArgs.VideoFilter(videoFilter)
}
var audioArgs ffmpeg.Args
audioArgs = audioArgs.SkipAudio()
@@ -164,7 +150,6 @@ func (g Generator) transcodeCopyVideo(input string, options TranscodeOptions) ge
args := transcoder.Transcode(input, transcoder.TranscodeOptions{
OutputPath: tmpFn,
VideoCodec: ffmpeg.VideoCodecCopy,
VideoArgs: videoArgs,
AudioArgs: audioArgs,
})

View File

@@ -11,9 +11,9 @@ import (
"strings"
"time"
"github.com/robertkrimen/otto"
"gopkg.in/yaml.v2"
"github.com/stashapp/stash/pkg/javascript"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil"
@@ -528,19 +528,19 @@ func (p *postProcessLbToKg) Apply(ctx context.Context, value string, q mappedQue
type postProcessJavascript string
func (p *postProcessJavascript) Apply(ctx context.Context, value string, q mappedQuery) string {
vm := otto.New()
vm := javascript.NewVM()
if err := vm.Set("value", value); err != nil {
logger.Warnf("javascript failed to set value: %v", err)
return value
}
script, err := vm.Compile("", "(function() { "+string(*p)+"})()")
script, err := javascript.CompileScript("", "(function() { "+string(*p)+"})()")
if err != nil {
logger.Warnf("javascript failed to compile: %v", err)
return value
}
output, err := vm.Run(script)
output, err := vm.RunProgram(script)
if err != nil {
logger.Warnf("javascript failed to run: %v", err)
return value

View File

@@ -1034,6 +1034,7 @@ type TagQueryInput struct {
// Filter to search name - assumes like query unless quoted
Name *string `json:"name,omitempty"`
// Filter to category ID
IsFavorite *bool `json:"is_favorite,omitempty"`
CategoryID *string `json:"category_id,omitempty"`
Page int `json:"page"`
PerPage int `json:"per_page"`

View File

@@ -676,6 +676,9 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc
return !strings.EqualFold(s, p.Name)
})
// #4596 - stash-box may return duplicate aliases. Filter these out
p.Aliases = stringslice.UniqueFold(p.Aliases)
alias := strings.Join(p.Aliases, ", ")
sp.Aliases = &alias
}

View File

@@ -29,3 +29,18 @@ func FromString(s string, sep string) []string {
}
return v
}
// Unique returns a slice containing only unique values from the provided slice.
// The comparison is case-insensitive.
func UniqueFold(s []string) []string {
seen := make(map[string]struct{})
var ret []string
for _, v := range s {
if _, exists := seen[strings.ToLower(v)]; exists {
continue
}
seen[strings.ToLower(v)] = struct{}{}
ret = append(ret, v)
}
return ret
}

View File

@@ -10,9 +10,6 @@ import (
"path/filepath"
"time"
"github.com/golang-migrate/migrate/v4"
sqlite3mig "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/fsutil"
@@ -33,7 +30,7 @@ const (
dbConnTimeout = 30
)
var appSchemaVersion uint = 55
var appSchemaVersion uint = 58
//go:embed migrations/*.sql
var migrationsBox embed.FS
@@ -144,7 +141,7 @@ func (db *Database) Open(dbPath string) error {
if databaseSchemaVersion == 0 {
// new database, just run the migrations
if err := db.RunMigrations(); err != nil {
if err := db.RunAllMigrations(); err != nil {
return fmt.Errorf("error running initial schema migrations: %w", err)
}
} else {
@@ -312,11 +309,6 @@ func (db *Database) RestoreFromBackup(backupPath string) error {
return os.Rename(backupPath, db.dbPath)
}
// Migrate the database
func (db *Database) needsMigration() bool {
return db.schemaVersion != appSchemaVersion
}
func (db *Database) AppSchemaVersion() uint {
return appSchemaVersion
}
@@ -349,100 +341,6 @@ func (db *Database) Version() uint {
return db.schemaVersion
}
func (db *Database) getMigrate() (*migrate.Migrate, error) {
migrations, err := iofs.New(migrationsBox, "migrations")
if err != nil {
return nil, err
}
const disableForeignKeys = true
conn, err := db.open(disableForeignKeys)
if err != nil {
return nil, err
}
driver, err := sqlite3mig.WithInstance(conn.DB, &sqlite3mig.Config{})
if err != nil {
return nil, err
}
// use sqlite3Driver so that migration has access to durationToTinyInt
return migrate.NewWithInstance(
"iofs",
migrations,
db.dbPath,
driver,
)
}
func (db *Database) getDatabaseSchemaVersion() (uint, error) {
m, err := db.getMigrate()
if err != nil {
return 0, err
}
defer m.Close()
ret, _, _ := m.Version()
return ret, nil
}
// Migrate the database
func (db *Database) RunMigrations() error {
ctx := context.Background()
m, err := db.getMigrate()
if err != nil {
return err
}
defer m.Close()
databaseSchemaVersion, _, _ := m.Version()
stepNumber := appSchemaVersion - databaseSchemaVersion
if stepNumber != 0 {
logger.Infof("Migrating database from version %d to %d", databaseSchemaVersion, appSchemaVersion)
// run each migration individually, and run custom migrations as needed
var i uint = 1
for ; i <= stepNumber; i++ {
newVersion := databaseSchemaVersion + i
// run pre migrations as needed
if err := db.runCustomMigrations(ctx, preMigrations[newVersion]); err != nil {
return fmt.Errorf("running pre migrations for schema version %d: %w", newVersion, err)
}
err = m.Steps(1)
if err != nil {
// migration failed
return err
}
// run post migrations as needed
if err := db.runCustomMigrations(ctx, postMigrations[newVersion]); err != nil {
return fmt.Errorf("running post migrations for schema version %d: %w", newVersion, err)
}
}
}
// update the schema version
db.schemaVersion, _, _ = m.Version()
// re-initialise the database
const disableForeignKeys = false
db.db, err = db.open(disableForeignKeys)
if err != nil {
return fmt.Errorf("re-initializing the database: %w", err)
}
// optimize database after migration
err = db.Optimise(ctx)
if err != nil {
logger.Warnf("error while performing post-migration optimisation: %v", err)
}
return nil
}
func (db *Database) Optimise(ctx context.Context) error {
logger.Info("Optimising database")
@@ -524,28 +422,3 @@ func (db *Database) QuerySQL(ctx context.Context, query string, args []interface
return cols, ret, nil
}
func (db *Database) runCustomMigrations(ctx context.Context, fns []customMigrationFunc) error {
for _, fn := range fns {
if err := db.runCustomMigration(ctx, fn); err != nil {
return err
}
}
return nil
}
func (db *Database) runCustomMigration(ctx context.Context, fn customMigrationFunc) error {
const disableForeignKeys = false
d, err := db.open(disableForeignKeys)
if err != nil {
return err
}
defer d.Close()
if err := fn(ctx, d); err != nil {
return err
}
return nil
}

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