Merge pull request #2602 from stashapp/develop

Merge 0.15 to master
This commit is contained in:
WithoutPants 2022-05-20 11:35:56 +10:00 committed by GitHub
commit c7b53777dc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
315 changed files with 24309 additions and 11682 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,12 +15,12 @@ NOTE: You may need to run the `go get` commands outside the project directory to
### Windows
1. Download and install [Go for Windows](https://golang.org/dl/)
2. Download and install [MingW](https://sourceforge.net/projects/mingw-w64/)
2. Download and 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
View file

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

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

View file

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

View file

@ -69,6 +69,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
scaleUp
resetZoomOnNav
scrollMode
scrollAttemptsBeforeChange
}
disableDropdownCreate {
performer

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -342,6 +342,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
setBool(config.ImageLightboxScaleUp, options.ScaleUp)
setBool(config.ImageLightboxResetZoomOnNav, options.ResetZoomOnNav)
setString(config.ImageLightboxScrollMode, (*string)(options.ScrollMode))
if options.ScrollAttemptsBeforeChange != nil {
c.Set(config.ImageLightboxScrollAttemptsBeforeChange, *options.ScrollAttemptsBeforeChange)
}
}
if input.CSS != nil {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -145,12 +145,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
}

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

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

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

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

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

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

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

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View 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
}

View 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
}

View 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
}

View 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
}

View file

@ -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
View 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()
}
}

View file

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

View file

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

View file

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

View file

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

View 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
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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{})

View file

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