Compare commits

...

87 Commits

Author SHA1 Message Date
7dJx1qP
8e222ae387 Fix locale import missing translation properties (#2772)
* Fix locale import missing translation properties
2022-07-25 10:46:43 +10:00
WithoutPants
7f86509674 Update changelog 2022-07-22 17:34:39 +10:00
WithoutPants
4b7ef76321 Focus scene player after loading new scene (#2758)
* Focus scene player after loading new scene
2022-07-22 17:32:03 +10:00
WithoutPants
c21c334553 Fix % character in tag name causing UI crash (#2757)
* Fix % character in tag name causing UI crash
2022-07-22 17:29:16 +10:00
WithoutPants
db3138b33f Fix keyboard shortcuts not working after selecting (#2756)
* Fix keyboard shortcuts not working after selecting
2022-07-22 17:25:48 +10:00
WithoutPants
e532f9a683 Allow unauthenticated access to UI assets (#2755)
* Allow unauthenticated access to UI assets
2022-07-22 17:23:40 +10:00
peolic
b8262f5641 Fix non-default video stream from ffprobe result (#2752)
* Fix non-default video stream from ffprobe result
2022-07-22 17:21:39 +10:00
WithoutPants
6f7cc11b86 Fix incorrect image displayed in lightbox (#2754)
* Fix incorrect image displayed in lightbox
* Add changelog
2022-07-22 17:18:11 +10:00
WithoutPants
9ac5d3f3d9 Fix messages when adding/removing gallery image (#2748) 2022-07-18 11:07:17 +10:00
WithoutPants
11d5c7f386 Fix UI crash when scene has stash ids but no stash-box configured (#2749)
* Add error boundary to show navbar on error
* Fix crash when scene has stash-ids but no stash-box configured
2022-07-18 10:46:39 +10:00
WithoutPants
2427519100 Enable automated build for files-refactor (#2738) 2022-07-13 15:46:44 +10:00
DingDongSoLong4
91e3fcc7ff Fix lodash-es typos (#2724)
* Fix LocalForage not saving items
* Fix bulk update typos
2022-07-13 10:08:02 +10:00
kermieisinthehouse
4fa103dd08 Fix alphabetical css (#2733) 2022-07-09 21:51:39 -07:00
erri120
3a3a296995 Add nowrap to rating stars and overflow-wrap to title for mobile (#2731) 2022-07-09 21:23:17 -07:00
stash-translation-bot
351472f654 Translations update from Stash (#2690)
* Translated using Weblate (French)

Currently translated at 93.7% (768 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/

* Added translation using Weblate (Thai)

* Translated using Weblate (Thai)

Currently translated at 5.2% (43 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/th/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (819 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hans/

* Translated using Weblate (Korean)

Currently translated at 99.3% (814 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/ko/

* Translated using Weblate (Korean)

Currently translated at 99.8% (818 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/ko/

* Translated using Weblate (English (United Kingdom))

Currently translated at 100.0% (819 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/en_GB/

* Translated using Weblate (Romanian)

Currently translated at 45.6% (374 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/ro/

Co-authored-by: - <adr.web@hotmail.fr>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Mr.Kay <taksayir@gmail.com>
Co-authored-by: Philip Wang <philpw99@gmail.com>
Co-authored-by: Barack Obama <starebod@gmail.com>
Co-authored-by: yc <yechan24680@gmail.com>
Co-authored-by: Bogdan <bogddan1337@pm.me>
2022-07-04 17:22:53 -07:00
bnkai
92acd78401 Fix name,path filter naming (#2712)
* Fix name,path filter naming

* * fmt-ui
2022-07-04 17:07:19 -07:00
Russell Harmon
813495c7f7 Set crossorigin use-credentials for manifest.json. (#2706)
This is necessary when running behind a password-protected reverse
proxy, as otherwise Chrome doesn't send HTTP basic auth credentials when
requesting this file.

See
https://stackoverflow.com/questions/6294622/html5-manifest-cache-behind-basic-auth
for more information.
2022-06-29 21:49:04 -07:00
WithoutPants
d32e375521 Treat edited values as scraped (#2702)
* Treat edited values as scraped
* Fix scrape selection on change
2022-06-30 09:25:13 +10:00
DingDongSoLong4
b3bc5b999f Fix saved filter search (#2698)
* Fix saved filter search excluding filters with uppercase characters
2022-06-27 11:38:13 +10:00
WithoutPants
f3e6cb7b0e Fix proxy prefix replacement (#2694) 2022-06-27 09:53:40 +10:00
WithoutPants
5e7bf1c2d7 Update changelog 2022-06-23 11:18:20 +10:00
DingDongSoLong4
63e1bbf35d UI and filter fixes (#2686)
* Use primitive string in recommendation row props
* Use unique keys in recommendation rows

The keys for the cards used while loading clash with the ids of the actual cards, causing a list unique key warning.

* List filter alignment tweaks
* Rework list hook filtering
* Internationalise checksum correctly
2022-06-22 15:45:47 +10:00
WithoutPants
3b4b20e9b2 React code splitting (#2603)
* Code split using react lazy
* Split locales
* Move to lodash-es
* Import individual icons
2022-06-22 14:41:31 +10:00
stash-translation-bot
33b68b4464 Translations update from Stash (#2653)
* Translated using Weblate (Swedish)

Currently translated at 100.0% (818 of 818 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/sv/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.8% (817 of 818 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/pt_BR/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.8% (817 of 818 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/pt_BR/

* Translated using Weblate (French)

Currently translated at 93.7% (767 of 818 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/

* Translated using Weblate (German)

Currently translated at 100.0% (818 of 818 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/de/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/

* Translated using Weblate (Italian)

Currently translated at 100.0% (819 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/it/

* Translated using Weblate (Polish)

Currently translated at 100.0% (819 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/pl/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 99.7% (817 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hant/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/

* Translated using Weblate (Spanish)

Currently translated at 98.1% (804 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/es/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (819 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/ja/

* Translated using Weblate (French)

Currently translated at 93.5% (766 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/

* Translated using Weblate (French)

Currently translated at 93.7% (768 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 100.0% (819 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/pt_BR/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (819 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/sv/

* Translated using Weblate (German)

Currently translated at 100.0% (819 of 819 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/de/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/

Co-authored-by: Alpaca Serious <srhsgsef@gmail.com>
Co-authored-by: GeneX <gxetrek@gmail.com>
Co-authored-by: ponei <poneialt@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: - <adr.web@hotmail.fr>
Co-authored-by: Phasetime <phasetime@protonmail.com>
Co-authored-by: BViking78 <bviking78@gmail.com>
Co-authored-by: Coscosname <coscosname@gmail.com>
Co-authored-by: Still <dev@stillu.cc>
Co-authored-by: Alex <majocorsi@gmail.com>
Co-authored-by: 風林火山 <nezoko@digdig.org>
Co-authored-by: rather not <big_brother_is_watching4u@protonmail.com>
2022-06-21 19:02:34 -07:00
iampabber
1ab02a1748 Use hotkeys '[' and ']' to scrub video player forwards and backwards by 10% of the scene (#2678)
* Use hotkeys '[' and ']' to scrub video player forwards and backwards by 10% of the scene
* Don't loop back to beginning
* Add manual keybind entry

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2022-06-22 11:33:17 +10:00
bnkai
6cfb7fe79d Fix Synopsis json string in movie jsonschema (#2664)
* Fix Synopsis json string in movie jsonschema
* backwards compatible movie synopsis import
2022-06-22 10:59:39 +10:00
TgSeed
abd76f7e58 Fix/ffprobe unmarshalling error (#2685)
Fix/ffprobe unmarshalling error
2022-06-22 10:49:14 +10:00
WithoutPants
733ca2aa6f Run build on all pull requests 2022-06-19 11:54:00 +10:00
WithoutPants
c2f7617952 Separate filter buttons from query field (#2668) 2022-06-15 11:51:05 +10:00
WithoutPants
75a795b2e6 Add tag recommendation row (#2673) 2022-06-15 11:23:39 +10:00
WithoutPants
6029918d22 Fix ui config conversion (#2672) 2022-06-15 10:31:09 +10:00
dumdum7
582ffa1420 Don't switch to landscape for portrait videos (#2665) 2022-06-14 11:19:12 +10:00
WithoutPants
900ba936a9 Update changelog 2022-06-14 10:41:35 +10:00
WithoutPants
a2e8f69028 Fix scene player event handler initialisation (#2656) 2022-06-14 10:39:46 +10:00
CJ
9264c15540 Customize recommendations (#2592)
* refactored common code in recommendation row
* Implement front page options in config
* Allow customisation from front page
* Rename recommendations to front page
* Add generic UI settings
* Support adding premade filters

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2022-06-14 10:34:04 +10:00
WithoutPants
ff724d82cc Don't trim extension for folders when auto-tagging galleries (#2658) 2022-06-08 09:02:11 +10:00
WithoutPants
456e9409e0 Update scene screenshot in edit panel (#2657) 2022-06-08 08:58:59 +10:00
WithoutPants
5e455d6530 Add alias for Laos (#2655) 2022-06-08 08:58:42 +10:00
WithoutPants
3d52bad9bd Add missing gallery card classes (#2654) 2022-06-08 08:58:25 +10:00
stash-translation-bot
803d865348 Translations update from Stash (#2627)
* Translated using Weblate (French)

Currently translated at 88.8% (725 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/

* Translated using Weblate (German)

Currently translated at 97.6% (797 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/de/

* Translated using Weblate (French)

Currently translated at 89.4% (730 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/

* Translated using Weblate (French)

Currently translated at 89.4% (730 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (816 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hant/

* Added translation using Weblate (Hungarian)

* Translated using Weblate (German)

Currently translated at 98.1% (803 of 818 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/de/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 98.7% (808 of 818 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/pt_BR/

* Translated using Weblate (Polish)

Currently translated at 100.0% (818 of 818 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/pl/

* Translated using Weblate (Hungarian)

Currently translated at 48.5% (397 of 818 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/hu/

* Translated using Weblate (Italian)

Currently translated at 100.0% (818 of 818 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/it/

* Translated using Weblate (Portuguese (Brazil))

Currently translated at 99.7% (816 of 818 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/pt_BR/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (818 of 818 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hant/

* Translated using Weblate (Finnish)

Currently translated at 93.7% (767 of 818 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fi/

* Translated using Weblate (French)

Currently translated at 91.5% (749 of 818 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/

* Translated using Weblate (French)

Currently translated at 91.5% (749 of 818 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/

Co-authored-by: - <adr.web@hotmail.fr>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Phasetime <phasetime@protonmail.com>
Co-authored-by: Still <dev@stillu.cc>
Co-authored-by: Foltin <foltincsaba@gmail.com>
Co-authored-by: ponei <poneialt@gmail.com>
Co-authored-by: Coscosname <coscosname@gmail.com>
Co-authored-by: BViking78 <bviking78@gmail.com>
Co-authored-by: Aa <jarruraita@outlook.com>
2022-06-05 17:06:17 -07:00
Emilo2
8a1c349976 Fix scraping more than 40 scenes from stash-box (#2638) 2022-06-03 09:37:24 +10:00
InfiniteTF
d68d022893 Add support for submitting stash-box scene updates by draft (#2577) 2022-06-01 14:53:31 +10:00
InfiniteTF
e51083c26d Update stash-box fingerprint query to fully support distance matching (#2509) 2022-06-01 12:59:06 +10:00
DingDongSoLong4
49f579e08e Fix gallery updating (#2611) 2022-06-01 11:58:44 +10:00
HijackHornet
1c18ec1501 Fixed windows install links (#2635) 2022-05-30 08:43:01 -07:00
kermieisinthehouse
88111e4064 Remove (Preview) From Korean 2022-05-25 19:37:31 -07:00
kermieisinthehouse
ba2979096a Update README.md 2022-05-25 19:35:24 -07:00
stash-translation-bot
34eb624438 Translations update from Stash (#2604)
* Translated using Weblate (Chinese (Simplified))

Currently translated at 98.7% (806 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hans/

* Translated using Weblate (Korean)

Currently translated at 77.6% (634 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/ko/

* Translated using Weblate (French)

Currently translated at 86.0% (702 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/

* Translated using Weblate (French)

Currently translated at 87.2% (712 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/

* Translated using Weblate (French)

Currently translated at 87.2% (712 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/

* Translated using Weblate (Finnish)

Currently translated at 93.2% (761 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fi/

* Translated using Weblate (French)

Currently translated at 83.7% (683 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/

* Translated using Weblate (Korean)

Currently translated at 100.0% (816 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/ko/

* Translated using Weblate (French)

Currently translated at 87.6% (715 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/

* Translated using Weblate (French)

Currently translated at 87.5% (714 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/

* Translated using Weblate (French)

Currently translated at 87.5% (714 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/fr/

Co-authored-by: LiboSUN <learbo@outlook.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: yc <yechan24680@gmail.com>
Co-authored-by: - <adr.web@hotmail.fr>
Co-authored-by: Aa <jarruraita@outlook.com>
2022-05-25 19:33:11 -07:00
kermieisinthehouse
a142ec223e Add Korean Language (#2601) 2022-05-19 15:38:42 -07:00
WithoutPants
b2ac022357 Update changelog for #2594 2022-05-18 12:11:56 +10:00
stash-translation-bot
2653342ae1 Translations update from Stash (#2589)
* Translated using Weblate (Swedish)

Currently translated at 100.0% (816 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/sv/

* Translated using Weblate (Japanese)

Currently translated at 100.0% (816 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/ja/

* Added translation using Weblate (Romanian)

* Translated using Weblate (Romanian)

Currently translated at 5.7% (47 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/ro/

* Added translation using Weblate (Korean)

* Translated using Weblate (Korean)

Currently translated at 68.8% (562 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/ko/

* Update translation files

Updated by "Remove blank strings" hook in Weblate.

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/

Co-authored-by: Alpaca Serious <srhsgsef@gmail.com>
Co-authored-by: 風林火山 <nezoko@digdig.org>
Co-authored-by: Imagine <pyrolaziano@protonmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: yc <yechan24680@gmail.com>
2022-05-17 19:07:50 -07:00
DingDongSoLong4
df24f90735 Fix gallery scan (#2594)
The incorrect error check means that images is always 0, which means scanImages is also always true, which causes the images inside the zip to be unnecessarily rescanned every time.
2022-05-18 12:03:54 +10:00
kermieisinthehouse
1200d4472a CSS: Fix mobile filter tags (#2584) 2022-05-16 09:50:51 +10:00
kermieisinthehouse
55366fa66f Add Danish and Polish i18n support (#2586) 2022-05-13 12:45:05 -07:00
stash-translation-bot
c232372f0c Translations update from Stash (#2576)
* Translated using Weblate (Swedish)

Currently translated at 100.0% (797 of 797 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/sv/

* Translated using Weblate (Danish)

Currently translated at 100.0% (815 of 815 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/da/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (815 of 815 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/sv/

* Translated using Weblate (Polish)

Currently translated at 99.3% (810 of 815 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/pl/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/

* Translated using Weblate (Italian)

Currently translated at 100.0% (816 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/it/

* Translated using Weblate (Polish)

Currently translated at 100.0% (816 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/pl/

* Translated using Weblate (Danish)

Currently translated at 100.0% (816 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/da/

* Added translation using Weblate (Czech)

* Translated using Weblate (Czech)

Currently translated at 0.4% (4 of 816 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/cs/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/

Co-authored-by: Alpaca Serious <srhsgsef@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Hidden Hiddenson <078emil@protonmail.com>
Co-authored-by: Wasylq <Wasylq@protonmail.com>
Co-authored-by: BViking78 <bviking78@gmail.com>
Co-authored-by: Thor Skinderholm <el.thoro@gmail.com>
Co-authored-by: Mrgrpm <mrgrpm58@gmail.com>
2022-05-13 12:44:41 -07:00
kermieisinthehouse
53c6ae4ea5 Correct 'reload scrapes' path (#2583) (#2585)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
Co-authored-by: techie2000 <38585780+techie2000@users.noreply.github.com>
2022-05-13 12:08:20 -07:00
cj
dce4591911 Recommendations page bug fixes and refactoring (#2578)
* Changed Most Active Studios to Latest Studios
* dynamically create view all link and created message for view all
* created shared determineSlidesToScroll method
* removed added code in Shared/index.ts
* renamed getSlickSettings to getSlickSliderSettings
* Updated row headers to follow plex naming convention
* removed extra s in Sceness
* updated row header css to better align header text with view all anchor
2022-05-12 12:57:41 +10:00
WithoutPants
31cb8e2cbd Fix direct stream not working in Safari (#2581) 2022-05-12 12:10:46 +10:00
WithoutPants
ea2fcd9d7f Improve Handy integration (#2555)
* Refactor interactive into context
* Stop the interactive device when leaving page
* Show interactive state if not ready
* Handle navigation and looping
2022-05-10 16:38:34 +10:00
cj
bc85614ff9 Recommendation home page (#2571) 2022-05-10 11:51:20 +10:00
stash-translation-bot
1e7b85fbe5 Translations update from Stash (#2499)
* Translated using Weblate (Japanese)

Currently translated at 100.0% (791 of 791 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/ja/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 99.8% (790 of 791 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hant/

* Translated using Weblate (Italian)

Currently translated at 100.0% (791 of 791 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/it/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (791 of 791 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hant/

* Translated using Weblate (Polish)

Currently translated at 99.8% (790 of 791 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/pl/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (791 of 791 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/es/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (794 of 794 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hant/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (794 of 794 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/sv/

* Update translation files

Updated by "Cleanup translation files" hook in Weblate.

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/

* Translated using Weblate (Chinese (Traditional))

Currently translated at 100.0% (794 of 794 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hant/

* Translated using Weblate (Polish)

Currently translated at 99.7% (792 of 794 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/pl/

* Translated using Weblate (Italian)

Currently translated at 100.0% (794 of 794 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/it/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (794 of 794 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/sv/

* Translated using Weblate (Spanish)

Currently translated at 100.0% (794 of 794 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/es/

* Translated using Weblate (Polish)

Currently translated at 99.8% (793 of 794 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/pl/

* Translated using Weblate (Chinese (Simplified))

Currently translated at 100.0% (794 of 794 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/zh_Hans/

* Translated using Weblate (Swedish)

Currently translated at 100.0% (794 of 794 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/sv/

* Translated using Weblate (Danish)

Currently translated at 27.2% (216 of 794 strings)

Translation: Stash/Stash Desktop Client
Translate-URL: https://translate.stashapp.cc/projects/stash/stash-desktop-client/da/

Co-authored-by: 風林火山 <nezoko@digdig.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: Still <dev@stillu.cc>
Co-authored-by: BViking78 <5@example.com>
Co-authored-by: Coscosname <coscosname@gmail.com>
Co-authored-by: failead0r <6@example.com>
Co-authored-by: Alpaca Serious <srhsgsef@gmail.com>
Co-authored-by: Philip Wang <philpw99@gmail.com>
Co-authored-by: Hidden Hiddenson <078emil@protonmail.com>
2022-05-09 16:48:57 -07:00
WithoutPants
9ca3874707 Fix slideshow autoplaying when on another tab (#2563) 2022-05-06 12:23:06 +10:00
WithoutPants
73ded0d97d Prevent lightbox transition until specific number of scroll events (#2544)
* Delay before nav to next image on scroll
* Add config for scroll attempts before transition
2022-05-06 12:22:26 +10:00
cj
c1a096a1a6 Caption support (#2462)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2022-05-06 11:59:28 +10:00
HijackHornet
ab1b30ffb7 Clarifying Mingw installation and windows usage (#2566)
Because i lost over an hour making it work
2022-05-05 11:11:52 -07:00
WithoutPants
00e98a9c09 Fix thumbnail generation (#2561) 2022-05-05 12:11:10 +10:00
WithoutPants
d77f47824c Don't show dialog box when run from terminal (#2560) 2022-05-05 11:30:10 +10:00
WithoutPants
727644fab3 Update changelog 2022-05-05 11:09:59 +10:00
WithoutPants
dcd7595c07 Change playback rates and fix rates after seeking (#2550) 2022-05-05 11:06:47 +10:00
WithoutPants
ce175dcfc6 Fix audio not in video previews (#2547) 2022-05-05 11:04:01 +10:00
WithoutPants
1964481ff2 Play scene when scrubber clicked (#2546) 2022-05-05 11:03:46 +10:00
WithoutPants
a8456dd188 Remove windowsgui from cross-compile-windows build target 2022-05-04 17:15:28 +10:00
WithoutPants
a6f15273d9 Improve Windows stash behaviour (#2543)
* Rename manager.instance to Manager
* Show dialog message on fatal error on Windows
* Hide console windows explicitly on icon launch

Gets rid of the windowsgui flag, which causes all sorts of issues.
Instead, checks if stash was launched from the icon, and if so hides the console.

* Remove fixconsole
* Add changelog entries
2022-05-04 10:37:32 +10:00
WithoutPants
36aa51a187 Migrate vtt contents when hash changes (#2554) 2022-05-04 09:29:20 +10:00
WithoutPants
1a6f5619ae Encode query string (#2552) 2022-05-04 09:29:05 +10:00
WithoutPants
21a26fadd8 Generate single preview video for short scenes (#2553) 2022-05-04 09:28:48 +10:00
WithoutPants
20ffd4d51d Accept gif and webp for images (#2551) 2022-05-04 09:27:50 +10:00
WithoutPants
e87fd516d6 Fix streaming scenes not able to be deleted (#2549)
* Don't navigate away from scene if delete failed
* Close connection on cancel
2022-05-04 09:27:22 +10:00
InfiniteTF
0c2dc17e8e Fix crash when cancelling pending tasks (#2527) 2022-04-25 16:21:21 +10:00
WithoutPants
1b91937004 Fix image thumbnail generation (#2524)
* Better logging for thumbnail generation errors
* Reduce verbosity for thumbnail generation
* Provide stdin during thumbnail generation
2022-04-25 15:56:06 +10:00
WithoutPants
9e606feb76 Refresh marker panel on marker create (#2502)
* Update scene markers on create
* Improve display of markers without titles
* Fix marker title not populating
2022-04-21 11:33:04 +10:00
DingDongSoLong4
340f47cda8 Fix GraphQL Playground CSP directives for Firefox (#2518) 2022-04-18 12:25:46 +10:00
WithoutPants
aacf07feef Restructure ffmpeg (#2392)
* Refactor transcode generation
* Move phash generation into separate package
* Refactor image thumbnail generation
* Move JSONTime to separate package
* Ffmpeg refactoring
* Refactor live transcoding
* Refactor scene marker preview generation
* Refactor preview generation
* Refactor screenshot generation
* Refactor sprite generation
* Change ffmpeg.IsStreamable to return error
* Move frame rate calculation into ffmpeg
* Refactor file locking
* Refactor title set during scan
* Add missing lockmanager instance
* Return error instead of logging in MatchContainer
2022-04-18 10:50:10 +10:00
InfiniteTF
cdaa191155 Fix submission of scene drafts without performers (#2515) 2022-04-18 10:43:27 +10:00
SmallCoccinelle
401660e6a3 Hoist context, enable errchkjson (#2488)
* Make the script scraper context-aware

Connect the context to the command execution. This means command
execution can be aborted if the context is canceled. The context is
usually bound to user-interaction, i.e., a scraper operation issued
by the user. Hence, it seems correct to abort a command if the user
aborts.

* Enable errchkjson

Some json marshal calls are *safe* in that they can never fail. This is
conditional on the types of the the data being encoded. errchkjson finds
those calls which are unsafe, and also not checked for errors.

Add logging warnings to the place where unsafe encodings might happen.
This can help uncover usage bugs early in stash if they are tripped,
making debugging easier.

While here, keep the checker enabled in the linter to capture future
uses of json marshalling.

* Pass the context for zip file scanning.

* Pass the context in scanning

* Pass context, replace context.TODO()

Where applicable, pass the context down toward the lower functions in
the call stack. Replace uses of context.TODO() with the passed context.

This makes the code more context-aware, and you can rely on aborting
contexts to clean up subsystems to a far greater extent now.

I've left the cases where there is a context in a struct. My gut feeling
is that they have solutions that are nice, but they require more deep
thinking to unveil how to handle it.

* Remove context from task-structs

As a rule, contexts are better passed explicitly to functions than they
are passed implicitly via structs. In the case of tasks, we already
have a valid context in scope when creating the struct, so remove ctx
from the struct and use the scoped context instead.

With this change it is clear that the scanning functions are under a
context, and the task-starting caller has jurisdiction over the context
and its lifetime. A reader of the code don't have to figure out where
the context are coming from anymore.

While here, connect context.TODO() to the newly scoped context in most
of the scan code.

* Remove context from autotag struct too

* Make more context-passing explicit

In all of these cases, there is an applicable context which is close
in the call-tree. Hook up to this context.

* Simplify context passing in manager

The managers context handling generally wants to use an outer context
if applicable. However, the code doesn't pass it explicitly, but stores
it in a struct. Pull out the context from the struct and use it to
explicitly pass it.

At a later point in time, we probably want to handle this by handing
over the job to a different (program-lifetime) context for background
jobs, but this will do for a start.
2022-04-15 11:34:53 +10:00
WithoutPants
a7beeb32b0 Fix video layout on touch enabled devices (#2501) 2022-04-15 10:51:49 +10:00
dependabot[bot]
50e83a3555 Bump moment from 2.29.1 to 2.29.2 in /ui/v2.5 (#2495)
Bumps [moment](https://github.com/moment/moment) from 2.29.1 to 2.29.2.
- [Release notes](https://github.com/moment/moment/releases)
- [Changelog](https://github.com/moment/moment/blob/develop/CHANGELOG.md)
- [Commits](https://github.com/moment/moment/compare/2.29.1...2.29.2)

---
updated-dependencies:
- dependency-name: moment
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>

Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-04-14 08:51:47 +10:00
494 changed files with 29752 additions and 13394 deletions

View File

@@ -2,9 +2,8 @@ name: Build
on:
push:
branches: [ develop, master ]
branches: [ develop, master, files-refactor ]
pull_request:
branches: [ develop ]
release:
types: [ published ]
@@ -131,6 +130,10 @@ jobs:
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
run : git tag -f latest_develop; git push -f --tags
- name: Update files-refactor-release tag
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/files-refactor' }}
run : git tag -f files-refactor-release; git push -f --tags
- name: Development Release
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
uses: marvinpinto/action-automatic-releases@v1.1.2
@@ -150,7 +153,9 @@ jobs:
CHECKSUMS_SHA1
- name: Master release
if: ${{ github.event_name == 'release' && github.ref != 'refs/tags/latest_develop' }}
# NOTE: this isn't perfect, but should cover most scenarios
# DON'T create tag names starting with "v" if they are not stable releases
if: ${{ github.event_name == 'release' && !startsWith(github.ref, 'refs/tags/v') }}
uses: WithoutPants/github-release@v2.0.4
with:
token: "${{ secrets.GITHUB_TOKEN }}"
@@ -166,6 +171,24 @@ jobs:
CHECKSUMS_SHA1
gzip: false
- name: Files refactor Release
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/files-refactor' }}
uses: softprops/action-gh-release@v1
with:
token: "${{ secrets.GITHUB_TOKEN }}"
prerelease: true
tag_name: files-refactor-release
target_commitish: refs/heads/files-refactor
files: |
dist/stash-macos-intel
dist/stash-macos-applesilicon
dist/stash-win.exe
dist/stash-linux
dist/stash-linux-arm64v8
dist/stash-linux-arm32v7
dist/stash-linux-arm32v6
CHECKSUMS_SHA1
- name: Development Docker
if: ${{ github.repository == 'stashapp/stash' && github.event_name == 'push' && github.ref == 'refs/heads/develop' }}
env:
@@ -181,7 +204,9 @@ jobs:
bash ./docker/ci/x86_64/docker_push.sh development
- name: Release Docker
if: ${{ github.repository == 'stashapp/stash' && github.event_name == 'release' && github.ref != 'refs/tags/latest_develop' }}
# NOTE: this isn't perfect, but should cover most scenarios
# DON'T create tag names starting with "v" if they are not stable releases
if: ${{ github.repository == 'stashapp/stash' && github.event_name == 'release' && !startsWith(github.ref, 'refs/tags/v') }}
env:
DOCKER_CLI_EXPERIMENTAL: enabled
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}

View File

@@ -20,7 +20,7 @@ linters:
# Linters added by the stash project.
# - contextcheck
- dogsled
# - errchkjson
- errchkjson
- errorlint
# - exhaustive
- exportloopref

View File

@@ -7,6 +7,8 @@ client:
models:
Date:
model: github.com/99designs/gqlgen/graphql.String
SceneDraftInput:
model: github.com/stashapp/stash/pkg/scraper/stashbox/graphql.SceneDraftInput
endpoint:
# This points to stashdb.org currently, but can be directed at any stash-box
# instance. It is used for generation only.

View File

@@ -50,12 +50,6 @@ ifndef OFFICIAL_BUILD
$(eval OFFICIAL_BUILD := false)
endif
ifdef IS_WIN_OS
ifndef SUPPRESS_WINDOWSGUI
PLATFORM_SPECIFIC_LDFLAGS := -H windowsgui
endif
endif
build: pre-build
build:
$(eval LDFLAGS := $(LDFLAGS) -X 'github.com/stashapp/stash/internal/api.version=$(STASH_VERSION)' -X 'github.com/stashapp/stash/internal/api.buildstamp=$(BUILD_DATE)' -X 'github.com/stashapp/stash/internal/api.githash=$(GITHASH)')
@@ -77,7 +71,6 @@ cross-compile-windows: export GOARCH := amd64
cross-compile-windows: export CC := x86_64-w64-mingw32-gcc
cross-compile-windows: export CXX := x86_64-w64-mingw32-g++
cross-compile-windows: OUTPUT := -o dist/stash-win.exe
cross-compile-windows: PLATFORM_SPECIFIC_LDFLAGS := -H windowsgui
cross-compile-windows: build-release-static
cross-compile-macos-intel: export GOOS := darwin

View File

@@ -45,9 +45,9 @@ Many community-maintained scrapers are available for download at the [Community
# Translation
[![Translate](https://translate.stashapp.cc/widgets/stash/-/stash-desktop-client/svg-badge.svg)](https://translate.stashapp.cc/engage/stash/)
🇧🇷 🇨🇳 🇳🇱 🇬🇧 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇯🇵 🇪🇸 🇸🇪 🇹🇼 🇹🇷
🇧🇷 🇨🇳 🇩🇰 🇳🇱 🇬🇧 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇯🇵 🇰🇷 🇵🇱 🇪🇸 🇸🇪 🇹🇼 🇹🇷
Stash is available in 13 languages (so far!) and it could be in your language too. If you want to help us translate Stash into your language, you can make an account at [translate.stashapp.cc](https://translate.stashapp.cc/projects/stash/stash-desktop-client/) to get started contributing new languages or improving existing ones. Thanks!
Stash is available in 16 languages (so far!) and it could be in your language too. If you want to help us translate Stash into your language, you can make an account at [translate.stashapp.cc](https://translate.stashapp.cc/projects/stash/stash-desktop-client/) to get started contributing new languages or improving existing ones. Thanks!
# Support (FAQ)

View File

@@ -7,7 +7,6 @@ import (
"os/signal"
"syscall"
"github.com/apenwarr/fixconsole"
"github.com/stashapp/stash/internal/api"
"github.com/stashapp/stash/internal/desktop"
"github.com/stashapp/stash/internal/manager"
@@ -17,17 +16,22 @@ import (
_ "github.com/golang-migrate/migrate/v4/source/file"
)
func init() {
// On Windows, attach to parent shell
err := fixconsole.FixConsoleIfNeeded()
if err != nil {
fmt.Printf("FixConsoleOutput: %v\n", err)
}
}
func main() {
manager.Initialize()
api.Start()
defer recoverPanic()
_, err := manager.Initialize()
if err != nil {
panic(err)
}
go func() {
defer recoverPanic()
if err := api.Start(); err != nil {
handleError(err)
} else {
manager.GetInstance().Shutdown(0)
}
}()
go handleSignals()
desktop.Start(manager.GetInstance(), &manager.FaviconProvider{UIBox: ui.UIBox})
@@ -35,6 +39,21 @@ func main() {
blockForever()
}
func recoverPanic() {
if p := recover(); p != nil {
handleError(fmt.Errorf("Panic: %v", p))
}
}
func handleError(err error) {
if desktop.IsDesktop() {
desktop.FatalError(err)
manager.GetInstance().Shutdown(0)
} else {
panic(err)
}
}
func handleSignals() {
// handle signals
signals := make(chan os.Signal, 1)

View File

@@ -15,12 +15,12 @@ NOTE: You may need to run the `go get` commands outside the project directory to
### Windows
1. Download and install [Go for Windows](https://golang.org/dl/)
2. Download and install [MingW](https://sourceforge.net/projects/mingw-w64/)
2. Download and extract [MingW64](https://sourceforge.net/projects/mingw-w64/files/) (scroll down and select x86_64-posix-seh, dont use the autoinstaller it doesnt work)
3. Search for "advanced system settings" and open the system properties dialog.
1. Click the `Environment Variables` button
2. Under system variables find the `Path`. Edit and add `C:\Program Files\mingw-w64\*\mingw64\bin` (replace * with the correct path).
2. Under system variables find the `Path`. Edit and add `C:\MinGW\bin` (replace with the correct path to where you extracted MingW64).
NOTE: The `make` command in Windows will be `mingw32-make` with MingW.
NOTE: The `make` command in Windows will be `mingw32-make` with MingW. For example `make pre-ui` will be `mingw32-make pre-ui`
### macOS

7
go.mod
View File

@@ -46,13 +46,14 @@ require (
)
require (
github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29
github.com/asticode/go-astisub v0.20.0
github.com/go-chi/httplog v0.2.1
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4
github.com/hashicorp/golang-lru v0.5.4
github.com/kermieisinthehouse/gosx-notifier v0.1.1
github.com/kermieisinthehouse/systray v1.2.4
github.com/lucasb-eyer/go-colorful v1.2.0
github.com/spf13/cast v1.4.1
github.com/vearutop/statigz v1.1.6
github.com/vektah/gqlparser/v2 v2.4.1
)
@@ -60,7 +61,8 @@ require (
require (
github.com/agnivade/levenshtein v1.1.1 // indirect
github.com/antchfx/xpath v1.2.0 // indirect
github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab // indirect
github.com/asticode/go-astikit v0.20.0 // indirect
github.com/asticode/go-astits v1.8.0 // indirect
github.com/chromedp/sysutil v1.0.0 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
@@ -89,7 +91,6 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rs/zerolog v1.26.1 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/spf13/cast v1.4.1 // indirect
github.com/spf13/cobra v1.4.0 // indirect
github.com/spf13/jwalterweatherman v1.1.0 // indirect
github.com/stretchr/objx v0.2.0 // indirect

12
go.sum
View File

@@ -97,10 +97,6 @@ github.com/antchfx/xpath v1.2.0/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwq
github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY=
github.com/apache/arrow/go/arrow v0.0.0-20200601151325-b2287a20f230/go.mod h1:QNYViu/X0HXDHw7m3KXzWSVXIbfUvJqBFe6Gj8/pYA0=
github.com/apache/arrow/go/arrow v0.0.0-20210521153258-78c88a9f517b/go.mod h1:R4hW3Ug0s+n4CUsWHKOj00Pu01ZqU4x/hSF5kXUcXKQ=
github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29 h1:muXWUcay7DDy1/hEQWrYlBy+g0EuwT70sBHg65SeUc4=
github.com/apenwarr/fixconsole v0.0.0-20191012055117-5a9f6489cc29/go.mod h1:JYWahgHer+Z2xbsgHPtaDYVWzeHDminu+YIBWkxpCAY=
github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab h1:CMGzRRCjnD50RjUFSArBLuCxiDvdp7b8YPAcikBEQ+k=
github.com/apenwarr/w32 v0.0.0-20190407065021-aa00fece76ab/go.mod h1:nfFtvHn2Hgs9G1u0/J6LHQv//EksNC+7G8vXmd1VTJ8=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hCbHZ8TKRvWD2dDTCfh9M9ya+I9JpbB7O8o=
@@ -108,6 +104,12 @@ github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmV
github.com/armon/go-metrics v0.3.10/go.mod h1:4O98XIr/9W0sxpJ8UaYkvjk10Iff7SnFrb4QAOwNTFc=
github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8=
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.20.0 h1:mKuLwgGkQj35RRHFiTcq+2hgR7g1mHiYiIkr9UNTmXw=
github.com/asticode/go-astisub v0.20.0/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 v1.17.7/go.mod h1:KmX6BPdI08NWTb3/sm4ZGu5ShLoqVDhKgpiN924inxo=
github.com/aws/aws-sdk-go-v2 v1.3.2/go.mod h1:7OaACgj2SX3XGWnrIjGlJM22h6yD6MEWKvm7levnnM8=
github.com/aws/aws-sdk-go-v2 v1.6.0/go.mod h1:tI4KhsR5VkzlUa2DZAdwx7wCAYGwkZZ1H31PYrBFx1w=
@@ -629,6 +631,7 @@ github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/profile v1.4.0/go.mod h1:NWz/XGvpEW1FyYQ7fCx4dqYBLlfTcE+A9FLAkNKqjFE=
github.com/pkg/sftp v1.10.1/go.mod h1:lYOWFsE0bwd1+KfKJaKeuokY15vzFx25BLbzYYoAxZI=
github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -955,7 +958,6 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h
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-20190405154228-4b34438f7a67/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-20190419153524-e8e3143a4f4a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@@ -44,3 +44,6 @@ models:
model: github.com/stashapp/stash/pkg/models.SavedFilter
StashID:
model: github.com/stashapp/stash/pkg/models.StashID
SceneCaption:
model: github.com/stashapp/stash/pkg/models.SceneCaption

View File

@@ -69,6 +69,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
scaleUp
resetZoomOnNav
scrollMode
scrollAttemptsBeforeChange
}
disableDropdownCreate {
performer
@@ -184,4 +185,5 @@ fragment ConfigData on ConfigResult {
defaults {
...ConfigDefaultSettingsData
}
ui
}

View File

@@ -13,6 +13,10 @@ fragment SlimSceneData on Scene {
phash
interactive
interactive_speed
captions {
language_code
caption_type
}
file {
size
@@ -35,6 +39,7 @@ fragment SlimSceneData on Scene {
sprite
funscript
interactive_heatmap
caption
}
scene_markers {

View File

@@ -13,6 +13,10 @@ fragment SceneData on Scene {
phash
interactive
interactive_speed
captions {
language_code
caption_type
}
created_at
updated_at
@@ -37,6 +41,7 @@ fragment SceneData on Scene {
sprite
funscript
interactive_heatmap
caption
}
scene_markers {

View File

@@ -36,6 +36,10 @@ mutation ConfigureDefaults($input: ConfigDefaultSettingsInput!) {
}
}
mutation ConfigureUI($input: Map!) {
configureUI(input: $input)
}
mutation GenerateAPIKey($input: GenerateAPIKeyInput!) {
generateAPIKey(input: $input)
}

View File

@@ -1,4 +1,10 @@
query FindSavedFilters($mode: FilterMode!) {
query FindSavedFilter($id: ID!) {
findSavedFilter(id: $id) {
...SavedFilterData
}
}
query FindSavedFilters($mode: FilterMode) {
findSavedFilters(mode: $mode) {
...SavedFilterData
}

View File

@@ -1,7 +1,8 @@
"""The query root for this schema"""
type Query {
# Filters
findSavedFilters(mode: FilterMode!): [SavedFilter!]!
findSavedFilter(id: ID!): SavedFilter
findSavedFilters(mode: FilterMode): [SavedFilter!]!
findDefaultFilter(mode: FilterMode!): SavedFilter
"""Find a scene by ID or Checksum"""
@@ -114,12 +115,6 @@ type Query {
"""Scrape a list of performers from a query"""
scrapeFreeonesPerformerList(query: String!): [String!]! @deprecated(reason: "use scrapeSinglePerformer with scraper_id = builtin_freeones")
"""Query StashBox for scenes"""
queryStashBoxScene(input: StashBoxSceneQueryInput!): [ScrapedScene!]! @deprecated(reason: "use scrapeSingleScene or scrapeMultiScenes")
"""Query StashBox for performers"""
queryStashBoxPerformer(input: StashBoxPerformerQueryInput!): [StashBoxPerformerQueryResult!]! @deprecated(reason: "use scrapeSinglePerformer or scrapeMultiPerformers")
# === end deprecated methods ===
# Plugins
"""List loaded plugins"""
plugins: [Plugin!]
@@ -244,6 +239,11 @@ type Mutation {
configureScraping(input: ConfigScrapingInput!): ConfigScrapingResult!
configureDefaults(input: ConfigDefaultSettingsInput!): ConfigDefaultSettingsResult!
# overwrites the entire UI configuration
configureUI(input: Map!): Map!
# sets a single UI key value
configureUISetting(key: String!, value: Any): Map!
"""Generate and set (or clear) API key"""
generateAPIKey(input: GenerateAPIKeyInput!): String!

View File

@@ -217,6 +217,7 @@ input ConfigImageLightboxInput {
scaleUp: Boolean
resetZoomOnNav: Boolean
scrollMode: ImageLightboxScrollMode
scrollAttemptsBeforeChange: Int
}
type ConfigImageLightboxResult {
@@ -225,6 +226,7 @@ type ConfigImageLightboxResult {
scaleUp: Boolean
resetZoomOnNav: Boolean
scrollMode: ImageLightboxScrollMode
scrollAttemptsBeforeChange: Int!
}
input ConfigInterfaceInput {
@@ -411,6 +413,7 @@ type ConfigResult {
dlna: ConfigDLNAResult!
scraping: ConfigScrapingResult!
defaults: ConfigDefaultSettingsResult!
ui: Map!
}
"""Directory structure of a path"""

View File

@@ -174,6 +174,8 @@ input SceneFilterType {
interactive: Boolean
"""Filter by InteractiveSpeed"""
interactive_speed: IntCriterionInput
"""Filter by captions"""
captions: StringCriterionInput
}
input MovieFilterType {

View File

@@ -4,4 +4,9 @@ Timestamp is a point in time. It is always output as RFC3339-compatible time poi
It can be input as a RFC3339 string, or as "<4h" for "4 hours in the past" or ">5m"
for "5 minutes in the future"
"""
scalar Timestamp
scalar Timestamp
# generic JSON object
scalar Map
scalar Any

View File

@@ -19,6 +19,7 @@ type ScenePathsType {
sprite: String # Resolver
funscript: String # Resolver
interactive_heatmap: String # Resolver
caption: String # Resolver
}
type SceneMovie {
@@ -26,6 +27,11 @@ type SceneMovie {
scene_index: Int
}
type SceneCaption {
language_code: String!
caption_type: String!
}
type Scene {
id: ID!
checksum: String
@@ -41,6 +47,7 @@ type Scene {
phash: String
interactive: Boolean!
interactive_speed: Int
captions: [SceneCaption!]
created_at: Time!
updated_at: Time!
file_mod_time: Time

View File

@@ -129,6 +129,12 @@ query FindScenesByFullFingerprints($fingerprints: [FingerprintQueryInput!]!) {
}
}
query FindScenesBySceneFingerprints($fingerprints: [[FingerprintQueryInput!]!]!) {
findScenesBySceneFingerprints(fingerprints: $fingerprints) {
...SceneFragment
}
}
query SearchScene($term: String!) {
searchScene(term: $term) {
...SceneFragment

View File

@@ -26,7 +26,8 @@ const (
)
func allowUnauthenticated(r *http.Request) bool {
return strings.HasPrefix(r.URL.Path, loginEndPoint) || r.URL.Path == "/css"
// #2715 - allow access to UI files
return strings.HasPrefix(r.URL.Path, loginEndPoint) || r.URL.Path == "/css" || strings.HasPrefix(r.URL.Path, "/assets")
}
func authenticateHandler() func(http.Handler) http.Handler {

View File

@@ -23,6 +23,9 @@ var matcher = language.NewMatcher([]language.Tag{
language.MustParse("nl-NL"),
language.MustParse("ru-RU"),
language.MustParse("tr-TR"),
language.MustParse("da-DK"),
language.MustParse("pl-PL"),
language.MustParse("ko-KR"),
})
// newCollator parses a locale into a collator

View File

@@ -98,6 +98,7 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*models.S
spritePath := builder.GetSpriteURL()
chaptersVttPath := builder.GetChaptersVTTURL()
funscriptPath := builder.GetFunscriptURL()
captionBasePath := builder.GetCaptionURL()
interactiveHeatmap := builder.GetInteractiveHeatmapURL()
return &models.ScenePathsType{
@@ -110,6 +111,7 @@ func (r *sceneResolver) Paths(ctx context.Context, obj *models.Scene) (*models.S
Sprite: &spritePath,
Funscript: &funscriptPath,
InteractiveHeatmap: &interactiveHeatmap,
Caption: &captionBasePath,
}, nil
}
@@ -124,6 +126,17 @@ func (r *sceneResolver) SceneMarkers(ctx context.Context, obj *models.Scene) (re
return ret, nil
}
func (r *sceneResolver) Captions(ctx context.Context, obj *models.Scene) (ret []*models.SceneCaption, err error) {
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
ret, err = repo.Scene().GetCaptions(obj.ID)
return err
}); err != nil {
return nil, err
}
return ret, err
}
func (r *sceneResolver) Galleries(ctx context.Context, obj *models.Scene) (ret []*models.Gallery, err error) {
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
ret, err = repo.Gallery().FindBySceneID(obj.ID)

View File

@@ -342,6 +342,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
setBool(config.ImageLightboxScaleUp, options.ScaleUp)
setBool(config.ImageLightboxResetZoomOnNav, options.ResetZoomOnNav)
setString(config.ImageLightboxScrollMode, (*string)(options.ScrollMode))
if options.ScrollAttemptsBeforeChange != nil {
c.Set(config.ImageLightboxScrollAttemptsBeforeChange, *options.ScrollAttemptsBeforeChange)
}
}
if input.CSS != nil {
@@ -497,3 +501,23 @@ func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input models.Gene
return newAPIKey, nil
}
func (r *mutationResolver) ConfigureUI(ctx context.Context, input map[string]interface{}) (map[string]interface{}, error) {
c := config.GetInstance()
c.SetUIConfiguration(input)
if err := c.Write(); err != nil {
return c.GetUIConfiguration(), err
}
return c.GetUIConfiguration(), nil
}
func (r *mutationResolver) ConfigureUISetting(ctx context.Context, key string, value interface{}) (map[string]interface{}, error) {
c := config.GetInstance()
cfg := c.GetUIConfiguration()
cfg[key] = value
return r.ConfigureUI(ctx, cfg)
}

View File

@@ -61,7 +61,7 @@ func (r *mutationResolver) ExportObjects(ctx context.Context, input models.Expor
var wg sync.WaitGroup
wg.Add(1)
t.Start(&wg)
t.Start(ctx, &wg)
if t.DownloadHash != "" {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)

View File

@@ -66,6 +66,7 @@ func makeConfigResult() *models.ConfigResult {
Dlna: makeConfigDLNAResult(),
Scraping: makeConfigScrapingResult(),
Defaults: makeConfigDefaultsResult(),
UI: makeConfigUIResult(),
}
}
@@ -216,6 +217,10 @@ func makeConfigDefaultsResult() *models.ConfigDefaultSettingsResult {
}
}
func makeConfigUIResult() map[string]interface{} {
return config.GetInstance().GetUIConfiguration()
}
func (r *queryResolver) ValidateStashBoxCredentials(ctx context.Context, input models.StashBoxInput) (*models.StashBoxValidationResult, error) {
client := stashbox.NewClient(models.StashBox{Endpoint: input.Endpoint, APIKey: input.APIKey}, r.txnManager)
user, err := client.GetUser(ctx)

View File

@@ -2,13 +2,33 @@ package api
import (
"context"
"strconv"
"github.com/stashapp/stash/pkg/models"
)
func (r *queryResolver) FindSavedFilters(ctx context.Context, mode models.FilterMode) (ret []*models.SavedFilter, err error) {
func (r *queryResolver) FindSavedFilter(ctx context.Context, id string) (ret *models.SavedFilter, err error) {
idInt, err := strconv.Atoi(id)
if err != nil {
return nil, err
}
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
ret, err = repo.SavedFilter().FindByMode(mode)
ret, err = repo.SavedFilter().Find(idInt)
return err
}); err != nil {
return nil, err
}
return ret, err
}
func (r *queryResolver) FindSavedFilters(ctx context.Context, mode *models.FilterMode) (ret []*models.SavedFilter, err error) {
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
if mode != nil {
ret, err = repo.SavedFilter().FindByMode(*mode)
} else {
ret, err = repo.SavedFilter().All()
}
return err
}); err != nil {
return nil, err

View File

@@ -227,46 +227,6 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models
return marshalScrapedMovie(content)
}
func (r *queryResolver) QueryStashBoxScene(ctx context.Context, input models.StashBoxSceneQueryInput) ([]*models.ScrapedScene, error) {
boxes := config.GetInstance().GetStashBoxes()
if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) {
return nil, fmt.Errorf("%w: invalid stash_box_index %d", ErrInput, input.StashBoxIndex)
}
client := stashbox.NewClient(*boxes[input.StashBoxIndex], r.txnManager)
if len(input.SceneIds) > 0 {
return client.FindStashBoxScenesByFingerprintsFlat(ctx, input.SceneIds)
}
if input.Q != nil {
return client.QueryStashBoxScene(ctx, *input.Q)
}
return nil, nil
}
func (r *queryResolver) QueryStashBoxPerformer(ctx context.Context, input models.StashBoxPerformerQueryInput) ([]*models.StashBoxPerformerQueryResult, error) {
boxes := config.GetInstance().GetStashBoxes()
if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) {
return nil, fmt.Errorf("%w: invalid stash_box_index %d", ErrInput, input.StashBoxIndex)
}
client := stashbox.NewClient(*boxes[input.StashBoxIndex], r.txnManager)
if len(input.PerformerIds) > 0 {
return client.FindStashBoxPerformersByNames(ctx, input.PerformerIds)
}
if input.Q != nil {
return client.QueryStashBoxPerformer(ctx, *input.Q)
}
return nil, nil
}
func (r *queryResolver) getStashBoxClient(index int) (*stashbox.Client, error) {
boxes := config.GetInstance().GetStashBoxes()
@@ -280,6 +240,15 @@ func (r *queryResolver) getStashBoxClient(index int) (*stashbox.Client, error) {
func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source models.ScraperSourceInput, input models.ScrapeSingleSceneInput) ([]*models.ScrapedScene, error) {
var ret []*models.ScrapedScene
var sceneID int
if input.SceneID != nil {
var err error
sceneID, err = strconv.Atoi(*input.SceneID)
if err != nil {
return nil, fmt.Errorf("%w: sceneID is not an integer: '%s'", ErrInput, *input.SceneID)
}
}
switch {
case source.ScraperID != nil:
var err error
@@ -288,11 +257,6 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source models.Scr
switch {
case input.SceneID != nil:
var sceneID int
sceneID, err = strconv.Atoi(*input.SceneID)
if err != nil {
return nil, fmt.Errorf("%w: sceneID is not an integer: '%s'", ErrInput, *input.SceneID)
}
c, err = r.scraperCache().ScrapeID(ctx, *source.ScraperID, sceneID, models.ScrapeContentTypeScene)
if c != nil {
content = []models.ScrapedContent{c}
@@ -324,7 +288,7 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source models.Scr
switch {
case input.SceneID != nil:
ret, err = client.FindStashBoxScenesByFingerprintsFlat(ctx, []string{*input.SceneID})
ret, err = client.FindStashBoxSceneByFingerprints(ctx, sceneID)
case input.Query != nil:
ret, err = client.QueryStashBoxScene(ctx, *input.Query)
default:
@@ -352,7 +316,12 @@ func (r *queryResolver) ScrapeMultiScenes(ctx context.Context, source models.Scr
return nil, err
}
return client.FindStashBoxScenesByFingerprints(ctx, input.SceneIds)
sceneIDs, err := stringslice.StringSliceToIntSlice(input.SceneIds)
if err != nil {
return nil, err
}
return client.FindStashBoxScenesByFingerprints(ctx, sceneIDs)
}
return nil, errors.New("scraper_id or stash_box_index must be set")

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
"os/exec"
"strconv"
"github.com/go-chi/chi"
@@ -50,6 +51,11 @@ func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
// don't log for unsupported image format
if !errors.Is(err, image.ErrNotSupportedForThumbnail) {
logger.Errorf("error generating thumbnail for image: %s", err.Error())
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
logger.Errorf("stderr: %s", string(exitErr.Stderr))
}
}
// backwards compatibility - fallback to original image instead

View File

@@ -1,6 +1,7 @@
package api
import (
"bytes"
"context"
"net/http"
"strconv"
@@ -41,6 +42,7 @@ func (rs sceneRoutes) Routes() chi.Router {
r.Get("/vtt/chapter", rs.ChapterVtt)
r.Get("/funscript", rs.Funscript)
r.Get("/interactive_heatmap", rs.InteractiveHeatmap)
r.Get("/caption", rs.CaptionLang)
r.Get("/scene_marker/{sceneMarkerId}/stream", rs.SceneMarkerStream)
r.Get("/scene_marker/{sceneMarkerId}/preview", rs.SceneMarkerPreview)
@@ -54,25 +56,6 @@ func (rs sceneRoutes) Routes() chi.Router {
// region Handlers
func getSceneFileContainer(scene *models.Scene) ffmpeg.Container {
var container ffmpeg.Container
if scene.Format.Valid {
container = ffmpeg.Container(scene.Format.String)
} else { // container isn't in the DB
// shouldn't happen, fallback to ffprobe
ffprobe := manager.GetInstance().FFProbe
tmpVideoFile, err := ffprobe.NewVideoFile(scene.Path, false)
if err != nil {
logger.Errorf("[transcode] error reading video file: %v", err)
return ffmpeg.Container("")
}
container = ffmpeg.MatchContainer(tmpVideoFile.Container, scene.Path)
}
return container
}
func (rs sceneRoutes) StreamDirect(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
@@ -86,7 +69,11 @@ func (rs sceneRoutes) StreamMKV(w http.ResponseWriter, r *http.Request) {
// only allow mkv streaming if the scene container is an mkv already
scene := r.Context().Value(sceneKey).(*models.Scene)
container := getSceneFileContainer(scene)
container, err := manager.GetSceneFileContainer(scene)
if err != nil {
logger.Errorf("[transcode] error getting container: %v", err)
}
if container != ffmpeg.Matroska {
w.WriteHeader(http.StatusBadRequest)
if _, err := w.Write([]byte("not an mkv file")); err != nil {
@@ -95,22 +82,22 @@ func (rs sceneRoutes) StreamMKV(w http.ResponseWriter, r *http.Request) {
return
}
rs.streamTranscode(w, r, ffmpeg.CodecMKVAudio)
rs.streamTranscode(w, r, ffmpeg.StreamFormatMKVAudio)
}
func (rs sceneRoutes) StreamWebM(w http.ResponseWriter, r *http.Request) {
rs.streamTranscode(w, r, ffmpeg.CodecVP9)
rs.streamTranscode(w, r, ffmpeg.StreamFormatVP9)
}
func (rs sceneRoutes) StreamMp4(w http.ResponseWriter, r *http.Request) {
rs.streamTranscode(w, r, ffmpeg.CodecH264)
rs.streamTranscode(w, r, ffmpeg.StreamFormatH264)
}
func (rs sceneRoutes) StreamHLS(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
ffprobe := manager.GetInstance().FFProbe
videoFile, err := ffprobe.NewVideoFile(scene.Path, false)
videoFile, err := ffprobe.NewVideoFile(scene.Path)
if err != nil {
logger.Errorf("[stream] error reading video file: %v", err)
return
@@ -122,7 +109,7 @@ func (rs sceneRoutes) StreamHLS(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", ffmpeg.MimeHLS)
var str strings.Builder
ffmpeg.WriteHLSPlaylist(*videoFile, r.URL.String(), &str)
ffmpeg.WriteHLSPlaylist(videoFile.Duration, r.URL.String(), &str)
requestByteRange := createByteRange(r.Header.Get("Range"))
if requestByteRange.RawString != "" {
@@ -139,45 +126,51 @@ func (rs sceneRoutes) StreamHLS(w http.ResponseWriter, r *http.Request) {
}
func (rs sceneRoutes) StreamTS(w http.ResponseWriter, r *http.Request) {
rs.streamTranscode(w, r, ffmpeg.CodecHLS)
rs.streamTranscode(w, r, ffmpeg.StreamFormatHLS)
}
func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, videoCodec ffmpeg.Codec) {
logger.Debugf("Streaming as %s", videoCodec.MimeType)
func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, streamFormat ffmpeg.StreamFormat) {
logger.Debugf("Streaming as %s", streamFormat.MimeType)
scene := r.Context().Value(sceneKey).(*models.Scene)
// needs to be transcoded
ffprobe := manager.GetInstance().FFProbe
videoFile, err := ffprobe.NewVideoFile(scene.Path, false)
if err != nil {
logger.Errorf("[stream] error reading video file: %v", err)
return
}
// start stream based on query param, if provided
if err = r.ParseForm(); err != nil {
if err := r.ParseForm(); err != nil {
logger.Warnf("[stream] error parsing query form: %v", err)
}
startTime := r.Form.Get("start")
ss, _ := strconv.ParseFloat(startTime, 64)
requestedSize := r.Form.Get("resolution")
var stream *ffmpeg.Stream
audioCodec := ffmpeg.MissingUnsupported
if scene.AudioCodec.Valid {
audioCodec = ffmpeg.AudioCodec(scene.AudioCodec.String)
audioCodec = ffmpeg.ProbeAudioCodec(scene.AudioCodec.String)
}
options := ffmpeg.TranscodeStreamOptions{
Input: scene.Path,
Codec: streamFormat,
VideoOnly: audioCodec == ffmpeg.MissingUnsupported,
VideoWidth: int(scene.Width.Int64),
VideoHeight: int(scene.Height.Int64),
StartTime: ss,
MaxTranscodeSize: config.GetInstance().GetMaxStreamingTranscodeSize().GetMaxResolution(),
}
options := ffmpeg.GetTranscodeStreamOptions(*videoFile, videoCodec, audioCodec)
options.StartTime = startTime
options.MaxTranscodeSize = config.GetInstance().GetMaxStreamingTranscodeSize()
if requestedSize != "" {
options.MaxTranscodeSize = models.StreamingResolutionEnum(requestedSize)
options.MaxTranscodeSize = models.StreamingResolutionEnum(requestedSize).GetMaxResolution()
}
encoder := manager.GetInstance().FFMPEG
stream, err = encoder.GetTranscodeStream(options)
lm := manager.GetInstance().ReadLockManager
streamRequestCtx := manager.NewStreamRequestContext(w, r)
lockCtx := lm.ReadLock(streamRequestCtx, scene.Path)
defer lockCtx.Cancel()
stream, err := encoder.GetTranscodeStream(lockCtx, options)
if err != nil {
logger.Errorf("[stream] error transcoding video file: %v", err)
@@ -188,6 +181,8 @@ func (rs sceneRoutes) streamTranscode(w http.ResponseWriter, r *http.Request, vi
return
}
lockCtx.AttachCommand(stream.Cmd)
stream.Serve(w, r)
}
@@ -202,7 +197,7 @@ func (rs sceneRoutes) Screenshot(w http.ResponseWriter, r *http.Request) {
func (rs sceneRoutes) Preview(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
filepath := manager.GetInstance().Paths.Scene.GetVideoPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
serveFileNoCache(w, r, filepath)
}
@@ -216,7 +211,7 @@ func serveFileNoCache(w http.ResponseWriter, r *http.Request, filepath string) {
func (rs sceneRoutes) Webp(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
filepath := manager.GetInstance().Paths.Scene.GetStreamPreviewImagePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
filepath := manager.GetInstance().Paths.Scene.GetWebpPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()))
http.ServeFile(w, r, filepath)
}
@@ -291,6 +286,46 @@ func (rs sceneRoutes) InteractiveHeatmap(w http.ResponseWriter, r *http.Request)
http.ServeFile(w, r, filepath)
}
func (rs sceneRoutes) Caption(w http.ResponseWriter, r *http.Request, lang string, ext string) {
s := r.Context().Value(sceneKey).(*models.Scene)
if err := rs.txnManager.WithReadTxn(r.Context(), func(repo models.ReaderRepository) error {
var err error
captions, err := repo.Scene().GetCaptions(s.ID)
for _, caption := range captions {
if lang == caption.LanguageCode && ext == caption.CaptionType {
sub, err := scene.ReadSubs(caption.Path(s.Path))
if err == nil {
var b bytes.Buffer
err = sub.WriteToWebVTT(&b)
if err == nil {
w.Header().Set("Content-Type", "text/vtt")
w.Header().Add("Cache-Control", "no-cache")
_, _ = b.WriteTo(w)
}
return err
}
logger.Debugf("Error while reading subs: %v", err)
}
}
return err
}); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
func (rs sceneRoutes) CaptionLang(w http.ResponseWriter, r *http.Request) {
// serve caption based on lang query param, if provided
if err := r.ParseForm(); err != nil {
logger.Warnf("[caption] error parsing query form: %v", err)
}
l := r.Form.Get("lang")
ext := r.Form.Get("type")
rs.Caption(w, r, l, ext)
}
func (rs sceneRoutes) VttThumbs(w http.ResponseWriter, r *http.Request) {
scene := r.Context().Value(sceneKey).(*models.Scene)
w.Header().Set("Content-Type", "text/vtt")
@@ -324,7 +359,7 @@ func (rs sceneRoutes) SceneMarkerStream(w http.ResponseWriter, r *http.Request)
return
}
filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds))
filepath := manager.GetInstance().Paths.SceneMarkers.GetVideoPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds))
http.ServeFile(w, r, filepath)
}
@@ -347,7 +382,7 @@ func (rs sceneRoutes) SceneMarkerPreview(w http.ResponseWriter, r *http.Request)
return
}
filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamPreviewImagePath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds))
filepath := manager.GetInstance().Paths.SceneMarkers.GetWebpPreviewPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds))
// If the image doesn't exist, send the placeholder
exists, _ := fsutil.FileExists(filepath)
@@ -380,7 +415,7 @@ func (rs sceneRoutes) SceneMarkerScreenshot(w http.ResponseWriter, r *http.Reque
return
}
filepath := manager.GetInstance().Paths.SceneMarkers.GetStreamScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds))
filepath := manager.GetInstance().Paths.SceneMarkers.GetScreenshotPath(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), int(sceneMarker.Seconds))
// If the image doesn't exist, send the placeholder
exists, _ := fsutil.FileExists(filepath)

View File

@@ -41,7 +41,7 @@ var githash string
var uiBox = ui.UIBox
var loginUIBox = ui.LoginUIBox
func Start() {
func Start() error {
initialiseImages()
r := chi.NewRouter()
@@ -227,7 +227,7 @@ func Start() {
prefix := getProxyPrefix(r.Header)
if prefix != "" {
r.URL.Path = strings.Replace(r.URL.Path, prefix, "", 1)
r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)
}
r.URL.Path = uiRootDir + r.URL.Path
@@ -263,16 +263,18 @@ func Start() {
displayAddress = "http://" + displayAddress + "/"
}
go func() {
if tlsConfig != nil {
logger.Infof("stash is running at " + displayAddress)
logger.Error(server.ListenAndServeTLS("", ""))
} else {
logger.Infof("stash is running at " + displayAddress)
logger.Error(server.ListenAndServe())
}
manager.GetInstance().Shutdown(0)
}()
logger.Infof("stash is running at " + displayAddress)
if tlsConfig != nil {
err = server.ListenAndServeTLS("", "")
} else {
err = server.ListenAndServe()
}
if !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
func printVersion() {
@@ -357,7 +359,7 @@ func SecurityHeadersMiddleware(next http.Handler) http.Handler {
}
connectableOrigins += "; "
cspDirectives := "default-src data: 'self' 'unsafe-inline';" + connectableOrigins + "img-src data: *; script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline' 'unsafe-eval'; style-src-elem 'self' https://cdn.jsdelivr.net 'unsafe-inline' ; media-src 'self' blob:; child-src 'none'; object-src 'none'; form-action 'self'"
cspDirectives := "default-src data: 'self' 'unsafe-inline';" + connectableOrigins + "img-src data: *; script-src 'self' https://cdn.jsdelivr.net 'unsafe-inline' 'unsafe-eval'; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; style-src-elem 'self' https://cdn.jsdelivr.net 'unsafe-inline'; media-src 'self' blob:; child-src 'none'; object-src 'none'; form-action 'self'"
w.Header().Set("Referrer-Policy", "same-origin")
w.Header().Set("X-Content-Type-Options", "nosniff")

View File

@@ -67,6 +67,10 @@ func (b SceneURLBuilder) GetFunscriptURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/funscript"
}
func (b SceneURLBuilder) GetCaptionURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/caption"
}
func (b SceneURLBuilder) GetInteractiveHeatmapURL() string {
return b.BaseURL + "/scene/" + b.SceneID + "/interactive_heatmap"
}

View File

@@ -7,12 +7,16 @@ import (
)
func getGalleryFileTagger(s *models.Gallery, cache *match.Cache) tagger {
// only trim the extension if gallery is file-based
trimExt := s.Zip
return tagger{
ID: s.ID,
Type: "gallery",
Name: s.GetTitle(),
Path: s.Path.String,
cache: cache,
ID: s.ID,
Type: "gallery",
Name: s.GetTitle(),
Path: s.Path.String,
trimExt: trimExt,
cache: cache,
}
}

View File

@@ -34,6 +34,7 @@ func generateNamePatterns(name, separator, ext string) []string {
ret = append(ret, fmt.Sprintf("aaa%s%s.%s", separator, name, ext))
ret = append(ret, fmt.Sprintf("aaa%s%s%sbbb.%s", separator, name, separator, ext))
ret = append(ret, fmt.Sprintf("dir/%s%saaa.%s", name, separator, ext))
ret = append(ret, fmt.Sprintf("dir%sdir/%s%saaa.%s", separator, name, separator, ext))
ret = append(ret, fmt.Sprintf("dir\\%s%saaa.%s", name, separator, ext))
ret = append(ret, fmt.Sprintf("%s%saaa/dir/bbb.%s", name, separator, ext))
ret = append(ret, fmt.Sprintf("%s%saaa\\dir\\bbb.%s", name, separator, ext))

View File

@@ -22,10 +22,11 @@ import (
)
type tagger struct {
ID int
Type string
Name string
Path string
ID int
Type string
Name string
Path string
trimExt bool
cache *match.Cache
}
@@ -41,7 +42,7 @@ func (t *tagger) addLog(otherType, otherName string) {
}
func (t *tagger) tagPerformers(performerReader models.PerformerReader, addFunc addLinkFunc) error {
others, err := match.PathToPerformers(t.Path, performerReader, t.cache)
others, err := match.PathToPerformers(t.Path, performerReader, t.cache, t.trimExt)
if err != nil {
return err
}
@@ -62,7 +63,7 @@ func (t *tagger) tagPerformers(performerReader models.PerformerReader, addFunc a
}
func (t *tagger) tagStudios(studioReader models.StudioReader, addFunc addLinkFunc) error {
studio, err := match.PathToStudio(t.Path, studioReader, t.cache)
studio, err := match.PathToStudio(t.Path, studioReader, t.cache, t.trimExt)
if err != nil {
return err
}
@@ -83,7 +84,7 @@ func (t *tagger) tagStudios(studioReader models.StudioReader, addFunc addLinkFun
}
func (t *tagger) tagTags(tagReader models.TagReader, addFunc addLinkFunc) error {
others, err := match.PathToTags(t.Path, tagReader, t.cache)
others, err := match.PathToTags(t.Path, tagReader, t.cache, t.trimExt)
if err != nil {
return err
}

View File

@@ -29,6 +29,8 @@ type FaviconProvider interface {
// MUST be run on the main goroutine or will have no effect on macOS
func Start(shutdownHandler ShutdownHandler, faviconProvider FaviconProvider) {
if IsDesktop() {
hideConsole()
c := config.GetInstance()
if !c.GetNoBrowser() {
openURLInBrowser("")
@@ -60,6 +62,10 @@ func SendNotification(title string, text string) {
}
func IsDesktop() bool {
if isDoubleClickLaunched() {
return true
}
// Check if running under root
if os.Getuid() == 0 {
return false

View File

@@ -34,3 +34,11 @@ func sendNotification(notificationTitle string, notificationText string) {
func revealInFileManager(path string) {
exec.Command(`open`, `-R`, path)
}
func isDoubleClickLaunched() bool {
return false
}
func hideConsole() {
}

View File

@@ -37,3 +37,11 @@ func sendNotification(notificationTitle string, notificationText string) {
func revealInFileManager(path string) {
}
func isDoubleClickLaunched() bool {
return false
}
func hideConsole() {
}

View File

@@ -5,12 +5,19 @@ package desktop
import (
"os/exec"
"syscall"
"unsafe"
"github.com/go-toast/toast"
"github.com/stashapp/stash/pkg/logger"
"golang.org/x/sys/windows/svc"
)
var (
kernel32 = syscall.NewLazyDLL("kernel32.dll")
user32 = syscall.NewLazyDLL("user32.dll")
)
func isService() bool {
result, err := svc.IsWindowsService()
if err != nil {
@@ -20,6 +27,40 @@ func isService() bool {
return result
}
// Detect if windows golang executable file is running via double click or from cmd/shell terminator
// https://stackoverflow.com/questions/8610489/distinguish-if-program-runs-by-clicking-on-the-icon-typing-its-name-in-the-cons?rq=1
// https://github.com/shirou/w32/blob/master/kernel32.go
// https://github.com/kbinani/win/blob/master/kernel32.go#L3268
// win.GetConsoleProcessList(new(uint32), win.DWORD(2))
// from https://gist.github.com/yougg/213250cc04a52e2b853590b06f49d865
func isDoubleClickLaunched() bool {
lp := kernel32.NewProc("GetConsoleProcessList")
if lp != nil {
var pids [2]uint32
var maxCount uint32 = 2
ret, _, _ := lp.Call(uintptr(unsafe.Pointer(&pids)), uintptr(maxCount))
if ret > 1 {
return false
}
}
return true
}
func hideConsole() {
const SW_HIDE = 0
h := getConsoleWindow()
lp := user32.NewProc("ShowWindow")
// don't want to check for errors and can't prevent dogsled
_, _, _ = lp.Call(h, SW_HIDE) //nolint:dogsled
}
func getConsoleWindow() uintptr {
lp := kernel32.NewProc("GetConsoleWindow")
ret, _, _ := lp.Call()
return ret
}
func isServerDockerized() bool {
return false
}

View File

@@ -0,0 +1,9 @@
//go:build !windows
// +build !windows
package desktop
func FatalError(err error) int {
// nothing to do
return 0
}

View File

@@ -0,0 +1,33 @@
//go:build windows
// +build windows
package desktop
import (
"fmt"
"syscall"
"unsafe"
)
func FatalError(err error) int {
const (
NULL = 0
MB_OK = 0
MB_ICONERROR = 0x10
)
return messageBox(NULL, fmt.Sprintf("Error: %v", err), "Stash - Fatal Error", MB_OK|MB_ICONERROR)
}
func messageBox(hwnd uintptr, caption, title string, flags uint) int {
lpText, _ := syscall.UTF16PtrFromString(caption)
lpCaption, _ := syscall.UTF16PtrFromString(title)
ret, _, _ := syscall.NewLazyDLL("user32.dll").NewProc("MessageBoxW").Call(
uintptr(hwnd),
uintptr(unsafe.Pointer(lpText)),
uintptr(unsafe.Pointer(lpCaption)),
uintptr(flags))
return int(ret)
}

View File

@@ -145,12 +145,15 @@ const (
defaultWallPlayback = "video"
// Image lightbox options
legacyImageLightboxSlideshowDelay = "slideshow_delay"
ImageLightboxSlideshowDelay = "image_lightbox.slideshow_delay"
ImageLightboxDisplayMode = "image_lightbox.display_mode"
ImageLightboxScaleUp = "image_lightbox.scale_up"
ImageLightboxResetZoomOnNav = "image_lightbox.reset_zoom_on_nav"
ImageLightboxScrollMode = "image_lightbox.scroll_mode"
legacyImageLightboxSlideshowDelay = "slideshow_delay"
ImageLightboxSlideshowDelay = "image_lightbox.slideshow_delay"
ImageLightboxDisplayMode = "image_lightbox.display_mode"
ImageLightboxScaleUp = "image_lightbox.scale_up"
ImageLightboxResetZoomOnNav = "image_lightbox.reset_zoom_on_nav"
ImageLightboxScrollMode = "image_lightbox.scroll_mode"
ImageLightboxScrollAttemptsBeforeChange = "image_lightbox.scroll_attempts_before_change"
UI = "ui"
defaultImageLightboxSlideshowDelay = 5000
@@ -955,6 +958,9 @@ func (i *Instance) GetImageLightboxOptions() models.ConfigImageLightboxResult {
mode := models.ImageLightboxScrollMode(v.GetString(ImageLightboxScrollMode))
ret.ScrollMode = &mode
}
if v := i.viperWith(ImageLightboxScrollAttemptsBeforeChange); v != nil {
ret.ScrollAttemptsBeforeChange = v.GetInt(ImageLightboxScrollAttemptsBeforeChange)
}
return ret
}
@@ -967,6 +973,26 @@ func (i *Instance) GetDisableDropdownCreate() *models.ConfigDisableDropdownCreat
}
}
func (i *Instance) 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)
}
func (i *Instance) SetUIConfiguration(v 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
i.viper(UI).Set(UI, toSnakeCaseMap(v))
}
func (i *Instance) GetCSSPath() string {
// use custom.css in the same directory as the config file
configFileUsed := i.GetConfigFile()

View File

@@ -0,0 +1,100 @@
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 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()
}
func fromSnakeCase(v string) string {
var buf bytes.Buffer
cap := false
for i, c := range v {
switch {
case c == '_' && i > 0:
cap = true
case cap:
buf.WriteRune(unicode.ToUpper(c))
cap = false
default:
buf.WriteRune(c)
}
}
return buf.String()
}
// copyAndInsensitiviseMap behaves like insensitiviseMap, but creates a copy of
// any map it makes case insensitive.
func toSnakeCaseMap(m map[string]interface{}) map[string]interface{} {
nm := make(map[string]interface{})
for key, val := range m {
adjKey := toSnakeCase(key)
nm[adjKey] = val
}
return nm
}
// convertMapValue converts values into something that can be marshalled in JSON
// This means converting map[interface{}]interface{} to map[string]interface{} where ever
// encountered.
func convertMapValue(val interface{}) interface{} {
switch v := val.(type) {
case map[interface{}]interface{}:
ret := cast.ToStringMap(v)
for k, vv := range ret {
ret[k] = convertMapValue(vv)
}
return ret
case map[string]interface{}:
ret := make(map[string]interface{})
for k, vv := range v {
ret[k] = convertMapValue(vv)
}
return ret
case []interface{}:
ret := make([]interface{}, len(v))
for i, vv := range v {
ret[i] = convertMapValue(vv)
}
return ret
default:
return v
}
}
func fromSnakeCaseMap(m map[string]interface{}) map[string]interface{} {
nm := make(map[string]interface{})
for key, val := range m {
adjKey := fromSnakeCase(key)
nm[adjKey] = convertMapValue(val)
}
return nm
}

View File

@@ -0,0 +1,82 @@
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,20 +1,17 @@
package manager
import (
"bytes"
"context"
"fmt"
"math"
"runtime"
"strconv"
"strings"
"github.com/stashapp/stash/pkg/exec"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
)
type GeneratorInfo struct {
type generatorInfo struct {
ChunkCount int
FrameRate float64
NumberOfFrames int
@@ -22,27 +19,21 @@ type GeneratorInfo struct {
// NthFrame used for sprite generation
NthFrame int
ChunkDuration float64
ExcludeStart string
ExcludeEnd string
VideoFile ffmpeg.VideoFile
Audio bool // used for preview generation
}
func newGeneratorInfo(videoFile ffmpeg.VideoFile) (*GeneratorInfo, error) {
func newGeneratorInfo(videoFile ffmpeg.VideoFile) (*generatorInfo, error) {
exists, err := fsutil.FileExists(videoFile.Path)
if !exists {
logger.Errorf("video file not found")
return nil, err
}
generator := &GeneratorInfo{VideoFile: videoFile}
generator := &generatorInfo{VideoFile: videoFile}
return generator, nil
}
func (g *GeneratorInfo) calculateFrameRate(videoStream *ffmpeg.FFProbeStream) error {
func (g *generatorInfo) calculateFrameRate(videoStream *ffmpeg.FFProbeStream) error {
var framerate float64
if g.VideoFile.FrameRate == 0 {
framerate, _ = strconv.ParseFloat(videoStream.RFrameRate, 64)
@@ -58,30 +49,15 @@ func (g *GeneratorInfo) calculateFrameRate(videoStream *ffmpeg.FFProbeStream) er
// If we are missing the frame count or frame rate then seek through the file and extract the info with regex
if numberOfFrames == 0 || !isValidFloat64(framerate) {
args := []string{
"-nostats",
"-i", g.VideoFile.Path,
"-vcodec", "copy",
"-f", "rawvideo",
"-y",
}
if runtime.GOOS == "windows" {
args = append(args, "nul") // https://stackoverflow.com/questions/313111/is-there-a-dev-null-on-windows
info, err := instance.FFMPEG.CalculateFrameRate(context.TODO(), &g.VideoFile)
if err != nil {
logger.Errorf("error calculating frame rate: %v", err)
} else {
args = append(args, "/dev/null")
}
command := exec.Command(string(instance.FFMPEG), args...)
var stdErrBuffer bytes.Buffer
command.Stderr = &stdErrBuffer // Frames go to stderr rather than stdout
if err := command.Run(); err == nil {
stdErrString := stdErrBuffer.String()
if numberOfFrames == 0 {
numberOfFrames = ffmpeg.GetFrameFromRegex(stdErrString)
numberOfFrames = info.NumberOfFrames
}
if !isValidFloat64(framerate) {
time := ffmpeg.GetTimeFromRegex(stdErrString)
framerate = math.Round((float64(numberOfFrames)/time)*100) / 100
framerate = info.FrameRate
}
}
}
@@ -107,7 +83,7 @@ func isValidFloat64(value float64) bool {
return !math.IsNaN(value) && value != 0
}
func (g *GeneratorInfo) configure() error {
func (g *generatorInfo) configure() error {
videoStream := g.VideoFile.VideoStream
if videoStream == nil {
return fmt.Errorf("missing video stream")
@@ -127,36 +103,3 @@ func (g *GeneratorInfo) configure() error {
return nil
}
func (g GeneratorInfo) getExcludeValue(v string) float64 {
if strings.HasSuffix(v, "%") && len(v) > 1 {
// proportion of video duration
v = v[0 : len(v)-1]
prop, _ := strconv.ParseFloat(v, 64)
return prop / 100.0 * g.VideoFile.Duration
}
prop, _ := strconv.ParseFloat(v, 64)
return prop
}
// getStepSizeAndOffset calculates the step size for preview generation and
// the starting offset.
//
// Step size is calculated based on the duration of the video file, minus the
// excluded duration. The offset is based on the ExcludeStart. If the total
// excluded duration exceeds the duration of the video, then offset is 0, and
// the video duration is used to calculate the step size.
func (g GeneratorInfo) getStepSizeAndOffset() (stepSize float64, offset float64) {
duration := g.VideoFile.Duration
excludeStart := g.getExcludeValue(g.ExcludeStart)
excludeEnd := g.getExcludeValue(g.ExcludeEnd)
if duration > excludeStart+excludeEnd {
duration = duration - excludeStart - excludeEnd
offset = excludeStart
}
stepSize = duration / float64(g.ChunkCount)
return
}

View File

@@ -1,99 +0,0 @@
package manager
import (
"fmt"
"image"
"image/color"
"math"
"github.com/corona10/goimagehash"
"github.com/disintegration/imaging"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
)
type PhashGenerator struct {
Info *GeneratorInfo
VideoChecksum string
Columns int
Rows int
}
func NewPhashGenerator(videoFile ffmpeg.VideoFile, checksum string) (*PhashGenerator, error) {
exists, err := fsutil.FileExists(videoFile.Path)
if !exists {
return nil, err
}
generator, err := newGeneratorInfo(videoFile)
if err != nil {
return nil, err
}
return &PhashGenerator{
Info: generator,
VideoChecksum: checksum,
Columns: 5,
Rows: 5,
}, nil
}
func (g *PhashGenerator) Generate() (*uint64, error) {
encoder := instance.FFMPEG
sprite, err := g.generateSprite(&encoder)
if err != nil {
return nil, err
}
hash, err := goimagehash.PerceptionHash(sprite)
if err != nil {
return nil, err
}
hashValue := hash.GetHash()
return &hashValue, nil
}
func (g *PhashGenerator) generateSprite(encoder *ffmpeg.Encoder) (image.Image, error) {
logger.Infof("[generator] generating phash sprite for %s", g.Info.VideoFile.Path)
// Generate sprite image offset by 5% on each end to avoid intro/outros
chunkCount := g.Columns * g.Rows
offset := 0.05 * g.Info.VideoFile.Duration
stepSize := (0.9 * g.Info.VideoFile.Duration) / float64(chunkCount)
var images []image.Image
for i := 0; i < chunkCount; i++ {
time := offset + (float64(i) * stepSize)
options := ffmpeg.SpriteScreenshotOptions{
Time: time,
Width: 160,
}
img, err := encoder.SpriteScreenshot(g.Info.VideoFile, options)
if err != nil {
return nil, err
}
images = append(images, img)
}
// Combine all of the thumbnails into a sprite image
if len(images) == 0 {
return nil, fmt.Errorf("images slice is empty, failed to generate phash sprite for %s", g.Info.VideoFile.Path)
}
width := images[0].Bounds().Size().X
height := images[0].Bounds().Size().Y
canvasWidth := width * g.Columns
canvasHeight := height * g.Rows
montage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{})
for index := 0; index < len(images); index++ {
x := width * (index % g.Columns)
y := height * int(math.Floor(float64(index)/float64(g.Rows)))
img := images[index]
montage = imaging.Paste(montage, img, image.Pt(x, y))
}
return montage, nil
}

View File

@@ -1,174 +0,0 @@
package manager
import (
"bufio"
"fmt"
"os"
"path/filepath"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
)
type PreviewGenerator struct {
Info *GeneratorInfo
VideoChecksum string
VideoFilename string
ImageFilename string
OutputDirectory string
GenerateVideo bool
GenerateImage bool
PreviewPreset string
Overwrite bool
}
func NewPreviewGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, videoFilename string, imageFilename string, outputDirectory string, generateVideo bool, generateImage bool, previewPreset string) (*PreviewGenerator, error) {
exists, err := fsutil.FileExists(videoFile.Path)
if !exists {
return nil, err
}
generator, err := newGeneratorInfo(videoFile)
if err != nil {
return nil, err
}
generator.ChunkCount = 12 // 12 segments to the preview
return &PreviewGenerator{
Info: generator,
VideoChecksum: videoChecksum,
VideoFilename: videoFilename,
ImageFilename: imageFilename,
OutputDirectory: outputDirectory,
GenerateVideo: generateVideo,
GenerateImage: generateImage,
PreviewPreset: previewPreset,
}, nil
}
func (g *PreviewGenerator) Generate() error {
logger.Infof("[generator] generating scene preview for %s", g.Info.VideoFile.Path)
if err := g.Info.configure(); err != nil {
return err
}
encoder := instance.FFMPEG
if g.GenerateVideo {
if err := g.generateVideo(&encoder, false); err != nil {
logger.Warnf("[generator] failed generating scene preview, trying fallback")
if err := g.generateVideo(&encoder, true); err != nil {
return err
}
}
}
if g.GenerateImage {
if err := g.generateImage(&encoder); err != nil {
return err
}
}
return nil
}
func (g *PreviewGenerator) generateConcatFile() error {
f, err := os.Create(g.getConcatFilePath())
if err != nil {
return err
}
defer f.Close()
w := bufio.NewWriter(f)
for i := 0; i < g.Info.ChunkCount; i++ {
num := fmt.Sprintf("%.3d", i)
filename := "preview_" + g.VideoChecksum + "_" + num + ".mp4"
_, _ = w.WriteString(fmt.Sprintf("file '%s'\n", filename))
}
return w.Flush()
}
func (g *PreviewGenerator) generateVideo(encoder *ffmpeg.Encoder, fallback bool) error {
outputPath := filepath.Join(g.OutputDirectory, g.VideoFilename)
outputExists, _ := fsutil.FileExists(outputPath)
if !g.Overwrite && outputExists {
return nil
}
err := g.generateConcatFile()
if err != nil {
return err
}
var tmpFiles []string // a list of tmp files used during the preview generation
tmpFiles = append(tmpFiles, g.getConcatFilePath()) // add concat filename to tmpFiles
defer func() { removeFiles(tmpFiles) }() // remove tmpFiles when done
stepSize, offset := g.Info.getStepSizeAndOffset()
durationSegment := g.Info.ChunkDuration
if durationSegment < 0.75 { // a very short duration can create files without a video stream
durationSegment = 0.75 // use 0.75 in that case
logger.Warnf("[generator] Segment duration (%f) too short.Using 0.75 instead.", g.Info.ChunkDuration)
}
includeAudio := g.Info.Audio
for i := 0; i < g.Info.ChunkCount; i++ {
time := offset + (float64(i) * stepSize)
num := fmt.Sprintf("%.3d", i)
filename := "preview_" + g.VideoChecksum + "_" + num + ".mp4"
chunkOutputPath := instance.Paths.Generated.GetTmpPath(filename)
tmpFiles = append(tmpFiles, chunkOutputPath) // add chunk filename to tmpFiles
options := ffmpeg.ScenePreviewChunkOptions{
StartTime: time,
Duration: durationSegment,
Width: 640,
OutputPath: chunkOutputPath,
Audio: includeAudio,
}
if err := encoder.ScenePreviewVideoChunk(g.Info.VideoFile, options, g.PreviewPreset, fallback); err != nil {
return err
}
}
videoOutputPath := filepath.Join(g.OutputDirectory, g.VideoFilename)
if err := encoder.ScenePreviewVideoChunkCombine(g.Info.VideoFile, g.getConcatFilePath(), videoOutputPath); err != nil {
return err
}
logger.Debug("created video preview: ", videoOutputPath)
return nil
}
func (g *PreviewGenerator) generateImage(encoder *ffmpeg.Encoder) error {
outputPath := filepath.Join(g.OutputDirectory, g.ImageFilename)
outputExists, _ := fsutil.FileExists(outputPath)
if !g.Overwrite && outputExists {
return nil
}
videoPreviewPath := filepath.Join(g.OutputDirectory, g.VideoFilename)
tmpOutputPath := instance.Paths.Generated.GetTmpPath(g.ImageFilename)
if err := encoder.ScenePreviewVideoToImage(g.Info.VideoFile, 640, videoPreviewPath, tmpOutputPath); err != nil {
return err
}
if err := fsutil.SafeMove(tmpOutputPath, outputPath); err != nil {
return err
}
logger.Debug("created video preview image: ", outputPath)
return nil
}
func (g *PreviewGenerator) getConcatFilePath() string {
return instance.Paths.Generated.GetTmpPath(fmt.Sprintf("files_%s.txt", g.VideoChecksum))
}
func removeFiles(list []string) {
for _, f := range list {
if err := os.Remove(f); err != nil {
logger.Warnf("[generator] Delete error: %s", err)
}
}
}

View File

@@ -1,25 +1,22 @@
package manager
import (
"context"
"errors"
"fmt"
"image"
"image/color"
"math"
"os"
"path/filepath"
"strings"
"github.com/disintegration/imaging"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/utils"
"github.com/stashapp/stash/pkg/scene/generate"
)
type SpriteGenerator struct {
Info *GeneratorInfo
Info *generatorInfo
VideoChecksum string
ImageOutputPath string
@@ -29,6 +26,8 @@ type SpriteGenerator struct {
SlowSeek bool // use alternate seek function, very slow!
Overwrite bool
g *generate.Generator
}
func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageOutputPath string, vttOutputPath string, rows int, cols int) (*SpriteGenerator, error) {
@@ -49,7 +48,7 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO
slowSeek = true
// do an actual frame count of the file ( number of frames = read frames)
ffprobe := GetInstance().FFProbe
fc, err := ffprobe.GetReadFrameCount(&videoFile)
fc, err := ffprobe.GetReadFrameCount(videoFile.Path)
if err == nil {
if fc != videoFile.FrameCount {
logger.Warnf("[generator] updating framecount (%d) for %s with read frames count (%d)", videoFile.FrameCount, videoFile.Path, fc)
@@ -75,22 +74,25 @@ func NewSpriteGenerator(videoFile ffmpeg.VideoFile, videoChecksum string, imageO
Rows: rows,
SlowSeek: slowSeek,
Columns: cols,
g: &generate.Generator{
Encoder: instance.FFMPEG,
LockManager: instance.ReadLockManager,
ScenePaths: instance.Paths.Scene,
},
}, nil
}
func (g *SpriteGenerator) Generate() error {
encoder := instance.FFMPEG
if err := g.generateSpriteImage(&encoder); err != nil {
if err := g.generateSpriteImage(); err != nil {
return err
}
if err := g.generateSpriteVTT(&encoder); err != nil {
if err := g.generateSpriteVTT(); err != nil {
return err
}
return nil
}
func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error {
func (g *SpriteGenerator) generateSpriteImage() error {
if !g.Overwrite && g.imageExists() {
return nil
}
@@ -105,13 +107,7 @@ func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error {
for i := 0; i < g.Info.ChunkCount; i++ {
time := float64(i) * stepSize
options := ffmpeg.SpriteScreenshotOptions{
Time: time,
Width: 160,
}
img, err := encoder.SpriteScreenshot(g.Info.VideoFile, options)
img, err := g.g.SpriteScreenshot(context.TODO(), g.Info.VideoFile.Path, time)
if err != nil {
return err
}
@@ -128,11 +124,8 @@ func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error {
if frame >= math.MaxInt || frame <= math.MinInt {
return errors.New("invalid frame number conversion")
}
options := ffmpeg.SpriteScreenshotOptions{
Frame: int(frame),
Width: 160,
}
img, err := encoder.SpriteScreenshotSlow(g.Info.VideoFile, options)
img, err := g.g.SpriteScreenshotSlow(context.TODO(), g.Info.VideoFile.Path, int(frame))
if err != nil {
return err
}
@@ -144,41 +137,16 @@ func (g *SpriteGenerator) generateSpriteImage(encoder *ffmpeg.Encoder) error {
if len(images) == 0 {
return fmt.Errorf("images slice is empty, failed to generate sprite images for %s", g.Info.VideoFile.Path)
}
// Combine all of the thumbnails into a sprite image
width := images[0].Bounds().Size().X
height := images[0].Bounds().Size().Y
canvasWidth := width * g.Columns
canvasHeight := height * g.Rows
montage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{})
for index := 0; index < len(images); index++ {
x := width * (index % g.Columns)
y := height * int(math.Floor(float64(index)/float64(g.Rows)))
img := images[index]
montage = imaging.Paste(montage, img, image.Pt(x, y))
}
return imaging.Save(montage, g.ImageOutputPath)
return imaging.Save(g.g.CombineSpriteImages(images), g.ImageOutputPath)
}
func (g *SpriteGenerator) generateSpriteVTT(encoder *ffmpeg.Encoder) error {
func (g *SpriteGenerator) generateSpriteVTT() error {
if !g.Overwrite && g.vttExists() {
return nil
}
logger.Infof("[generator] generating sprite vtt for %s", g.Info.VideoFile.Path)
spriteImage, err := os.Open(g.ImageOutputPath)
if err != nil {
return err
}
defer spriteImage.Close()
spriteImageName := filepath.Base(g.ImageOutputPath)
image, _, err := image.DecodeConfig(spriteImage)
if err != nil {
return err
}
width := image.Width / g.Columns
height := image.Height / g.Rows
var stepSize float64
if !g.SlowSeek {
stepSize = float64(g.Info.NthFrame) / g.Info.FrameRate
@@ -189,20 +157,7 @@ func (g *SpriteGenerator) generateSpriteVTT(encoder *ffmpeg.Encoder) error {
stepSize /= g.Info.FrameRate
}
vttLines := []string{"WEBVTT", ""}
for index := 0; index < g.Info.ChunkCount; index++ {
x := width * (index % g.Columns)
y := height * int(math.Floor(float64(index)/float64(g.Rows)))
startTime := utils.GetVTTTime(float64(index) * stepSize)
endTime := utils.GetVTTTime(float64(index+1) * stepSize)
vttLines = append(vttLines, startTime+" --> "+endTime)
vttLines = append(vttLines, fmt.Sprintf("%s#xywh=%d,%d,%d,%d", spriteImageName, x, y, width, height))
vttLines = append(vttLines, "")
}
vtt := strings.Join(vttLines, "\n")
return os.WriteFile(g.VTTOutputPath, []byte(vtt), 0644)
return g.g.SpriteVTT(context.TODO(), g.VTTOutputPath, g.ImageOutputPath, stepSize)
}
func (g *SpriteGenerator) imageExists() bool {

15
internal/manager/log.go Normal file
View File

@@ -0,0 +1,15 @@
package manager
import (
"errors"
"os/exec"
"github.com/stashapp/stash/pkg/logger"
)
func logErrorOutput(err error) {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
logger.Errorf("command stderr: %v", string(exitErr.Stderr))
}
}

View File

@@ -31,15 +31,17 @@ import (
"github.com/stashapp/stash/ui"
)
type singleton struct {
type Manager struct {
Config *config.Instance
Logger *log.Logger
Paths *paths.Paths
FFMPEG ffmpeg.Encoder
FFMPEG ffmpeg.FFMpeg
FFProbe ffmpeg.FFProbe
ReadLockManager *fsutil.ReadLockManager
SessionStore *session.Store
JobManager *job.Manager
@@ -56,84 +58,94 @@ type singleton struct {
scanSubs *subscriptionManager
}
var instance *singleton
var instance *Manager
var once sync.Once
func GetInstance() *singleton {
Initialize()
func GetInstance() *Manager {
if _, err := Initialize(); err != nil {
panic(err)
}
return instance
}
func Initialize() *singleton {
func Initialize() (*Manager, error) {
var err error
once.Do(func() {
ctx := context.TODO()
cfg, err := config.Initialize()
if err != nil {
panic(fmt.Sprintf("error initializing configuration: %s", err.Error()))
}
l := initLog()
initProfiling(cfg.GetCPUProfilePath())
instance = &singleton{
Config: cfg,
Logger: l,
DownloadStore: NewDownloadStore(),
PluginCache: plugin.NewCache(cfg),
TxnManager: sqlite.NewTransactionManager(),
scanSubs: &subscriptionManager{},
}
instance.JobManager = initJobManager()
sceneServer := SceneServer{
TXNManager: instance.TxnManager,
}
instance.DLNAService = dlna.NewService(instance.TxnManager, instance.Config, &sceneServer)
if !cfg.IsNewSystem() {
logger.Infof("using config file: %s", cfg.GetConfigFile())
if err == nil {
err = cfg.Validate()
}
if err != nil {
panic(fmt.Sprintf("error initializing configuration: %s", err.Error()))
} else if err := instance.PostInit(ctx); err != nil {
panic(err)
}
initSecurity(cfg)
} else {
cfgFile := cfg.GetConfigFile()
if cfgFile != "" {
cfgFile += " "
}
// create temporary session store - this will be re-initialised
// after config is complete
instance.SessionStore = session.NewStore(cfg)
logger.Warnf("config file %snot found. Assuming new system...", cfgFile)
}
if err = initFFMPEG(); err != nil {
logger.Warnf("could not initialize FFMPEG subsystem: %v", err)
}
// if DLNA is enabled, start it now
if instance.Config.GetDLNADefaultEnabled() {
if err := instance.DLNAService.Start(nil); err != nil {
logger.Warnf("could not start DLNA service: %v", err)
}
}
err = initialize()
})
return instance
return instance, err
}
func initialize() error {
ctx := context.TODO()
cfg, err := config.Initialize()
if err != nil {
return fmt.Errorf("initializing configuration: %w", err)
}
l := initLog()
initProfiling(cfg.GetCPUProfilePath())
instance = &Manager{
Config: cfg,
Logger: l,
ReadLockManager: fsutil.NewReadLockManager(),
DownloadStore: NewDownloadStore(),
PluginCache: plugin.NewCache(cfg),
TxnManager: sqlite.NewTransactionManager(),
scanSubs: &subscriptionManager{},
}
instance.JobManager = initJobManager()
sceneServer := SceneServer{
TXNManager: instance.TxnManager,
}
instance.DLNAService = dlna.NewService(instance.TxnManager, instance.Config, &sceneServer)
if !cfg.IsNewSystem() {
logger.Infof("using config file: %s", cfg.GetConfigFile())
if err == nil {
err = cfg.Validate()
}
if err != nil {
return fmt.Errorf("error initializing configuration: %w", err)
} else if err := instance.PostInit(ctx); err != nil {
return err
}
initSecurity(cfg)
} else {
cfgFile := cfg.GetConfigFile()
if cfgFile != "" {
cfgFile += " "
}
// create temporary session store - this will be re-initialised
// after config is complete
instance.SessionStore = session.NewStore(cfg)
logger.Warnf("config file %snot found. Assuming new system...", cfgFile)
}
if err = initFFMPEG(ctx); err != nil {
logger.Warnf("could not initialize FFMPEG subsystem: %v", err)
}
// if DLNA is enabled, start it now
if instance.Config.GetDLNADefaultEnabled() {
if err := instance.DLNAService.Start(nil); err != nil {
logger.Warnf("could not start DLNA service: %v", err)
}
}
return nil
}
func initJobManager() *job.Manager {
@@ -148,8 +160,13 @@ func initJobManager() *job.Manager {
case j := <-c.RemovedJob:
if instance.Config.GetNotificationsEnabled() {
cleanDesc := strings.TrimRight(j.Description, ".")
timeElapsed := j.EndTime.Sub(*j.StartTime)
if j.StartTime == nil {
// Task was never started
return
}
timeElapsed := j.EndTime.Sub(*j.StartTime)
desktop.SendNotification("Task Finished", "Task \""+cleanDesc+"\" is finished in "+formatDuration(timeElapsed)+".")
}
case <-ctx.Done():
@@ -189,9 +206,7 @@ func initProfiling(cpuProfilePath string) {
}
}
func initFFMPEG() error {
ctx := context.TODO()
func initFFMPEG(ctx context.Context) error {
// only do this if we have a config file set
if instance.Config.GetConfigFile() != "" {
// use same directory as config path
@@ -220,7 +235,7 @@ func initFFMPEG() error {
}
}
instance.FFMPEG = ffmpeg.Encoder(ffmpegPath)
instance.FFMPEG = ffmpeg.FFMpeg(ffmpegPath)
instance.FFProbe = ffmpeg.FFProbe(ffprobePath)
}
@@ -239,7 +254,7 @@ func initLog() *log.Logger {
// PostInit initialises the paths, caches and txnManager after the initial
// configuration has been set. Should only be called if the configuration
// is valid.
func (s *singleton) PostInit(ctx context.Context) error {
func (s *Manager) PostInit(ctx context.Context) error {
if err := s.Config.SetInitialConfig(); err != nil {
logger.Warnf("could not set initial configuration: %v", err)
}
@@ -299,7 +314,7 @@ func writeStashIcon() {
}
// initScraperCache initializes a new scraper cache and returns it.
func (s *singleton) initScraperCache() *scraper.Cache {
func (s *Manager) initScraperCache() *scraper.Cache {
ret, err := scraper.NewCache(config.GetInstance(), s.TxnManager)
if err != nil {
@@ -309,7 +324,7 @@ func (s *singleton) initScraperCache() *scraper.Cache {
return ret
}
func (s *singleton) RefreshConfig() {
func (s *Manager) RefreshConfig() {
s.Paths = paths.NewPaths(s.Config.GetGeneratedPath())
config := s.Config
if config.Validate() == nil {
@@ -336,7 +351,7 @@ func (s *singleton) RefreshConfig() {
// RefreshScraperCache refreshes the scraper cache. Call this when scraper
// configuration changes.
func (s *singleton) RefreshScraperCache() {
func (s *Manager) RefreshScraperCache() {
s.ScraperCache = s.initScraperCache()
}
@@ -355,7 +370,7 @@ func setSetupDefaults(input *models.SetupInput) {
}
}
func (s *singleton) Setup(ctx context.Context, input models.SetupInput) error {
func (s *Manager) Setup(ctx context.Context, input models.SetupInput) error {
setSetupDefaults(&input)
c := s.Config
@@ -404,14 +419,14 @@ func (s *singleton) Setup(ctx context.Context, input models.SetupInput) error {
s.Config.FinalizeSetup()
if err := initFFMPEG(); err != nil {
if err := initFFMPEG(ctx); err != nil {
return fmt.Errorf("error initializing FFMPEG subsystem: %v", err)
}
return nil
}
func (s *singleton) validateFFMPEG() error {
func (s *Manager) validateFFMPEG() error {
if s.FFMPEG == "" || s.FFProbe == "" {
return errors.New("missing ffmpeg and/or ffprobe")
}
@@ -419,7 +434,7 @@ func (s *singleton) validateFFMPEG() error {
return nil
}
func (s *singleton) Migrate(ctx context.Context, input models.MigrateInput) error {
func (s *Manager) Migrate(ctx context.Context, input models.MigrateInput) error {
// always backup so that we can roll back to the previous version if
// migration fails
backupPath := input.BackupPath
@@ -459,7 +474,7 @@ func (s *singleton) Migrate(ctx context.Context, input models.MigrateInput) erro
return nil
}
func (s *singleton) GetSystemStatus() *models.SystemStatus {
func (s *Manager) GetSystemStatus() *models.SystemStatus {
status := models.SystemStatusEnumOk
dbSchema := int(database.Version())
dbPath := database.DatabasePath()
@@ -482,7 +497,7 @@ func (s *singleton) GetSystemStatus() *models.SystemStatus {
}
// Shutdown gracefully stops the manager
func (s *singleton) Shutdown(code int) {
func (s *Manager) Shutdown(code int) {
// stop any profiling at exit
pprof.StopCPUProfile()

View File

@@ -12,6 +12,7 @@ import (
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
)
func isGallery(pathname string) bool {
@@ -19,6 +20,10 @@ func isGallery(pathname string) bool {
return fsutil.MatchExtension(pathname, gExt)
}
func isCaptions(pathname string) bool {
return fsutil.MatchExtension(pathname, scene.CaptionExts)
}
func isVideo(pathname string) bool {
vidExt := config.GetInstance().GetVideoExtensions()
return fsutil.MatchExtension(pathname, vidExt)
@@ -53,11 +58,11 @@ func getScanPaths(inputPaths []string) []*models.StashConfig {
// ScanSubscribe subscribes to a notification that is triggered when a
// scan or clean is complete.
func (s *singleton) ScanSubscribe(ctx context.Context) <-chan bool {
func (s *Manager) ScanSubscribe(ctx context.Context) <-chan bool {
return s.scanSubs.subscribe(ctx)
}
func (s *singleton) Scan(ctx context.Context, input models.ScanMetadataInput) (int, error) {
func (s *Manager) Scan(ctx context.Context, input models.ScanMetadataInput) (int, error) {
if err := s.validateFFMPEG(); err != nil {
return 0, err
}
@@ -71,7 +76,7 @@ func (s *singleton) Scan(ctx context.Context, input models.ScanMetadataInput) (i
return s.JobManager.Add(ctx, "Scanning...", &scanJob), nil
}
func (s *singleton) Import(ctx context.Context) (int, error) {
func (s *Manager) Import(ctx context.Context) (int, error) {
config := config.GetInstance()
metadataPath := config.GetMetadataPath()
if metadataPath == "" {
@@ -93,7 +98,7 @@ func (s *singleton) Import(ctx context.Context) (int, error) {
return s.JobManager.Add(ctx, "Importing...", j), nil
}
func (s *singleton) Export(ctx context.Context) (int, error) {
func (s *Manager) Export(ctx context.Context) (int, error) {
config := config.GetInstance()
metadataPath := config.GetMetadataPath()
if metadataPath == "" {
@@ -108,13 +113,13 @@ func (s *singleton) Export(ctx context.Context) (int, error) {
full: true,
fileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),
}
task.Start(&wg)
task.Start(ctx, &wg)
})
return s.JobManager.Add(ctx, "Exporting...", j), nil
}
func (s *singleton) RunSingleTask(ctx context.Context, t Task) int {
func (s *Manager) RunSingleTask(ctx context.Context, t Task) int {
var wg sync.WaitGroup
wg.Add(1)
@@ -126,35 +131,7 @@ func (s *singleton) RunSingleTask(ctx context.Context, t Task) int {
return s.JobManager.Add(ctx, t.GetDescription(), j)
}
func setGeneratePreviewOptionsInput(optionsInput *models.GeneratePreviewOptionsInput) {
config := config.GetInstance()
if optionsInput.PreviewSegments == nil {
val := config.GetPreviewSegments()
optionsInput.PreviewSegments = &val
}
if optionsInput.PreviewSegmentDuration == nil {
val := config.GetPreviewSegmentDuration()
optionsInput.PreviewSegmentDuration = &val
}
if optionsInput.PreviewExcludeStart == nil {
val := config.GetPreviewExcludeStart()
optionsInput.PreviewExcludeStart = &val
}
if optionsInput.PreviewExcludeEnd == nil {
val := config.GetPreviewExcludeEnd()
optionsInput.PreviewExcludeEnd = &val
}
if optionsInput.PreviewPreset == nil {
val := config.GetPreviewPreset()
optionsInput.PreviewPreset = &val
}
}
func (s *singleton) Generate(ctx context.Context, input models.GenerateMetadataInput) (int, error) {
func (s *Manager) Generate(ctx context.Context, input models.GenerateMetadataInput) (int, error) {
if err := s.validateFFMPEG(); err != nil {
return 0, err
}
@@ -170,16 +147,16 @@ func (s *singleton) Generate(ctx context.Context, input models.GenerateMetadataI
return s.JobManager.Add(ctx, "Generating...", j), nil
}
func (s *singleton) GenerateDefaultScreenshot(ctx context.Context, sceneId string) int {
func (s *Manager) GenerateDefaultScreenshot(ctx context.Context, sceneId string) int {
return s.generateScreenshot(ctx, sceneId, nil)
}
func (s *singleton) GenerateScreenshot(ctx context.Context, sceneId string, at float64) int {
func (s *Manager) GenerateScreenshot(ctx context.Context, sceneId string, at float64) int {
return s.generateScreenshot(ctx, sceneId, &at)
}
// generate default screenshot if at is nil
func (s *singleton) generateScreenshot(ctx context.Context, sceneId string, at *float64) int {
func (s *Manager) generateScreenshot(ctx context.Context, sceneId string, at *float64) int {
if err := instance.Paths.Generated.EnsureTmpDir(); err != nil {
logger.Warnf("failure generating screenshot: %v", err)
}
@@ -192,7 +169,7 @@ func (s *singleton) generateScreenshot(ctx context.Context, sceneId string, at *
}
var scene *models.Scene
if err := s.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
if err := s.TxnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
var err error
scene, err = r.Scene().Find(sceneIdInt)
return err
@@ -216,7 +193,7 @@ func (s *singleton) generateScreenshot(ctx context.Context, sceneId string, at *
return s.JobManager.Add(ctx, fmt.Sprintf("Generating screenshot for scene id %s", sceneId), j)
}
func (s *singleton) AutoTag(ctx context.Context, input models.AutoTagMetadataInput) int {
func (s *Manager) AutoTag(ctx context.Context, input models.AutoTagMetadataInput) int {
j := autoTagJob{
txnManager: s.TxnManager,
input: input,
@@ -225,7 +202,7 @@ func (s *singleton) AutoTag(ctx context.Context, input models.AutoTagMetadataInp
return s.JobManager.Add(ctx, "Auto-tagging...", &j)
}
func (s *singleton) Clean(ctx context.Context, input models.CleanMetadataInput) int {
func (s *Manager) Clean(ctx context.Context, input models.CleanMetadataInput) int {
j := cleanJob{
txnManager: s.TxnManager,
input: input,
@@ -235,13 +212,13 @@ func (s *singleton) Clean(ctx context.Context, input models.CleanMetadataInput)
return s.JobManager.Add(ctx, "Cleaning...", &j)
}
func (s *singleton) MigrateHash(ctx context.Context) int {
func (s *Manager) MigrateHash(ctx context.Context) int {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) {
fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm()
logger.Infof("Migrating generated files for %s naming hash", fileNamingAlgo.String())
var scenes []*models.Scene
if err := s.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
if err := s.TxnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
var err error
scenes, err = r.Scene().All()
return err
@@ -283,7 +260,7 @@ func (s *singleton) MigrateHash(ctx context.Context) int {
return s.JobManager.Add(ctx, "Migrating scene hashes...", j)
}
func (s *singleton) StashBoxBatchPerformerTag(ctx context.Context, input models.StashBoxBatchPerformerTagInput) int {
func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input models.StashBoxBatchPerformerTagInput) int {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) {
logger.Infof("Initiating stash-box batch performer tag")
@@ -303,7 +280,7 @@ func (s *singleton) StashBoxBatchPerformerTag(ctx context.Context, input models.
// This is why we mark this section nolint. In principle, we should look to
// rewrite the section at some point, to avoid the linter warning.
if len(input.PerformerIds) > 0 { //nolint:gocritic
if err := s.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
if err := s.TxnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
performerQuery := r.Performer()
for _, performerID := range input.PerformerIds {
@@ -343,7 +320,7 @@ func (s *singleton) StashBoxBatchPerformerTag(ctx context.Context, input models.
// However, this doesn't really help with readability of the current section. Mark it
// as nolint for now. In the future we'd like to rewrite this code by factoring some of
// this into separate functions.
if err := s.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
if err := s.TxnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
performerQuery := r.Performer()
var performers []*models.Performer
var err error
@@ -384,7 +361,7 @@ func (s *singleton) StashBoxBatchPerformerTag(ctx context.Context, input models.
for _, task := range tasks {
wg.Add(1)
progress.ExecuteTask(task.Description(), func() {
task.Start()
task.Start(ctx)
wg.Done()
})

View File

@@ -3,6 +3,6 @@ package manager
import "context"
// PostMigrate is executed after migrations have been executed.
func (s *singleton) PostMigrate(ctx context.Context) {
func (s *Manager) PostMigrate(ctx context.Context) {
setInitialMD5Config(ctx, s.TxnManager)
}

View File

@@ -1,52 +1,43 @@
package manager
import (
"context"
"net/http"
"sync"
"github.com/stashapp/stash/internal/manager/config"
"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 (
streamingFiles = make(map[string][]*http.ResponseWriter)
streamingFilesMutex = sync.RWMutex{}
)
func RegisterStream(filepath string, w *http.ResponseWriter) {
streamingFilesMutex.Lock()
streams := streamingFiles[filepath]
streamingFiles[filepath] = append(streams, w)
streamingFilesMutex.Unlock()
type StreamRequestContext struct {
context.Context
ResponseWriter http.ResponseWriter
}
func deregisterStream(filepath string, w *http.ResponseWriter) {
streamingFilesMutex.Lock()
defer streamingFilesMutex.Unlock()
streams := streamingFiles[filepath]
for i, v := range streams {
if v == w {
streamingFiles[filepath] = append(streams[:i], streams[i+1:]...)
return
}
func NewStreamRequestContext(w http.ResponseWriter, r *http.Request) *StreamRequestContext {
return &StreamRequestContext{
Context: r.Context(),
ResponseWriter: w,
}
}
func WaitAndDeregisterStream(filepath string, w *http.ResponseWriter, r *http.Request) {
notify := r.Context().Done()
go func() {
<-notify
deregisterStream(filepath, w)
}()
func (c *StreamRequestContext) Cancel() {
hj, ok := (c.ResponseWriter).(http.Hijacker)
if !ok {
return
}
// hijack and close the connection
conn, _, _ := hj.Hijack()
if conn != nil {
conn.Close()
}
}
func KillRunningStreams(scene *models.Scene, fileNamingAlgo models.HashAlgorithm) {
killRunningStreams(scene.Path)
instance.ReadLockManager.Cancel(scene.Path)
sceneHash := scene.GetHash(fileNamingAlgo)
@@ -55,32 +46,7 @@ func KillRunningStreams(scene *models.Scene, fileNamingAlgo models.HashAlgorithm
}
transcodePath := GetInstance().Paths.Scene.GetTranscodePath(sceneHash)
killRunningStreams(transcodePath)
}
func killRunningStreams(path string) {
ffmpeg.KillRunningEncoders(path)
streamingFilesMutex.RLock()
streams := streamingFiles[path]
streamingFilesMutex.RUnlock()
for _, w := range streams {
hj, ok := (*w).(http.Hijacker)
if !ok {
// if we can't close the connection can't really do anything else
logger.Warnf("cannot close running stream for: %s", path)
return
}
// hijack and close the connection
conn, _, err := hj.Hijack()
if err != nil {
logger.Errorf("cannot close running stream for '%s' due to error: %s", path, err.Error())
} else {
conn.Close()
}
}
instance.ReadLockManager.Cancel(transcodePath)
}
type SceneServer struct {
@@ -91,9 +57,13 @@ func (s *SceneServer) StreamSceneDirect(scene *models.Scene, w http.ResponseWrit
fileNamingAlgo := config.GetInstance().GetVideoFileNamingAlgorithm()
filepath := GetInstance().Paths.Scene.GetStreamPath(scene.Path, scene.GetHash(fileNamingAlgo))
RegisterStream(filepath, &w)
streamRequestCtx := NewStreamRequestContext(w, r)
// #2579 - hijacking and closing the connection here causes video playback to fail in Safari
// We trust that the request context will be closed, so we don't need to call Cancel on the
// returned context here.
_ = GetInstance().ReadLockManager.ReadLock(streamRequestCtx, filepath)
http.ServeFile(w, r, filepath)
WaitAndDeregisterStream(filepath, &w, r)
}
func (s *SceneServer) ServeScreenshot(scene *models.Scene, w http.ResponseWriter, r *http.Request) {

View File

@@ -16,12 +16,12 @@ func GetSceneFileContainer(scene *models.Scene) (ffmpeg.Container, error) {
} else { // container isn't in the DB
// shouldn't happen, fallback to ffprobe
ffprobe := GetInstance().FFProbe
tmpVideoFile, err := ffprobe.NewVideoFile(scene.Path, false)
tmpVideoFile, err := ffprobe.NewVideoFile(scene.Path)
if err != nil {
return ffmpeg.Container(""), fmt.Errorf("error reading video file: %v", err)
}
container = ffmpeg.MatchContainer(tmpVideoFile.Container, scene.Path)
return ffmpeg.MatchContainer(tmpVideoFile.Container, scene.Path)
}
return container, nil
@@ -74,7 +74,7 @@ func GetSceneStreamPaths(scene *models.Scene, directStreamURL string, maxStreami
// direct stream should only apply when the audio codec is supported
audioCodec := ffmpeg.MissingUnsupported
if scene.AudioCodec.Valid {
audioCodec = ffmpeg.AudioCodec(scene.AudioCodec.String)
audioCodec = ffmpeg.ProbeAudioCodec(scene.AudioCodec.String)
}
// don't care if we can't get the container

View File

@@ -1,20 +0,0 @@
package manager
import (
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/logger"
)
func makeScreenshot(probeResult ffmpeg.VideoFile, outputPath string, quality int, width int, time float64) {
encoder := instance.FFMPEG
options := ffmpeg.ScreenshotOptions{
OutputPath: outputPath,
Quality: quality,
Time: time,
Width: width,
}
if err := encoder.Screenshot(probeResult, options); err != nil {
logger.Warnf("[encoder] failure to generate screenshot: %v", err)
}
}

View File

@@ -55,13 +55,12 @@ func (j *autoTagJob) autoTagFiles(ctx context.Context, progress *job.Progress, p
performers: performers,
studios: studios,
tags: tags,
ctx: ctx,
progress: progress,
txnManager: j.txnManager,
cache: &j.cache,
}
t.process()
t.process(ctx)
}
func (j *autoTagJob) autoTagSpecific(ctx context.Context, progress *job.Progress) {
@@ -74,7 +73,7 @@ func (j *autoTagJob) autoTagSpecific(ctx context.Context, progress *job.Progress
studioCount := len(studioIds)
tagCount := len(tagIds)
if err := j.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
if err := j.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
performerQuery := r.Performer()
studioQuery := r.Studio()
tagQuery := r.Tag()
@@ -124,7 +123,7 @@ func (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progre
for _, performerId := range performerIds {
var performers []*models.Performer
if err := j.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
if err := j.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
performerQuery := r.Performer()
ignoreAutoTag := false
perPage := -1
@@ -162,7 +161,7 @@ func (j *autoTagJob) autoTagPerformers(ctx context.Context, progress *job.Progre
return nil
}
if err := j.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
if err := j.txnManager.WithTxn(ctx, func(r models.Repository) error {
if err := autotag.PerformerScenes(performer, paths, r.Scene(), &j.cache); err != nil {
return err
}
@@ -197,7 +196,7 @@ func (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress,
for _, studioId := range studioIds {
var studios []*models.Studio
if err := j.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
if err := j.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
studioQuery := r.Studio()
ignoreAutoTag := false
perPage := -1
@@ -235,7 +234,7 @@ func (j *autoTagJob) autoTagStudios(ctx context.Context, progress *job.Progress,
return nil
}
if err := j.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
if err := j.txnManager.WithTxn(ctx, func(r models.Repository) error {
aliases, err := r.Studio().GetAliases(studio.ID)
if err != nil {
return err
@@ -274,7 +273,7 @@ func (j *autoTagJob) autoTagTags(ctx context.Context, progress *job.Progress, pa
for _, tagId := range tagIds {
var tags []*models.Tag
if err := j.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
if err := j.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
tagQuery := r.Tag()
ignoreAutoTag := false
perPage := -1
@@ -307,7 +306,7 @@ func (j *autoTagJob) autoTagTags(ctx context.Context, progress *job.Progress, pa
return nil
}
if err := j.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
if err := j.txnManager.WithTxn(ctx, func(r models.Repository) error {
aliases, err := r.Tag().GetAliases(tag.ID)
if err != nil {
return err
@@ -345,7 +344,6 @@ type autoTagFilesTask struct {
studios bool
tags bool
ctx context.Context
progress *job.Progress
txnManager models.TransactionManager
cache *match.Cache
@@ -467,8 +465,8 @@ func (t *autoTagFilesTask) getCount(r models.ReaderRepository) (int, error) {
return sceneCount + imageCount + galleryCount, nil
}
func (t *autoTagFilesTask) processScenes(r models.ReaderRepository) error {
if job.IsCancelled(t.ctx) {
func (t *autoTagFilesTask) processScenes(ctx context.Context, r models.ReaderRepository) error {
if job.IsCancelled(ctx) {
return nil
}
@@ -485,7 +483,7 @@ func (t *autoTagFilesTask) processScenes(r models.ReaderRepository) error {
}
for _, ss := range scenes {
if job.IsCancelled(t.ctx) {
if job.IsCancelled(ctx) {
return nil
}
@@ -500,7 +498,7 @@ func (t *autoTagFilesTask) processScenes(r models.ReaderRepository) error {
var wg sync.WaitGroup
wg.Add(1)
go tt.Start(&wg)
go tt.Start(ctx, &wg)
wg.Wait()
t.progress.Increment()
@@ -520,8 +518,8 @@ func (t *autoTagFilesTask) processScenes(r models.ReaderRepository) error {
return nil
}
func (t *autoTagFilesTask) processImages(r models.ReaderRepository) error {
if job.IsCancelled(t.ctx) {
func (t *autoTagFilesTask) processImages(ctx context.Context, r models.ReaderRepository) error {
if job.IsCancelled(ctx) {
return nil
}
@@ -538,7 +536,7 @@ func (t *autoTagFilesTask) processImages(r models.ReaderRepository) error {
}
for _, ss := range images {
if job.IsCancelled(t.ctx) {
if job.IsCancelled(ctx) {
return nil
}
@@ -553,7 +551,7 @@ func (t *autoTagFilesTask) processImages(r models.ReaderRepository) error {
var wg sync.WaitGroup
wg.Add(1)
go tt.Start(&wg)
go tt.Start(ctx, &wg)
wg.Wait()
t.progress.Increment()
@@ -573,8 +571,8 @@ func (t *autoTagFilesTask) processImages(r models.ReaderRepository) error {
return nil
}
func (t *autoTagFilesTask) processGalleries(r models.ReaderRepository) error {
if job.IsCancelled(t.ctx) {
func (t *autoTagFilesTask) processGalleries(ctx context.Context, r models.ReaderRepository) error {
if job.IsCancelled(ctx) {
return nil
}
@@ -591,7 +589,7 @@ func (t *autoTagFilesTask) processGalleries(r models.ReaderRepository) error {
}
for _, ss := range galleries {
if job.IsCancelled(t.ctx) {
if job.IsCancelled(ctx) {
return nil
}
@@ -606,7 +604,7 @@ func (t *autoTagFilesTask) processGalleries(r models.ReaderRepository) error {
var wg sync.WaitGroup
wg.Add(1)
go tt.Start(&wg)
go tt.Start(ctx, &wg)
wg.Wait()
t.progress.Increment()
@@ -626,8 +624,8 @@ func (t *autoTagFilesTask) processGalleries(r models.ReaderRepository) error {
return nil
}
func (t *autoTagFilesTask) process() {
if err := t.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
func (t *autoTagFilesTask) process(ctx context.Context) {
if err := t.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
total, err := t.getCount(r)
if err != nil {
return err
@@ -638,21 +636,21 @@ func (t *autoTagFilesTask) process() {
logger.Infof("Starting autotag of %d files", total)
logger.Info("Autotagging scenes...")
if err := t.processScenes(r); err != nil {
if err := t.processScenes(ctx, r); err != nil {
return err
}
logger.Info("Autotagging images...")
if err := t.processImages(r); err != nil {
if err := t.processImages(ctx, r); err != nil {
return err
}
logger.Info("Autotagging galleries...")
if err := t.processGalleries(r); err != nil {
if err := t.processGalleries(ctx, r); err != nil {
return err
}
if job.IsCancelled(t.ctx) {
if job.IsCancelled(ctx) {
logger.Info("Stopping due to user request")
}
@@ -673,9 +671,9 @@ type autoTagSceneTask struct {
cache *match.Cache
}
func (t *autoTagSceneTask) Start(wg *sync.WaitGroup) {
func (t *autoTagSceneTask) Start(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
if err := t.txnManager.WithTxn(ctx, func(r models.Repository) error {
if t.performers {
if err := autotag.ScenePerformers(t.scene, r.Scene(), r.Performer(), t.cache); err != nil {
return fmt.Errorf("error tagging scene performers for %s: %v", t.scene.Path, err)
@@ -709,9 +707,9 @@ type autoTagImageTask struct {
cache *match.Cache
}
func (t *autoTagImageTask) Start(wg *sync.WaitGroup) {
func (t *autoTagImageTask) Start(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
if err := t.txnManager.WithTxn(ctx, func(r models.Repository) error {
if t.performers {
if err := autotag.ImagePerformers(t.image, r.Image(), r.Performer(), t.cache); err != nil {
return fmt.Errorf("error tagging image performers for %s: %v", t.image.Path, err)
@@ -745,9 +743,9 @@ type autoTagGalleryTask struct {
cache *match.Cache
}
func (t *autoTagGalleryTask) Start(wg *sync.WaitGroup) {
func (t *autoTagGalleryTask) Start(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
if err := t.txnManager.WithTxn(ctx, func(r models.Repository) error {
if t.performers {
if err := autotag.GalleryPerformers(t.gallery, r.Gallery(), r.Performer(), t.cache); err != nil {
return fmt.Errorf("error tagging gallery performers for %s: %v", t.gallery.Path.String, err)

View File

@@ -29,7 +29,7 @@ func (j *cleanJob) Execute(ctx context.Context, progress *job.Progress) {
logger.Infof("Running in Dry Mode")
}
if err := j.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
if err := j.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
total, err := j.getCount(r)
if err != nil {
return fmt.Errorf("error getting count: %w", err)
@@ -401,7 +401,7 @@ func (j *cleanJob) deleteScene(ctx context.Context, fileNamingAlgorithm models.H
Paths: GetInstance().Paths,
}
var s *models.Scene
if err := j.txnManager.WithTxn(context.TODO(), func(repo models.Repository) error {
if err := j.txnManager.WithTxn(ctx, func(repo models.Repository) error {
qb := repo.Scene()
var err error
@@ -431,7 +431,7 @@ func (j *cleanJob) deleteScene(ctx context.Context, fileNamingAlgorithm models.H
func (j *cleanJob) deleteGallery(ctx context.Context, galleryID int) {
var g *models.Gallery
if err := j.txnManager.WithTxn(context.TODO(), func(repo models.Repository) error {
if err := j.txnManager.WithTxn(ctx, func(repo models.Repository) error {
qb := repo.Gallery()
var err error
@@ -459,7 +459,7 @@ func (j *cleanJob) deleteImage(ctx context.Context, imageID int) {
}
var i *models.Image
if err := j.txnManager.WithTxn(context.TODO(), func(repo models.Repository) error {
if err := j.txnManager.WithTxn(ctx, func(repo models.Repository) error {
qb := repo.Image()
var err error

View File

@@ -18,6 +18,7 @@ import (
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/json"
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/models/paths"
"github.com/stashapp/stash/pkg/movie"
@@ -96,7 +97,7 @@ func CreateExportTask(a models.HashAlgorithm, input models.ExportObjectsInput) *
}
}
func (t *ExportTask) Start(wg *sync.WaitGroup) {
func (t *ExportTask) Start(ctx context.Context, wg *sync.WaitGroup) {
defer wg.Done()
// @manager.total = Scene.count + Gallery.count + Performer.count + Studio.count + Movie.count
workerCount := runtime.GOMAXPROCS(0) // set worker count to number of cpus available
@@ -129,7 +130,7 @@ func (t *ExportTask) Start(wg *sync.WaitGroup) {
paths.EnsureJSONDirs(t.baseDir)
txnErr := t.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
txnErr := t.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
// include movie scenes and gallery images
if !t.full {
// only include movie scenes if includeDependencies is also set
@@ -1038,7 +1039,7 @@ func (t *ExportTask) ExportScrapedItems(repo models.ReaderRepository) {
}
newScrapedItemJSON.Studio = studioName
updatedAt := models.JSONTime{Time: scrapedItem.UpdatedAt.Timestamp} // TODO keeping ruby format
updatedAt := json.JSONTime{Time: scrapedItem.UpdatedAt.Timestamp} // TODO keeping ruby format
newScrapedItemJSON.UpdatedAt = updatedAt
scraped = append(scraped, newScrapedItemJSON)

View File

@@ -12,6 +12,7 @@ import (
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/scene/generate"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/utils"
)
@@ -67,15 +68,23 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
logger.Error(err.Error())
}
g := &generate.Generator{
Encoder: instance.FFMPEG,
LockManager: instance.ReadLockManager,
MarkerPaths: instance.Paths.SceneMarkers,
ScenePaths: instance.Paths.Scene,
Overwrite: j.overwrite,
}
if err := j.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
qb := r.Scene()
if len(j.input.SceneIDs) == 0 && len(j.input.MarkerIDs) == 0 {
totals = j.queueTasks(ctx, queue)
totals = j.queueTasks(ctx, g, queue)
} else {
if len(j.input.SceneIDs) > 0 {
scenes, err = qb.FindMany(sceneIDs)
for _, s := range scenes {
j.queueSceneJobs(s, queue, &totals)
j.queueSceneJobs(ctx, g, s, queue, &totals)
}
}
@@ -85,7 +94,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
return err
}
for _, m := range markers {
j.queueMarkerJob(m, queue, &totals)
j.queueMarkerJob(g, m, queue, &totals)
}
}
}
@@ -142,7 +151,7 @@ func (j *GenerateJob) Execute(ctx context.Context, progress *job.Progress) {
logger.Info(fmt.Sprintf("Generate finished (%s)", elapsed))
}
func (j *GenerateJob) queueTasks(ctx context.Context, queue chan<- Task) totalsGenerate {
func (j *GenerateJob) queueTasks(ctx context.Context, g *generate.Generator, queue chan<- Task) totalsGenerate {
var totals totalsGenerate
const batchSize = 1000
@@ -165,7 +174,7 @@ func (j *GenerateJob) queueTasks(ctx context.Context, queue chan<- Task) totalsG
return context.Canceled
}
j.queueSceneJobs(ss, queue, &totals)
j.queueSceneJobs(ctx, g, ss, queue, &totals)
}
if len(scenes) != batchSize {
@@ -185,7 +194,42 @@ func (j *GenerateJob) queueTasks(ctx context.Context, queue chan<- Task) totalsG
return totals
}
func (j *GenerateJob) queueSceneJobs(scene *models.Scene, queue chan<- Task, totals *totalsGenerate) {
func getGeneratePreviewOptions(optionsInput models.GeneratePreviewOptionsInput) generate.PreviewOptions {
config := config.GetInstance()
ret := generate.PreviewOptions{
Segments: config.GetPreviewSegments(),
SegmentDuration: config.GetPreviewSegmentDuration(),
ExcludeStart: config.GetPreviewExcludeStart(),
ExcludeEnd: config.GetPreviewExcludeEnd(),
Preset: config.GetPreviewPreset().String(),
Audio: config.GetPreviewAudio(),
}
if optionsInput.PreviewSegments != nil {
ret.Segments = *optionsInput.PreviewSegments
}
if optionsInput.PreviewSegmentDuration != nil {
ret.SegmentDuration = *optionsInput.PreviewSegmentDuration
}
if optionsInput.PreviewExcludeStart != nil {
ret.ExcludeStart = *optionsInput.PreviewExcludeStart
}
if optionsInput.PreviewExcludeEnd != nil {
ret.ExcludeEnd = *optionsInput.PreviewExcludeEnd
}
if optionsInput.PreviewPreset != nil {
ret.Preset = optionsInput.PreviewPreset.String()
}
return ret
}
func (j *GenerateJob) queueSceneJobs(ctx context.Context, g *generate.Generator, scene *models.Scene, queue chan<- Task, totals *totalsGenerate) {
if utils.IsTrue(j.input.Sprites) {
task := &GenerateSpriteTask{
Scene: *scene,
@@ -200,19 +244,21 @@ func (j *GenerateJob) queueSceneJobs(scene *models.Scene, queue chan<- Task, tot
}
}
generatePreviewOptions := j.input.PreviewOptions
if generatePreviewOptions == nil {
generatePreviewOptions = &models.GeneratePreviewOptionsInput{}
}
options := getGeneratePreviewOptions(*generatePreviewOptions)
if utils.IsTrue(j.input.Previews) {
generatePreviewOptions := j.input.PreviewOptions
if generatePreviewOptions == nil {
generatePreviewOptions = &models.GeneratePreviewOptionsInput{}
}
setGeneratePreviewOptionsInput(generatePreviewOptions)
task := &GeneratePreviewTask{
Scene: *scene,
ImagePreview: utils.IsTrue(j.input.ImagePreviews),
Options: *generatePreviewOptions,
Options: options,
Overwrite: j.overwrite,
fileNamingAlgorithm: j.fileNamingAlgo,
generator: g,
}
sceneHash := scene.GetHash(task.fileNamingAlgorithm)
@@ -241,9 +287,11 @@ func (j *GenerateJob) queueSceneJobs(scene *models.Scene, queue chan<- Task, tot
fileNamingAlgorithm: j.fileNamingAlgo,
ImagePreview: utils.IsTrue(j.input.MarkerImagePreviews),
Screenshot: utils.IsTrue(j.input.MarkerScreenshots),
generator: g,
}
markers := task.markersNeeded()
markers := task.markersNeeded(ctx)
if markers > 0 {
totals.markers += int64(markers)
totals.tasks++
@@ -259,6 +307,7 @@ func (j *GenerateJob) queueSceneJobs(scene *models.Scene, queue chan<- Task, tot
Overwrite: j.overwrite,
Force: forceTranscode,
fileNamingAlgorithm: j.fileNamingAlgo,
g: g,
}
if task.isTranscodeNeeded() {
totals.transcodes++
@@ -298,12 +347,13 @@ func (j *GenerateJob) queueSceneJobs(scene *models.Scene, queue chan<- Task, tot
}
}
func (j *GenerateJob) queueMarkerJob(marker *models.SceneMarker, queue chan<- Task, totals *totalsGenerate) {
func (j *GenerateJob) queueMarkerJob(g *generate.Generator, marker *models.SceneMarker, queue chan<- Task, totals *totalsGenerate) {
task := &GenerateMarkersTask{
TxnManager: j.txnManager,
Marker: marker,
Overwrite: j.overwrite,
fileNamingAlgorithm: j.fileNamingAlgo,
generator: g,
}
totals.markers++
totals.tasks++

View File

@@ -47,7 +47,7 @@ func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) {
var s *models.Scene
if err := t.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
if err := t.TxnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
var err error
s, err = r.Scene().FindByPath(t.Scene.Path)
return err
@@ -56,7 +56,7 @@ func (t *GenerateInteractiveHeatmapSpeedTask) Start(ctx context.Context) {
return
}
if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error {
if err := t.TxnManager.WithTxn(ctx, func(r models.Repository) error {
qb := r.Scene()
scenePartial := models.ScenePartial{
ID: s.ID,

View File

@@ -4,12 +4,12 @@ import (
"context"
"fmt"
"path/filepath"
"strconv"
"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/scene/generate"
)
type GenerateMarkersTask struct {
@@ -21,6 +21,8 @@ type GenerateMarkersTask struct {
ImagePreview bool
Screenshot bool
generator *generate.Generator
}
func (t *GenerateMarkersTask) GetDescription() string {
@@ -35,12 +37,12 @@ func (t *GenerateMarkersTask) GetDescription() string {
func (t *GenerateMarkersTask) Start(ctx context.Context) {
if t.Scene != nil {
t.generateSceneMarkers()
t.generateSceneMarkers(ctx)
}
if t.Marker != nil {
var scene *models.Scene
if err := t.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
if err := t.TxnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
var err error
scene, err = r.Scene().Find(int(t.Marker.SceneID.Int64))
return err
@@ -55,7 +57,7 @@ func (t *GenerateMarkersTask) Start(ctx context.Context) {
}
ffprobe := instance.FFProbe
videoFile, err := ffprobe.NewVideoFile(t.Scene.Path, false)
videoFile, err := ffprobe.NewVideoFile(t.Scene.Path)
if err != nil {
logger.Errorf("error reading video file: %s", err.Error())
return
@@ -65,9 +67,9 @@ func (t *GenerateMarkersTask) Start(ctx context.Context) {
}
}
func (t *GenerateMarkersTask) generateSceneMarkers() {
func (t *GenerateMarkersTask) generateSceneMarkers(ctx context.Context) {
var sceneMarkers []*models.SceneMarker
if err := t.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
if err := t.TxnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
var err error
sceneMarkers, err = r.SceneMarker().FindBySceneID(t.Scene.ID)
return err
@@ -81,7 +83,7 @@ func (t *GenerateMarkersTask) generateSceneMarkers() {
}
ffprobe := instance.FFProbe
videoFile, err := ffprobe.NewVideoFile(t.Scene.Path, false)
videoFile, err := ffprobe.NewVideoFile(t.Scene.Path)
if err != nil {
logger.Errorf("error reading video file: %s", err.Error())
return
@@ -107,70 +109,32 @@ func (t *GenerateMarkersTask) generateMarker(videoFile *ffmpeg.VideoFile, scene
sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)
seconds := int(sceneMarker.Seconds)
videoExists := t.videoExists(sceneHash, seconds)
imageExists := !t.ImagePreview || t.imageExists(sceneHash, seconds)
screenshotExists := !t.Screenshot || t.screenshotExists(sceneHash, seconds)
g := t.generator
baseFilename := strconv.Itoa(seconds)
options := ffmpeg.SceneMarkerOptions{
ScenePath: scene.Path,
Seconds: seconds,
Width: 640,
Audio: instance.Config.GetPreviewAudio(),
if err := g.MarkerPreviewVideo(context.TODO(), videoFile.Path, sceneHash, seconds, instance.Config.GetPreviewAudio()); err != nil {
logger.Errorf("[generator] failed to generate marker video: %v", err)
logErrorOutput(err)
}
encoder := instance.FFMPEG
if t.Overwrite || !videoExists {
videoFilename := baseFilename + ".mp4"
videoPath := instance.Paths.SceneMarkers.GetStreamPath(sceneHash, seconds)
options.OutputPath = instance.Paths.Generated.GetTmpPath(videoFilename) // tmp output in case the process ends abruptly
if err := encoder.SceneMarkerVideo(*videoFile, options); err != nil {
logger.Errorf("[generator] failed to generate marker video: %s", err)
} else {
_ = fsutil.SafeMove(options.OutputPath, videoPath)
logger.Debug("created marker video: ", videoPath)
if t.ImagePreview {
if err := g.SceneMarkerWebp(context.TODO(), videoFile.Path, sceneHash, seconds); err != nil {
logger.Errorf("[generator] failed to generate marker image: %v", err)
logErrorOutput(err)
}
}
if t.ImagePreview && (t.Overwrite || !imageExists) {
imageFilename := baseFilename + ".webp"
imagePath := instance.Paths.SceneMarkers.GetStreamPreviewImagePath(sceneHash, seconds)
options.OutputPath = instance.Paths.Generated.GetTmpPath(imageFilename) // tmp output in case the process ends abruptly
if err := encoder.SceneMarkerImage(*videoFile, options); err != nil {
logger.Errorf("[generator] failed to generate marker image: %s", err)
} else {
_ = fsutil.SafeMove(options.OutputPath, imagePath)
logger.Debug("created marker image: ", imagePath)
}
}
if t.Screenshot && (t.Overwrite || !screenshotExists) {
screenshotFilename := baseFilename + ".jpg"
screenshotPath := instance.Paths.SceneMarkers.GetStreamScreenshotPath(sceneHash, seconds)
screenshotOptions := ffmpeg.ScreenshotOptions{
OutputPath: instance.Paths.Generated.GetTmpPath(screenshotFilename), // tmp output in case the process ends abruptly
Quality: 2,
Width: videoFile.Width,
Time: float64(seconds),
}
if err := encoder.Screenshot(*videoFile, screenshotOptions); err != nil {
logger.Errorf("[generator] failed to generate marker screenshot: %s", err)
} else {
_ = fsutil.SafeMove(screenshotOptions.OutputPath, screenshotPath)
logger.Debug("created marker screenshot: ", screenshotPath)
if t.Screenshot {
if err := g.SceneMarkerScreenshot(context.TODO(), videoFile.Path, sceneHash, seconds, videoFile.Width); err != nil {
logger.Errorf("[generator] failed to generate marker screenshot: %v", err)
logErrorOutput(err)
}
}
}
func (t *GenerateMarkersTask) markersNeeded() int {
func (t *GenerateMarkersTask) markersNeeded(ctx context.Context) int {
markers := 0
var sceneMarkers []*models.SceneMarker
if err := t.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
if err := t.TxnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
var err error
sceneMarkers, err = r.SceneMarker().FindBySceneID(t.Scene.ID)
return err
@@ -212,7 +176,7 @@ func (t *GenerateMarkersTask) videoExists(sceneChecksum string, seconds int) boo
return false
}
videoPath := instance.Paths.SceneMarkers.GetStreamPath(sceneChecksum, seconds)
videoPath := instance.Paths.SceneMarkers.GetVideoPreviewPath(sceneChecksum, seconds)
videoExists, _ := fsutil.FileExists(videoPath)
return videoExists
@@ -223,7 +187,7 @@ func (t *GenerateMarkersTask) imageExists(sceneChecksum string, seconds int) boo
return false
}
imagePath := instance.Paths.SceneMarkers.GetStreamPreviewImagePath(sceneChecksum, seconds)
imagePath := instance.Paths.SceneMarkers.GetWebpPreviewPath(sceneChecksum, seconds)
imageExists, _ := fsutil.FileExists(imagePath)
return imageExists
@@ -234,7 +198,7 @@ func (t *GenerateMarkersTask) screenshotExists(sceneChecksum string, seconds int
return false
}
screenshotPath := instance.Paths.SceneMarkers.GetStreamScreenshotPath(sceneChecksum, seconds)
screenshotPath := instance.Paths.SceneMarkers.GetScreenshotPath(sceneChecksum, seconds)
screenshotExists, _ := fsutil.FileExists(screenshotPath)
return screenshotExists

View File

@@ -5,6 +5,7 @@ import (
"database/sql"
"fmt"
"github.com/stashapp/stash/pkg/hash/videophash"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
@@ -26,26 +27,20 @@ func (t *GeneratePhashTask) Start(ctx context.Context) {
}
ffprobe := instance.FFProbe
videoFile, err := ffprobe.NewVideoFile(t.Scene.Path, false)
videoFile, err := ffprobe.NewVideoFile(t.Scene.Path)
if err != nil {
logger.Errorf("error reading video file: %s", err.Error())
return
}
sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)
generator, err := NewPhashGenerator(*videoFile, sceneHash)
if err != nil {
logger.Errorf("error creating phash generator: %s", err.Error())
return
}
hash, err := generator.Generate()
hash, err := videophash.Generate(instance.FFMPEG, videoFile)
if err != nil {
logger.Errorf("error generating phash: %s", err.Error())
logErrorOutput(err)
return
}
if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
if err := t.txnManager.WithTxn(ctx, func(r models.Repository) error {
qb := r.Scene()
hashValue := sql.NullInt64{Int64: int64(*hash), Valid: true}
scenePartial := models.ScenePartial{

View File

@@ -4,20 +4,22 @@ import (
"context"
"fmt"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene/generate"
)
type GeneratePreviewTask struct {
Scene models.Scene
ImagePreview bool
Options models.GeneratePreviewOptionsInput
Options generate.PreviewOptions
Overwrite bool
fileNamingAlgorithm models.HashAlgorithm
generator *generate.Generator
}
func (t *GeneratePreviewTask) GetDescription() string {
@@ -25,43 +27,51 @@ func (t *GeneratePreviewTask) GetDescription() string {
}
func (t *GeneratePreviewTask) Start(ctx context.Context) {
videoFilename := t.videoFilename()
videoChecksum := t.Scene.GetHash(t.fileNamingAlgorithm)
imageFilename := t.imageFilename()
if !t.Overwrite && !t.required() {
return
}
ffprobe := instance.FFProbe
videoFile, err := ffprobe.NewVideoFile(t.Scene.Path, false)
videoFile, err := ffprobe.NewVideoFile(t.Scene.Path)
if err != nil {
logger.Errorf("error reading video file: %s", err.Error())
logger.Errorf("error reading video file: %v", err)
return
}
const generateVideo = true
generator, err := NewPreviewGenerator(*videoFile, videoChecksum, videoFilename, imageFilename, instance.Paths.Generated.Screenshots, generateVideo, t.ImagePreview, t.Options.PreviewPreset.String())
videoChecksum := t.Scene.GetHash(t.fileNamingAlgorithm)
if err != nil {
logger.Errorf("error creating preview generator: %s", err.Error())
if err := t.generateVideo(videoChecksum, videoFile.Duration); err != nil {
logger.Errorf("error generating preview: %v", err)
logErrorOutput(err)
return
}
generator.Overwrite = t.Overwrite
// set the preview generation configuration from the global config
generator.Info.ChunkCount = *t.Options.PreviewSegments
generator.Info.ChunkDuration = *t.Options.PreviewSegmentDuration
generator.Info.ExcludeStart = *t.Options.PreviewExcludeStart
generator.Info.ExcludeEnd = *t.Options.PreviewExcludeEnd
generator.Info.Audio = config.GetInstance().GetPreviewAudio()
if err := generator.Generate(); err != nil {
logger.Errorf("error generating preview: %s", err.Error())
return
if t.ImagePreview {
if err := t.generateWebp(videoChecksum); err != nil {
logger.Errorf("error generating preview webp: %v", err)
logErrorOutput(err)
}
}
}
func (t GeneratePreviewTask) generateVideo(videoChecksum string, videoDuration float64) error {
videoFilename := t.Scene.Path
if err := t.generator.PreviewVideo(context.TODO(), videoFilename, videoDuration, videoChecksum, t.Options, true); err != nil {
logger.Warnf("[generator] failed generating scene preview, trying fallback")
if err := t.generator.PreviewVideo(context.TODO(), videoFilename, videoDuration, videoChecksum, t.Options, true); err != nil {
return err
}
}
return nil
}
func (t GeneratePreviewTask) generateWebp(videoChecksum string) error {
videoFilename := t.Scene.Path
return t.generator.PreviewWebp(context.TODO(), videoFilename, videoChecksum)
}
func (t GeneratePreviewTask) required() bool {
sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)
videoExists := t.doesVideoPreviewExist(sceneHash)
@@ -74,7 +84,7 @@ func (t *GeneratePreviewTask) doesVideoPreviewExist(sceneChecksum string) bool {
return false
}
videoExists, _ := fsutil.FileExists(instance.Paths.Scene.GetStreamPreviewPath(sceneChecksum))
videoExists, _ := fsutil.FileExists(instance.Paths.Scene.GetVideoPreviewPath(sceneChecksum))
return videoExists
}
@@ -83,14 +93,6 @@ func (t *GeneratePreviewTask) doesImagePreviewExist(sceneChecksum string) bool {
return false
}
imageExists, _ := fsutil.FileExists(instance.Paths.Scene.GetStreamPreviewImagePath(sceneChecksum))
imageExists, _ := fsutil.FileExists(instance.Paths.Scene.GetWebpPreviewPath(sceneChecksum))
return imageExists
}
func (t *GeneratePreviewTask) videoFilename() string {
return t.Scene.GetHash(t.fileNamingAlgorithm) + ".mp4"
}
func (t *GeneratePreviewTask) imageFilename() string {
return t.Scene.GetHash(t.fileNamingAlgorithm) + ".webp"
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/scene/generate"
)
type GenerateScreenshotTask struct {
@@ -22,7 +23,7 @@ type GenerateScreenshotTask struct {
func (t *GenerateScreenshotTask) Start(ctx context.Context) {
scenePath := t.Scene.Path
ffprobe := instance.FFProbe
probeResult, err := ffprobe.NewVideoFile(scenePath, false)
probeResult, err := ffprobe.NewVideoFile(scenePath)
if err != nil {
logger.Error(err.Error())
@@ -44,7 +45,21 @@ func (t *GenerateScreenshotTask) Start(ctx context.Context) {
// which also generates the thumbnail
logger.Debugf("Creating screenshot for %s", scenePath)
makeScreenshot(*probeResult, normalPath, 2, probeResult.Width, at)
g := generate.Generator{
Encoder: instance.FFMPEG,
LockManager: instance.ReadLockManager,
ScenePaths: instance.Paths.Scene,
Overwrite: true,
}
if err := g.Screenshot(context.TODO(), probeResult.Path, checksum, probeResult.Width, probeResult.Duration, generate.ScreenshotOptions{
At: &at,
}); err != nil {
logger.Errorf("Error generating screenshot: %v", err)
logErrorOutput(err)
return
}
f, err := os.Open(normalPath)
if err != nil {
@@ -59,7 +74,7 @@ func (t *GenerateScreenshotTask) Start(ctx context.Context) {
return
}
if err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
if err := t.txnManager.WithTxn(ctx, func(r models.Repository) error {
qb := r.Scene()
updatedTime := time.Now()
updatedScene := models.ScenePartial{

View File

@@ -25,7 +25,7 @@ func (t *GenerateSpriteTask) Start(ctx context.Context) {
}
ffprobe := instance.FFProbe
videoFile, err := ffprobe.NewVideoFile(t.Scene.Path, false)
videoFile, err := ffprobe.NewVideoFile(t.Scene.Path)
if err != nil {
logger.Errorf("error reading video file: %s", err.Error())
return
@@ -44,6 +44,7 @@ func (t *GenerateSpriteTask) Start(ctx context.Context) {
if err := generator.Generate(); err != nil {
logger.Errorf("error generating sprite: %s", err.Error())
logErrorOutput(err)
return
}
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"strconv"
"github.com/stashapp/stash/internal/identify"
"github.com/stashapp/stash/pkg/job"
@@ -212,7 +211,7 @@ type stashboxSource struct {
}
func (s stashboxSource) ScrapeScene(ctx context.Context, sceneID int) (*models.ScrapedScene, error) {
results, err := s.FindStashBoxScenesByFingerprintsFlat(ctx, []string{strconv.Itoa(sceneID)})
results, err := s.FindStashBoxSceneByFingerprints(ctx, sceneID)
if err != nil {
return nil, fmt.Errorf("error querying stash-box using scene ID %d: %w", sceneID, err)
}

View File

@@ -18,6 +18,7 @@ import (
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/json"
"github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/models/paths"
"github.com/stashapp/stash/pkg/movie"
@@ -613,7 +614,7 @@ func (t *ImportTask) ImportImages(ctx context.Context) {
var currentLocation = time.Now().Location()
func (t *ImportTask) getTimeFromJSONTime(jsonTime models.JSONTime) time.Time {
func (t *ImportTask) getTimeFromJSONTime(jsonTime json.JSONTime) time.Time {
if currentLocation != nil {
if jsonTime.IsZero() {
return time.Now().In(currentLocation)

View File

@@ -9,7 +9,7 @@ import (
"github.com/stashapp/stash/pkg/models"
)
func (s *singleton) RunPluginTask(ctx context.Context, pluginID string, taskName string, args []*models.PluginArgInput) int {
func (s *Manager) RunPluginTask(ctx context.Context, pluginID string, taskName string, args []*models.PluginArgInput) int {
j := job.MakeJobExec(func(jobCtx context.Context, progress *job.Progress) {
pluginProgress := make(chan float64)
task, err := s.PluginCache.CreateTask(ctx, pluginID, taskName, args, pluginProgress)

View File

@@ -16,6 +16,8 @@ import (
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/scene/generate"
"github.com/stashapp/stash/pkg/utils"
)
@@ -97,7 +99,6 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) {
GenerateThumbnails: utils.IsTrue(input.ScanGenerateThumbnails),
progress: progress,
CaseSensitiveFs: f.caseSensitiveFs,
ctx: ctx,
mutexManager: mutexManager,
}
@@ -135,7 +136,7 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) {
UseFileMetadata: false,
}
go task.associateGallery(&wg)
go task.associateGallery(ctx, &wg)
wg.Wait()
}
logger.Info("Finished gallery association")
@@ -187,7 +188,7 @@ func (j *ScanJob) queueFiles(ctx context.Context, paths []*models.StashConfig, s
}
total++
if !j.doesPathExist(path) {
if !j.doesPathExist(ctx, path) {
newFiles++
}
@@ -212,14 +213,14 @@ func (j *ScanJob) queueFiles(ctx context.Context, paths []*models.StashConfig, s
return
}
func (j *ScanJob) doesPathExist(path string) bool {
func (j *ScanJob) doesPathExist(ctx context.Context, path string) bool {
config := config.GetInstance()
vidExt := config.GetVideoExtensions()
imgExt := config.GetImageExtensions()
gExt := config.GetGalleryExtensions()
ret := false
txnErr := j.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
txnErr := j.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
switch {
case fsutil.MatchExtension(path, gExt):
g, _ := r.Gallery().FindByPath(path)
@@ -248,7 +249,6 @@ func (j *ScanJob) doesPathExist(path string) bool {
}
type ScanTask struct {
ctx context.Context
TxnManager models.TransactionManager
file file.SourceFile
UseFileMetadata bool
@@ -275,9 +275,11 @@ func (t *ScanTask) Start(ctx context.Context) {
case isGallery(path):
t.scanGallery(ctx)
case isVideo(path):
s = t.scanScene()
s = t.scanScene(ctx)
case isImage(path):
t.scanImage()
t.scanImage(ctx)
case isCaptions(path):
t.associateCaptions(ctx)
}
})
@@ -320,28 +322,24 @@ func (t *ScanTask) Start(ctx context.Context) {
iwg.Add()
go t.progress.ExecuteTask(fmt.Sprintf("Generating preview for %s", path), func() {
config := config.GetInstance()
var previewSegmentDuration = config.GetPreviewSegmentDuration()
var previewSegments = config.GetPreviewSegments()
var previewExcludeStart = config.GetPreviewExcludeStart()
var previewExcludeEnd = config.GetPreviewExcludeEnd()
var previewPresent = config.GetPreviewPreset()
options := getGeneratePreviewOptions(models.GeneratePreviewOptionsInput{})
const overwrite = false
// NOTE: the reuse of this model like this is painful.
previewOptions := models.GeneratePreviewOptionsInput{
PreviewSegments: &previewSegments,
PreviewSegmentDuration: &previewSegmentDuration,
PreviewExcludeStart: &previewExcludeStart,
PreviewExcludeEnd: &previewExcludeEnd,
PreviewPreset: &previewPresent,
g := &generate.Generator{
Encoder: instance.FFMPEG,
LockManager: instance.ReadLockManager,
MarkerPaths: instance.Paths.SceneMarkers,
ScenePaths: instance.Paths.Scene,
Overwrite: overwrite,
}
taskPreview := GeneratePreviewTask{
Scene: *s,
ImagePreview: t.GenerateImagePreview,
Options: previewOptions,
Overwrite: false,
Options: options,
Overwrite: overwrite,
fileNamingAlgorithm: t.fileNamingAlgorithm,
generator: g,
}
taskPreview.Start(ctx)
iwg.Done()
@@ -356,6 +354,7 @@ func walkFilesToScan(s *models.StashConfig, f filepath.WalkFunc) error {
vidExt := config.GetVideoExtensions()
imgExt := config.GetImageExtensions()
gExt := config.GetGalleryExtensions()
capExt := scene.CaptionExts
excludeVidRegex := generateRegexps(config.GetExcludes())
excludeImgRegex := generateRegexps(config.GetImageExcludes())
@@ -399,6 +398,10 @@ func walkFilesToScan(s *models.StashConfig, f filepath.WalkFunc) error {
}
}
if fsutil.MatchExtension(path, capExt) {
return f(path, info, err)
}
return nil
})
}

View File

@@ -25,7 +25,7 @@ func (t *ScanTask) scanGallery(ctx context.Context) {
var err error
g, err = r.Gallery().FindByPath(path)
if g != nil && err != nil {
if g != nil && err == nil {
images, err = r.Image().CountByGalleryID(g.ID)
if err != nil {
return fmt.Errorf("error getting images for zip gallery %s: %s", path, err.Error())
@@ -42,7 +42,6 @@ func (t *ScanTask) scanGallery(ctx context.Context) {
Scanner: gallery.FileScanner(&file.FSHasher{}),
ImageExtensions: instance.Config.GetImageExtensions(),
StripFileExtension: t.StripFileExtension,
Ctx: t.ctx,
CaseSensitiveFs: t.CaseSensitiveFs,
TxnManager: t.TxnManager,
Paths: instance.Paths,
@@ -52,7 +51,7 @@ func (t *ScanTask) scanGallery(ctx context.Context) {
var err error
if g != nil {
g, scanImages, err = scanner.ScanExisting(g, t.file)
g, scanImages, err = scanner.ScanExisting(ctx, g, t.file)
if err != nil {
logger.Error(err.Error())
return
@@ -61,7 +60,7 @@ func (t *ScanTask) scanGallery(ctx context.Context) {
// scan the zip files if the gallery has no images
scanImages = scanImages || images == 0
} else {
g, scanImages, err = scanner.ScanNew(t.file)
g, scanImages, err = scanner.ScanNew(ctx, t.file)
if err != nil {
logger.Error(err.Error())
}
@@ -69,18 +68,18 @@ func (t *ScanTask) scanGallery(ctx context.Context) {
if g != nil {
if scanImages {
t.scanZipImages(g)
t.scanZipImages(ctx, g)
} else {
// in case thumbnails have been deleted, regenerate them
t.regenerateZipImages(g)
t.regenerateZipImages(ctx, g)
}
}
}
// associates a gallery to a scene with the same basename
func (t *ScanTask) associateGallery(wg *sizedwaitgroup.SizedWaitGroup) {
func (t *ScanTask) associateGallery(ctx context.Context, wg *sizedwaitgroup.SizedWaitGroup) {
path := t.file.Path()
if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error {
if err := t.TxnManager.WithTxn(ctx, func(r models.Repository) error {
qb := r.Gallery()
sqb := r.Scene()
g, err := qb.FindByPath(path)
@@ -133,7 +132,7 @@ func (t *ScanTask) associateGallery(wg *sizedwaitgroup.SizedWaitGroup) {
wg.Done()
}
func (t *ScanTask) scanZipImages(zipGallery *models.Gallery) {
func (t *ScanTask) scanZipImages(ctx context.Context, zipGallery *models.Gallery) {
err := walkGalleryZip(zipGallery.Path.String, func(f *zip.File) error {
// copy this task and change the filename
subTask := *t
@@ -143,7 +142,7 @@ func (t *ScanTask) scanZipImages(zipGallery *models.Gallery) {
subTask.zipGallery = zipGallery
// run the subtask and wait for it to complete
subTask.Start(context.TODO())
subTask.Start(ctx)
return nil
})
if err != nil {
@@ -151,9 +150,9 @@ func (t *ScanTask) scanZipImages(zipGallery *models.Gallery) {
}
}
func (t *ScanTask) regenerateZipImages(zipGallery *models.Gallery) {
func (t *ScanTask) regenerateZipImages(ctx context.Context, zipGallery *models.Gallery) {
var images []*models.Image
if err := t.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
if err := t.TxnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
iqb := r.Image()
var err error

View File

@@ -4,6 +4,7 @@ import (
"context"
"database/sql"
"errors"
"os/exec"
"path/filepath"
"time"
@@ -18,11 +19,11 @@ import (
"github.com/stashapp/stash/pkg/plugin"
)
func (t *ScanTask) scanImage() {
func (t *ScanTask) scanImage(ctx context.Context) {
var i *models.Image
path := t.file.Path()
if err := t.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
if err := t.TxnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
var err error
i, err = r.Image().FindByPath(path)
return err
@@ -34,7 +35,6 @@ func (t *ScanTask) scanImage() {
scanner := image.Scanner{
Scanner: image.FileScanner(&file.FSHasher{}),
StripFileExtension: t.StripFileExtension,
Ctx: t.ctx,
TxnManager: t.TxnManager,
Paths: GetInstance().Paths,
PluginCache: instance.PluginCache,
@@ -43,13 +43,13 @@ func (t *ScanTask) scanImage() {
var err error
if i != nil {
i, err = scanner.ScanExisting(i, t.file)
i, err = scanner.ScanExisting(ctx, i, t.file)
if err != nil {
logger.Error(err.Error())
return
}
} else {
i, err = scanner.ScanNew(t.file)
i, err = scanner.ScanNew(ctx, t.file)
if err != nil {
logger.Error(err.Error())
return
@@ -58,7 +58,7 @@ func (t *ScanTask) scanImage() {
if i != nil {
if t.zipGallery != nil {
// associate with gallery
if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error {
if err := t.TxnManager.WithTxn(ctx, func(r models.Repository) error {
return gallery.AddImage(r.Gallery(), t.zipGallery.ID, i.ID)
}); err != nil {
logger.Error(err.Error())
@@ -69,7 +69,7 @@ func (t *ScanTask) scanImage() {
logger.Infof("Associating image %s with folder gallery", i.Path)
var galleryID int
var isNewGallery bool
if err := t.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error {
if err := t.TxnManager.WithTxn(ctx, func(r models.Repository) error {
var err error
galleryID, isNewGallery, err = t.associateImageWithFolderGallery(i.ID, r.Gallery())
return err
@@ -79,7 +79,7 @@ func (t *ScanTask) scanImage() {
}
if isNewGallery {
GetInstance().PluginCache.ExecutePostHooks(t.ctx, galleryID, plugin.GalleryCreatePost, nil, nil)
GetInstance().PluginCache.ExecutePostHooks(ctx, galleryID, plugin.GalleryCreatePost, nil, nil)
}
}
}
@@ -159,6 +159,11 @@ func (t *ScanTask) generateThumbnail(i *models.Image) {
// don't log for animated images
if !errors.Is(err, image.ErrNotSupportedForThumbnail) {
logger.Errorf("error getting thumbnail for image %s: %s", i.Path, err.Error())
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
logger.Errorf("stderr: %s", string(exitErr.Stderr))
}
}
return
}

View File

@@ -2,14 +2,30 @@ package manager
import (
"context"
"path/filepath"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/ffmpeg"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/scene/generate"
)
func (t *ScanTask) scanScene() *models.Scene {
type sceneScreenshotter struct {
g *generate.Generator
}
func (ss *sceneScreenshotter) GenerateScreenshot(ctx context.Context, probeResult *ffmpeg.VideoFile, hash string) error {
return ss.g.Screenshot(ctx, probeResult.Path, hash, probeResult.Width, probeResult.Duration, generate.ScreenshotOptions{})
}
func (ss *sceneScreenshotter) GenerateThumbnail(ctx context.Context, probeResult *ffmpeg.VideoFile, hash string) error {
return ss.g.Thumbnail(ctx, probeResult.Path, hash, probeResult.Duration, generate.ScreenshotOptions{})
}
func (t *ScanTask) scanScene(ctx context.Context) *models.Scene {
logError := func(err error) *models.Scene {
logger.Error(err.Error())
return nil
@@ -18,7 +34,7 @@ func (t *ScanTask) scanScene() *models.Scene {
var retScene *models.Scene
var s *models.Scene
if err := t.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
if err := t.TxnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
var err error
s, err = r.Scene().FindByPath(t.file.Path())
return err
@@ -27,22 +43,29 @@ func (t *ScanTask) scanScene() *models.Scene {
return nil
}
g := &generate.Generator{
Encoder: instance.FFMPEG,
LockManager: instance.ReadLockManager,
ScenePaths: instance.Paths.Scene,
}
scanner := scene.Scanner{
Scanner: scene.FileScanner(&file.FSHasher{}, t.fileNamingAlgorithm, t.calculateMD5),
StripFileExtension: t.StripFileExtension,
FileNamingAlgorithm: t.fileNamingAlgorithm,
Ctx: t.ctx,
TxnManager: t.TxnManager,
Paths: GetInstance().Paths,
Screenshotter: &instance.FFMPEG,
VideoFileCreator: &instance.FFProbe,
PluginCache: instance.PluginCache,
MutexManager: t.mutexManager,
UseFileMetadata: t.UseFileMetadata,
Screenshotter: &sceneScreenshotter{
g: g,
},
VideoFileCreator: &instance.FFProbe,
PluginCache: instance.PluginCache,
MutexManager: t.mutexManager,
UseFileMetadata: t.UseFileMetadata,
}
if s != nil {
if err := scanner.ScanExisting(s, t.file); err != nil {
if err := scanner.ScanExisting(ctx, s, t.file); err != nil {
return logError(err)
}
@@ -50,10 +73,55 @@ func (t *ScanTask) scanScene() *models.Scene {
}
var err error
retScene, err = scanner.ScanNew(t.file)
retScene, err = scanner.ScanNew(ctx, t.file)
if err != nil {
return logError(err)
}
return retScene
}
// associates captions to scene/s with the same basename
func (t *ScanTask) associateCaptions(ctx context.Context) {
vExt := config.GetInstance().GetVideoExtensions()
captionPath := t.file.Path()
captionLang := scene.GetCaptionsLangFromPath(captionPath)
relatedFiles := scene.GenerateCaptionCandidates(captionPath, vExt)
if err := t.TxnManager.WithTxn(ctx, func(r models.Repository) error {
var err error
sqb := r.Scene()
for _, scenePath := range relatedFiles {
s, er := sqb.FindByPath(scenePath)
if er != nil {
logger.Errorf("Error searching for scene %s: %v", scenePath, er)
continue
}
if s != nil { // found related Scene
logger.Debugf("Matched captions to scene %s", s.Path)
captions, er := sqb.GetCaptions(s.ID)
if er == nil {
fileExt := filepath.Ext(captionPath)
ext := fileExt[1:]
if !scene.IsLangInCaptions(captionLang, ext, captions) { // only update captions if language code is not present
newCaption := &models.SceneCaption{
LanguageCode: captionLang,
Filename: filepath.Base(captionPath),
CaptionType: ext,
}
captions = append(captions, newCaption)
er = sqb.UpdateCaptions(s.ID, captions)
if er == nil {
logger.Debugf("Updated captions for scene %s. Added %s", s.Path, captionLang)
}
}
}
}
}
return err
}); err != nil {
logger.Error(err.Error())
}
}

View File

@@ -22,8 +22,8 @@ type StashBoxPerformerTagTask struct {
excluded_fields []string
}
func (t *StashBoxPerformerTagTask) Start() {
t.stashBoxPerformerTag(context.TODO())
func (t *StashBoxPerformerTagTask) Start(ctx context.Context) {
t.stashBoxPerformerTag(ctx)
}
func (t *StashBoxPerformerTagTask) Description() string {
@@ -156,7 +156,7 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) {
partial.URL = &value
}
txnErr := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
txnErr := t.txnManager.WithTxn(ctx, func(r models.Repository) error {
_, err := r.Performer().Update(partial)
if !t.refresh {
@@ -218,7 +218,7 @@ func (t *StashBoxPerformerTagTask) stashBoxPerformerTag(ctx context.Context) {
URL: getNullString(performer.URL),
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
}
err := t.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
err := t.txnManager.WithTxn(ctx, func(r models.Repository) error {
createdPerformer, err := r.Performer().Create(newPerformer)
if err != nil {
return err

View File

@@ -6,9 +6,9 @@ import (
"github.com/stashapp/stash/internal/manager/config"
"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/scene/generate"
)
type GenerateTranscodeTask struct {
@@ -18,6 +18,8 @@ type GenerateTranscodeTask struct {
// is true, generate even if video is browser-supported
Force bool
g *generate.Generator
}
func (t *GenerateTranscodeTask) GetDescription() string {
@@ -33,65 +35,60 @@ func (t *GenerateTranscodeTask) Start(ctc context.Context) {
ffprobe := instance.FFProbe
var container ffmpeg.Container
if t.Scene.Format.Valid {
container = ffmpeg.Container(t.Scene.Format.String)
} else { // container isn't in the DB
// shouldn't happen unless user hasn't scanned after updating to PR#384+ version
tmpVideoFile, err := ffprobe.NewVideoFile(t.Scene.Path, false)
if err != nil {
logger.Errorf("[transcode] error reading video file: %s", err.Error())
return
}
container = ffmpeg.MatchContainer(tmpVideoFile.Container, t.Scene.Path)
var err error
container, err = GetSceneFileContainer(&t.Scene)
if err != nil {
logger.Errorf("[transcode] error getting scene container: %s", err.Error())
return
}
videoCodec := t.Scene.VideoCodec.String
audioCodec := ffmpeg.MissingUnsupported
if t.Scene.AudioCodec.Valid {
audioCodec = ffmpeg.AudioCodec(t.Scene.AudioCodec.String)
audioCodec = ffmpeg.ProbeAudioCodec(t.Scene.AudioCodec.String)
}
if !t.Force && ffmpeg.IsStreamable(videoCodec, audioCodec, container) {
if !t.Force && ffmpeg.IsStreamable(videoCodec, audioCodec, container) == nil {
return
}
videoFile, err := ffprobe.NewVideoFile(t.Scene.Path, false)
// TODO - move transcode generation logic elsewhere
videoFile, err := ffprobe.NewVideoFile(t.Scene.Path)
if err != nil {
logger.Errorf("[transcode] error reading video file: %s", err.Error())
return
}
sceneHash := t.Scene.GetHash(t.fileNamingAlgorithm)
outputPath := instance.Paths.Generated.GetTmpPath(sceneHash + ".mp4")
transcodeSize := config.GetInstance().GetMaxTranscodeSize()
options := ffmpeg.TranscodeOptions{
OutputPath: outputPath,
MaxTranscodeSize: transcodeSize,
w, h := videoFile.TranscodeScale(transcodeSize.GetMaxResolution())
options := generate.TranscodeOptions{
Width: w,
Height: h,
}
encoder := instance.FFMPEG
if videoCodec == ffmpeg.H264 { // for non supported h264 files stream copy the video part
if audioCodec == ffmpeg.MissingUnsupported {
encoder.CopyVideo(*videoFile, options)
err = t.g.TranscodeCopyVideo(context.TODO(), videoFile.Path, sceneHash, options)
} else {
encoder.TranscodeAudio(*videoFile, options)
err = t.g.TranscodeAudio(context.TODO(), videoFile.Path, sceneHash, options)
}
} else {
if audioCodec == ffmpeg.MissingUnsupported {
// ffmpeg fails if it trys to transcode an unsupported audio codec
encoder.TranscodeVideo(*videoFile, options)
// ffmpeg fails if it tries to transcode an unsupported audio codec
err = t.g.TranscodeVideo(context.TODO(), videoFile.Path, sceneHash, options)
} else {
encoder.Transcode(*videoFile, options)
err = t.g.Transcode(context.TODO(), videoFile.Path, sceneHash, options)
}
}
if err := fsutil.SafeMove(outputPath, instance.Paths.Scene.GetTranscodePath(sceneHash)); err != nil {
logger.Errorf("[transcode] error generating transcode: %s", err.Error())
if err != nil {
logger.Errorf("[transcode] error generating transcode: %v", err)
return
}
logger.Debugf("[transcode] <%s> created transcode: %s", sceneHash, outputPath)
}
// return true if transcode is needed
@@ -111,14 +108,14 @@ func (t *GenerateTranscodeTask) isTranscodeNeeded() bool {
container := ""
audioCodec := ffmpeg.MissingUnsupported
if t.Scene.AudioCodec.Valid {
audioCodec = ffmpeg.AudioCodec(t.Scene.AudioCodec.String)
audioCodec = ffmpeg.ProbeAudioCodec(t.Scene.AudioCodec.String)
}
if t.Scene.Format.Valid {
container = t.Scene.Format.String
}
if ffmpeg.IsStreamable(videoCodec, audioCodec, ffmpeg.Container(container)) {
if ffmpeg.IsStreamable(videoCodec, audioCodec, ffmpeg.Container(container)) == nil {
return false
}

View File

@@ -23,7 +23,7 @@ import (
var DB *sqlx.DB
var WriteMu sync.Mutex
var dbPath string
var appSchemaVersion uint = 30
var appSchemaVersion uint = 31
var databaseSchemaVersion uint
//go:embed migrations/*.sql

View File

@@ -1 +1 @@
ALTER TABLE `scenes` ADD COLUMN `interactive_speed` int
ALTER TABLE `scenes` ADD COLUMN `interactive_speed` int

View File

@@ -0,0 +1,8 @@
CREATE TABLE `scene_captions` (
`scene_id` integer,
`language_code` varchar(255) NOT NULL,
`filename` varchar(255) NOT NULL,
`caption_type` varchar(255) NOT NULL,
primary key (`scene_id`, `language_code`, `caption_type`),
foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE
);

View File

@@ -12,5 +12,5 @@ import (
// hideExecShell hides the windows when executing on Windows.
func hideExecShell(cmd *exec.Cmd) {
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS}
cmd.SysProcAttr = &syscall.SysProcAttr{CreationFlags: windows.DETACHED_PROCESS & windows.CREATE_NO_WINDOW}
}

136
pkg/ffmpeg/browser.go Normal file
View File

@@ -0,0 +1,136 @@
package ffmpeg
import (
"errors"
"fmt"
)
// only support H264 by default, since Safari does not support VP8/VP9
var defaultSupportedCodecs = []string{H264, H265}
var validForH264Mkv = []Container{Mp4, Matroska}
var validForH264 = []Container{Mp4}
var validForH265Mkv = []Container{Mp4, Matroska}
var validForH265 = []Container{Mp4}
var validForVp8 = []Container{Webm}
var validForVp9Mkv = []Container{Webm, Matroska}
var validForVp9 = []Container{Webm}
var validForHevcMkv = []Container{Mp4, Matroska}
var validForHevc = []Container{Mp4}
var validAudioForMkv = []ProbeAudioCodec{Aac, Mp3, Vorbis, Opus}
var validAudioForWebm = []ProbeAudioCodec{Vorbis, Opus}
var validAudioForMp4 = []ProbeAudioCodec{Aac, Mp3}
var (
// ErrUnsupportedVideoCodecForBrowser is returned when the video codec is not supported for browser streaming.
ErrUnsupportedVideoCodecForBrowser = errors.New("unsupported video codec for browser")
// ErrUnsupportedVideoCodecContainer is returned when the video codec/container combination is not supported for browser streaming.
ErrUnsupportedVideoCodecContainer = errors.New("video codec/container combination is unsupported for browser streaming")
// ErrUnsupportedAudioCodecContainer is returned when the audio codec/container combination is not supported for browser streaming.
ErrUnsupportedAudioCodecContainer = errors.New("audio codec/container combination is unsupported for browser streaming")
)
// IsStreamable returns nil if the file is streamable, or an error if it is not.
func IsStreamable(videoCodec string, audioCodec ProbeAudioCodec, container Container) error {
supportedVideoCodecs := defaultSupportedCodecs
// check if the video codec matches the supported codecs
if !isValidCodec(videoCodec, supportedVideoCodecs) {
return fmt.Errorf("%w: %s", ErrUnsupportedVideoCodecForBrowser, videoCodec)
}
if !isValidCombo(videoCodec, container, supportedVideoCodecs) {
return fmt.Errorf("%w: %s/%s", ErrUnsupportedVideoCodecContainer, videoCodec, container)
}
if !IsValidAudioForContainer(audioCodec, container) {
return fmt.Errorf("%w: %s/%s", ErrUnsupportedAudioCodecContainer, audioCodec, container)
}
return nil
}
func isValidCodec(codecName string, supportedCodecs []string) bool {
for _, c := range supportedCodecs {
if c == codecName {
return true
}
}
return false
}
func isValidAudio(audio ProbeAudioCodec, validCodecs []ProbeAudioCodec) bool {
// if audio codec is missing or unsupported by ffmpeg we can't do anything about it
// report it as valid so that the file can at least be streamed directly if the video codec is supported
if audio == MissingUnsupported {
return true
}
for _, c := range validCodecs {
if c == audio {
return true
}
}
return false
}
// IsValidAudioForContainer returns true if the audio codec is valid for the container.
func IsValidAudioForContainer(audio ProbeAudioCodec, format Container) bool {
switch format {
case Matroska:
return isValidAudio(audio, validAudioForMkv)
case Webm:
return isValidAudio(audio, validAudioForWebm)
case Mp4:
return isValidAudio(audio, validAudioForMp4)
}
return false
}
// isValidCombo checks if a codec/container combination is valid.
// Returns true on validity, false otherwise
func isValidCombo(codecName string, format Container, supportedVideoCodecs []string) bool {
supportMKV := isValidCodec(Mkv, supportedVideoCodecs)
supportHEVC := isValidCodec(Hevc, supportedVideoCodecs)
switch codecName {
case H264:
if supportMKV {
return isValidForContainer(format, validForH264Mkv)
}
return isValidForContainer(format, validForH264)
case H265:
if supportMKV {
return isValidForContainer(format, validForH265Mkv)
}
return isValidForContainer(format, validForH265)
case Vp8:
return isValidForContainer(format, validForVp8)
case Vp9:
if supportMKV {
return isValidForContainer(format, validForVp9Mkv)
}
return isValidForContainer(format, validForVp9)
case Hevc:
if supportHEVC {
if supportMKV {
return isValidForContainer(format, validForHevcMkv)
}
return isValidForContainer(format, validForHevc)
}
}
return false
}
func isValidForContainer(format Container, validContainers []Container) bool {
for _, fmt := range validContainers {
if fmt == format {
return true
}
}
return false
}

38
pkg/ffmpeg/codec.go Normal file
View File

@@ -0,0 +1,38 @@
package ffmpeg
type VideoCodec string
func (c VideoCodec) Args() []string {
if c == "" {
return nil
}
return []string{"-c:v", string(c)}
}
var (
VideoCodecLibX264 VideoCodec = "libx264"
VideoCodecLibWebP VideoCodec = "libwebp"
VideoCodecBMP VideoCodec = "bmp"
VideoCodecMJpeg VideoCodec = "mjpeg"
VideoCodecVP9 VideoCodec = "libvpx-vp9"
VideoCodecVPX VideoCodec = "libvpx"
VideoCodecLibX265 VideoCodec = "libx265"
VideoCodecCopy VideoCodec = "copy"
)
type AudioCodec string
func (c AudioCodec) Args() []string {
if c == "" {
return nil
}
return []string{"-c:a", string(c)}
}
var (
AudioCodecAAC AudioCodec = "aac"
AudioCodecLibOpus AudioCodec = "libopus"
AudioCodecCopy AudioCodec = "copy"
)

59
pkg/ffmpeg/container.go Normal file
View File

@@ -0,0 +1,59 @@
package ffmpeg
type Container string
type ProbeAudioCodec string
const (
Mp4 Container = "mp4"
M4v Container = "m4v"
Mov Container = "mov"
Wmv Container = "wmv"
Webm Container = "webm"
Matroska Container = "matroska"
Avi Container = "avi"
Flv Container = "flv"
Mpegts Container = "mpegts"
Aac ProbeAudioCodec = "aac"
Mp3 ProbeAudioCodec = "mp3"
Opus ProbeAudioCodec = "opus"
Vorbis ProbeAudioCodec = "vorbis"
MissingUnsupported ProbeAudioCodec = ""
Mp4Ffmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // browsers support all of them
M4vFfmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // so we don't care that ffmpeg
MovFfmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // can't differentiate between them
WmvFfmpeg string = "asf"
WebmFfmpeg string = "matroska,webm"
MatroskaFfmpeg string = "matroska,webm"
AviFfmpeg string = "avi"
FlvFfmpeg string = "flv"
MpegtsFfmpeg string = "mpegts"
H264 string = "h264"
H265 string = "h265" // found in rare cases from a faulty encoder
Hevc string = "hevc"
Vp8 string = "vp8"
Vp9 string = "vp9"
Mkv string = "mkv" // only used from the browser to indicate mkv support
Hls string = "hls" // only used from the browser to indicate hls support
)
var ffprobeToContainer = map[string]Container{
Mp4Ffmpeg: Mp4,
WmvFfmpeg: Wmv,
AviFfmpeg: Avi,
FlvFfmpeg: Flv,
MpegtsFfmpeg: Mpegts,
MatroskaFfmpeg: Matroska,
}
func MatchContainer(format string, filePath string) (Container, error) { // match ffprobe string to our Container
container := ffprobeToContainer[format]
if container == Matroska {
return magicContainer(filePath) // use magic number instead of ffprobe for matroska,webm
}
if container == "" { // if format is not in our Container list leave it as ffprobes reported format_name
container = Container(format)
}
return container, nil
}

View File

@@ -1,164 +0,0 @@
package ffmpeg
import (
"bytes"
"io"
"os"
"os/exec"
"strings"
"sync"
"time"
stashExec "github.com/stashapp/stash/pkg/exec"
"github.com/stashapp/stash/pkg/logger"
)
type Encoder string
var (
runningEncoders = make(map[string][]*os.Process)
runningEncodersMutex = sync.RWMutex{}
)
func registerRunningEncoder(path string, process *os.Process) {
runningEncodersMutex.Lock()
processes := runningEncoders[path]
runningEncoders[path] = append(processes, process)
runningEncodersMutex.Unlock()
}
func deregisterRunningEncoder(path string, process *os.Process) {
runningEncodersMutex.Lock()
defer runningEncodersMutex.Unlock()
processes := runningEncoders[path]
for i, v := range processes {
if v == process {
runningEncoders[path] = append(processes[:i], processes[i+1:]...)
return
}
}
}
func waitAndDeregister(path string, cmd *exec.Cmd) error {
err := cmd.Wait()
deregisterRunningEncoder(path, cmd.Process)
return err
}
func KillRunningEncoders(path string) {
runningEncodersMutex.RLock()
processes := runningEncoders[path]
runningEncodersMutex.RUnlock()
for _, process := range processes {
// assume it worked, don't check for error
logger.Infof("Killing encoder process for file: %s", path)
if err := process.Kill(); err != nil {
logger.Warnf("failed to kill process %v: %v", process.Pid, err)
}
// wait for the process to die before returning
// don't wait more than a few seconds
done := make(chan error)
go func() {
_, err := process.Wait()
done <- err
}()
select {
case <-done:
return
case <-time.After(5 * time.Second):
return
}
}
}
// FFmpeg runner with progress output, used for transcodes
func (e *Encoder) runTranscode(probeResult VideoFile, args []string) (string, error) {
cmd := stashExec.Command(string(*e), args...)
stderr, err := cmd.StderrPipe()
if err != nil {
logger.Error("FFMPEG stderr not available: " + err.Error())
}
stdout, err := cmd.StdoutPipe()
if nil != err {
logger.Error("FFMPEG stdout not available: " + err.Error())
}
if err = cmd.Start(); err != nil {
return "", err
}
buf := make([]byte, 80)
lastProgress := 0.0
var errBuilder strings.Builder
for {
n, err := stderr.Read(buf)
if n > 0 {
data := string(buf[0:n])
time := GetTimeFromRegex(data)
if time > 0 && probeResult.Duration > 0 {
progress := time / probeResult.Duration
if progress > lastProgress+0.01 {
logger.Infof("Progress %.2f", progress)
lastProgress = progress
}
}
errBuilder.WriteString(data)
}
if err != nil {
break
}
}
stdoutData, _ := io.ReadAll(stdout)
stdoutString := string(stdoutData)
registerRunningEncoder(probeResult.Path, cmd.Process)
err = waitAndDeregister(probeResult.Path, cmd)
if err != nil {
// error message should be in the stderr stream
logger.Errorf("ffmpeg error when running command <%s>: %s", strings.Join(cmd.Args, " "), errBuilder.String())
return stdoutString, err
}
return stdoutString, nil
}
func (e *Encoder) run(sourcePath string, args []string, stdin io.Reader) (string, error) {
cmd := stashExec.Command(string(*e), args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
cmd.Stdin = stdin
if err := cmd.Start(); err != nil {
return "", err
}
var err error
if sourcePath != "" {
registerRunningEncoder(sourcePath, cmd.Process)
err = waitAndDeregister(sourcePath, cmd)
} else {
err = cmd.Wait()
}
if err != nil {
// error message should be in the stderr stream
logger.Errorf("ffmpeg error when running command <%s>: %s", strings.Join(cmd.Args, " "), stderr.String())
return stdout.String(), err
}
return stdout.String(), nil
}

View File

@@ -1,72 +0,0 @@
package ffmpeg
import (
"fmt"
"strconv"
)
type SceneMarkerOptions struct {
ScenePath string
Seconds int
Width int
OutputPath string
Audio bool
}
func (e *Encoder) SceneMarkerVideo(probeResult VideoFile, options SceneMarkerOptions) error {
argsAudio := []string{
"-c:a", "aac",
"-b:a", "64k",
}
if !options.Audio {
argsAudio = []string{
"-an",
}
}
args := []string{
"-v", "error",
"-ss", strconv.Itoa(options.Seconds),
"-t", "20",
"-i", probeResult.Path,
"-max_muxing_queue_size", "1024", // https://trac.ffmpeg.org/ticket/6375
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-profile:v", "high",
"-level", "4.2",
"-preset", "veryslow",
"-crf", "24",
"-movflags", "+faststart",
"-threads", "4",
"-vf", fmt.Sprintf("scale=%v:-2", options.Width),
"-sws_flags", "lanczos",
"-strict", "-2",
}
args = append(args, argsAudio...)
args = append(args, options.OutputPath)
_, err := e.run(probeResult.Path, args, nil)
return err
}
func (e *Encoder) SceneMarkerImage(probeResult VideoFile, options SceneMarkerOptions) error {
args := []string{
"-v", "error",
"-ss", strconv.Itoa(options.Seconds),
"-t", "5",
"-i", probeResult.Path,
"-c:v", "libwebp",
"-lossless", "1",
"-q:v", "70",
"-compression_level", "6",
"-preset", "default",
"-loop", "0",
"-threads", "4",
"-vf", fmt.Sprintf("scale=%v:-2,fps=12", options.Width),
"-an",
options.OutputPath,
}
_, err := e.run(probeResult.Path, args, nil)
return err
}

View File

@@ -1,135 +0,0 @@
package ffmpeg
import (
"fmt"
"runtime"
"strconv"
"strings"
)
type ScenePreviewChunkOptions struct {
StartTime float64
Duration float64
Width int
OutputPath string
Audio bool
}
func (e *Encoder) ScenePreviewVideoChunk(probeResult VideoFile, options ScenePreviewChunkOptions, preset string, fallback bool) error {
var fastSeek float64
var slowSeek float64
fallbackMinSlowSeek := 20.0
args := []string{
"-v", "error",
}
argsAudio := []string{
"-c:a", "aac",
"-b:a", "128k",
}
if !options.Audio {
argsAudio = []string{
"-an",
}
}
// Non-fallback: enable xerror.
// "-xerror" causes ffmpeg to fail on warnings, often the preview is fine but could be broken.
if !fallback {
args = append(args, "-xerror")
fastSeek = options.StartTime
slowSeek = 0
} else {
// In fallback mode, disable "-xerror" and try a combination of fast/slow seek instead of just fastseek
// Commonly with avi/wmv ffmpeg doesn't seem to always predict the right start point to begin decoding when
// using fast seek. If you force ffmpeg to decode more, it avoids the "blocky green artifact" issue.
if options.StartTime > fallbackMinSlowSeek {
// Handle seeks longer than fallbackMinSlowSeek with fast/slow seeks
// Allow for at least fallbackMinSlowSeek seconds of slow seek
fastSeek = options.StartTime - fallbackMinSlowSeek
slowSeek = fallbackMinSlowSeek
} else {
// Handle seeks shorter than fallbackMinSlowSeek with only slow seeks.
slowSeek = options.StartTime
fastSeek = 0
}
}
if fastSeek > 0 {
args = append(args, "-ss")
args = append(args, strconv.FormatFloat(fastSeek, 'f', 2, 64))
}
args = append(args, "-i")
args = append(args, probeResult.Path)
if slowSeek > 0 {
args = append(args, "-ss")
args = append(args, strconv.FormatFloat(slowSeek, 'f', 2, 64))
}
args2 := []string{
"-t", strconv.FormatFloat(options.Duration, 'f', 2, 64),
"-max_muxing_queue_size", "1024", // https://trac.ffmpeg.org/ticket/6375
"-y",
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-profile:v", "high",
"-level", "4.2",
"-preset", preset,
"-crf", "21",
"-threads", "4",
"-vf", fmt.Sprintf("scale=%v:-2", options.Width),
"-strict", "-2",
}
args = append(args, args2...)
args = append(args, argsAudio...)
args = append(args, options.OutputPath)
_, err := e.run(probeResult.Path, args, nil)
return err
}
// fixWindowsPath replaces \ with / in the given path because the \ isn't recognized as valid on windows ffmpeg
func fixWindowsPath(str string) string {
if runtime.GOOS == "windows" {
return strings.ReplaceAll(str, `\`, "/")
}
return str
}
func (e *Encoder) ScenePreviewVideoChunkCombine(probeResult VideoFile, concatFilePath string, outputPath string) error {
args := []string{
"-v", "error",
"-f", "concat",
"-i", fixWindowsPath(concatFilePath),
"-y",
"-c", "copy",
outputPath,
}
_, err := e.run(probeResult.Path, args, nil)
return err
}
func (e *Encoder) ScenePreviewVideoToImage(probeResult VideoFile, width int, videoPreviewPath string, outputPath string) error {
args := []string{
"-v", "error",
"-i", videoPreviewPath,
"-y",
"-c:v", "libwebp",
"-lossless", "1",
"-q:v", "70",
"-compression_level", "6",
"-preset", "default",
"-loop", "0",
"-threads", "4",
"-vf", fmt.Sprintf("scale=%v:-2,fps=12", width),
"-an",
outputPath,
}
_, err := e.run(probeResult.Path, args, nil)
return err
}

View File

@@ -1,34 +0,0 @@
package ffmpeg
import "fmt"
type ScreenshotOptions struct {
OutputPath string
Quality int
Time float64
Width int
Verbosity string
}
func (e *Encoder) Screenshot(probeResult VideoFile, options ScreenshotOptions) error {
if options.Verbosity == "" {
options.Verbosity = "error"
}
if options.Quality == 0 {
options.Quality = 1
}
args := []string{
"-v", options.Verbosity,
"-ss", fmt.Sprintf("%v", options.Time),
"-y",
"-i", probeResult.Path,
"-vframes", "1",
"-q:v", fmt.Sprintf("%v", options.Quality),
"-vf", fmt.Sprintf("scale=%v:-1", options.Width),
"-f", "image2",
options.OutputPath,
}
_, err := e.run(probeResult.Path, args, nil)
return err
}

View File

@@ -1,67 +0,0 @@
package ffmpeg
import (
"fmt"
"image"
"strings"
)
type SpriteScreenshotOptions struct {
Time float64
Frame int
Width int
}
func (e *Encoder) SpriteScreenshot(probeResult VideoFile, options SpriteScreenshotOptions) (image.Image, error) {
args := []string{
"-v", "error",
"-ss", fmt.Sprintf("%v", options.Time),
"-i", probeResult.Path,
"-vframes", "1",
"-vf", fmt.Sprintf("scale=%v:-1", options.Width),
"-c:v", "bmp",
"-f", "rawvideo",
"-",
}
data, err := e.run(probeResult.Path, args, nil)
if err != nil {
return nil, err
}
reader := strings.NewReader(data)
img, _, err := image.Decode(reader)
if err != nil {
return nil, err
}
return img, err
}
// SpriteScreenshotSlow uses the select filter to get a single frame from a videofile instead of seeking
// It is very slow and should only be used for files with very small duration in secs / frame count
func (e *Encoder) SpriteScreenshotSlow(probeResult VideoFile, options SpriteScreenshotOptions) (image.Image, error) {
args := []string{
"-v", "error",
"-i", probeResult.Path,
"-vsync", "0", // do not create/drop frames
"-vframes", "1",
"-vf", fmt.Sprintf("select=eq(n\\,%d),scale=%v:-1", options.Frame, options.Width), // keep only frame number options.Frame
"-c:v", "bmp",
"-f", "rawvideo",
"-",
}
data, err := e.run(probeResult.Path, args, nil)
if err != nil {
return nil, err
}
reader := strings.NewReader(data)
img, _, err := image.Decode(reader)
if err != nil {
return nil, err
}
return img, err
}

View File

@@ -1,111 +0,0 @@
package ffmpeg
import (
"strconv"
"github.com/stashapp/stash/pkg/models"
)
type TranscodeOptions struct {
OutputPath string
MaxTranscodeSize models.StreamingResolutionEnum
}
func calculateTranscodeScale(probeResult VideoFile, maxTranscodeSize models.StreamingResolutionEnum) string {
maxSize := 0
switch maxTranscodeSize {
case models.StreamingResolutionEnumLow:
maxSize = 240
case models.StreamingResolutionEnumStandard:
maxSize = 480
case models.StreamingResolutionEnumStandardHd:
maxSize = 720
case models.StreamingResolutionEnumFullHd:
maxSize = 1080
case models.StreamingResolutionEnumFourK:
maxSize = 2160
}
// get the smaller dimension of the video file
videoSize := probeResult.Height
if probeResult.Width < videoSize {
videoSize = probeResult.Width
}
// if our streaming resolution is larger than the video dimension
// or we are streaming the original resolution, then just set the
// input width
if maxSize >= videoSize || maxSize == 0 {
return "iw:-2"
}
// we're setting either the width or height
// we'll set the smaller dimesion
if probeResult.Width > probeResult.Height {
// set the height
return "-2:" + strconv.Itoa(maxSize)
}
return strconv.Itoa(maxSize) + ":-2"
}
func (e *Encoder) Transcode(probeResult VideoFile, options TranscodeOptions) {
scale := calculateTranscodeScale(probeResult, options.MaxTranscodeSize)
args := []string{
"-i", probeResult.Path,
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-profile:v", "high",
"-level", "4.2",
"-preset", "superfast",
"-crf", "23",
"-vf", "scale=" + scale,
"-c:a", "aac",
"-strict", "-2",
options.OutputPath,
}
_, _ = e.runTranscode(probeResult, args)
}
// TranscodeVideo transcodes the video, and removes the audio.
// In some videos where the audio codec is not supported by ffmpeg,
// ffmpeg fails if you try to transcode the audio
func (e *Encoder) TranscodeVideo(probeResult VideoFile, options TranscodeOptions) {
scale := calculateTranscodeScale(probeResult, options.MaxTranscodeSize)
args := []string{
"-i", probeResult.Path,
"-an",
"-c:v", "libx264",
"-pix_fmt", "yuv420p",
"-profile:v", "high",
"-level", "4.2",
"-preset", "superfast",
"-crf", "23",
"-vf", "scale=" + scale,
options.OutputPath,
}
_, _ = e.runTranscode(probeResult, args)
}
// TranscodeAudio will copy the video stream as is, and transcode audio.
func (e *Encoder) TranscodeAudio(probeResult VideoFile, options TranscodeOptions) {
args := []string{
"-i", probeResult.Path,
"-c:v", "copy",
"-c:a", "aac",
"-strict", "-2",
options.OutputPath,
}
_, _ = e.runTranscode(probeResult, args)
}
// CopyVideo will copy the video stream as is, and drop the audio stream.
func (e *Encoder) CopyVideo(probeResult VideoFile, options TranscodeOptions) {
args := []string{
"-i", probeResult.Path,
"-an",
"-c:v", "copy",
options.OutputPath,
}
_, _ = e.runTranscode(probeResult, args)
}

17
pkg/ffmpeg/ffmpeg.go Normal file
View File

@@ -0,0 +1,17 @@
// Package ffmpeg provides a wrapper around the ffmpeg and ffprobe executables.
package ffmpeg
import (
"context"
"os/exec"
stashExec "github.com/stashapp/stash/pkg/exec"
)
// FFMpeg provides an interface to ffmpeg.
type FFMpeg string
// Returns an exec.Cmd that can be used to run ffmpeg using args.
func (f *FFMpeg) Command(ctx context.Context, args []string) *exec.Cmd {
return stashExec.CommandContext(ctx, string(*f), args...)
}

View File

@@ -5,7 +5,6 @@ import (
"fmt"
"math"
"os"
"path/filepath"
"strconv"
"strings"
"time"
@@ -14,188 +13,7 @@ import (
"github.com/stashapp/stash/pkg/logger"
)
type Container string
type AudioCodec string
const (
Mp4 Container = "mp4"
M4v Container = "m4v"
Mov Container = "mov"
Wmv Container = "wmv"
Webm Container = "webm"
Matroska Container = "matroska"
Avi Container = "avi"
Flv Container = "flv"
Mpegts Container = "mpegts"
Aac AudioCodec = "aac"
Mp3 AudioCodec = "mp3"
Opus AudioCodec = "opus"
Vorbis AudioCodec = "vorbis"
MissingUnsupported AudioCodec = ""
Mp4Ffmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // browsers support all of them
M4vFfmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // so we don't care that ffmpeg
MovFfmpeg string = "mov,mp4,m4a,3gp,3g2,mj2" // can't differentiate between them
WmvFfmpeg string = "asf"
WebmFfmpeg string = "matroska,webm"
MatroskaFfmpeg string = "matroska,webm"
AviFfmpeg string = "avi"
FlvFfmpeg string = "flv"
MpegtsFfmpeg string = "mpegts"
H264 string = "h264"
H265 string = "h265" // found in rare cases from a faulty encoder
Hevc string = "hevc"
Vp8 string = "vp8"
Vp9 string = "vp9"
Mkv string = "mkv" // only used from the browser to indicate mkv support
Hls string = "hls" // only used from the browser to indicate hls support
MimeWebm string = "video/webm"
MimeMkv string = "video/x-matroska"
MimeMp4 string = "video/mp4"
MimeHLS string = "application/vnd.apple.mpegurl"
MimeMpegts string = "video/MP2T"
)
// only support H264 by default, since Safari does not support VP8/VP9
var DefaultSupportedCodecs = []string{H264, H265}
var validForH264Mkv = []Container{Mp4, Matroska}
var validForH264 = []Container{Mp4}
var validForH265Mkv = []Container{Mp4, Matroska}
var validForH265 = []Container{Mp4}
var validForVp8 = []Container{Webm}
var validForVp9Mkv = []Container{Webm, Matroska}
var validForVp9 = []Container{Webm}
var validForHevcMkv = []Container{Mp4, Matroska}
var validForHevc = []Container{Mp4}
var validAudioForMkv = []AudioCodec{Aac, Mp3, Vorbis, Opus}
var validAudioForWebm = []AudioCodec{Vorbis, Opus}
var validAudioForMp4 = []AudioCodec{Aac, Mp3}
// ContainerToFfprobe maps user readable container strings to ffprobe's format_name.
// On some formats ffprobe can't differentiate
var ContainerToFfprobe = map[Container]string{
Mp4: Mp4Ffmpeg,
M4v: M4vFfmpeg,
Mov: MovFfmpeg,
Wmv: WmvFfmpeg,
Webm: WebmFfmpeg,
Matroska: MatroskaFfmpeg,
Avi: AviFfmpeg,
Flv: FlvFfmpeg,
Mpegts: MpegtsFfmpeg,
}
var FfprobeToContainer = map[string]Container{
Mp4Ffmpeg: Mp4,
WmvFfmpeg: Wmv,
AviFfmpeg: Avi,
FlvFfmpeg: Flv,
MpegtsFfmpeg: Mpegts,
MatroskaFfmpeg: Matroska,
}
func MatchContainer(format string, filePath string) Container { // match ffprobe string to our Container
container := FfprobeToContainer[format]
if container == Matroska {
container = magicContainer(filePath) // use magic number instead of ffprobe for matroska,webm
}
if container == "" { // if format is not in our Container list leave it as ffprobes reported format_name
container = Container(format)
}
return container
}
func isValidCodec(codecName string, supportedCodecs []string) bool {
for _, c := range supportedCodecs {
if c == codecName {
return true
}
}
return false
}
func isValidAudio(audio AudioCodec, validCodecs []AudioCodec) bool {
// if audio codec is missing or unsupported by ffmpeg we can't do anything about it
// report it as valid so that the file can at least be streamed directly if the video codec is supported
if audio == MissingUnsupported {
return true
}
for _, c := range validCodecs {
if c == audio {
return true
}
}
return false
}
func IsValidAudioForContainer(audio AudioCodec, format Container) bool {
switch format {
case Matroska:
return isValidAudio(audio, validAudioForMkv)
case Webm:
return isValidAudio(audio, validAudioForWebm)
case Mp4:
return isValidAudio(audio, validAudioForMp4)
}
return false
}
func isValidForContainer(format Container, validContainers []Container) bool {
for _, fmt := range validContainers {
if fmt == format {
return true
}
}
return false
}
// isValidCombo checks if a codec/container combination is valid.
// Returns true on validity, false otherwise
func isValidCombo(codecName string, format Container, supportedVideoCodecs []string) bool {
supportMKV := isValidCodec(Mkv, supportedVideoCodecs)
supportHEVC := isValidCodec(Hevc, supportedVideoCodecs)
switch codecName {
case H264:
if supportMKV {
return isValidForContainer(format, validForH264Mkv)
}
return isValidForContainer(format, validForH264)
case H265:
if supportMKV {
return isValidForContainer(format, validForH265Mkv)
}
return isValidForContainer(format, validForH265)
case Vp8:
return isValidForContainer(format, validForVp8)
case Vp9:
if supportMKV {
return isValidForContainer(format, validForVp9Mkv)
}
return isValidForContainer(format, validForVp9)
case Hevc:
if supportHEVC {
if supportMKV {
return isValidForContainer(format, validForHevcMkv)
}
return isValidForContainer(format, validForHevc)
}
}
return false
}
func IsStreamable(videoCodec string, audioCodec AudioCodec, container Container) bool {
supportedVideoCodecs := DefaultSupportedCodecs
// check if the video codec matches the supported codecs
return isValidCodec(videoCodec, supportedVideoCodecs) && isValidCombo(videoCodec, container, supportedVideoCodecs) && IsValidAudioForContainer(audioCodec, container)
}
// VideoFile represents the ffprobe output for a video file.
type VideoFile struct {
JSON FFProbeJSON
AudioStream *FFProbeStream
@@ -222,11 +40,38 @@ type VideoFile struct {
AudioCodec string
}
// FFProbe
// TranscodeScale calculates the dimension scaling for a transcode, where maxSize is the maximum size of the longest dimension of the input video.
// If no scaling is required, then returns 0, 0.
// Returns -2 for the dimension that will scale to maintain aspect ratio.
func (v *VideoFile) TranscodeScale(maxSize int) (int, int) {
// get the smaller dimension of the video file
videoSize := v.Height
if v.Width < videoSize {
videoSize = v.Width
}
// if our streaming resolution is larger than the video dimension
// or we are streaming the original resolution, then just set the
// input width
if maxSize >= videoSize || maxSize == 0 {
return 0, 0
}
// we're setting either the width or height
// we'll set the smaller dimesion
if v.Width > v.Height {
// set the height
return -2, maxSize
}
return maxSize, -2
}
// FFProbe provides an interface to the ffprobe executable.
type FFProbe string
// Execute exec command and bind result to struct.
func (f *FFProbe) NewVideoFile(videoPath string, stripExt bool) (*VideoFile, error) {
// 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...)
out, err := cmd.Output()
@@ -240,28 +85,29 @@ func (f *FFProbe) NewVideoFile(videoPath string, stripExt bool) (*VideoFile, err
return nil, fmt.Errorf("error unmarshalling video data for <%s>: %s", videoPath, err.Error())
}
return parse(videoPath, probeJSON, stripExt)
return parse(videoPath, probeJSON)
}
// GetReadFrameCount counts the actual frames of the video file
func (f *FFProbe) GetReadFrameCount(vf *VideoFile) (int64, error) {
args := []string{"-v", "quiet", "-print_format", "json", "-count_frames", "-show_format", "-show_streams", "-show_error", vf.Path}
// GetReadFrameCount counts the actual frames of the video file.
// 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()
if err != nil {
return 0, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", vf.Path, string(out), err.Error())
return 0, fmt.Errorf("FFProbe encountered an error with <%s>.\nError JSON:\n%s\nError: %s", path, string(out), err.Error())
}
probeJSON := &FFProbeJSON{}
if err := json.Unmarshal(out, probeJSON); err != nil {
return 0, fmt.Errorf("error unmarshalling video data for <%s>: %s", vf.Path, err.Error())
return 0, fmt.Errorf("error unmarshalling video data for <%s>: %s", path, err.Error())
}
fc, err := parse(vf.Path, probeJSON, false)
fc, err := parse(path, probeJSON)
return fc.FrameCount, err
}
func parse(filePath string, probeJSON *FFProbeJSON, stripExt bool) (*VideoFile, error) {
func parse(filePath string, probeJSON *FFProbeJSON) (*VideoFile, error) {
if probeJSON == nil {
return nil, fmt.Errorf("failed to get ffprobe json for <%s>", filePath)
}
@@ -276,11 +122,6 @@ func parse(filePath string, probeJSON *FFProbeJSON, stripExt bool) (*VideoFile,
result.Path = filePath
result.Title = probeJSON.Format.Tags.Title
if result.Title == "" {
// default title to filename
result.SetTitleFromPath(stripExt)
}
result.Comment = probeJSON.Format.Tags.Comment
result.Bitrate, _ = strconv.ParseInt(probeJSON.Format.BitRate, 10, 64)
@@ -356,19 +197,21 @@ func (v *VideoFile) getVideoStream() *FFProbeStream {
}
func (v *VideoFile) getStreamIndex(fileType string, probeJSON FFProbeJSON) int {
ret := -1
for i, stream := range probeJSON.Streams {
if stream.CodecType == fileType {
return i
// skip cover art/thumbnails
if stream.CodecType == fileType && stream.Disposition.AttachedPic == 0 {
// prefer default stream
if stream.Disposition.Default == 1 {
return i
}
// backwards compatible behaviour - fallback to first matching stream
if ret == -1 {
ret = i
}
}
}
return -1
}
func (v *VideoFile) SetTitleFromPath(stripExtension bool) {
v.Title = filepath.Base(v.Path)
if stripExtension {
ext := filepath.Ext(v.Title)
v.Title = strings.TrimSuffix(v.Title, ext)
}
return ret
}

78
pkg/ffmpeg/filter.go Normal file
View File

@@ -0,0 +1,78 @@
package ffmpeg
import "fmt"
// VideoFilter represents video filter parameters to be passed to ffmpeg.
type VideoFilter string
// Args converts the video filter parameters to a slice of arguments to be passed to ffmpeg.
// Returns an empty slice if the filter is empty.
func (f VideoFilter) Args() []string {
if f == "" {
return nil
}
return []string{"-vf", string(f)}
}
// ScaleWidth returns a VideoFilter scaling the width to the given width, maintaining aspect ratio and a height as a multiple of 2.
func (f VideoFilter) ScaleWidth(w int) VideoFilter {
return f.ScaleDimensions(w, -2)
}
func (f VideoFilter) ScaleHeight(h int) VideoFilter {
return f.ScaleDimensions(-2, h)
}
// ScaleDimesions returns a VideoFilter scaling using w and h. Use -n to maintain aspect ratio and maintain as multiple of n.
func (f VideoFilter) ScaleDimensions(w, h int) VideoFilter {
return f.Append(fmt.Sprintf("scale=%v:%v", w, h))
}
// ScaleMaxSize returns a VideoFilter scaling to maxDimensions, maintaining aspect ratio using force_original_aspect_ratio=decrease.
func (f VideoFilter) ScaleMaxSize(maxDimensions int) VideoFilter {
return f.Append(fmt.Sprintf("scale=%v:%v:force_original_aspect_ratio=decrease", maxDimensions, maxDimensions))
}
// ScaleMax returns a VideoFilter scaling to maxSize. It will scale width if it is larger than height, otherwise it will scale height.
func (f VideoFilter) ScaleMax(inputWidth, inputHeight, maxSize int) VideoFilter {
// get the smaller dimension of the input
videoSize := inputHeight
if inputWidth < videoSize {
videoSize = inputWidth
}
// if maxSize is larger than the video dimension, then no-op
if maxSize >= videoSize || maxSize == 0 {
return f
}
// we're setting either the width or height
// we'll set the smaller dimesion
if inputWidth > inputHeight {
// set the height
return f.ScaleDimensions(-2, maxSize)
}
return f.ScaleDimensions(maxSize, -2)
}
// Fps returns a VideoFilter setting the frames per second.
func (f VideoFilter) Fps(fps int) VideoFilter {
return f.Append(fmt.Sprintf("fps=%v", fps))
}
// Select returns a VideoFilter to select the given frame.
func (f VideoFilter) Select(frame int) VideoFilter {
return f.Append(fmt.Sprintf("select=eq(n\\,%d)", frame))
}
// Append returns a VideoFilter appending the given string.
func (f VideoFilter) Append(s string) VideoFilter {
// if filter is empty, then just set
if f == "" {
return VideoFilter(s)
}
return VideoFilter(fmt.Sprintf("%s,%s", f, s))
}

43
pkg/ffmpeg/format.go Normal file
View File

@@ -0,0 +1,43 @@
package ffmpeg
// Format represents the input/output format for ffmpeg.
type Format string
// Args converts the Format to a slice of arguments to be passed to ffmpeg.
func (f Format) Args() []string {
if f == "" {
return nil
}
return []string{"-f", string(f)}
}
var (
FormatConcat Format = "concat"
FormatImage2 Format = "image2"
FormatRawVideo Format = "rawvideo"
FormatMpegTS Format = "mpegts"
FormatMP4 Format = "mp4"
FormatWebm Format = "webm"
FormatMatroska Format = "matroska"
)
// ImageFormat represents the input format for an image for ffmpeg.
type ImageFormat string
// Args converts the ImageFormat to a slice of arguments to be passed to ffmpeg.
func (f ImageFormat) Args() []string {
if f == "" {
return nil
}
return []string{"-f", string(f)}
}
var (
ImageFormatJpeg ImageFormat = "mjpeg"
ImageFormatPng ImageFormat = "png_pipe"
ImageFormatWebp ImageFormat = "webp_pipe"
ImageFormatImage2Pipe ImageFormat = "image2pipe"
)

76
pkg/ffmpeg/frame_rate.go Normal file
View File

@@ -0,0 +1,76 @@
package ffmpeg
import (
"bytes"
"context"
"math"
"regexp"
"strconv"
)
// FrameInfo contains the number of frames and the frame rate for a video file.
type FrameInfo struct {
FrameRate float64
NumberOfFrames int
}
// CalculateFrameRate calculates the frame rate and number of frames of the video file.
// Used where the frame rate or NbFrames is missing or invalid in the ffprobe output.
func (f FFMpeg) CalculateFrameRate(ctx context.Context, v *VideoFile) (*FrameInfo, error) {
var args Args
args = append(args, "-nostats")
args = args.Input(v.Path).
VideoCodec(VideoCodecCopy).
Format(FormatRawVideo).
Overwrite().
NullOutput()
command := f.Command(ctx, args)
var stdErrBuffer bytes.Buffer
command.Stderr = &stdErrBuffer // Frames go to stderr rather than stdout
err := command.Run()
if err == nil {
var ret FrameInfo
stdErrString := stdErrBuffer.String()
ret.NumberOfFrames = getFrameFromRegex(stdErrString)
time := getTimeFromRegex(stdErrString)
ret.FrameRate = math.Round((float64(ret.NumberOfFrames)/time)*100) / 100
return &ret, nil
}
return nil, err
}
var timeRegex = regexp.MustCompile(`time=\s*(\d+):(\d+):(\d+.\d+)`)
var frameRegex = regexp.MustCompile(`frame=\s*([0-9]+)`)
func getTimeFromRegex(str string) float64 {
regexResult := timeRegex.FindStringSubmatch(str)
// Bail early if we don't have the results we expect
if len(regexResult) != 4 {
return 0
}
h, _ := strconv.ParseFloat(regexResult[1], 64)
m, _ := strconv.ParseFloat(regexResult[2], 64)
s, _ := strconv.ParseFloat(regexResult[3], 64)
hours := h * 3600
minutes := m * 60
seconds := s
return hours + minutes + seconds
}
func getFrameFromRegex(str string) int {
regexResult := frameRegex.FindStringSubmatch(str)
// Bail early if we don't have the results we expect
if len(regexResult) < 2 {
return 0
}
result, _ := strconv.Atoi(regexResult[1])
return result
}

49
pkg/ffmpeg/generate.go Normal file
View File

@@ -0,0 +1,49 @@
package ffmpeg
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os/exec"
"strings"
)
// Generate runs ffmpeg with the given args and waits for it to finish.
// Returns an error if the command fails. If the command fails, the return
// value will be of type *exec.ExitError.
func (f FFMpeg) Generate(ctx context.Context, args Args) error {
cmd := f.Command(ctx, args)
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Start(); err != nil {
return fmt.Errorf("error starting command: %w", err)
}
if err := cmd.Wait(); err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitErr.Stderr = stderr.Bytes()
err = exitErr
}
return fmt.Errorf("error running ffmpeg command <%s>: %w", strings.Join(args, " "), err)
}
return nil
}
// GenerateOutput runs ffmpeg with the given args and returns it standard output.
func (f FFMpeg) GenerateOutput(ctx context.Context, args []string, stdin io.Reader) ([]byte, error) {
cmd := f.Command(ctx, args)
cmd.Stdin = stdin
ret, err := cmd.Output()
if err != nil {
return nil, fmt.Errorf("error running ffmpeg command <%s>: %w", strings.Join(args, " "), err)
}
return ret, nil
}

View File

@@ -8,7 +8,8 @@ import (
const hlsSegmentLength = 10.0
func WriteHLSPlaylist(probeResult VideoFile, baseUrl string, w io.Writer) {
// WriteHLSPlaylist writes a HLS playlist to w using baseUrl as the base URL for TS streams.
func WriteHLSPlaylist(duration float64, baseUrl string, w io.Writer) {
fmt.Fprint(w, "#EXTM3U\n")
fmt.Fprint(w, "#EXT-X-VERSION:3\n")
fmt.Fprint(w, "#EXT-X-MEDIA-SEQUENCE:0\n")
@@ -16,8 +17,6 @@ func WriteHLSPlaylist(probeResult VideoFile, baseUrl string, w io.Writer) {
fmt.Fprintf(w, "#EXT-X-TARGETDURATION:%d\n", int(hlsSegmentLength))
fmt.Fprint(w, "#EXT-X-PLAYLIST-TYPE:VOD\n")
duration := probeResult.Duration
leftover := duration
upTo := 0.0

View File

@@ -1,34 +0,0 @@
package ffmpeg
import (
"bytes"
"fmt"
)
func (e *Encoder) ImageThumbnail(image *bytes.Buffer, format string, maxDimensions int, path string) ([]byte, error) {
// ffmpeg spends a long sniffing image format when data is piped through stdio, so we pass the format explicitly instead
var ffmpegformat string
switch format {
case "jpeg":
ffmpegformat = "mjpeg"
case "png":
ffmpegformat = "png_pipe"
case "webp":
ffmpegformat = "webp_pipe"
}
args := []string{
"-f", ffmpegformat,
"-i", "-",
"-vf", fmt.Sprintf("scale=%v:%v:force_original_aspect_ratio=decrease", maxDimensions, maxDimensions),
"-c:v", "mjpeg",
"-q:v", "5",
"-f", "image2pipe",
"-",
}
data, err := e.run(path, args, image)
return []byte(data), err
}

View File

@@ -3,8 +3,6 @@ package ffmpeg
import (
"bytes"
"os"
"github.com/stashapp/stash/pkg/logger"
)
// detect file format from magic file number
@@ -42,11 +40,10 @@ func containsMatroskaSignature(buf, subType []byte) bool {
// Returns the zero-value on errors or no-match. Implements mkv or
// webm only, as ffprobe can't distinguish between them and not all
// browsers support mkv
func magicContainer(filePath string) Container {
func magicContainer(filePath string) (Container, error) {
file, err := os.Open(filePath)
if err != nil {
logger.Errorf("[magicfile] %v", err)
return ""
return "", err
}
defer file.Close()
@@ -54,15 +51,14 @@ func magicContainer(filePath string) Container {
buf := make([]byte, 4096)
_, err = file.Read(buf)
if err != nil {
logger.Errorf("[magicfile] %v", err)
return ""
return "", err
}
if webm(buf) {
return Webm
return Webm, nil
}
if mkv(buf) {
return Matroska
return Matroska, nil
}
return ""
return "", nil
}

178
pkg/ffmpeg/options.go Normal file
View File

@@ -0,0 +1,178 @@
package ffmpeg
import (
"fmt"
"runtime"
)
// Arger is an interface that can be used to append arguments to an Args slice.
type Arger interface {
Args() []string
}
// Args represents a slice of arguments to be passed to ffmpeg.
type Args []string
// LogLevel sets the LogLevel to l and returns the result.
func (a Args) LogLevel(l LogLevel) Args {
if l == "" {
return a
}
return append(a, l.Args()...)
}
// XError adds the -xerror flag and returns the result.
func (a Args) XError() Args {
return append(a, "-xerror")
}
// Overwrite adds the overwrite flag (-y) and returns the result.
func (a Args) Overwrite() Args {
return append(a, "-y")
}
// Seek adds a seek (-ss) to the given seconds and returns the result.
func (a Args) Seek(seconds float64) Args {
return append(a, "-ss", fmt.Sprint(seconds))
}
// Duration sets the duration (-t) to the given seconds and returns the result.
func (a Args) Duration(seconds float64) Args {
return append(a, "-t", fmt.Sprint(seconds))
}
// Input adds the input (-i) and returns the result.
func (a Args) Input(i string) Args {
return append(a, "-i", i)
}
// Output adds the output o and returns the result.
func (a Args) Output(o string) Args {
return append(a, o)
}
// NullOutput adds a null output and returns the result.
// On Windows, this outputs to NUL, on everything else, /dev/null.
func (a Args) NullOutput() Args {
var output string
if runtime.GOOS == "windows" {
output = "nul" // https://stackoverflow.com/questions/313111/is-there-a-dev-null-on-windows
} else {
output = "/dev/null"
}
return a.Output(output)
}
// VideoFrames adds the -frames:v with f and returns the result.
func (a Args) VideoFrames(f int) Args {
return append(a, "-frames:v", fmt.Sprint(f))
}
// FixedQualityScaleVideo adds the -q:v argument with q and returns the result.
func (a Args) FixedQualityScaleVideo(q int) Args {
return append(a, "-q:v", fmt.Sprint(q))
}
// VideoFilter adds the vf video filter and returns the result.
func (a Args) VideoFilter(vf VideoFilter) Args {
return append(a, vf.Args()...)
}
// VSync adds the VsyncMethod and returns the result.
func (a Args) VSync(m VSyncMethod) Args {
return append(a, m.Args()...)
}
// AudioBitrate adds the -b:a argument with b and returns the result.
func (a Args) AudioBitrate(b string) Args {
return append(a, "-b:a", b)
}
// MaxMuxingQueueSize adds the -max_muxing_queue_size argument with s and returns the result.
func (a Args) MaxMuxingQueueSize(s int) Args {
// https://trac.ffmpeg.org/ticket/6375
return append(a, "-max_muxing_queue_size", fmt.Sprint(s))
}
// SkipAudio adds the skip audio flag (-an) and returns the result.
func (a Args) SkipAudio() Args {
return append(a, "-an")
}
// VideoCodec adds the given video codec and returns the result.
func (a Args) VideoCodec(c VideoCodec) Args {
return append(a, c.Args()...)
}
// AudioCodec adds the given audio codec and returns the result.
func (a Args) AudioCodec(c AudioCodec) Args {
return append(a, c.Args()...)
}
// Format adds the format flag with f and returns the result.
func (a Args) Format(f Format) Args {
return append(a, f.Args()...)
}
// ImageFormat adds the image format (using -f) and returns the result.
func (a Args) ImageFormat(f ImageFormat) Args {
return append(a, f.Args()...)
}
// AppendArgs appends the given Arger to the Args and returns the result.
func (a Args) AppendArgs(o Arger) Args {
return append(a, o.Args()...)
}
// Args returns a string slice of the arguments.
func (a Args) Args() []string {
return []string(a)
}
// LogLevel represents the log level of ffmpeg.
type LogLevel string
// Args returns the arguments to set the log level in ffmpeg.
func (l LogLevel) Args() []string {
if l == "" {
return nil
}
return []string{"-v", string(l)}
}
// LogLevels for ffmpeg. See -v entry under https://ffmpeg.org/ffmpeg.html#Generic-options
var (
LogLevelQuiet LogLevel = "quiet"
LogLevelPanic LogLevel = "panic"
LogLevelFatal LogLevel = "fatal"
LogLevelError LogLevel = "error"
LogLevelWarning LogLevel = "warning"
LogLevelInfo LogLevel = "info"
LogLevelVerbose LogLevel = "verbose"
LogLevelDebug LogLevel = "debug"
LogLevelTrace LogLevel = "trace"
)
// VSyncMethod represents the vsync method of ffmpeg.
type VSyncMethod string
// Args returns the arguments to set the vsync method in ffmpeg.
func (m VSyncMethod) Args() []string {
if m == "" {
return nil
}
return []string{"-vsync", string(m)}
}
// Video sync methods for ffmpeg. See -vsync entry under https://ffmpeg.org/ffmpeg.html#Advanced-options
var (
VSyncMethodPassthrough VSyncMethod = "0"
VSyncMethodCFR VSyncMethod = "1"
VSyncMethodVFR VSyncMethod = "2"
VSyncMethodDrop VSyncMethod = "drop"
VSyncMethodAuto VSyncMethod = "-1"
)

View File

@@ -1,38 +0,0 @@
package ffmpeg
import (
"regexp"
"strconv"
)
var TimeRegex = regexp.MustCompile(`time=\s*(\d+):(\d+):(\d+.\d+)`)
var FrameRegex = regexp.MustCompile(`frame=\s*([0-9]+)`)
func GetTimeFromRegex(str string) float64 {
regexResult := TimeRegex.FindStringSubmatch(str)
// Bail early if we don't have the results we expect
if len(regexResult) != 4 {
return 0
}
h, _ := strconv.ParseFloat(regexResult[1], 64)
m, _ := strconv.ParseFloat(regexResult[2], 64)
s, _ := strconv.ParseFloat(regexResult[3], 64)
hours := h * 3600
minutes := m * 60
seconds := s
return hours + minutes + seconds
}
func GetFrameFromRegex(str string) int {
regexResult := FrameRegex.FindStringSubmatch(str)
// Bail early if we don't have the results we expect
if len(regexResult) < 2 {
return 0
}
result, _ := strconv.Atoi(regexResult[1])
return result
}

View File

@@ -1,40 +1,38 @@
package ffmpeg
import (
"context"
"io"
"net/http"
"os"
"strconv"
"os/exec"
"strings"
stashExec "github.com/stashapp/stash/pkg/exec"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
)
const CopyStreamCodec = "copy"
const (
MimeWebm string = "video/webm"
MimeMkv string = "video/x-matroska"
MimeMp4 string = "video/mp4"
MimeHLS string = "application/vnd.apple.mpegurl"
MimeMpegts string = "video/MP2T"
)
// Stream represents an ongoing transcoded stream.
type Stream struct {
Stdout io.ReadCloser
Process *os.Process
options TranscodeStreamOptions
Cmd *exec.Cmd
mimeType string
}
// Serve is an http handler function that serves the stream.
func (s *Stream) Serve(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", s.mimeType)
w.WriteHeader(http.StatusOK)
logger.Infof("[stream] transcoding video file to %s", s.mimeType)
// handle if client closes the connection
notify := r.Context().Done()
go func() {
<-notify
if err := s.Process.Kill(); err != nil {
logger.Warnf("unable to kill os process %v: %v", s.Process.Pid, err)
}
}()
// process killing should be handled by command context
_, err := io.Copy(w, s.Stdout)
if err != nil {
@@ -42,148 +40,137 @@ func (s *Stream) Serve(w http.ResponseWriter, r *http.Request) {
}
}
type Codec struct {
Codec string
format string
// StreamFormat represents a transcode stream format.
type StreamFormat struct {
MimeType string
codec VideoCodec
format Format
extraArgs []string
hls bool
}
var CodecHLS = Codec{
Codec: "libx264",
format: "mpegts",
MimeType: MimeMpegts,
extraArgs: []string{
"-acodec", "aac",
"-pix_fmt", "yuv420p",
"-preset", "veryfast",
"-crf", "25",
},
hls: true,
}
var (
StreamFormatHLS = StreamFormat{
codec: VideoCodecLibX264,
format: FormatMpegTS,
MimeType: MimeMpegts,
extraArgs: []string{
"-acodec", "aac",
"-pix_fmt", "yuv420p",
"-preset", "veryfast",
"-crf", "25",
},
hls: true,
}
var CodecH264 = Codec{
Codec: "libx264",
format: "mp4",
MimeType: MimeMp4,
extraArgs: []string{
"-movflags", "frag_keyframe+empty_moov",
"-pix_fmt", "yuv420p",
"-preset", "veryfast",
"-crf", "25",
},
}
StreamFormatH264 = StreamFormat{
codec: VideoCodecLibX264,
format: FormatMP4,
MimeType: MimeMp4,
extraArgs: []string{
"-movflags", "frag_keyframe+empty_moov",
"-pix_fmt", "yuv420p",
"-preset", "veryfast",
"-crf", "25",
},
}
var CodecVP9 = Codec{
Codec: "libvpx-vp9",
format: "webm",
MimeType: MimeWebm,
extraArgs: []string{
"-deadline", "realtime",
"-cpu-used", "5",
"-row-mt", "1",
"-crf", "30",
"-b:v", "0",
"-pix_fmt", "yuv420p",
},
}
StreamFormatVP9 = StreamFormat{
codec: VideoCodecVP9,
format: FormatWebm,
MimeType: MimeWebm,
extraArgs: []string{
"-deadline", "realtime",
"-cpu-used", "5",
"-row-mt", "1",
"-crf", "30",
"-b:v", "0",
"-pix_fmt", "yuv420p",
},
}
var CodecVP8 = Codec{
Codec: "libvpx",
format: "webm",
MimeType: MimeWebm,
extraArgs: []string{
"-deadline", "realtime",
"-cpu-used", "5",
"-crf", "12",
"-b:v", "3M",
"-pix_fmt", "yuv420p",
},
}
StreamFormatVP8 = StreamFormat{
codec: VideoCodecVPX,
format: FormatWebm,
MimeType: MimeWebm,
extraArgs: []string{
"-deadline", "realtime",
"-cpu-used", "5",
"-crf", "12",
"-b:v", "3M",
"-pix_fmt", "yuv420p",
},
}
var CodecHEVC = Codec{
Codec: "libx265",
format: "mp4",
MimeType: MimeMp4,
extraArgs: []string{
"-movflags", "frag_keyframe",
"-preset", "veryfast",
"-crf", "30",
},
}
StreamFormatHEVC = StreamFormat{
codec: VideoCodecLibX265,
format: FormatMP4,
MimeType: MimeMp4,
extraArgs: []string{
"-movflags", "frag_keyframe",
"-preset", "veryfast",
"-crf", "30",
},
}
// it is very common in MKVs to have just the audio codec unsupported
// copy the video stream, transcode the audio and serve as Matroska
var CodecMKVAudio = Codec{
Codec: CopyStreamCodec,
format: "matroska",
MimeType: MimeMkv,
extraArgs: []string{
"-c:a", "libopus",
"-b:a", "96k",
"-vbr", "on",
},
}
// it is very common in MKVs to have just the audio codec unsupported
// copy the video stream, transcode the audio and serve as Matroska
StreamFormatMKVAudio = StreamFormat{
codec: VideoCodecCopy,
format: FormatMatroska,
MimeType: MimeMkv,
extraArgs: []string{
"-c:a", "libopus",
"-b:a", "96k",
"-vbr", "on",
},
}
)
// TranscodeStreamOptions represents options for live transcoding a video file.
type TranscodeStreamOptions struct {
ProbeResult VideoFile
Codec Codec
StartTime string
MaxTranscodeSize models.StreamingResolutionEnum
Input string
Codec StreamFormat
StartTime float64
MaxTranscodeSize int
// original video dimensions
VideoWidth int
VideoHeight int
// transcode the video, remove the audio
// in some videos where the audio codec is not supported by ffmpeg
// ffmpeg fails if you try to transcode the audio
VideoOnly bool
}
func GetTranscodeStreamOptions(probeResult VideoFile, videoCodec Codec, audioCodec AudioCodec) TranscodeStreamOptions {
options := TranscodeStreamOptions{
ProbeResult: probeResult,
Codec: videoCodec,
}
func (o TranscodeStreamOptions) getStreamArgs() Args {
var args Args
args = append(args, "-hide_banner")
args = args.LogLevel(LogLevelError)
if audioCodec == MissingUnsupported {
// ffmpeg fails if it trys to transcode a non supported audio codec
options.VideoOnly = true
}
return options
}
func (o TranscodeStreamOptions) getStreamArgs() []string {
args := []string{
"-hide_banner",
"-v", "error",
}
if o.StartTime != "" {
args = append(args, "-ss", o.StartTime)
if o.StartTime != 0 {
args = args.Seek(o.StartTime)
}
if o.Codec.hls {
// we only serve a fixed segment length
args = append(args, "-t", strconv.Itoa(int(hlsSegmentLength)))
args = args.Duration(hlsSegmentLength)
}
args = append(args,
"-i", o.ProbeResult.Path,
)
args = args.Input(o.Input)
if o.VideoOnly {
args = append(args, "-an")
args = args.SkipAudio()
}
args = append(args,
"-c:v", o.Codec.Codec,
)
args = args.VideoCodec(o.Codec.codec)
// don't set scale when copying video stream
if o.Codec.Codec != CopyStreamCodec {
scale := calculateTranscodeScale(o.ProbeResult, o.MaxTranscodeSize)
args = append(args,
"-vf", "scale="+scale,
)
if o.Codec.codec != VideoCodecCopy {
var videoFilter VideoFilter
videoFilter = videoFilter.ScaleMax(o.VideoWidth, o.VideoHeight, o.MaxTranscodeSize)
args = args.VideoFilter(videoFilter)
}
if len(o.Codec.extraArgs) > 0 {
@@ -193,20 +180,18 @@ func (o TranscodeStreamOptions) getStreamArgs() []string {
args = append(args,
// this is needed for 5-channel ac3 files
"-ac", "2",
"-f", o.Codec.format,
"pipe:",
)
args = args.Format(o.Codec.format)
args = args.Output("pipe:")
return args
}
func (e *Encoder) GetTranscodeStream(options TranscodeStreamOptions) (*Stream, error) {
return e.stream(options.ProbeResult, options)
}
func (e *Encoder) stream(probeResult VideoFile, options TranscodeStreamOptions) (*Stream, error) {
// GetTranscodeStream starts the live transcoding process using ffmpeg and returns a stream.
func (f *FFMpeg) GetTranscodeStream(ctx context.Context, options TranscodeStreamOptions) (*Stream, error) {
args := options.getStreamArgs()
cmd := stashExec.Command(string(*e), args...)
cmd := f.Command(ctx, args)
logger.Debugf("Streaming via: %s", strings.Join(cmd.Args, " "))
stdout, err := cmd.StdoutPipe()
@@ -225,13 +210,6 @@ func (e *Encoder) stream(probeResult VideoFile, options TranscodeStreamOptions)
return nil, err
}
registerRunningEncoder(probeResult.Path, cmd.Process)
go func() {
if err := waitAndDeregister(probeResult.Path, cmd); err != nil {
logger.Warnf("Error while deregistering ffmpeg stream: %v", err)
}
}()
// stderr must be consumed or the process deadlocks
go func() {
stderrData, _ := io.ReadAll(stderr)
@@ -243,8 +221,7 @@ func (e *Encoder) stream(probeResult VideoFile, options TranscodeStreamOptions)
ret := &Stream{
Stdout: stdout,
Process: cmd.Process,
options: options,
Cmd: cmd,
mimeType: options.Codec.MimeType,
}
return ret, nil

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