mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Improve image scanning performance and thumbnail generation (#1655)
* Improve image scanning performance and thumbnail generation * Add vips-tools to build image * Add option to write generated thumbnails to disk * Fallback to image if thumbnail generation fails Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
parent
1e8a8efe3e
commit
9cb1eccadb
16 changed files with 234 additions and 35 deletions
|
|
@ -36,7 +36,7 @@ RUN make build
|
||||||
|
|
||||||
# Final Runnable Image
|
# Final Runnable Image
|
||||||
FROM alpine:latest
|
FROM alpine:latest
|
||||||
RUN apk add --no-cache ca-certificates
|
RUN apk add --no-cache ca-certificates vips-tools
|
||||||
COPY --from=backend /stash/stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/
|
COPY --from=backend /stash/stash /ffmpeg/ffmpeg /ffmpeg/ffprobe /usr/bin/
|
||||||
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
|
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
|
||||||
EXPOSE 9999
|
EXPOSE 9999
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,7 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-pi; \
|
||||||
ENV DEBIAN_FRONTEND=noninteractive
|
ENV DEBIAN_FRONTEND=noninteractive
|
||||||
RUN apt update && apt install -y python3 python-is-python3 python3-requests python3-requests-toolbelt python3-lxml python3-pip && pip3 install cloudscraper
|
RUN apt update && apt install -y python3 python-is-python3 python3-requests python3-requests-toolbelt python3-lxml python3-pip && pip3 install cloudscraper
|
||||||
FROM ubuntu:20.04 as app
|
FROM ubuntu:20.04 as app
|
||||||
RUN apt update && apt install -y python3 python-is-python3 python3-requests python3-requests-toolbelt python3-lxml python3-mechanicalsoup ffmpeg && rm -rf /var/lib/apt/lists/*
|
run apt update && apt install -y python3 python-is-python3 python3-requests python3-requests-toolbelt python3-lxml python3-mechanicalsoup ffmpeg libvips-tools && rm -rf /var/lib/apt/lists/*
|
||||||
COPY --from=prep /stash /usr/bin/
|
COPY --from=prep /stash /usr/bin/
|
||||||
COPY --from=prep /usr/local/lib/python3.8/dist-packages /usr/local/lib/python3.8/dist-packages
|
COPY --from=prep /usr/local/lib/python3.8/dist-packages /usr/local/lib/python3.8/dist-packages
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
|
||||||
previewPreset
|
previewPreset
|
||||||
maxTranscodeSize
|
maxTranscodeSize
|
||||||
maxStreamingTranscodeSize
|
maxStreamingTranscodeSize
|
||||||
|
writeImageThumbnails
|
||||||
apiKey
|
apiKey
|
||||||
username
|
username
|
||||||
password
|
password
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,8 @@ input ConfigGeneralInput {
|
||||||
maxTranscodeSize: StreamingResolutionEnum
|
maxTranscodeSize: StreamingResolutionEnum
|
||||||
"""Max streaming transcode size"""
|
"""Max streaming transcode size"""
|
||||||
maxStreamingTranscodeSize: StreamingResolutionEnum
|
maxStreamingTranscodeSize: StreamingResolutionEnum
|
||||||
|
"""Write image thumbnails to disk when generating on the fly"""
|
||||||
|
writeImageThumbnails: Boolean
|
||||||
"""Username"""
|
"""Username"""
|
||||||
username: String
|
username: String
|
||||||
"""Password"""
|
"""Password"""
|
||||||
|
|
@ -136,6 +138,8 @@ type ConfigGeneralResult {
|
||||||
maxTranscodeSize: StreamingResolutionEnum
|
maxTranscodeSize: StreamingResolutionEnum
|
||||||
"""Max streaming transcode size"""
|
"""Max streaming transcode size"""
|
||||||
maxStreamingTranscodeSize: StreamingResolutionEnum
|
maxStreamingTranscodeSize: StreamingResolutionEnum
|
||||||
|
"""Write image thumbnails to disk when generating on the fly"""
|
||||||
|
writeImageThumbnails: Boolean!
|
||||||
"""API Key"""
|
"""API Key"""
|
||||||
apiKey: String!
|
apiKey: String!
|
||||||
"""Username"""
|
"""Username"""
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ input ScanMetadataInput {
|
||||||
scanGenerateSprites: Boolean
|
scanGenerateSprites: Boolean
|
||||||
"""Generate phashes during scan"""
|
"""Generate phashes during scan"""
|
||||||
scanGeneratePhashes: Boolean
|
scanGeneratePhashes: Boolean
|
||||||
|
"""Generate image thumbnails during scan"""
|
||||||
|
scanGenerateThumbnails: Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
input CleanMetadataInput {
|
input CleanMetadataInput {
|
||||||
|
|
|
||||||
|
|
@ -115,6 +115,10 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
|
||||||
c.Set(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String())
|
c.Set(config.MaxStreamingTranscodeSize, input.MaxStreamingTranscodeSize.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if input.WriteImageThumbnails != nil {
|
||||||
|
c.Set(config.WriteImageThumbnails, *input.WriteImageThumbnails)
|
||||||
|
}
|
||||||
|
|
||||||
if input.Username != nil {
|
if input.Username != nil {
|
||||||
c.Set(config.Username, input.Username)
|
c.Set(config.Username, input.Username)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
|
||||||
PreviewPreset: config.GetPreviewPreset(),
|
PreviewPreset: config.GetPreviewPreset(),
|
||||||
MaxTranscodeSize: &maxTranscodeSize,
|
MaxTranscodeSize: &maxTranscodeSize,
|
||||||
MaxStreamingTranscodeSize: &maxStreamingTranscodeSize,
|
MaxStreamingTranscodeSize: &maxStreamingTranscodeSize,
|
||||||
|
WriteImageThumbnails: config.IsWriteImageThumbnails(),
|
||||||
APIKey: config.GetAPIKey(),
|
APIKey: config.GetAPIKey(),
|
||||||
Username: config.GetUsername(),
|
Username: config.GetUsername(),
|
||||||
Password: config.GetPasswordHash(),
|
Password: config.GetPasswordHash(),
|
||||||
|
|
|
||||||
|
|
@ -33,15 +33,33 @@ func (rs imageRoutes) Routes() chi.Router {
|
||||||
// region Handlers
|
// region Handlers
|
||||||
|
|
||||||
func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
func (rs imageRoutes) Thumbnail(w http.ResponseWriter, r *http.Request) {
|
||||||
image := r.Context().Value(imageKey).(*models.Image)
|
img := r.Context().Value(imageKey).(*models.Image)
|
||||||
filepath := manager.GetInstance().Paths.Generated.GetThumbnailPath(image.Checksum, models.DefaultGthumbWidth)
|
filepath := manager.GetInstance().Paths.Generated.GetThumbnailPath(img.Checksum, models.DefaultGthumbWidth)
|
||||||
|
|
||||||
// if the thumbnail doesn't exist, fall back to the original file
|
w.Header().Add("Cache-Control", "max-age=604800000")
|
||||||
|
|
||||||
|
// if the thumbnail doesn't exist, encode on the fly
|
||||||
exists, _ := utils.FileExists(filepath)
|
exists, _ := utils.FileExists(filepath)
|
||||||
if exists {
|
if exists {
|
||||||
http.ServeFile(w, r, filepath)
|
http.ServeFile(w, r, filepath)
|
||||||
} else {
|
} else {
|
||||||
rs.Image(w, r)
|
encoder := image.NewThumbnailEncoder(manager.GetInstance().FFMPEGPath)
|
||||||
|
data, err := encoder.GetThumbnail(img, models.DefaultGthumbWidth)
|
||||||
|
if err != nil {
|
||||||
|
logger.Errorf("error generating thumbnail for image: %s", err.Error())
|
||||||
|
|
||||||
|
// backwards compatibility - fallback to original image instead
|
||||||
|
rs.Image(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// write the generated thumbnail to disk if enabled
|
||||||
|
if manager.GetInstance().Config.IsWriteImageThumbnails() {
|
||||||
|
if err := utils.WriteFile(filepath, data); err != nil {
|
||||||
|
logger.Errorf("error writing thumbnail for image %s: %s", img.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.Write(data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,18 @@ func GetSourceImage(i *models.Image) (image.Image, error) {
|
||||||
return srcImage, nil
|
return srcImage, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func DecodeSourceImage(i *models.Image) (*image.Config, *string, error) {
|
||||||
|
f, err := openSourceImage(i.Path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
config, format, err := image.DecodeConfig(f)
|
||||||
|
|
||||||
|
return &config, &format, err
|
||||||
|
}
|
||||||
|
|
||||||
func CalculateMD5(path string) (string, error) {
|
func CalculateMD5(path string) (string, error) {
|
||||||
f, err := openSourceImage(path)
|
f, err := openSourceImage(path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -154,15 +166,15 @@ func SetFileDetails(i *models.Image) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
src, _ := GetSourceImage(i)
|
config, _, err := DecodeSourceImage(i)
|
||||||
|
|
||||||
if src != nil {
|
if err == nil {
|
||||||
i.Width = sql.NullInt64{
|
i.Width = sql.NullInt64{
|
||||||
Int64: int64(src.Bounds().Max.X),
|
Int64: int64(config.Width),
|
||||||
Valid: true,
|
Valid: true,
|
||||||
}
|
}
|
||||||
i.Height = sql.NullInt64{
|
i.Height = sql.NullInt64{
|
||||||
Int64: int64(src.Bounds().Max.Y),
|
Int64: int64(config.Height),
|
||||||
Valid: true,
|
Valid: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,39 +2,126 @@ package image
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"image"
|
"errors"
|
||||||
"image/jpeg"
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/disintegration/imaging"
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
|
"github.com/stashapp/stash/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ThumbnailNeeded(srcImage image.Image, maxSize int) bool {
|
var vipsPath string
|
||||||
dim := srcImage.Bounds().Max
|
var once sync.Once
|
||||||
w := dim.X
|
|
||||||
h := dim.Y
|
|
||||||
|
|
||||||
return w > maxSize || h > maxSize
|
type ThumbnailEncoder struct {
|
||||||
|
FFMPEGPath string
|
||||||
|
VipsPath string
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetVipsPath() string {
|
||||||
|
once.Do(func() {
|
||||||
|
vipsPath, _ = exec.LookPath("vips")
|
||||||
|
})
|
||||||
|
return vipsPath
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewThumbnailEncoder(ffmpegPath string) ThumbnailEncoder {
|
||||||
|
return ThumbnailEncoder{
|
||||||
|
FFMPEGPath: ffmpegPath,
|
||||||
|
VipsPath: GetVipsPath(),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetThumbnail returns the thumbnail image of the provided image resized to
|
// GetThumbnail returns the thumbnail image of the provided image resized to
|
||||||
// the provided max size. It resizes based on the largest X/Y direction.
|
// the provided max size. It resizes based on the largest X/Y direction.
|
||||||
// It returns nil and an error if an error occurs reading, decoding or encoding
|
// It returns nil and an error if an error occurs reading, decoding or encoding
|
||||||
// the image.
|
// the image.
|
||||||
func GetThumbnail(srcImage image.Image, maxSize int) ([]byte, error) {
|
func (e *ThumbnailEncoder) GetThumbnail(img *models.Image, maxSize int) ([]byte, error) {
|
||||||
var resizedImage image.Image
|
reader, err := openSourceImage(img.Path)
|
||||||
|
|
||||||
// if height is longer then resize by height instead of width
|
|
||||||
dim := srcImage.Bounds().Max
|
|
||||||
if dim.Y > dim.X {
|
|
||||||
resizedImage = imaging.Resize(srcImage, 0, maxSize, imaging.Box)
|
|
||||||
} else {
|
|
||||||
resizedImage = imaging.Resize(srcImage, maxSize, 0, imaging.Box)
|
|
||||||
}
|
|
||||||
|
|
||||||
buf := new(bytes.Buffer)
|
|
||||||
err := jpeg.Encode(buf, resizedImage, nil)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return buf.Bytes(), nil
|
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
buf.ReadFrom(reader)
|
||||||
|
|
||||||
|
_, format, err := DecodeSourceImage(img)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if format != nil && *format == "gif" {
|
||||||
|
return buf.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.VipsPath != "" {
|
||||||
|
return e.getVipsThumbnail(buf, maxSize)
|
||||||
|
} else {
|
||||||
|
return e.getFFMPEGThumbnail(buf, format, maxSize, img.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ThumbnailEncoder) getVipsThumbnail(image *bytes.Buffer, maxSize int) ([]byte, error) {
|
||||||
|
args := []string{
|
||||||
|
"thumbnail_source",
|
||||||
|
"[descriptor=0]",
|
||||||
|
".jpg[Q=70,strip]",
|
||||||
|
fmt.Sprint(maxSize),
|
||||||
|
"--size", "down",
|
||||||
|
}
|
||||||
|
data, err := e.run(e.VipsPath, args, image)
|
||||||
|
|
||||||
|
return []byte(data), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ThumbnailEncoder) getFFMPEGThumbnail(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
|
||||||
|
ffmpegformat := ""
|
||||||
|
if format != nil && *format == "jpeg" {
|
||||||
|
ffmpegformat = "mjpeg"
|
||||||
|
} else if format != nil && *format == "png" {
|
||||||
|
ffmpegformat = "png_pipe"
|
||||||
|
} else if format != nil && *format == "webp" {
|
||||||
|
ffmpegformat = "webp_pipe"
|
||||||
|
} else {
|
||||||
|
return nil, errors.New("unsupported image format")
|
||||||
|
}
|
||||||
|
|
||||||
|
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(e.FFMPEGPath, args, image)
|
||||||
|
|
||||||
|
return []byte(data), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ThumbnailEncoder) run(path string, args []string, stdin *bytes.Buffer) (string, error) {
|
||||||
|
cmd := exec.Command(path, args...)
|
||||||
|
|
||||||
|
var stdout, stderr bytes.Buffer
|
||||||
|
cmd.Stdout = &stdout
|
||||||
|
cmd.Stderr = &stderr
|
||||||
|
cmd.Stdin = stdin
|
||||||
|
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
err := cmd.Wait()
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// error message should be in the stderr stream
|
||||||
|
logger.Errorf("image encoder error when running command <%s>: %s", strings.Join(cmd.Args, " "), stderr.String())
|
||||||
|
return stdout.String(), err
|
||||||
|
}
|
||||||
|
|
||||||
|
return stdout.String(), nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,9 @@ const previewExcludeStartDefault = "0"
|
||||||
const PreviewExcludeEnd = "preview_exclude_end"
|
const PreviewExcludeEnd = "preview_exclude_end"
|
||||||
const previewExcludeEndDefault = "0"
|
const previewExcludeEndDefault = "0"
|
||||||
|
|
||||||
|
const WriteImageThumbnails = "write_image_thumbnails"
|
||||||
|
const writeImageThumbnailsDefault = true
|
||||||
|
|
||||||
const Host = "host"
|
const Host = "host"
|
||||||
const Port = "port"
|
const Port = "port"
|
||||||
const ExternalHost = "external_host"
|
const ExternalHost = "external_host"
|
||||||
|
|
@ -595,6 +598,14 @@ func (i *Instance) GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum
|
||||||
return models.StreamingResolutionEnum(ret)
|
return models.StreamingResolutionEnum(ret)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsWriteImageThumbnails returns true if image thumbnails should be written
|
||||||
|
// to disk after generating on the fly.
|
||||||
|
func (i *Instance) IsWriteImageThumbnails() bool {
|
||||||
|
i.RLock()
|
||||||
|
defer i.RUnlock()
|
||||||
|
return viper.GetBool(WriteImageThumbnails)
|
||||||
|
}
|
||||||
|
|
||||||
func (i *Instance) GetAPIKey() string {
|
func (i *Instance) GetAPIKey() string {
|
||||||
i.RLock()
|
i.RLock()
|
||||||
defer i.RUnlock()
|
defer i.RUnlock()
|
||||||
|
|
@ -968,6 +979,8 @@ func (i *Instance) setDefaultValues() error {
|
||||||
viper.SetDefault(PreviewAudio, previewAudioDefault)
|
viper.SetDefault(PreviewAudio, previewAudioDefault)
|
||||||
viper.SetDefault(SoundOnPreview, false)
|
viper.SetDefault(SoundOnPreview, false)
|
||||||
|
|
||||||
|
viper.SetDefault(WriteImageThumbnails, writeImageThumbnailsDefault)
|
||||||
|
|
||||||
viper.SetDefault(Database, defaultDatabaseFilePath)
|
viper.SetDefault(Database, defaultDatabaseFilePath)
|
||||||
|
|
||||||
// Set generated to the metadata path for backwards compat
|
// Set generated to the metadata path for backwards compat
|
||||||
|
|
|
||||||
|
|
@ -103,6 +103,7 @@ func (j *ScanJob) Execute(ctx context.Context, progress *job.Progress) {
|
||||||
GenerateImagePreview: utils.IsTrue(input.ScanGenerateImagePreviews),
|
GenerateImagePreview: utils.IsTrue(input.ScanGenerateImagePreviews),
|
||||||
GenerateSprite: utils.IsTrue(input.ScanGenerateSprites),
|
GenerateSprite: utils.IsTrue(input.ScanGenerateSprites),
|
||||||
GeneratePhash: utils.IsTrue(input.ScanGeneratePhashes),
|
GeneratePhash: utils.IsTrue(input.ScanGeneratePhashes),
|
||||||
|
GenerateThumbnails: utils.IsTrue(input.ScanGenerateThumbnails),
|
||||||
progress: progress,
|
progress: progress,
|
||||||
CaseSensitiveFs: csFs,
|
CaseSensitiveFs: csFs,
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
|
|
@ -221,6 +222,7 @@ type ScanTask struct {
|
||||||
GeneratePhash bool
|
GeneratePhash bool
|
||||||
GeneratePreview bool
|
GeneratePreview bool
|
||||||
GenerateImagePreview bool
|
GenerateImagePreview bool
|
||||||
|
GenerateThumbnails bool
|
||||||
zipGallery *models.Gallery
|
zipGallery *models.Gallery
|
||||||
progress *job.Progress
|
progress *job.Progress
|
||||||
CaseSensitiveFs bool
|
CaseSensitiveFs bool
|
||||||
|
|
@ -1275,20 +1277,26 @@ func (t *ScanTask) associateImageWithFolderGallery(imageID int, qb models.Galler
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *ScanTask) generateThumbnail(i *models.Image) {
|
func (t *ScanTask) generateThumbnail(i *models.Image) {
|
||||||
|
if !t.GenerateThumbnails {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
thumbPath := GetInstance().Paths.Generated.GetThumbnailPath(i.Checksum, models.DefaultGthumbWidth)
|
thumbPath := GetInstance().Paths.Generated.GetThumbnailPath(i.Checksum, models.DefaultGthumbWidth)
|
||||||
exists, _ := utils.FileExists(thumbPath)
|
exists, _ := utils.FileExists(thumbPath)
|
||||||
if exists {
|
if exists {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
srcImage, err := image.GetSourceImage(i)
|
config, _, err := image.DecodeSourceImage(i)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("error reading image %s: %s", i.Path, err.Error())
|
logger.Errorf("error reading image %s: %s", i.Path, err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if image.ThumbnailNeeded(srcImage, models.DefaultGthumbWidth) {
|
if config.Height > models.DefaultGthumbWidth || config.Width > models.DefaultGthumbWidth {
|
||||||
data, err := image.GetThumbnail(srcImage, models.DefaultGthumbWidth)
|
encoder := image.NewThumbnailEncoder(instance.FFMPEGPath)
|
||||||
|
data, err := encoder.GetThumbnail(i, models.DefaultGthumbWidth)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("error getting thumbnail for image %s: %s", i.Path, err.Error())
|
logger.Errorf("error getting thumbnail for image %s: %s", i.Path, err.Error())
|
||||||
return
|
return
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@
|
||||||
* Support filtering Movies by Performers. ([#1675](https://github.com/stashapp/stash/pull/1675))
|
* Support filtering Movies by Performers. ([#1675](https://github.com/stashapp/stash/pull/1675))
|
||||||
|
|
||||||
### 🎨 Improvements
|
### 🎨 Improvements
|
||||||
|
* Optimised image thumbnail generation (optionally using `libvips`) and made optional. ([#1655](https://github.com/stashapp/stash/pull/1655))
|
||||||
* Added missing image table indexes, resulting in a significant performance improvement. ([#1740](https://github.com/stashapp/stash/pull/1740))
|
* Added missing image table indexes, resulting in a significant performance improvement. ([#1740](https://github.com/stashapp/stash/pull/1740))
|
||||||
* Support scraper script logging to specific log levels. ([#1648](https://github.com/stashapp/stash/pull/1648))
|
* Support scraper script logging to specific log levels. ([#1648](https://github.com/stashapp/stash/pull/1648))
|
||||||
* Added sv-SE language option. ([#1691](https://github.com/stashapp/stash/pull/1691))
|
* Added sv-SE language option. ([#1691](https://github.com/stashapp/stash/pull/1691))
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ export const SettingsConfigurationPanel: React.FC = () => {
|
||||||
const [maxStreamingTranscodeSize, setMaxStreamingTranscodeSize] = useState<
|
const [maxStreamingTranscodeSize, setMaxStreamingTranscodeSize] = useState<
|
||||||
GQL.StreamingResolutionEnum | undefined
|
GQL.StreamingResolutionEnum | undefined
|
||||||
>(undefined);
|
>(undefined);
|
||||||
|
const [writeImageThumbnails, setWriteImageThumbnails] = useState(true);
|
||||||
const [username, setUsername] = useState<string | undefined>(undefined);
|
const [username, setUsername] = useState<string | undefined>(undefined);
|
||||||
const [password, setPassword] = useState<string | undefined>(undefined);
|
const [password, setPassword] = useState<string | undefined>(undefined);
|
||||||
const [maxSessionAge, setMaxSessionAge] = useState<number>(0);
|
const [maxSessionAge, setMaxSessionAge] = useState<number>(0);
|
||||||
|
|
@ -157,6 +158,7 @@ export const SettingsConfigurationPanel: React.FC = () => {
|
||||||
previewPreset: (previewPreset as GQL.PreviewPreset) ?? undefined,
|
previewPreset: (previewPreset as GQL.PreviewPreset) ?? undefined,
|
||||||
maxTranscodeSize,
|
maxTranscodeSize,
|
||||||
maxStreamingTranscodeSize,
|
maxStreamingTranscodeSize,
|
||||||
|
writeImageThumbnails,
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
maxSessionAge,
|
maxSessionAge,
|
||||||
|
|
@ -203,6 +205,7 @@ export const SettingsConfigurationPanel: React.FC = () => {
|
||||||
setMaxStreamingTranscodeSize(
|
setMaxStreamingTranscodeSize(
|
||||||
conf.general.maxStreamingTranscodeSize ?? undefined
|
conf.general.maxStreamingTranscodeSize ?? undefined
|
||||||
);
|
);
|
||||||
|
setWriteImageThumbnails(conf.general.writeImageThumbnails);
|
||||||
setUsername(conf.general.username);
|
setUsername(conf.general.username);
|
||||||
setPassword(conf.general.password);
|
setPassword(conf.general.password);
|
||||||
setMaxSessionAge(conf.general.maxSessionAge);
|
setMaxSessionAge(conf.general.maxSessionAge);
|
||||||
|
|
@ -834,6 +837,30 @@ export const SettingsConfigurationPanel: React.FC = () => {
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
|
<Form.Group>
|
||||||
|
<h4>{intl.formatMessage({ id: "images" })}</h4>
|
||||||
|
|
||||||
|
<Form.Group>
|
||||||
|
<Form.Check
|
||||||
|
id="write-image-thumbnails"
|
||||||
|
checked={writeImageThumbnails}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: "config.ui.images.options.write_image_thumbnails.heading",
|
||||||
|
})}
|
||||||
|
onChange={() => setWriteImageThumbnails(!writeImageThumbnails)}
|
||||||
|
/>
|
||||||
|
<Form.Text className="text-muted">
|
||||||
|
{intl.formatMessage({
|
||||||
|
id: "config.ui.images.options.write_image_thumbnails.description",
|
||||||
|
})}
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<hr />
|
||||||
|
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<h4>{intl.formatMessage({ id: "performers" })}</h4>
|
<h4>{intl.formatMessage({ id: "performers" })}</h4>
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,9 @@ export const SettingsTasksPanel: React.FC = () => {
|
||||||
const [scanGeneratePhashes, setScanGeneratePhashes] = useState<boolean>(
|
const [scanGeneratePhashes, setScanGeneratePhashes] = useState<boolean>(
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
|
const [scanGenerateThumbnails, setScanGenerateThumbnails] = useState<boolean>(
|
||||||
|
false
|
||||||
|
);
|
||||||
const [cleanDryRun, setCleanDryRun] = useState<boolean>(false);
|
const [cleanDryRun, setCleanDryRun] = useState<boolean>(false);
|
||||||
const [
|
const [
|
||||||
scanGenerateImagePreviews,
|
scanGenerateImagePreviews,
|
||||||
|
|
@ -161,6 +164,7 @@ export const SettingsTasksPanel: React.FC = () => {
|
||||||
scanGenerateImagePreviews,
|
scanGenerateImagePreviews,
|
||||||
scanGenerateSprites,
|
scanGenerateSprites,
|
||||||
scanGeneratePhashes,
|
scanGeneratePhashes,
|
||||||
|
scanGenerateThumbnails,
|
||||||
});
|
});
|
||||||
Toast.success({
|
Toast.success({
|
||||||
content: intl.formatMessage(
|
content: intl.formatMessage(
|
||||||
|
|
@ -403,6 +407,14 @@ export const SettingsTasksPanel: React.FC = () => {
|
||||||
})}
|
})}
|
||||||
onChange={() => setScanGeneratePhashes(!scanGeneratePhashes)}
|
onChange={() => setScanGeneratePhashes(!scanGeneratePhashes)}
|
||||||
/>
|
/>
|
||||||
|
<Form.Check
|
||||||
|
id="scan-generate-thumbnails"
|
||||||
|
checked={scanGenerateThumbnails}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: "config.tasks.generate_thumbnails_during_scan",
|
||||||
|
})}
|
||||||
|
onChange={() => setScanGenerateThumbnails(!scanGenerateThumbnails)}
|
||||||
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<Button
|
<Button
|
||||||
|
|
|
||||||
|
|
@ -282,6 +282,7 @@
|
||||||
"generate_previews_during_scan": "Generate image previews during scan (animated WebP previews, only required if Preview Type is set to Animated Image)",
|
"generate_previews_during_scan": "Generate image previews during scan (animated WebP previews, only required if Preview Type is set to Animated Image)",
|
||||||
"generate_sprites_during_scan": "Generate sprites during scan (for the scene scrubber)",
|
"generate_sprites_during_scan": "Generate sprites during scan (for the scene scrubber)",
|
||||||
"generate_video_previews_during_scan": "Generate previews during scan (video previews which play when hovering over a scene)",
|
"generate_video_previews_during_scan": "Generate previews during scan (video previews which play when hovering over a scene)",
|
||||||
|
"generate_thumbnails_during_scan": "Generate thumbnails for images during scan.",
|
||||||
"generated_content": "Generated Content",
|
"generated_content": "Generated Content",
|
||||||
"import_from_exported_json": "Import from exported JSON in the metadata directory. Wipes the existing database.",
|
"import_from_exported_json": "Import from exported JSON in the metadata directory. Wipes the existing database.",
|
||||||
"incremental_import": "Incremental import from a supplied export zip file.",
|
"incremental_import": "Incremental import from a supplied export zip file.",
|
||||||
|
|
@ -327,6 +328,14 @@
|
||||||
"description": "Time offset in milliseconds for interactive scripts playback.",
|
"description": "Time offset in milliseconds for interactive scripts playback.",
|
||||||
"heading": "Funscript Offset (ms)"
|
"heading": "Funscript Offset (ms)"
|
||||||
},
|
},
|
||||||
|
"images": {
|
||||||
|
"options": {
|
||||||
|
"write_image_thumbnails": {
|
||||||
|
"heading": "Write image thumbnails",
|
||||||
|
"description": "Write image thumbnails to disk when generated on-the-fly"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"language": {
|
"language": {
|
||||||
"heading": "Language"
|
"heading": "Language"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue