mirror of
https://github.com/stashapp/stash.git
synced 2026-04-25 16:35:21 +02:00
commit
c7b53777dc
315 changed files with 24309 additions and 11682 deletions
|
|
@ -20,7 +20,7 @@ linters:
|
|||
# Linters added by the stash project.
|
||||
# - contextcheck
|
||||
- dogsled
|
||||
# - errchkjson
|
||||
- errchkjson
|
||||
- errorlint
|
||||
# - exhaustive
|
||||
- exportloopref
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
7
Makefile
7
Makefile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -45,9 +45,9 @@ Many community-maintained scrapers are available for download at the [Community
|
|||
|
||||
# Translation
|
||||
[](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 15 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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 install [MingW](https://sourceforge.net/projects/mingw/) and select packages `mingw32-base`
|
||||
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).
|
||||
|
||||
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
|
||||
|
||||
|
|
|
|||
5
go.mod
5
go.mod
|
|
@ -46,7 +46,7 @@ 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
|
||||
|
|
@ -60,7 +60,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
|
||||
|
|
|
|||
12
go.sum
12
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
|
|||
scaleUp
|
||||
resetZoomOnNav
|
||||
scrollMode
|
||||
scrollAttemptsBeforeChange
|
||||
}
|
||||
disableDropdownCreate {
|
||||
performer
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -174,6 +174,8 @@ input SceneFilterType {
|
|||
interactive: Boolean
|
||||
"""Filter by InteractiveSpeed"""
|
||||
interactive_speed: IntCriterionInput
|
||||
"""Filter by captions"""
|
||||
captions: StringCriterionInput
|
||||
}
|
||||
|
||||
input MovieFilterType {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -41,7 +41,7 @@ var githash string
|
|||
var uiBox = ui.UIBox
|
||||
var loginUIBox = ui.LoginUIBox
|
||||
|
||||
func Start() {
|
||||
func Start() error {
|
||||
initialiseImages()
|
||||
|
||||
r := chi.NewRouter()
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,3 +37,11 @@ func sendNotification(notificationTitle string, notificationText string) {
|
|||
func revealInFileManager(path string) {
|
||||
|
||||
}
|
||||
|
||||
func isDoubleClickLaunched() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func hideConsole() {
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
9
internal/desktop/dialog_nonwindows.go
Normal file
9
internal/desktop/dialog_nonwindows.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
//go:build !windows
|
||||
// +build !windows
|
||||
|
||||
package desktop
|
||||
|
||||
func FatalError(err error) int {
|
||||
// nothing to do
|
||||
return 0
|
||||
}
|
||||
33
internal/desktop/dialog_windows.go
Normal file
33
internal/desktop/dialog_windows.go
Normal 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)
|
||||
}
|
||||
|
|
@ -145,12 +145,13 @@ 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"
|
||||
|
||||
defaultImageLightboxSlideshowDelay = 5000
|
||||
|
||||
|
|
@ -955,6 +956,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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
15
internal/manager/log.go
Normal 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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
ALTER TABLE `scenes` ADD COLUMN `interactive_speed` int
|
||||
ALTER TABLE `scenes` ADD COLUMN `interactive_speed` int
|
||||
|
|
|
|||
8
pkg/database/migrations/31_scenes_captions.up.sql
Normal file
8
pkg/database/migrations/31_scenes_captions.up.sql
Normal 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
|
||||
);
|
||||
136
pkg/ffmpeg/browser.go
Normal file
136
pkg/ffmpeg/browser.go
Normal 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
38
pkg/ffmpeg/codec.go
Normal 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
59
pkg/ffmpeg/container.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
17
pkg/ffmpeg/ffmpeg.go
Normal 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...)
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
||||
|
|
@ -364,11 +205,3 @@ func (v *VideoFile) getStreamIndex(fileType string, probeJSON FFProbeJSON) int {
|
|||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
78
pkg/ffmpeg/filter.go
Normal file
78
pkg/ffmpeg/filter.go
Normal 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
43
pkg/ffmpeg/format.go
Normal 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
76
pkg/ffmpeg/frame_rate.go
Normal 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
49
pkg/ffmpeg/generate.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
178
pkg/ffmpeg/options.go
Normal 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"
|
||||
)
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
40
pkg/ffmpeg/transcoder/image.go
Normal file
40
pkg/ffmpeg/transcoder/image.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package transcoder
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
)
|
||||
|
||||
var ErrUnsupportedFormat = errors.New("unsupported image format")
|
||||
|
||||
type ImageThumbnailOptions struct {
|
||||
InputFormat ffmpeg.ImageFormat
|
||||
OutputPath string
|
||||
MaxDimensions int
|
||||
Quality int
|
||||
}
|
||||
|
||||
func ImageThumbnail(input string, options ImageThumbnailOptions) ffmpeg.Args {
|
||||
var videoFilter ffmpeg.VideoFilter
|
||||
videoFilter = videoFilter.ScaleMaxSize(options.MaxDimensions)
|
||||
|
||||
var args ffmpeg.Args
|
||||
args = append(args, "-hide_banner")
|
||||
args = args.LogLevel(ffmpeg.LogLevelError)
|
||||
|
||||
args = args.Overwrite().
|
||||
ImageFormat(options.InputFormat).
|
||||
Input(input).
|
||||
VideoFilter(videoFilter).
|
||||
VideoCodec(ffmpeg.VideoCodecMJpeg)
|
||||
|
||||
if options.Quality > 0 {
|
||||
args = args.FixedQualityScaleVideo(options.Quality)
|
||||
}
|
||||
|
||||
args = args.ImageFormat(ffmpeg.ImageFormatImage2Pipe).
|
||||
Output(options.OutputPath)
|
||||
|
||||
return args
|
||||
}
|
||||
109
pkg/ffmpeg/transcoder/screenshot.go
Normal file
109
pkg/ffmpeg/transcoder/screenshot.go
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
package transcoder
|
||||
|
||||
import "github.com/stashapp/stash/pkg/ffmpeg"
|
||||
|
||||
type ScreenshotOptions struct {
|
||||
OutputPath string
|
||||
OutputType ScreenshotOutputType
|
||||
|
||||
// Quality is the quality scale. See https://ffmpeg.org/ffmpeg.html#Main-options
|
||||
Quality int
|
||||
|
||||
Width int
|
||||
|
||||
// Verbosity is the logging verbosity. Defaults to LogLevelError if not set.
|
||||
Verbosity ffmpeg.LogLevel
|
||||
|
||||
UseSelectFilter bool
|
||||
}
|
||||
|
||||
func (o *ScreenshotOptions) setDefaults() {
|
||||
if o.Verbosity == "" {
|
||||
o.Verbosity = ffmpeg.LogLevelError
|
||||
}
|
||||
}
|
||||
|
||||
type ScreenshotOutputType struct {
|
||||
codec ffmpeg.VideoCodec
|
||||
format ffmpeg.Format
|
||||
}
|
||||
|
||||
func (t ScreenshotOutputType) Args() []string {
|
||||
var ret []string
|
||||
if t.codec != "" {
|
||||
ret = append(ret, t.codec.Args()...)
|
||||
}
|
||||
if t.format != "" {
|
||||
ret = append(ret, t.format.Args()...)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
var (
|
||||
ScreenshotOutputTypeImage2 = ScreenshotOutputType{
|
||||
format: "image2",
|
||||
}
|
||||
ScreenshotOutputTypeBMP = ScreenshotOutputType{
|
||||
codec: ffmpeg.VideoCodecBMP,
|
||||
format: "rawvideo",
|
||||
}
|
||||
)
|
||||
|
||||
func ScreenshotTime(input string, t float64, options ScreenshotOptions) ffmpeg.Args {
|
||||
options.setDefaults()
|
||||
|
||||
var args ffmpeg.Args
|
||||
args = args.LogLevel(options.Verbosity)
|
||||
args = args.Overwrite()
|
||||
args = args.Seek(t)
|
||||
|
||||
args = args.Input(input)
|
||||
args = args.VideoFrames(1)
|
||||
|
||||
if options.Quality > 0 {
|
||||
args = args.FixedQualityScaleVideo(options.Quality)
|
||||
}
|
||||
|
||||
var vf ffmpeg.VideoFilter
|
||||
|
||||
if options.Width > 0 {
|
||||
vf = vf.ScaleWidth(options.Width)
|
||||
args = args.VideoFilter(vf)
|
||||
}
|
||||
|
||||
args = args.AppendArgs(options.OutputType)
|
||||
args = args.Output(options.OutputPath)
|
||||
|
||||
return args
|
||||
}
|
||||
|
||||
// ScreenshotFrame uses the select filter to get a single frame from the video.
|
||||
// It is very slow and should only be used for files with very small duration in secs / frame count.
|
||||
func ScreenshotFrame(input string, frame int, options ScreenshotOptions) ffmpeg.Args {
|
||||
options.setDefaults()
|
||||
|
||||
var args ffmpeg.Args
|
||||
args = args.LogLevel(options.Verbosity)
|
||||
args = args.Overwrite()
|
||||
|
||||
args = args.Input(input)
|
||||
args = args.VideoFrames(1)
|
||||
|
||||
args = args.VSync(ffmpeg.VSyncMethodPassthrough)
|
||||
|
||||
var vf ffmpeg.VideoFilter
|
||||
// keep only frame number options.Frame)
|
||||
vf = vf.Select(frame)
|
||||
|
||||
if options.Width > 0 {
|
||||
vf = vf.ScaleWidth(options.Width)
|
||||
}
|
||||
|
||||
args = args.VideoFilter(vf)
|
||||
|
||||
args = args.AppendArgs(options.OutputType)
|
||||
args = args.Output(options.OutputPath)
|
||||
|
||||
return args
|
||||
}
|
||||
67
pkg/ffmpeg/transcoder/splice.go
Normal file
67
pkg/ffmpeg/transcoder/splice.go
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
package transcoder
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
)
|
||||
|
||||
type SpliceOptions struct {
|
||||
OutputPath string
|
||||
Format ffmpeg.Format
|
||||
|
||||
VideoCodec ffmpeg.VideoCodec
|
||||
VideoArgs ffmpeg.Args
|
||||
|
||||
AudioCodec ffmpeg.AudioCodec
|
||||
AudioArgs ffmpeg.Args
|
||||
|
||||
// Verbosity is the logging verbosity. Defaults to LogLevelError if not set.
|
||||
Verbosity ffmpeg.LogLevel
|
||||
}
|
||||
|
||||
func (o *SpliceOptions) setDefaults() {
|
||||
if o.Verbosity == "" {
|
||||
o.Verbosity = ffmpeg.LogLevelError
|
||||
}
|
||||
}
|
||||
|
||||
// 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 Splice(concatFile string, options SpliceOptions) ffmpeg.Args {
|
||||
options.setDefaults()
|
||||
|
||||
var args ffmpeg.Args
|
||||
args = args.LogLevel(options.Verbosity)
|
||||
args = args.Format(ffmpeg.FormatConcat)
|
||||
args = args.Input(fixWindowsPath(concatFile))
|
||||
args = args.Overwrite()
|
||||
|
||||
// if video codec is not provided, then use copy
|
||||
if options.VideoCodec == "" {
|
||||
options.VideoCodec = ffmpeg.VideoCodecCopy
|
||||
}
|
||||
|
||||
args = args.VideoCodec(options.VideoCodec)
|
||||
args = args.AppendArgs(options.VideoArgs)
|
||||
|
||||
// if audio codec is not provided, then use copy
|
||||
if options.AudioCodec == "" {
|
||||
options.AudioCodec = ffmpeg.AudioCodecCopy
|
||||
}
|
||||
|
||||
args = args.AudioCodec(options.AudioCodec)
|
||||
args = args.AppendArgs(options.AudioArgs)
|
||||
|
||||
args = args.Format(options.Format)
|
||||
args = args.Output(options.OutputPath)
|
||||
|
||||
return args
|
||||
}
|
||||
99
pkg/ffmpeg/transcoder/transcode.go
Normal file
99
pkg/ffmpeg/transcoder/transcode.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package transcoder
|
||||
|
||||
import "github.com/stashapp/stash/pkg/ffmpeg"
|
||||
|
||||
type TranscodeOptions struct {
|
||||
OutputPath string
|
||||
Format ffmpeg.Format
|
||||
|
||||
VideoCodec ffmpeg.VideoCodec
|
||||
VideoArgs ffmpeg.Args
|
||||
|
||||
AudioCodec ffmpeg.AudioCodec
|
||||
AudioArgs ffmpeg.Args
|
||||
|
||||
// if XError is true, then ffmpeg will fail on warnings
|
||||
XError bool
|
||||
|
||||
StartTime float64
|
||||
SlowSeek bool
|
||||
Duration float64
|
||||
|
||||
// Verbosity is the logging verbosity. Defaults to LogLevelError if not set.
|
||||
Verbosity ffmpeg.LogLevel
|
||||
}
|
||||
|
||||
func (o *TranscodeOptions) setDefaults() {
|
||||
if o.Verbosity == "" {
|
||||
o.Verbosity = ffmpeg.LogLevelError
|
||||
}
|
||||
}
|
||||
|
||||
func Transcode(input string, options TranscodeOptions) ffmpeg.Args {
|
||||
options.setDefaults()
|
||||
|
||||
// TODO - this should probably be generalised and applied to all operations. Need to verify impact on phash algorithm.
|
||||
const fallbackMinSlowSeek = 20.0
|
||||
|
||||
var fastSeek float64
|
||||
var slowSeek float64
|
||||
|
||||
if !options.SlowSeek {
|
||||
fastSeek = options.StartTime
|
||||
slowSeek = 0
|
||||
} else {
|
||||
// In slowseek mode, 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
|
||||
}
|
||||
}
|
||||
|
||||
var args ffmpeg.Args
|
||||
args = args.LogLevel(options.Verbosity).Overwrite()
|
||||
|
||||
if options.XError {
|
||||
args = args.XError()
|
||||
}
|
||||
|
||||
if fastSeek > 0 {
|
||||
args = args.Seek(fastSeek)
|
||||
}
|
||||
|
||||
args = args.Input(input)
|
||||
|
||||
if slowSeek > 0 {
|
||||
args = args.Seek(slowSeek)
|
||||
}
|
||||
|
||||
if options.Duration > 0 {
|
||||
args = args.Duration(options.Duration)
|
||||
}
|
||||
|
||||
// https://trac.ffmpeg.org/ticket/6375
|
||||
args = args.MaxMuxingQueueSize(1024)
|
||||
|
||||
args = args.VideoCodec(options.VideoCodec)
|
||||
args = args.AppendArgs(options.VideoArgs)
|
||||
|
||||
// if audio codec is not provided, then skip it
|
||||
if options.AudioCodec == "" {
|
||||
args = args.SkipAudio()
|
||||
} else {
|
||||
args = args.AudioCodec(options.AudioCodec)
|
||||
}
|
||||
args = args.AppendArgs(options.AudioArgs)
|
||||
|
||||
args = args.Format(options.Format)
|
||||
args = args.Output(options.OutputPath)
|
||||
|
||||
return args
|
||||
}
|
||||
|
|
@ -1,9 +1,10 @@
|
|||
package ffmpeg
|
||||
|
||||
import (
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/json"
|
||||
)
|
||||
|
||||
// FFProbeJSON is the JSON output of ffprobe.
|
||||
type FFProbeJSON struct {
|
||||
Format struct {
|
||||
BitRate string `json:"bit_rate"`
|
||||
|
|
@ -17,13 +18,13 @@ type FFProbeJSON struct {
|
|||
Size string `json:"size"`
|
||||
StartTime string `json:"start_time"`
|
||||
Tags struct {
|
||||
CompatibleBrands string `json:"compatible_brands"`
|
||||
CreationTime models.JSONTime `json:"creation_time"`
|
||||
Encoder string `json:"encoder"`
|
||||
MajorBrand string `json:"major_brand"`
|
||||
MinorVersion string `json:"minor_version"`
|
||||
Title string `json:"title"`
|
||||
Comment string `json:"comment"`
|
||||
CompatibleBrands string `json:"compatible_brands"`
|
||||
CreationTime json.JSONTime `json:"creation_time"`
|
||||
Encoder string `json:"encoder"`
|
||||
MajorBrand string `json:"major_brand"`
|
||||
MinorVersion string `json:"minor_version"`
|
||||
Title string `json:"title"`
|
||||
Comment string `json:"comment"`
|
||||
} `json:"tags"`
|
||||
} `json:"format"`
|
||||
Streams []FFProbeStream `json:"streams"`
|
||||
|
|
@ -33,6 +34,7 @@ type FFProbeJSON struct {
|
|||
} `json:"error"`
|
||||
}
|
||||
|
||||
// FFProbeStream is a JSON representation of an ffmpeg stream.
|
||||
type FFProbeStream struct {
|
||||
AvgFrameRate string `json:"avg_frame_rate"`
|
||||
BitRate string `json:"bit_rate"`
|
||||
|
|
@ -79,10 +81,10 @@ type FFProbeStream struct {
|
|||
StartPts int `json:"start_pts"`
|
||||
StartTime string `json:"start_time"`
|
||||
Tags struct {
|
||||
CreationTime models.JSONTime `json:"creation_time"`
|
||||
HandlerName string `json:"handler_name"`
|
||||
Language string `json:"language"`
|
||||
Rotate string `json:"rotate"`
|
||||
CreationTime json.JSONTime `json:"creation_time"`
|
||||
HandlerName string `json:"handler_name"`
|
||||
Language string `json:"language"`
|
||||
Rotate string `json:"rotate"`
|
||||
} `json:"tags"`
|
||||
TimeBase string `json:"time_base"`
|
||||
Width int `json:"width,omitempty"`
|
||||
|
|
|
|||
115
pkg/fsutil/lock_manager.go
Normal file
115
pkg/fsutil/lock_manager.go
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
package fsutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Cancellable interface {
|
||||
Cancel()
|
||||
}
|
||||
|
||||
type LockContext struct {
|
||||
context.Context
|
||||
cancel context.CancelFunc
|
||||
|
||||
cmd *exec.Cmd
|
||||
}
|
||||
|
||||
func (c *LockContext) AttachCommand(cmd *exec.Cmd) {
|
||||
c.cmd = cmd
|
||||
}
|
||||
|
||||
func (c *LockContext) Cancel() {
|
||||
c.cancel()
|
||||
|
||||
if c.cmd != nil {
|
||||
// wait for the process to die before returning
|
||||
// don't wait more than a few seconds
|
||||
done := make(chan error)
|
||||
go func() {
|
||||
err := c.cmd.Wait()
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
return
|
||||
case <-time.After(5 * time.Second):
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReadLockManager manages read locks on file paths.
|
||||
type ReadLockManager struct {
|
||||
readLocks map[string][]*LockContext
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// NewReadLockManager creates a new ReadLockManager.
|
||||
func NewReadLockManager() *ReadLockManager {
|
||||
return &ReadLockManager{
|
||||
readLocks: make(map[string][]*LockContext),
|
||||
}
|
||||
}
|
||||
|
||||
// ReadLock adds a pending file read lock for fn to its storage, returning a context and cancel function.
|
||||
// Per standard WithCancel usage, cancel must be called when the lock is freed.
|
||||
func (m *ReadLockManager) ReadLock(ctx context.Context, fn string) *LockContext {
|
||||
retCtx, cancel := context.WithCancel(ctx)
|
||||
|
||||
// if Cancellable, call Cancel() when cancelled
|
||||
cancellable, ok := ctx.(Cancellable)
|
||||
if ok {
|
||||
origCancel := cancel
|
||||
cancel = func() {
|
||||
origCancel()
|
||||
cancellable.Cancel()
|
||||
}
|
||||
}
|
||||
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
locks := m.readLocks[fn]
|
||||
|
||||
cc := &LockContext{
|
||||
Context: retCtx,
|
||||
cancel: cancel,
|
||||
}
|
||||
m.readLocks[fn] = append(locks, cc)
|
||||
|
||||
go m.waitAndUnlock(fn, cc)
|
||||
|
||||
return cc
|
||||
}
|
||||
|
||||
func (m *ReadLockManager) waitAndUnlock(fn string, cc *LockContext) {
|
||||
<-cc.Done()
|
||||
|
||||
m.mutex.Lock()
|
||||
defer m.mutex.Unlock()
|
||||
|
||||
locks := m.readLocks[fn]
|
||||
for i, v := range locks {
|
||||
if v == cc {
|
||||
m.readLocks[fn] = append(locks[:i], locks[i+1:]...)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cancel cancels all read lock contexts associated with fn.
|
||||
func (m *ReadLockManager) Cancel(fn string) {
|
||||
m.mutex.RLock()
|
||||
locks := m.readLocks[fn]
|
||||
m.mutex.RUnlock()
|
||||
|
||||
for _, l := range locks {
|
||||
l.Cancel()
|
||||
<-l.Done()
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package gallery
|
|||
|
||||
import (
|
||||
"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/utils"
|
||||
)
|
||||
|
|
@ -12,8 +13,8 @@ func ToBasicJSON(gallery *models.Gallery) (*jsonschema.Gallery, error) {
|
|||
newGalleryJSON := jsonschema.Gallery{
|
||||
Checksum: gallery.Checksum,
|
||||
Zip: gallery.Zip,
|
||||
CreatedAt: models.JSONTime{Time: gallery.CreatedAt.Timestamp},
|
||||
UpdatedAt: models.JSONTime{Time: gallery.UpdatedAt.Timestamp},
|
||||
CreatedAt: json.JSONTime{Time: gallery.CreatedAt.Timestamp},
|
||||
UpdatedAt: json.JSONTime{Time: gallery.UpdatedAt.Timestamp},
|
||||
}
|
||||
|
||||
if gallery.Path.Valid {
|
||||
|
|
@ -21,7 +22,7 @@ func ToBasicJSON(gallery *models.Gallery) (*jsonschema.Gallery, error) {
|
|||
}
|
||||
|
||||
if gallery.FileModTime.Valid {
|
||||
newGalleryJSON.FileModTime = models.JSONTime{Time: gallery.FileModTime.Timestamp}
|
||||
newGalleryJSON.FileModTime = json.JSONTime{Time: gallery.FileModTime.Timestamp}
|
||||
}
|
||||
|
||||
if gallery.Title.Valid {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"errors"
|
||||
|
||||
"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/mocks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -79,10 +80,10 @@ func createFullJSONGallery() *jsonschema.Gallery {
|
|||
Rating: rating,
|
||||
Organized: organized,
|
||||
URL: url,
|
||||
CreatedAt: models.JSONTime{
|
||||
CreatedAt: json.JSONTime{
|
||||
Time: createTime,
|
||||
},
|
||||
UpdatedAt: models.JSONTime{
|
||||
UpdatedAt: json.JSONTime{
|
||||
Time: updateTime,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"time"
|
||||
|
||||
"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/mocks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -65,10 +66,10 @@ func TestImporterPreImport(t *testing.T) {
|
|||
Rating: rating,
|
||||
Organized: organized,
|
||||
URL: url,
|
||||
CreatedAt: models.JSONTime{
|
||||
CreatedAt: json.JSONTime{
|
||||
Time: createdAt,
|
||||
},
|
||||
UpdatedAt: models.JSONTime{
|
||||
UpdatedAt: json.JSONTime{
|
||||
Time: updatedAt,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -24,7 +24,6 @@ type Scanner struct {
|
|||
|
||||
ImageExtensions []string
|
||||
StripFileExtension bool
|
||||
Ctx context.Context
|
||||
CaseSensitiveFs bool
|
||||
TxnManager models.TransactionManager
|
||||
Paths *paths.Paths
|
||||
|
|
@ -39,7 +38,7 @@ func FileScanner(hasher file.Hasher) file.Scanner {
|
|||
}
|
||||
}
|
||||
|
||||
func (scanner *Scanner) ScanExisting(existing file.FileBased, file file.SourceFile) (retGallery *models.Gallery, scanImages bool, err error) {
|
||||
func (scanner *Scanner) ScanExisting(ctx context.Context, existing file.FileBased, file file.SourceFile) (retGallery *models.Gallery, scanImages bool, err error) {
|
||||
scanned, err := scanner.Scanner.ScanExisting(existing, file)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
|
|
@ -76,7 +75,7 @@ func (scanner *Scanner) ScanExisting(existing file.FileBased, file file.SourceFi
|
|||
done := make(chan struct{})
|
||||
scanner.MutexManager.Claim(mutexType, scanned.New.Checksum, done)
|
||||
|
||||
if err := scanner.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error {
|
||||
if err := scanner.TxnManager.WithTxn(ctx, func(r models.Repository) error {
|
||||
// free the mutex once transaction is complete
|
||||
defer close(done)
|
||||
|
||||
|
|
@ -94,13 +93,13 @@ func (scanner *Scanner) ScanExisting(existing file.FileBased, file file.SourceFi
|
|||
return nil, false, err
|
||||
}
|
||||
|
||||
scanner.PluginCache.ExecutePostHooks(scanner.Ctx, retGallery.ID, plugin.GalleryUpdatePost, nil, nil)
|
||||
scanner.PluginCache.ExecutePostHooks(ctx, retGallery.ID, plugin.GalleryUpdatePost, nil, nil)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (scanner *Scanner) ScanNew(file file.SourceFile) (retGallery *models.Gallery, scanImages bool, err error) {
|
||||
func (scanner *Scanner) ScanNew(ctx context.Context, file file.SourceFile) (retGallery *models.Gallery, scanImages bool, err error) {
|
||||
scanned, err := scanner.Scanner.ScanNew(file)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
|
|
@ -117,7 +116,7 @@ func (scanner *Scanner) ScanNew(file file.SourceFile) (retGallery *models.Galler
|
|||
scanner.MutexManager.Claim(mutexType, checksum, done)
|
||||
defer close(done)
|
||||
|
||||
if err := scanner.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error {
|
||||
if err := scanner.TxnManager.WithTxn(ctx, func(r models.Repository) error {
|
||||
qb := r.Gallery()
|
||||
|
||||
g, _ = qb.FindByChecksum(checksum)
|
||||
|
|
@ -183,9 +182,9 @@ func (scanner *Scanner) ScanNew(file file.SourceFile) (retGallery *models.Galler
|
|||
}
|
||||
|
||||
if isNewGallery {
|
||||
scanner.PluginCache.ExecutePostHooks(scanner.Ctx, g.ID, plugin.GalleryCreatePost, nil, nil)
|
||||
scanner.PluginCache.ExecutePostHooks(ctx, g.ID, plugin.GalleryCreatePost, nil, nil)
|
||||
} else if isUpdatedGallery {
|
||||
scanner.PluginCache.ExecutePostHooks(scanner.Ctx, g.ID, plugin.GalleryUpdatePost, nil, nil)
|
||||
scanner.PluginCache.ExecutePostHooks(ctx, g.ID, plugin.GalleryUpdatePost, nil, nil)
|
||||
}
|
||||
|
||||
scanImages = isNewGallery
|
||||
|
|
|
|||
103
pkg/hash/videophash/phash.go
Normal file
103
pkg/hash/videophash/phash.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
package videophash
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
"math"
|
||||
|
||||
"github.com/corona10/goimagehash"
|
||||
"github.com/disintegration/imaging"
|
||||
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
"github.com/stashapp/stash/pkg/ffmpeg/transcoder"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
)
|
||||
|
||||
const (
|
||||
screenshotSize = 160
|
||||
columns = 5
|
||||
rows = 5
|
||||
)
|
||||
|
||||
func Generate(encoder ffmpeg.FFMpeg, videoFile *ffmpeg.VideoFile) (*uint64, error) {
|
||||
sprite, err := generateSprite(encoder, videoFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
hash, err := goimagehash.PerceptionHash(sprite)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("computing phash from sprite: %w", err)
|
||||
}
|
||||
hashValue := hash.GetHash()
|
||||
return &hashValue, nil
|
||||
}
|
||||
|
||||
func generateSpriteScreenshot(encoder ffmpeg.FFMpeg, input string, t float64) (image.Image, error) {
|
||||
options := transcoder.ScreenshotOptions{
|
||||
Width: screenshotSize,
|
||||
OutputPath: "-",
|
||||
OutputType: transcoder.ScreenshotOutputTypeBMP,
|
||||
}
|
||||
|
||||
args := transcoder.ScreenshotTime(input, t, options)
|
||||
data, err := encoder.GenerateOutput(context.Background(), args, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader := bytes.NewReader(data)
|
||||
|
||||
img, _, err := image.Decode(reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decoding image: %w", err)
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
func combineImages(images []image.Image) image.Image {
|
||||
width := images[0].Bounds().Size().X
|
||||
height := images[0].Bounds().Size().Y
|
||||
canvasWidth := width * columns
|
||||
canvasHeight := height * rows
|
||||
montage := imaging.New(canvasWidth, canvasHeight, color.NRGBA{})
|
||||
for index := 0; index < len(images); index++ {
|
||||
x := width * (index % columns)
|
||||
y := height * int(math.Floor(float64(index)/float64(rows)))
|
||||
img := images[index]
|
||||
montage = imaging.Paste(montage, img, image.Pt(x, y))
|
||||
}
|
||||
|
||||
return montage
|
||||
}
|
||||
|
||||
func generateSprite(encoder ffmpeg.FFMpeg, videoFile *ffmpeg.VideoFile) (image.Image, error) {
|
||||
logger.Infof("[generator] generating phash sprite for %s", videoFile.Path)
|
||||
|
||||
// Generate sprite image offset by 5% on each end to avoid intro/outros
|
||||
chunkCount := columns * rows
|
||||
offset := 0.05 * videoFile.Duration
|
||||
stepSize := (0.9 * videoFile.Duration) / float64(chunkCount)
|
||||
var images []image.Image
|
||||
for i := 0; i < chunkCount; i++ {
|
||||
time := offset + (float64(i) * stepSize)
|
||||
|
||||
img, err := generateSpriteScreenshot(encoder, videoFile.Path, time)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating sprite screenshot: %w", 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", videoFile.Path)
|
||||
}
|
||||
|
||||
return combineImages(images), nil
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package image
|
|||
|
||||
import (
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/json"
|
||||
"github.com/stashapp/stash/pkg/models/jsonschema"
|
||||
)
|
||||
|
||||
|
|
@ -11,8 +12,8 @@ import (
|
|||
func ToBasicJSON(image *models.Image) *jsonschema.Image {
|
||||
newImageJSON := jsonschema.Image{
|
||||
Checksum: image.Checksum,
|
||||
CreatedAt: models.JSONTime{Time: image.CreatedAt.Timestamp},
|
||||
UpdatedAt: models.JSONTime{Time: image.UpdatedAt.Timestamp},
|
||||
CreatedAt: json.JSONTime{Time: image.CreatedAt.Timestamp},
|
||||
UpdatedAt: json.JSONTime{Time: image.UpdatedAt.Timestamp},
|
||||
}
|
||||
|
||||
if image.Title.Valid {
|
||||
|
|
@ -35,7 +36,7 @@ func getImageFileJSON(image *models.Image) *jsonschema.ImageFile {
|
|||
ret := &jsonschema.ImageFile{}
|
||||
|
||||
if image.FileModTime.Valid {
|
||||
ret.ModTime = models.JSONTime{Time: image.FileModTime.Timestamp}
|
||||
ret.ModTime = json.JSONTime{Time: image.FileModTime.Timestamp}
|
||||
}
|
||||
|
||||
if image.Size.Valid {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"errors"
|
||||
|
||||
"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/mocks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
|
@ -90,10 +91,10 @@ func createFullJSONImage() *jsonschema.Image {
|
|||
Size: size,
|
||||
Width: width,
|
||||
},
|
||||
CreatedAt: models.JSONTime{
|
||||
CreatedAt: json.JSONTime{
|
||||
Time: createTime,
|
||||
},
|
||||
UpdatedAt: models.JSONTime{
|
||||
UpdatedAt: json.JSONTime{
|
||||
Time: updateTime,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,6 @@ type Scanner struct {
|
|||
|
||||
StripFileExtension bool
|
||||
|
||||
Ctx context.Context
|
||||
CaseSensitiveFs bool
|
||||
TxnManager models.TransactionManager
|
||||
Paths *paths.Paths
|
||||
|
|
@ -37,7 +36,7 @@ func FileScanner(hasher file.Hasher) file.Scanner {
|
|||
}
|
||||
}
|
||||
|
||||
func (scanner *Scanner) ScanExisting(existing file.FileBased, file file.SourceFile) (retImage *models.Image, err error) {
|
||||
func (scanner *Scanner) ScanExisting(ctx context.Context, existing file.FileBased, file file.SourceFile) (retImage *models.Image, err error) {
|
||||
scanned, err := scanner.Scanner.ScanExisting(existing, file)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -72,7 +71,7 @@ func (scanner *Scanner) ScanExisting(existing file.FileBased, file file.SourceFi
|
|||
done := make(chan struct{})
|
||||
scanner.MutexManager.Claim(mutexType, scanned.New.Checksum, done)
|
||||
|
||||
if err := scanner.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error {
|
||||
if err := scanner.TxnManager.WithTxn(ctx, func(r models.Repository) error {
|
||||
// free the mutex once transaction is complete
|
||||
defer close(done)
|
||||
var err error
|
||||
|
|
@ -100,13 +99,13 @@ func (scanner *Scanner) ScanExisting(existing file.FileBased, file file.SourceFi
|
|||
}
|
||||
}
|
||||
|
||||
scanner.PluginCache.ExecutePostHooks(scanner.Ctx, retImage.ID, plugin.ImageUpdatePost, nil, nil)
|
||||
scanner.PluginCache.ExecutePostHooks(ctx, retImage.ID, plugin.ImageUpdatePost, nil, nil)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (scanner *Scanner) ScanNew(f file.SourceFile) (retImage *models.Image, err error) {
|
||||
func (scanner *Scanner) ScanNew(ctx context.Context, f file.SourceFile) (retImage *models.Image, err error) {
|
||||
scanned, err := scanner.Scanner.ScanNew(f)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -122,7 +121,7 @@ func (scanner *Scanner) ScanNew(f file.SourceFile) (retImage *models.Image, err
|
|||
|
||||
// check for image by checksum
|
||||
var existingImage *models.Image
|
||||
if err := scanner.TxnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
|
||||
if err := scanner.TxnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
|
||||
var err error
|
||||
existingImage, err = r.Image().FindByChecksum(checksum)
|
||||
return err
|
||||
|
|
@ -152,14 +151,14 @@ func (scanner *Scanner) ScanNew(f file.SourceFile) (retImage *models.Image, err
|
|||
Path: &path,
|
||||
}
|
||||
|
||||
if err := scanner.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error {
|
||||
if err := scanner.TxnManager.WithTxn(ctx, func(r models.Repository) error {
|
||||
retImage, err = r.Image().Update(imagePartial)
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
scanner.PluginCache.ExecutePostHooks(scanner.Ctx, existingImage.ID, plugin.ImageUpdatePost, nil, nil)
|
||||
scanner.PluginCache.ExecutePostHooks(ctx, existingImage.ID, plugin.ImageUpdatePost, nil, nil)
|
||||
}
|
||||
} else {
|
||||
logger.Infof("%s doesn't exist. Creating new item...", pathDisplayName)
|
||||
|
|
@ -177,7 +176,7 @@ func (scanner *Scanner) ScanNew(f file.SourceFile) (retImage *models.Image, err
|
|||
return nil, err
|
||||
}
|
||||
|
||||
if err := scanner.TxnManager.WithTxn(context.TODO(), func(r models.Repository) error {
|
||||
if err := scanner.TxnManager.WithTxn(ctx, func(r models.Repository) error {
|
||||
var err error
|
||||
retImage, err = r.Image().Create(newImage)
|
||||
return err
|
||||
|
|
@ -185,7 +184,7 @@ func (scanner *Scanner) ScanNew(f file.SourceFile) (retImage *models.Image, err
|
|||
return nil, err
|
||||
}
|
||||
|
||||
scanner.PluginCache.ExecutePostHooks(scanner.Ctx, retImage.ID, plugin.ImageCreatePost, nil, nil)
|
||||
scanner.PluginCache.ExecutePostHooks(ctx, retImage.ID, plugin.ImageCreatePost, nil, nil)
|
||||
}
|
||||
|
||||
return
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package image
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
|
|
@ -10,19 +11,24 @@ import (
|
|||
"sync"
|
||||
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
"github.com/stashapp/stash/pkg/ffmpeg/transcoder"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
const ffmpegImageQuality = 5
|
||||
|
||||
var vipsPath string
|
||||
var once sync.Once
|
||||
|
||||
var (
|
||||
ErrUnsupportedImageFormat = errors.New("unsupported image format")
|
||||
|
||||
// ErrNotSupportedForThumbnail is returned if the image format is not supported for thumbnail generation
|
||||
ErrNotSupportedForThumbnail = errors.New("unsupported image format for thumbnail")
|
||||
)
|
||||
|
||||
type ThumbnailEncoder struct {
|
||||
ffmpeg ffmpeg.Encoder
|
||||
ffmpeg ffmpeg.FFMpeg
|
||||
vips *vipsEncoder
|
||||
}
|
||||
|
||||
|
|
@ -33,7 +39,7 @@ func GetVipsPath() string {
|
|||
return vipsPath
|
||||
}
|
||||
|
||||
func NewThumbnailEncoder(ffmpegEncoder ffmpeg.Encoder) ThumbnailEncoder {
|
||||
func NewThumbnailEncoder(ffmpegEncoder ffmpeg.FFMpeg) ThumbnailEncoder {
|
||||
ret := ThumbnailEncoder{
|
||||
ffmpeg: ffmpegEncoder,
|
||||
}
|
||||
|
|
@ -86,6 +92,30 @@ func (e *ThumbnailEncoder) GetThumbnail(img *models.Image, maxSize int) ([]byte,
|
|||
if e.vips != nil && runtime.GOOS != "windows" {
|
||||
return e.vips.ImageThumbnail(buf, maxSize)
|
||||
} else {
|
||||
return e.ffmpeg.ImageThumbnail(buf, format, maxSize, img.Path)
|
||||
return e.ffmpegImageThumbnail(buf, format, maxSize)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *ThumbnailEncoder) ffmpegImageThumbnail(image *bytes.Buffer, format string, maxSize int) ([]byte, error) {
|
||||
var ffmpegFormat ffmpeg.ImageFormat
|
||||
|
||||
switch format {
|
||||
case "jpeg":
|
||||
ffmpegFormat = ffmpeg.ImageFormatJpeg
|
||||
case "png":
|
||||
ffmpegFormat = ffmpeg.ImageFormatPng
|
||||
case "webp":
|
||||
ffmpegFormat = ffmpeg.ImageFormatWebp
|
||||
default:
|
||||
return nil, ErrUnsupportedImageFormat
|
||||
}
|
||||
|
||||
args := transcoder.ImageThumbnail("-", transcoder.ImageThumbnailOptions{
|
||||
InputFormat: ffmpegFormat,
|
||||
OutputPath: "-",
|
||||
MaxDimensions: maxSize,
|
||||
Quality: ffmpegImageQuality,
|
||||
})
|
||||
|
||||
return e.ffmpeg.GenerateOutput(context.TODO(), args, image)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ func (m *Manager) Start(ctx context.Context, description string, e JobExec) int
|
|||
|
||||
m.queue = append(m.queue, &j)
|
||||
|
||||
m.dispatch(&j)
|
||||
m.dispatch(ctx, &j)
|
||||
|
||||
return j.ID
|
||||
}
|
||||
|
|
@ -145,7 +145,7 @@ func (m *Manager) dispatcher() {
|
|||
}
|
||||
}
|
||||
|
||||
done := m.dispatch(j)
|
||||
done := m.dispatch(j.outerCtx, j)
|
||||
|
||||
// unlock the mutex and wait for the job to finish
|
||||
m.mutex.Unlock()
|
||||
|
|
@ -169,15 +169,13 @@ func (m *Manager) newProgress(j *Job) *Progress {
|
|||
}
|
||||
}
|
||||
|
||||
func (m *Manager) dispatch(j *Job) (done chan struct{}) {
|
||||
func (m *Manager) dispatch(ctx context.Context, j *Job) (done chan struct{}) {
|
||||
// assumes lock held
|
||||
t := time.Now()
|
||||
j.StartTime = &t
|
||||
j.Status = StatusRunning
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(valueOnlyContext{
|
||||
j.outerCtx,
|
||||
})
|
||||
ctx, cancelFunc := context.WithCancel(valueOnlyContext{ctx})
|
||||
j.cancelFunc = cancelFunc
|
||||
|
||||
done = make(chan struct{})
|
||||
|
|
|
|||
|
|
@ -5,19 +5,19 @@ type ResolutionRange struct {
|
|||
}
|
||||
|
||||
var resolutionRanges = map[ResolutionEnum]ResolutionRange{
|
||||
ResolutionEnum("VERY_LOW"): {144, 239},
|
||||
ResolutionEnum("LOW"): {240, 359},
|
||||
ResolutionEnum("R360P"): {360, 479},
|
||||
ResolutionEnum("STANDARD"): {480, 539},
|
||||
ResolutionEnum("WEB_HD"): {540, 719},
|
||||
ResolutionEnum("STANDARD_HD"): {720, 1079},
|
||||
ResolutionEnum("FULL_HD"): {1080, 1439},
|
||||
ResolutionEnum("QUAD_HD"): {1440, 1919},
|
||||
ResolutionEnum("VR_HD"): {1920, 2159},
|
||||
ResolutionEnum("FOUR_K"): {2160, 2879},
|
||||
ResolutionEnum("FIVE_K"): {2880, 3383},
|
||||
ResolutionEnum("SIX_K"): {3384, 4319},
|
||||
ResolutionEnum("EIGHT_K"): {4320, 8639},
|
||||
ResolutionEnumVeryLow: {144, 239},
|
||||
ResolutionEnumLow: {240, 359},
|
||||
ResolutionEnumR360p: {360, 479},
|
||||
ResolutionEnumStandard: {480, 539},
|
||||
ResolutionEnumWebHd: {540, 719},
|
||||
ResolutionEnumStandardHd: {720, 1079},
|
||||
ResolutionEnumFullHd: {1080, 1439},
|
||||
ResolutionEnumQuadHd: {1440, 1919},
|
||||
ResolutionEnumVrHd: {1920, 2159},
|
||||
ResolutionEnumFourK: {2160, 2879},
|
||||
ResolutionEnumFiveK: {2880, 3383},
|
||||
ResolutionEnumSixK: {3384, 4319},
|
||||
ResolutionEnumEightK: {4320, 8639},
|
||||
}
|
||||
|
||||
// GetMaxResolution returns the maximum width or height that media must be
|
||||
|
|
@ -28,6 +28,19 @@ func (r *ResolutionEnum) GetMaxResolution() int {
|
|||
|
||||
// GetMinResolution returns the minimum width or height that media must be
|
||||
// to qualify as this resolution.
|
||||
func (r *ResolutionEnum) GetMinResolution() int {
|
||||
return resolutionRanges[*r].min
|
||||
func (r ResolutionEnum) GetMinResolution() int {
|
||||
return resolutionRanges[r].min
|
||||
}
|
||||
|
||||
var streamingResolutionMax = map[StreamingResolutionEnum]int{
|
||||
StreamingResolutionEnumLow: resolutionRanges[ResolutionEnumLow].min,
|
||||
StreamingResolutionEnumStandard: resolutionRanges[ResolutionEnumStandard].min,
|
||||
StreamingResolutionEnumStandardHd: resolutionRanges[ResolutionEnumStandardHd].min,
|
||||
StreamingResolutionEnumFullHd: resolutionRanges[ResolutionEnumFullHd].min,
|
||||
StreamingResolutionEnumFourK: resolutionRanges[ResolutionEnumFourK].min,
|
||||
StreamingResolutionEnumOriginal: 0,
|
||||
}
|
||||
|
||||
func (r StreamingResolutionEnum) GetMaxResolution() int {
|
||||
return streamingResolutionMax[r]
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue