Merge pull request #1697 from stashapp/develop

Merge develop to master for 0.9
This commit is contained in:
WithoutPants 2021-09-06 12:47:07 +10:00 committed by GitHub
commit ab10cf8251
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
681 changed files with 32177 additions and 32315 deletions

View file

@ -182,4 +182,4 @@ jobs:
docker buildx create --name builder --use
docker buildx inspect --bootstrap
docker buildx ls
bash ./docker/ci/x86_64/docker_push.sh latest
bash ./docker/ci/x86_64/docker_push.sh latest "${{ github.event.release.tag_name }}"

View file

@ -66,7 +66,7 @@ Stash can run over HTTPS with some additional work. First you must generate a S
This command would need customizing for your environment. [This link](https://stackoverflow.com/questions/10175812/how-to-create-a-self-signed-certificate-with-openssl) might be useful.
Once you have a certificate and key file name them `stash.crt` and `stash.key` and place them in the `~/.stash` directory. Stash detects these and starts up using HTTPS rather than HTTP.
Once you have a certificate and key file name them `stash.crt` and `stash.key` and place them in the same directory as the `config.yml` file, or the `~/.stash` directory. Stash detects these and starts up using HTTPS rather than HTTP.
# Customization

View file

@ -11,7 +11,7 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-pi; \
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
FROM ubuntu:20.04 as app
run apt update && apt install -y python3 python-is-python3 python3-requests python3-requests-toolbelt python3-lxml 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 && rm -rf /var/lib/apt/lists/*
COPY --from=prep /stash /usr/bin/
COPY --from=prep /usr/local/lib/python3.8/dist-packages /usr/local/lib/python3.8/dist-packages

View file

@ -1,8 +1,14 @@
#!/bin/bash
DOCKER_TAG=$1
DOCKER_TAGS=""
for TAG in "$@"
do
DOCKER_TAGS="$DOCKER_TAGS -t stashapp/stash:$TAG"
done
echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
# must build the image from dist directory
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push --output type=image,name=stashapp/stash:$DOCKER_TAG,push=true -f docker/ci/x86_64/Dockerfile dist/
docker buildx build --platform linux/amd64,linux/arm64,linux/arm/v7 --push $DOCKER_TAGS -f docker/ci/x86_64/Dockerfile dist/

18
go.mod
View file

@ -12,7 +12,8 @@ require (
github.com/disintegration/imaging v1.6.0
github.com/fvbommel/sortorder v1.0.2
github.com/go-chi/chi v4.0.2+incompatible
github.com/gobuffalo/packr/v2 v2.0.2
github.com/gobuffalo/logger v1.0.4 // indirect
github.com/gobuffalo/packr/v2 v2.8.1
github.com/golang-migrate/migrate/v4 v4.3.1
github.com/gorilla/securecookie v1.1.1
github.com/gorilla/sessions v1.2.0
@ -21,24 +22,27 @@ require (
github.com/jinzhu/copier v0.0.0-20190924061706-b57f9002281a
github.com/jmoiron/sqlx v1.2.0
github.com/json-iterator/go v1.1.9
github.com/knq/sysutil v0.0.0-20191005231841-15668db23d08 // indirect
github.com/karrick/godirwalk v1.16.1 // indirect
github.com/mattn/go-sqlite3 v1.14.6
github.com/natefinch/pie v0.0.0-20170715172608-9a0d72014007
github.com/remeh/sizedwaitgroup v1.0.0
github.com/robertkrimen/otto v0.0.0-20200922221731-ef014fd054ac
github.com/rs/cors v1.6.0
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f
github.com/sirupsen/logrus v1.4.2
github.com/sirupsen/logrus v1.8.1
github.com/spf13/afero v1.2.0 // indirect
github.com/spf13/pflag v1.0.3
github.com/spf13/viper v1.7.0
github.com/stretchr/testify v1.5.1
github.com/tidwall/gjson v1.6.0
github.com/tidwall/gjson v1.8.1
github.com/tidwall/pretty v1.2.0 // indirect
github.com/vektah/gqlparser/v2 v2.0.1
github.com/vektra/mockery/v2 v2.2.1
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97
golang.org/x/image v0.0.0-20210220032944-ac19c3e999fb
golang.org/x/net v0.0.0-20200822124328-c89045814202
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b // indirect
golang.org/x/tools v0.0.0-20200915031644-64986481280e // indirect
gopkg.in/sourcemap.v1 v1.0.5 // indirect
gopkg.in/yaml.v2 v2.3.0

545
go.sum

File diff suppressed because it is too large Load diff

View file

@ -32,6 +32,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
galleryExtensions
excludes
imageExcludes
customPerformerImageLocation
scraperUserAgent
scraperCertCheck
scraperCDPPath
@ -55,6 +56,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
language
slideshowDelay
handyKey
funscriptOffset
}
fragment ConfigDLNAData on ConfigDLNAResult {
@ -64,6 +66,13 @@ fragment ConfigDLNAData on ConfigDLNAResult {
interfaces
}
fragment ConfigScrapingData on ConfigScrapingResult {
scraperUserAgent
scraperCertCheck
scraperCDPPath
excludeTagPatterns
}
fragment ConfigData on ConfigResult {
general {
...ConfigGeneralData
@ -74,4 +83,7 @@ fragment ConfigData on ConfigResult {
dlna {
...ConfigDLNAData
}
scraping {
...ConfigScrapingData
}
}

View file

@ -23,6 +23,7 @@ fragment ScrapedPerformerData on ScrapedPerformer {
death_date
hair_color
weight
remote_site_id
}
fragment ScrapedScenePerformerData on ScrapedScenePerformer {

View file

@ -5,23 +5,14 @@ fragment StudioData on Studio {
url
parent_studio {
id
checksum
name
url
image_path
scene_count
image_count
gallery_count
}
child_studios {
id
checksum
name
url
image_path
scene_count
image_count
gallery_count
}
image_path
scene_count

View file

@ -24,6 +24,12 @@ mutation ConfigureDLNA($input: ConfigDLNAInput!) {
}
}
mutation ConfigureScraping($input: ConfigScrapingInput!) {
configureScraping(input: $input) {
...ConfigScrapingData
}
}
mutation GenerateAPIKey($input: GenerateAPIKeyInput!) {
generateAPIKey(input: $input)
}

View file

@ -41,6 +41,7 @@ query Stats {
stats {
scene_count,
scenes_size,
scenes_duration,
image_count,
images_size,
gallery_count,

View file

@ -212,6 +212,7 @@ type Mutation {
configureGeneral(input: ConfigGeneralInput!): ConfigGeneralResult!
configureInterface(input: ConfigInterfaceInput!): ConfigInterfaceResult!
configureDLNA(input: ConfigDLNAInput!): ConfigDLNAResult!
configureScraping(input: ConfigScrapingInput!): ConfigScrapingResult!
"""Generate and set (or clear) API key"""
generateAPIKey(input: GenerateAPIKeyInput!): String!

View file

@ -89,12 +89,14 @@ input ConfigGeneralInput {
excludes: [String!]
"""Array of file regexp to exclude from Image Scans"""
imageExcludes: [String!]
"""Custom Performer Image Location"""
customPerformerImageLocation: String
"""Scraper user agent string"""
scraperUserAgent: String
scraperUserAgent: String @deprecated(reason: "use mutation ConfigureScraping(input: ConfigScrapingInput) instead")
"""Scraper CDP path. Path to chrome executable or remote address"""
scraperCDPPath: String
scraperCDPPath: String @deprecated(reason: "use mutation ConfigureScraping(input: ConfigScrapingInput) instead")
"""Whether the scraper should check for invalid certificates"""
scraperCertCheck: Boolean!
scraperCertCheck: Boolean @deprecated(reason: "use mutation ConfigureScraping(input: ConfigScrapingInput) instead")
"""Stash-box instances used for tagging"""
stashBoxes: [StashBoxInput!]!
}
@ -162,12 +164,14 @@ type ConfigGeneralResult {
excludes: [String!]!
"""Array of file regexp to exclude from Image Scans"""
imageExcludes: [String!]!
"""Custom Performer Image Location"""
customPerformerImageLocation: String
"""Scraper user agent string"""
scraperUserAgent: String
scraperUserAgent: String @deprecated(reason: "use ConfigResult.scraping instead")
"""Scraper CDP path. Path to chrome executable or remote address"""
scraperCDPPath: String
scraperCDPPath: String @deprecated(reason: "use ConfigResult.scraping instead")
"""Whether the scraper should check for invalid certificates"""
scraperCertCheck: Boolean!
scraperCertCheck: Boolean! @deprecated(reason: "use ConfigResult.scraping instead")
"""Stash-box instances used for tagging"""
stashBoxes: [StashBox!]!
}
@ -196,6 +200,8 @@ input ConfigInterfaceInput {
slideshowDelay: Int
"""Handy Connection Key"""
handyKey: String
"""Funscript Time Offset"""
funscriptOffset: Int
}
type ConfigInterfaceResult {
@ -222,6 +228,8 @@ type ConfigInterfaceResult {
slideshowDelay: Int
"""Handy Connection Key"""
handyKey: String
"""Funscript Time Offset"""
funscriptOffset: Int
}
input ConfigDLNAInput {
@ -244,11 +252,34 @@ type ConfigDLNAResult {
interfaces: [String!]!
}
input ConfigScrapingInput {
"""Scraper user agent string"""
scraperUserAgent: String
"""Scraper CDP path. Path to chrome executable or remote address"""
scraperCDPPath: String
"""Whether the scraper should check for invalid certificates"""
scraperCertCheck: Boolean!
"""Tags blacklist during scraping"""
excludeTagPatterns: [String!]
}
type ConfigScrapingResult {
"""Scraper user agent string"""
scraperUserAgent: String
"""Scraper CDP path. Path to chrome executable or remote address"""
scraperCDPPath: String
"""Whether the scraper should check for invalid certificates"""
scraperCertCheck: Boolean!
"""Tags blacklist during scraping"""
excludeTagPatterns: [String!]!
}
"""All configuration settings"""
type ConfigResult {
general: ConfigGeneralResult!
interface: ConfigInterfaceResult!
dlna: ConfigDLNAResult!
scraping: ConfigScrapingResult!
}
"""Directory structure of a path"""

View file

@ -28,6 +28,11 @@ enum ResolutionEnum {
"8k", EIGHT_K
}
input ResolutionCriterionInput {
value: ResolutionEnum!
modifier: CriterionModifier!
}
input PerformerFilterType {
AND: PerformerFilterType
OR: PerformerFilterType
@ -126,7 +131,7 @@ input SceneFilterType {
"""Filter by o-counter"""
o_counter: IntCriterionInput
"""Filter by resolution"""
resolution: ResolutionEnum
resolution: ResolutionCriterionInput
"""Filter by duration (in seconds)"""
duration: IntCriterionInput
"""Filter to only include scenes which have markers. `true` or `false`"""
@ -215,7 +220,7 @@ input GalleryFilterType {
"""Filter by organized"""
organized: Boolean
"""Filter by average image resolution"""
average_resolution: ResolutionEnum
average_resolution: ResolutionCriterionInput
"""Filter to only include galleries with this studio"""
studios: HierarchicalMultiCriterionInput
"""Filter to only include galleries with these tags"""
@ -282,7 +287,7 @@ input ImageFilterType {
"""Filter by o-counter"""
o_counter: IntCriterionInput
"""Filter by resolution"""
resolution: ResolutionEnum
resolution: ResolutionCriterionInput
"""Filter to only include images missing this property"""
is_missing: String
"""Filter to only include images with this studio"""
@ -322,6 +327,10 @@ enum CriterionModifier {
MATCHES_REGEX,
"""NOT MATCHES REGEX"""
NOT_MATCHES_REGEX,
""">= AND <="""
BETWEEN,
"""< OR >"""
NOT_BETWEEN,
}
input StringCriterionInput {
@ -331,6 +340,7 @@ input StringCriterionInput {
input IntCriterionInput {
value: Int!
value2: Int
modifier: CriterionModifier!
}

View file

@ -125,7 +125,8 @@ type FindScenesResultType {
input SceneParserInput {
ignoreWords: [String!],
whitespaceCharacters: String,
capitalizeTitle: Boolean
capitalizeTitle: Boolean,
ignoreOrganized: Boolean
}
type SceneMovieID {

View file

@ -25,6 +25,7 @@ type ScrapedPerformer {
death_date: String
hair_color: String
weight: String
remote_site_id: String
}
input ScrapedPerformerInput {
@ -51,4 +52,5 @@ input ScrapedPerformerInput {
death_date: String
hair_color: String
weight: String
remote_site_id: String
}

View file

@ -1,6 +1,7 @@
type StatsResultType {
scene_count: Int!
scenes_size: Float!
scenes_duration: Float!
image_count: Int!
images_size: Float!
gallery_count: Int!

View file

@ -1,49 +1,67 @@
package api
import (
"math/rand"
"strings"
"github.com/gobuffalo/packr/v2"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/utils"
)
var performerBox *packr.Box
var performerBoxMale *packr.Box
type imageBox struct {
box *packr.Box
files []string
}
func newImageBox(box *packr.Box) *imageBox {
return &imageBox{
box: box,
files: box.List(),
}
}
var performerBox *imageBox
var performerBoxMale *imageBox
var performerBoxCustom *imageBox
func initialiseImages() {
performerBox = packr.New("Performer Box", "../../static/performer")
performerBoxMale = packr.New("Male Performer Box", "../../static/performer_male")
performerBox = newImageBox(packr.New("Performer Box", "../../static/performer"))
performerBoxMale = newImageBox(packr.New("Male Performer Box", "../../static/performer_male"))
initialiseCustomImages()
}
func getRandomPerformerImage(gender string) ([]byte, error) {
var box *packr.Box
switch strings.ToUpper(gender) {
case "FEMALE":
box = performerBox
case "MALE":
box = performerBoxMale
default:
box = performerBox
func initialiseCustomImages() {
customPath := config.GetInstance().GetCustomPerformerImageLocation()
if customPath != "" {
logger.Debugf("Loading custom performer images from %s", customPath)
// We need to set performerBoxCustom at runtime, as this is a custom path, and store it in a pointer.
performerBoxCustom = newImageBox(packr.Folder(customPath))
} else {
performerBoxCustom = nil
}
imageFiles := box.List()
index := rand.Intn(len(imageFiles))
return box.Find(imageFiles[index])
}
func getRandomPerformerImageUsingName(name, gender string) ([]byte, error) {
var box *packr.Box
switch strings.ToUpper(gender) {
case "FEMALE":
box = performerBox
case "MALE":
box = performerBoxMale
default:
box = performerBox
func getRandomPerformerImageUsingName(name, gender, customPath string) ([]byte, error) {
var box *imageBox
// If we have a custom path, we should return a new box in the given path.
if performerBoxCustom != nil && len(performerBoxCustom.files) > 0 {
box = performerBoxCustom
}
imageFiles := box.List()
if box == nil {
switch strings.ToUpper(gender) {
case "FEMALE":
box = performerBox
case "MALE":
box = performerBoxMale
default:
box = performerBox
}
}
imageFiles := box.files
index := utils.IntFromString(name) % uint64(len(imageFiles))
return box.Find(imageFiles[index])
return box.box.Find(imageFiles[index])
}

View file

@ -138,6 +138,7 @@ func (r *queryResolver) Stats(ctx context.Context) (*models.StatsResultType, err
tagsQB := repo.Tag()
scenesCount, _ := scenesQB.Count()
scenesSize, _ := scenesQB.Size()
scenesDuration, _ := scenesQB.Duration()
imageCount, _ := imageQB.Count()
imageSize, _ := imageQB.Size()
galleryCount, _ := galleryQB.Count()
@ -149,6 +150,7 @@ func (r *queryResolver) Stats(ctx context.Context) (*models.StatsResultType, err
ret = models.StatsResultType{
SceneCount: scenesCount,
ScenesSize: scenesSize,
ScenesDuration: scenesDuration,
ImageCount: imageCount,
ImagesSize: imageSize,
GalleryCount: galleryCount,

View file

@ -67,12 +67,12 @@ func (r *sceneResolver) File(ctx context.Context, obj *models.Scene) (*models.Sc
bitrate := int(obj.Bitrate.Int64)
return &models.SceneFileType{
Size: &obj.Size.String,
Duration: &obj.Duration.Float64,
Duration: handleFloat64(obj.Duration.Float64),
VideoCodec: &obj.VideoCodec.String,
AudioCodec: &obj.AudioCodec.String,
Width: &width,
Height: &height,
Framerate: &obj.Framerate.Float64,
Framerate: handleFloat64(obj.Framerate.Float64),
Bitrate: &bitrate,
}, nil
}

View file

@ -167,6 +167,11 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
c.Set(config.CreateGalleriesFromFolders, input.CreateGalleriesFromFolders)
if input.CustomPerformerImageLocation != nil {
c.Set(config.CustomPerformerImageLocation, *input.CustomPerformerImageLocation)
initialiseCustomImages()
}
refreshScraperCache := false
if input.ScraperUserAgent != nil {
c.Set(config.ScraperUserAgent, input.ScraperUserAgent)
@ -178,7 +183,9 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input models.Co
refreshScraperCache = true
}
c.Set(config.ScraperCertCheck, input.ScraperCertCheck)
if input.ScraperCertCheck != nil {
c.Set(config.ScraperCertCheck, input.ScraperCertCheck)
}
if input.StashBoxes != nil {
if err := c.ValidateStashBoxes(input.StashBoxes); err != nil {
@ -253,6 +260,10 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input models.
c.Set(config.HandyKey, *input.HandyKey)
}
if input.FunscriptOffset != nil {
c.Set(config.FunscriptOffset, *input.FunscriptOffset)
}
if err := c.Write(); err != nil {
return makeConfigInterfaceResult(), err
}
@ -291,6 +302,35 @@ func (r *mutationResolver) ConfigureDlna(ctx context.Context, input models.Confi
return makeConfigDLNAResult(), nil
}
func (r *mutationResolver) ConfigureScraping(ctx context.Context, input models.ConfigScrapingInput) (*models.ConfigScrapingResult, error) {
c := config.GetInstance()
refreshScraperCache := false
if input.ScraperUserAgent != nil {
c.Set(config.ScraperUserAgent, input.ScraperUserAgent)
refreshScraperCache = true
}
if input.ScraperCDPPath != nil {
c.Set(config.ScraperCDPPath, input.ScraperCDPPath)
refreshScraperCache = true
}
if input.ExcludeTagPatterns != nil {
c.Set(config.ScraperExcludeTagPatterns, input.ExcludeTagPatterns)
}
c.Set(config.ScraperCertCheck, input.ScraperCertCheck)
if refreshScraperCache {
manager.GetInstance().RefreshScraperCache()
}
if err := c.Write(); err != nil {
return makeConfigScrapingResult(), err
}
return makeConfigScrapingResult(), nil
}
func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input models.GenerateAPIKeyInput) (string, error) {
c := config.GetInstance()

View file

@ -39,6 +39,7 @@ func makeConfigResult() *models.ConfigResult {
General: makeConfigGeneralResult(),
Interface: makeConfigInterfaceResult(),
Dlna: makeConfigDLNAResult(),
Scraping: makeConfigScrapingResult(),
}
}
@ -49,45 +50,48 @@ func makeConfigGeneralResult() *models.ConfigGeneralResult {
maxTranscodeSize := config.GetMaxTranscodeSize()
maxStreamingTranscodeSize := config.GetMaxStreamingTranscodeSize()
customPerformerImageLocation := config.GetCustomPerformerImageLocation()
scraperUserAgent := config.GetScraperUserAgent()
scraperCDPPath := config.GetScraperCDPPath()
return &models.ConfigGeneralResult{
Stashes: config.GetStashPaths(),
DatabasePath: config.GetDatabasePath(),
GeneratedPath: config.GetGeneratedPath(),
ConfigFilePath: config.GetConfigFilePath(),
ScrapersPath: config.GetScrapersPath(),
CachePath: config.GetCachePath(),
CalculateMd5: config.IsCalculateMD5(),
VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),
ParallelTasks: config.GetParallelTasks(),
PreviewAudio: config.GetPreviewAudio(),
PreviewSegments: config.GetPreviewSegments(),
PreviewSegmentDuration: config.GetPreviewSegmentDuration(),
PreviewExcludeStart: config.GetPreviewExcludeStart(),
PreviewExcludeEnd: config.GetPreviewExcludeEnd(),
PreviewPreset: config.GetPreviewPreset(),
MaxTranscodeSize: &maxTranscodeSize,
MaxStreamingTranscodeSize: &maxStreamingTranscodeSize,
APIKey: config.GetAPIKey(),
Username: config.GetUsername(),
Password: config.GetPasswordHash(),
MaxSessionAge: config.GetMaxSessionAge(),
LogFile: &logFile,
LogOut: config.GetLogOut(),
LogLevel: config.GetLogLevel(),
LogAccess: config.GetLogAccess(),
VideoExtensions: config.GetVideoExtensions(),
ImageExtensions: config.GetImageExtensions(),
GalleryExtensions: config.GetGalleryExtensions(),
CreateGalleriesFromFolders: config.GetCreateGalleriesFromFolders(),
Excludes: config.GetExcludes(),
ImageExcludes: config.GetImageExcludes(),
ScraperUserAgent: &scraperUserAgent,
ScraperCertCheck: config.GetScraperCertCheck(),
ScraperCDPPath: &scraperCDPPath,
StashBoxes: config.GetStashBoxes(),
Stashes: config.GetStashPaths(),
DatabasePath: config.GetDatabasePath(),
GeneratedPath: config.GetGeneratedPath(),
ConfigFilePath: config.GetConfigFilePath(),
ScrapersPath: config.GetScrapersPath(),
CachePath: config.GetCachePath(),
CalculateMd5: config.IsCalculateMD5(),
VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),
ParallelTasks: config.GetParallelTasks(),
PreviewAudio: config.GetPreviewAudio(),
PreviewSegments: config.GetPreviewSegments(),
PreviewSegmentDuration: config.GetPreviewSegmentDuration(),
PreviewExcludeStart: config.GetPreviewExcludeStart(),
PreviewExcludeEnd: config.GetPreviewExcludeEnd(),
PreviewPreset: config.GetPreviewPreset(),
MaxTranscodeSize: &maxTranscodeSize,
MaxStreamingTranscodeSize: &maxStreamingTranscodeSize,
APIKey: config.GetAPIKey(),
Username: config.GetUsername(),
Password: config.GetPasswordHash(),
MaxSessionAge: config.GetMaxSessionAge(),
LogFile: &logFile,
LogOut: config.GetLogOut(),
LogLevel: config.GetLogLevel(),
LogAccess: config.GetLogAccess(),
VideoExtensions: config.GetVideoExtensions(),
ImageExtensions: config.GetImageExtensions(),
GalleryExtensions: config.GetGalleryExtensions(),
CreateGalleriesFromFolders: config.GetCreateGalleriesFromFolders(),
Excludes: config.GetExcludes(),
ImageExcludes: config.GetImageExcludes(),
CustomPerformerImageLocation: &customPerformerImageLocation,
ScraperUserAgent: &scraperUserAgent,
ScraperCertCheck: config.GetScraperCertCheck(),
ScraperCDPPath: &scraperCDPPath,
StashBoxes: config.GetStashBoxes(),
}
}
@ -105,6 +109,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
language := config.GetLanguage()
slideshowDelay := config.GetSlideshowDelay()
handyKey := config.GetHandyKey()
scriptOffset := config.GetFunscriptOffset()
return &models.ConfigInterfaceResult{
MenuItems: menuItems,
@ -119,6 +124,7 @@ func makeConfigInterfaceResult() *models.ConfigInterfaceResult {
Language: &language,
SlideshowDelay: &slideshowDelay,
HandyKey: &handyKey,
FunscriptOffset: &scriptOffset,
}
}
@ -132,3 +138,17 @@ func makeConfigDLNAResult() *models.ConfigDLNAResult {
Interfaces: config.GetDLNAInterfaces(),
}
}
func makeConfigScrapingResult() *models.ConfigScrapingResult {
config := config.GetInstance()
scraperUserAgent := config.GetScraperUserAgent()
scraperCDPPath := config.GetScraperCDPPath()
return &models.ConfigScrapingResult{
ScraperUserAgent: &scraperUserAgent,
ScraperCertCheck: config.GetScraperCertCheck(),
ScraperCDPPath: &scraperCDPPath,
ExcludeTagPatterns: config.GetScraperExcludeTagPatterns(),
}
}

View file

@ -7,6 +7,7 @@ import (
"github.com/go-chi/chi"
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
)
@ -39,7 +40,7 @@ func (rs performerRoutes) Image(w http.ResponseWriter, r *http.Request) {
}
if len(image) == 0 || defaultParam == "true" {
image, _ = getRandomPerformerImageUsingName(performer.Name.String, performer.Gender.String)
image, _ = getRandomPerformerImageUsingName(performer.Name.String, performer.Gender.String, config.GetInstance().GetCustomPerformerImageLocation())
}
utils.ServeImage(image, w, r)

View file

@ -27,7 +27,6 @@ import (
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/manager"
"github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/manager/paths"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/session"
"github.com/stashapp/stash/pkg/utils"
@ -286,34 +285,31 @@ func Start() {
displayAddress := displayHost + ":" + strconv.Itoa(c.GetPort())
address := c.GetHost() + ":" + strconv.Itoa(c.GetPort())
if tlsConfig := makeTLSConfig(); tlsConfig != nil {
httpsServer := &http.Server{
Addr: address,
Handler: r,
TLSConfig: tlsConfig,
}
tlsConfig, err := makeTLSConfig(c)
if err != nil {
// assume we don't want to start with a broken TLS configuration
panic(fmt.Errorf("error loading TLS config: %s", err.Error()))
}
go func() {
printVersion()
printLatestVersion()
logger.Infof("stash is listening on " + address)
server := &http.Server{
Addr: address,
Handler: r,
TLSConfig: tlsConfig,
}
go func() {
printVersion()
printLatestVersion()
logger.Infof("stash is listening on " + address)
if tlsConfig != nil {
logger.Infof("stash is running at https://" + displayAddress + "/")
logger.Error(httpsServer.ListenAndServeTLS("", ""))
}()
} else {
server := &http.Server{
Addr: address,
Handler: r,
}
go func() {
printVersion()
printLatestVersion()
logger.Infof("stash is listening on " + address)
logger.Error(server.ListenAndServeTLS("", ""))
} else {
logger.Infof("stash is running at http://" + displayAddress + "/")
logger.Error(server.ListenAndServe())
}()
}
}
}()
}
func printVersion() {
@ -328,27 +324,44 @@ func GetVersion() (string, string, string) {
return version, githash, buildstamp
}
func makeTLSConfig() *tls.Config {
cert, err := ioutil.ReadFile(paths.GetSSLCert())
if err != nil {
return nil
func makeTLSConfig(c *config.Instance) (*tls.Config, error) {
c.InitTLS()
certFile, keyFile := c.GetTLSFiles()
if certFile == "" && keyFile == "" {
// assume http configuration
return nil, nil
}
key, err := ioutil.ReadFile(paths.GetSSLKey())
// ensure both files are present
if certFile == "" {
return nil, errors.New("SSL certificate file must be present if key file is present")
}
if keyFile == "" {
return nil, errors.New("SSL key file must be present if certificate file is present")
}
cert, err := ioutil.ReadFile(certFile)
if err != nil {
return nil
return nil, fmt.Errorf("error reading SSL certificate file %s: %s", certFile, err.Error())
}
key, err := ioutil.ReadFile(keyFile)
if err != nil {
return nil, fmt.Errorf("error reading SSL key file %s: %s", keyFile, err.Error())
}
certs := make([]tls.Certificate, 1)
certs[0], err = tls.X509KeyPair(cert, key)
if err != nil {
return nil
return nil, fmt.Errorf("error parsing key pair: %s", err.Error())
}
tlsConfig := &tls.Config{
Certificates: certs,
}
return tlsConfig
return tlsConfig, nil
}
type contextKey struct {

View file

@ -1,7 +1,19 @@
package api
import "math"
// An enum https://golang.org/ref/spec#Iota
const (
create = iota // 0
update = iota // 1
)
// #1572 - Inf and NaN values cause the JSON marshaller to fail
// Return nil for these values
func handleFloat64(v float64) *float64 {
if math.IsInf(v, 0) || math.IsNaN(v) {
return nil
}
return &v
}

View file

@ -16,17 +16,6 @@ import (
"github.com/stashapp/stash/pkg/utils"
)
func findInPaths(paths []string, baseName string) string {
for _, p := range paths {
filePath := filepath.Join(p, baseName)
if exists, _ := utils.FileExists(filePath); exists {
return filePath
}
}
return ""
}
func GetPaths(paths []string) (string, string) {
var ffmpegPath, ffprobePath string
@ -38,10 +27,10 @@ func GetPaths(paths []string) (string, string) {
// Check if ffmpeg exists in the config directory
if ffmpegPath == "" {
ffmpegPath = findInPaths(paths, getFFMPEGFilename())
ffmpegPath = utils.FindInPaths(paths, getFFMPEGFilename())
}
if ffprobePath == "" {
ffprobePath = findInPaths(paths, getFFProbeFilename())
ffprobePath = utils.FindInPaths(paths, getFFProbeFilename())
}
return ffmpegPath, ffprobePath

View file

@ -4,7 +4,6 @@ import (
"context"
"errors"
"github.com/spf13/viper"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/models"
@ -26,16 +25,12 @@ func setInitialMD5Config(txnManager models.TransactionManager) {
usingMD5 := count != 0
defaultAlgorithm := models.HashAlgorithmOshash
if usingMD5 {
defaultAlgorithm = models.HashAlgorithmMd5
}
// TODO - this should use the config instance
viper.SetDefault(config.VideoFileNamingAlgorithm, defaultAlgorithm)
viper.SetDefault(config.CalculateMD5, usingMD5)
config := config.GetInstance()
config.SetChecksumDefaultValues(defaultAlgorithm, usingMD5)
if err := config.Write(); err != nil {
logger.Errorf("Error while writing configuration file: %s", err.Error())
}

View file

@ -9,6 +9,9 @@ import (
"runtime"
"strings"
"sync"
//"github.com/sasha-s/go-deadlock" // if you have deadlock issues
"golang.org/x/crypto/bcrypt"
"github.com/spf13/viper"
@ -95,6 +98,7 @@ const ScrapersPath = "scrapers_path"
const ScraperUserAgent = "scraper_user_agent"
const ScraperCertCheck = "scraper_cert_check"
const ScraperCDPPath = "scraper_cdp_path"
const ScraperExcludeTagPatterns = "scraper_exclude_tag_patterns"
// stash-box options
const StashBoxes = "stash_boxes"
@ -120,6 +124,7 @@ var defaultMenuItems = []string{"scenes", "images", "movies", "markers", "galler
const SoundOnPreview = "sound_on_preview"
const WallShowTitle = "wall_show_title"
const CustomPerformerImageLocation = "custom_performer_image_location"
const MaximumLoopDuration = "maximum_loop_duration"
const AutostartVideo = "autostart_video"
const ShowStudioAsText = "show_studio_as_text"
@ -127,6 +132,7 @@ const CSSEnabled = "cssEnabled"
const WallPlayback = "wall_playback"
const SlideshowDelay = "slideshow_delay"
const HandyKey = "handy_key"
const FunscriptOffset = "funscript_offset"
// DLNA options
const DLNAServerName = "dlna.server_name"
@ -151,18 +157,13 @@ func (e MissingConfigError) Error() string {
return fmt.Sprintf("missing the following mandatory settings: %s", strings.Join(e.missingFields, ", "))
}
func HasTLSConfig() bool {
ret, _ := utils.FileExists(paths.GetSSLCert())
if ret {
ret, _ = utils.FileExists(paths.GetSSLKey())
}
return ret
}
type Instance struct {
cpuProfilePath string
isNewSystem bool
certFile string
keyFile string
sync.RWMutex
//deadlock.RWMutex // for deadlock testing/issues
}
var instance *Instance
@ -179,9 +180,31 @@ func (i *Instance) IsNewSystem() bool {
}
func (i *Instance) SetConfigFile(fn string) {
i.Lock()
defer i.Unlock()
viper.SetConfigFile(fn)
}
func (i *Instance) InitTLS() {
configDirectory := i.GetConfigPath()
tlsPaths := []string{
configDirectory,
paths.GetStashHomeDirectory(),
}
i.certFile = utils.FindInPaths(tlsPaths, "stash.crt")
i.keyFile = utils.FindInPaths(tlsPaths, "stash.key")
}
func (i *Instance) GetTLSFiles() (certFile, keyFile string) {
return i.certFile, i.keyFile
}
func (i *Instance) HasTLSConfig() bool {
certFile, keyFile := i.GetTLSFiles()
return certFile != "" && keyFile != ""
}
// GetCPUProfilePath returns the path to the CPU profile file to output
// profiling info to. This is set only via a commandline flag. Returns an
// empty string if not set.
@ -190,6 +213,8 @@ func (i *Instance) GetCPUProfilePath() string {
}
func (i *Instance) Set(key string, value interface{}) {
i.Lock()
defer i.Unlock()
viper.Set(key, value)
}
@ -203,11 +228,15 @@ func (i *Instance) SetPassword(value string) {
}
func (i *Instance) Write() error {
i.Lock()
defer i.Unlock()
return viper.WriteConfig()
}
// GetConfigFile returns the full path to the used configuration file.
func (i *Instance) GetConfigFile() string {
i.RLock()
defer i.RUnlock()
return viper.ConfigFileUsed()
}
@ -224,6 +253,8 @@ func (i *Instance) GetDefaultDatabaseFilePath() string {
}
func (i *Instance) GetStashPaths() []*models.StashConfig {
i.RLock()
defer i.RUnlock()
var ret []*models.StashConfig
if err := viper.UnmarshalKey(Stash, &ret); err != nil || len(ret) == 0 {
// fallback to legacy format
@ -241,30 +272,44 @@ func (i *Instance) GetStashPaths() []*models.StashConfig {
}
func (i *Instance) GetConfigFilePath() string {
i.RLock()
defer i.RUnlock()
return viper.ConfigFileUsed()
}
func (i *Instance) GetCachePath() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(Cache)
}
func (i *Instance) GetGeneratedPath() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(Generated)
}
func (i *Instance) GetMetadataPath() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(Metadata)
}
func (i *Instance) GetDatabasePath() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(Database)
}
func (i *Instance) GetJWTSignKey() []byte {
i.RLock()
defer i.RUnlock()
return []byte(viper.GetString(JWTSignKey))
}
func (i *Instance) GetSessionStoreKey() []byte {
i.RLock()
defer i.RUnlock()
return []byte(viper.GetString(SessionStoreKey))
}
@ -277,14 +322,20 @@ func (i *Instance) GetDefaultScrapersPath() string {
}
func (i *Instance) GetExcludes() []string {
i.RLock()
defer i.RUnlock()
return viper.GetStringSlice(Exclude)
}
func (i *Instance) GetImageExcludes() []string {
i.RLock()
defer i.RUnlock()
return viper.GetStringSlice(ImageExclude)
}
func (i *Instance) GetVideoExtensions() []string {
i.RLock()
defer i.RUnlock()
ret := viper.GetStringSlice(VideoExtensions)
if ret == nil {
ret = defaultVideoExtensions
@ -293,6 +344,8 @@ func (i *Instance) GetVideoExtensions() []string {
}
func (i *Instance) GetImageExtensions() []string {
i.RLock()
defer i.RUnlock()
ret := viper.GetStringSlice(ImageExtensions)
if ret == nil {
ret = defaultImageExtensions
@ -301,6 +354,8 @@ func (i *Instance) GetImageExtensions() []string {
}
func (i *Instance) GetGalleryExtensions() []string {
i.RLock()
defer i.RUnlock()
ret := viper.GetStringSlice(GalleryExtensions)
if ret == nil {
ret = defaultGalleryExtensions
@ -309,10 +364,14 @@ func (i *Instance) GetGalleryExtensions() []string {
}
func (i *Instance) GetCreateGalleriesFromFolders() bool {
i.RLock()
defer i.RUnlock()
return viper.GetBool(CreateGalleriesFromFolders)
}
func (i *Instance) GetLanguage() string {
i.RLock()
defer i.RUnlock()
ret := viper.GetString(Language)
// default to English
@ -326,12 +385,16 @@ func (i *Instance) GetLanguage() string {
// IsCalculateMD5 returns true if MD5 checksums should be generated for
// scene video files.
func (i *Instance) IsCalculateMD5() bool {
i.RLock()
defer i.RUnlock()
return viper.GetBool(CalculateMD5)
}
// GetVideoFileNamingAlgorithm returns what hash algorithm should be used for
// naming generated scene video files.
func (i *Instance) GetVideoFileNamingAlgorithm() models.HashAlgorithm {
i.RLock()
defer i.RUnlock()
ret := viper.GetString(VideoFileNamingAlgorithm)
// default to oshash
@ -343,22 +406,30 @@ func (i *Instance) GetVideoFileNamingAlgorithm() models.HashAlgorithm {
}
func (i *Instance) GetScrapersPath() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(ScrapersPath)
}
func (i *Instance) GetScraperUserAgent() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(ScraperUserAgent)
}
// GetScraperCDPPath gets the path to the Chrome executable or remote address
// to an instance of Chrome.
func (i *Instance) GetScraperCDPPath() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(ScraperCDPPath)
}
// GetScraperCertCheck returns true if the scraper should check for insecure
// certificates when fetching an image or a page.
func (i *Instance) GetScraperCertCheck() bool {
i.RLock()
defer i.RUnlock()
ret := true
if viper.IsSet(ScraperCertCheck) {
ret = viper.GetBool(ScraperCertCheck)
@ -367,7 +438,20 @@ func (i *Instance) GetScraperCertCheck() bool {
return ret
}
func (i *Instance) GetScraperExcludeTagPatterns() []string {
i.RLock()
defer i.RUnlock()
var ret []string
if viper.IsSet(ScraperExcludeTagPatterns) {
ret = viper.GetStringSlice(ScraperExcludeTagPatterns)
}
return ret
}
func (i *Instance) GetStashBoxes() []*models.StashBox {
i.RLock()
defer i.RUnlock()
var boxes []*models.StashBox
viper.UnmarshalKey(StashBoxes, &boxes)
return boxes
@ -381,34 +465,48 @@ func (i *Instance) GetDefaultPluginsPath() string {
}
func (i *Instance) GetPluginsPath() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(PluginsPath)
}
func (i *Instance) GetHost() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(Host)
}
func (i *Instance) GetPort() int {
i.RLock()
defer i.RUnlock()
return viper.GetInt(Port)
}
func (i *Instance) GetExternalHost() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(ExternalHost)
}
// GetPreviewSegmentDuration returns the duration of a single segment in a
// scene preview file, in seconds.
func (i *Instance) GetPreviewSegmentDuration() float64 {
i.RLock()
defer i.RUnlock()
return viper.GetFloat64(PreviewSegmentDuration)
}
// GetParallelTasks returns the number of parallel tasks that should be started
// by scan or generate task.
func (i *Instance) GetParallelTasks() int {
i.RLock()
defer i.RUnlock()
return viper.GetInt(ParallelTasks)
}
func (i *Instance) GetParallelTasksWithAutoDetection() int {
i.RLock()
defer i.RUnlock()
parallelTasks := viper.GetInt(ParallelTasks)
if parallelTasks <= 0 {
parallelTasks = (runtime.NumCPU() / 4) + 1
@ -417,11 +515,15 @@ func (i *Instance) GetParallelTasksWithAutoDetection() int {
}
func (i *Instance) GetPreviewAudio() bool {
i.RLock()
defer i.RUnlock()
return viper.GetBool(PreviewAudio)
}
// GetPreviewSegments returns the amount of segments in a scene preview file.
func (i *Instance) GetPreviewSegments() int {
i.RLock()
defer i.RUnlock()
return viper.GetInt(PreviewSegments)
}
@ -432,6 +534,8 @@ func (i *Instance) GetPreviewSegments() int {
// in the preview. If the value is suffixed with a '%' character (for example
// '2%'), then it is interpreted as a proportion of the total video duration.
func (i *Instance) GetPreviewExcludeStart() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(PreviewExcludeStart)
}
@ -441,12 +545,16 @@ func (i *Instance) GetPreviewExcludeStart() string {
// when generating previews. If the value is suffixed with a '%' character,
// then it is interpreted as a proportion of the total video duration.
func (i *Instance) GetPreviewExcludeEnd() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(PreviewExcludeEnd)
}
// GetPreviewPreset returns the preset when generating previews. Defaults to
// Slow.
func (i *Instance) GetPreviewPreset() models.PreviewPreset {
i.RLock()
defer i.RUnlock()
ret := viper.GetString(PreviewPreset)
// default to slow
@ -458,6 +566,8 @@ func (i *Instance) GetPreviewPreset() models.PreviewPreset {
}
func (i *Instance) GetMaxTranscodeSize() models.StreamingResolutionEnum {
i.RLock()
defer i.RUnlock()
ret := viper.GetString(MaxTranscodeSize)
// default to original
@ -469,6 +579,8 @@ func (i *Instance) GetMaxTranscodeSize() models.StreamingResolutionEnum {
}
func (i *Instance) GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum {
i.RLock()
defer i.RUnlock()
ret := viper.GetString(MaxStreamingTranscodeSize)
// default to original
@ -480,19 +592,27 @@ func (i *Instance) GetMaxStreamingTranscodeSize() models.StreamingResolutionEnum
}
func (i *Instance) GetAPIKey() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(ApiKey)
}
func (i *Instance) GetUsername() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(Username)
}
func (i *Instance) GetPasswordHash() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(Password)
}
func (i *Instance) GetCredentials() (string, string) {
if i.HasCredentials() {
i.RLock()
defer i.RUnlock()
return viper.GetString(Username), viper.GetString(Password)
}
@ -500,12 +620,14 @@ func (i *Instance) GetCredentials() (string, string) {
}
func (i *Instance) HasCredentials() bool {
i.RLock()
defer i.RUnlock()
if !viper.IsSet(Username) || !viper.IsSet(Password) {
return false
}
username := i.GetUsername()
pwHash := i.GetPasswordHash()
username := viper.GetString(Username)
pwHash := viper.GetString(Password)
return username != "" && pwHash != ""
}
@ -554,6 +676,8 @@ func (i *Instance) ValidateStashBoxes(boxes []*models.StashBoxInput) error {
// GetMaxSessionAge gets the maximum age for session cookies, in seconds.
// Session cookie expiry times are refreshed every request.
func (i *Instance) GetMaxSessionAge() int {
i.Lock()
defer i.Unlock()
viper.SetDefault(MaxSessionAge, DefaultMaxSessionAge)
return viper.GetInt(MaxSessionAge)
}
@ -561,15 +685,21 @@ func (i *Instance) GetMaxSessionAge() int {
// GetCustomServedFolders gets the map of custom paths to their applicable
// filesystem locations
func (i *Instance) GetCustomServedFolders() URLMap {
i.RLock()
defer i.RUnlock()
return viper.GetStringMapString(CustomServedFolders)
}
func (i *Instance) GetCustomUILocation() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(CustomUILocation)
}
// Interface options
func (i *Instance) GetMenuItems() []string {
i.RLock()
defer i.RUnlock()
if viper.IsSet(MenuItems) {
return viper.GetStringSlice(MenuItems)
}
@ -577,40 +707,63 @@ func (i *Instance) GetMenuItems() []string {
}
func (i *Instance) GetSoundOnPreview() bool {
i.RLock()
defer i.RUnlock()
return viper.GetBool(SoundOnPreview)
}
func (i *Instance) GetWallShowTitle() bool {
i.Lock()
defer i.Unlock()
viper.SetDefault(WallShowTitle, true)
return viper.GetBool(WallShowTitle)
}
func (i *Instance) GetCustomPerformerImageLocation() string {
i.Lock()
defer i.Unlock()
viper.SetDefault(CustomPerformerImageLocation, "")
return viper.GetString(CustomPerformerImageLocation)
}
func (i *Instance) GetWallPlayback() string {
i.Lock()
defer i.Unlock()
viper.SetDefault(WallPlayback, "video")
return viper.GetString(WallPlayback)
}
func (i *Instance) GetMaximumLoopDuration() int {
i.Lock()
defer i.Unlock()
viper.SetDefault(MaximumLoopDuration, 0)
return viper.GetInt(MaximumLoopDuration)
}
func (i *Instance) GetAutostartVideo() bool {
i.Lock()
defer i.Unlock()
viper.SetDefault(AutostartVideo, false)
return viper.GetBool(AutostartVideo)
}
func (i *Instance) GetShowStudioAsText() bool {
i.Lock()
defer i.Unlock()
viper.SetDefault(ShowStudioAsText, false)
return viper.GetBool(ShowStudioAsText)
}
func (i *Instance) GetSlideshowDelay() int {
i.Lock()
defer i.Unlock()
viper.SetDefault(SlideshowDelay, 5000)
return viper.GetInt(SlideshowDelay)
}
func (i *Instance) GetCSSPath() string {
i.RLock()
defer i.RUnlock()
// use custom.css in the same directory as the config file
configFileUsed := viper.ConfigFileUsed()
configDir := filepath.Dir(configFileUsed)
@ -638,6 +791,8 @@ func (i *Instance) GetCSS() string {
}
func (i *Instance) SetCSS(css string) {
i.RLock()
defer i.RUnlock()
fn := i.GetCSSPath()
buf := []byte(css)
@ -646,39 +801,58 @@ func (i *Instance) SetCSS(css string) {
}
func (i *Instance) GetCSSEnabled() bool {
i.RLock()
defer i.RUnlock()
return viper.GetBool(CSSEnabled)
}
func (i *Instance) GetHandyKey() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(HandyKey)
}
func (i *Instance) GetFunscriptOffset() int {
viper.SetDefault(FunscriptOffset, 0)
return viper.GetInt(FunscriptOffset)
}
// GetDLNAServerName returns the visible name of the DLNA server. If empty,
// "stash" will be used.
func (i *Instance) GetDLNAServerName() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(DLNAServerName)
}
// GetDLNADefaultEnabled returns true if the DLNA is enabled by default.
func (i *Instance) GetDLNADefaultEnabled() bool {
i.RLock()
defer i.RUnlock()
return viper.GetBool(DLNADefaultEnabled)
}
// GetDLNADefaultIPWhitelist returns a list of IP addresses/wildcards that
// are allowed to use the DLNA service.
func (i *Instance) GetDLNADefaultIPWhitelist() []string {
i.RLock()
defer i.RUnlock()
return viper.GetStringSlice(DLNADefaultIPWhitelist)
}
// GetDLNAInterfaces returns a list of interface names to expose DLNA on. If
// empty, runs on all interfaces.
func (i *Instance) GetDLNAInterfaces() []string {
i.RLock()
defer i.RUnlock()
return viper.GetStringSlice(DLNAInterfaces)
}
// GetLogFile returns the filename of the file to output logs to.
// An empty string means that file logging will be disabled.
func (i *Instance) GetLogFile() string {
i.RLock()
defer i.RUnlock()
return viper.GetString(LogFile)
}
@ -686,6 +860,8 @@ func (i *Instance) GetLogFile() string {
// in addition to writing to a log file. Logging will be output to the
// terminal if file logging is disabled. Defaults to true.
func (i *Instance) GetLogOut() bool {
i.RLock()
defer i.RUnlock()
ret := true
if viper.IsSet(LogOut) {
ret = viper.GetBool(LogOut)
@ -697,6 +873,8 @@ func (i *Instance) GetLogOut() bool {
// GetLogLevel returns the lowest log level to write to the log.
// Should be one of "Debug", "Info", "Warning", "Error"
func (i *Instance) GetLogLevel() string {
i.RLock()
defer i.RUnlock()
const defaultValue = "Info"
value := viper.GetString(LogLevel)
@ -710,6 +888,8 @@ func (i *Instance) GetLogLevel() string {
// GetLogAccess returns true if http requests should be logged to the terminal.
// HTTP requests are not logged to the log file. Defaults to true.
func (i *Instance) GetLogAccess() bool {
i.RLock()
defer i.RUnlock()
ret := true
if viper.IsSet(LogAccess) {
ret = viper.GetBool(LogAccess)
@ -720,6 +900,8 @@ func (i *Instance) GetLogAccess() bool {
// Max allowed graphql upload size in megabytes
func (i *Instance) GetMaxUploadSize() int64 {
i.RLock()
defer i.RUnlock()
ret := int64(1024)
if viper.IsSet(MaxUploadSize) {
ret = viper.GetInt64(MaxUploadSize)
@ -728,6 +910,8 @@ func (i *Instance) GetMaxUploadSize() int64 {
}
func (i *Instance) Validate() error {
i.RLock()
defer i.RUnlock()
mandatoryPaths := []string{
Database,
Generated,
@ -750,7 +934,22 @@ func (i *Instance) Validate() error {
return nil
}
func (i *Instance) SetChecksumDefaultValues(defaultAlgorithm models.HashAlgorithm, usingMD5 bool) {
i.Lock()
defer i.Unlock()
viper.SetDefault(VideoFileNamingAlgorithm, defaultAlgorithm)
viper.SetDefault(CalculateMD5, usingMD5)
}
func (i *Instance) setDefaultValues() error {
// read data before write lock scope
defaultDatabaseFilePath := i.GetDefaultDatabaseFilePath()
defaultScrapersPath := i.GetDefaultScrapersPath()
defaultPluginsPath := i.GetDefaultPluginsPath()
i.Lock()
defer i.Unlock()
viper.SetDefault(ParallelTasks, parallelTasksDefault)
viper.SetDefault(PreviewSegmentDuration, previewSegmentDurationDefault)
viper.SetDefault(PreviewSegments, previewSegmentsDefault)
@ -759,14 +958,14 @@ func (i *Instance) setDefaultValues() error {
viper.SetDefault(PreviewAudio, previewAudioDefault)
viper.SetDefault(SoundOnPreview, false)
viper.SetDefault(Database, i.GetDefaultDatabaseFilePath())
viper.SetDefault(Database, defaultDatabaseFilePath)
// Set generated to the metadata path for backwards compat
viper.SetDefault(Generated, viper.GetString(Metadata))
// Set default scrapers and plugins paths
viper.SetDefault(ScrapersPath, i.GetDefaultScrapersPath())
viper.SetDefault(PluginsPath, i.GetDefaultPluginsPath())
viper.SetDefault(ScrapersPath, defaultScrapersPath)
viper.SetDefault(PluginsPath, defaultPluginsPath)
return viper.WriteConfig()
}

View file

@ -0,0 +1,100 @@
package config
import (
"sync"
"testing"
)
// should be run with -race
func TestConcurrentConfigAccess(t *testing.T) {
i := GetInstance()
const workers = 8
//const loops = 1000
const loops = 200
var wg sync.WaitGroup
for t := 0; t < workers; t++ {
wg.Add(1)
go func() {
for l := 0; l < loops; l++ {
i.SetInitialConfig()
i.HasCredentials()
i.GetCPUProfilePath()
i.GetConfigFile()
i.GetConfigPath()
i.GetDefaultDatabaseFilePath()
i.GetStashPaths()
i.GetConfigFilePath()
i.Set(Cache, i.GetCachePath())
i.Set(Generated, i.GetGeneratedPath())
i.Set(Metadata, i.GetMetadataPath())
i.Set(Database, i.GetDatabasePath())
i.Set(JWTSignKey, i.GetJWTSignKey())
i.Set(SessionStoreKey, i.GetSessionStoreKey())
i.GetDefaultScrapersPath()
i.Set(Exclude, i.GetExcludes())
i.Set(ImageExclude, i.GetImageExcludes())
i.Set(VideoExtensions, i.GetVideoExtensions())
i.Set(ImageExtensions, i.GetImageExtensions())
i.Set(GalleryExtensions, i.GetGalleryExtensions())
i.Set(CreateGalleriesFromFolders, i.GetCreateGalleriesFromFolders())
i.Set(Language, i.GetLanguage())
i.Set(VideoFileNamingAlgorithm, i.GetVideoFileNamingAlgorithm())
i.Set(ScrapersPath, i.GetScrapersPath())
i.Set(ScraperUserAgent, i.GetScraperUserAgent())
i.Set(ScraperCDPPath, i.GetScraperCDPPath())
i.Set(ScraperCertCheck, i.GetScraperCertCheck())
i.Set(ScraperExcludeTagPatterns, i.GetScraperExcludeTagPatterns())
i.Set(StashBoxes, i.GetStashBoxes())
i.GetDefaultPluginsPath()
i.Set(PluginsPath, i.GetPluginsPath())
i.Set(Host, i.GetHost())
i.Set(Port, i.GetPort())
i.Set(ExternalHost, i.GetExternalHost())
i.Set(PreviewSegmentDuration, i.GetPreviewSegmentDuration())
i.Set(ParallelTasks, i.GetParallelTasks())
i.Set(ParallelTasks, i.GetParallelTasksWithAutoDetection())
i.Set(PreviewAudio, i.GetPreviewAudio())
i.Set(PreviewSegments, i.GetPreviewSegments())
i.Set(PreviewExcludeStart, i.GetPreviewExcludeStart())
i.Set(PreviewExcludeEnd, i.GetPreviewExcludeEnd())
i.Set(PreviewPreset, i.GetPreviewPreset())
i.Set(MaxTranscodeSize, i.GetMaxTranscodeSize())
i.Set(MaxStreamingTranscodeSize, i.GetMaxStreamingTranscodeSize())
i.Set(ApiKey, i.GetAPIKey())
i.Set(Username, i.GetUsername())
i.Set(Password, i.GetPasswordHash())
i.GetCredentials()
i.Set(MaxSessionAge, i.GetMaxSessionAge())
i.Set(CustomServedFolders, i.GetCustomServedFolders())
i.Set(CustomUILocation, i.GetCustomUILocation())
i.Set(MenuItems, i.GetMenuItems())
i.Set(SoundOnPreview, i.GetSoundOnPreview())
i.Set(WallShowTitle, i.GetWallShowTitle())
i.Set(CustomPerformerImageLocation, i.GetCustomPerformerImageLocation())
i.Set(WallPlayback, i.GetWallPlayback())
i.Set(MaximumLoopDuration, i.GetMaximumLoopDuration())
i.Set(AutostartVideo, i.GetAutostartVideo())
i.Set(ShowStudioAsText, i.GetShowStudioAsText())
i.Set(SlideshowDelay, i.GetSlideshowDelay())
i.GetCSSPath()
i.GetCSS()
i.Set(CSSEnabled, i.GetCSSEnabled())
i.Set(HandyKey, i.GetHandyKey())
i.Set(DLNAServerName, i.GetDLNAServerName())
i.Set(DLNADefaultEnabled, i.GetDLNADefaultEnabled())
i.Set(DLNADefaultIPWhitelist, i.GetDLNADefaultIPWhitelist())
i.Set(DLNAInterfaces, i.GetDLNAInterfaces())
i.Set(LogFile, i.GetLogFile())
i.Set(LogOut, i.GetLogOut())
i.Set(LogLevel, i.GetLogLevel())
i.Set(LogAccess, i.GetLogAccess())
i.Set(MaxUploadSize, i.GetMaxUploadSize())
}
wg.Done()
}()
}
wg.Wait()
}

View file

@ -484,6 +484,11 @@ func (p *SceneFilenameParser) Parse(repo models.ReaderRepository) ([]*models.Sce
},
}
if p.ParserInput.IgnoreOrganized != nil && *p.ParserInput.IgnoreOrganized {
organized := false
sceneFilter.Organized = &organized
}
p.Filter.Q = nil
scenes, total, err := repo.Scene().Query(sceneFilter, p.Filter)

View file

@ -58,7 +58,6 @@ func GetInstance() *singleton {
func Initialize() *singleton {
once.Do(func() {
_ = utils.EnsureDir(paths.GetStashHomeDirectory())
cfg, err := config.Initialize()
if err != nil {
@ -269,6 +268,14 @@ func setSetupDefaults(input *models.SetupInput) {
func (s *singleton) Setup(input models.SetupInput) error {
setSetupDefaults(&input)
// create the config directory if it does not exist
configDir := filepath.Dir(input.ConfigLocation)
if exists, _ := utils.DirExists(configDir); !exists {
if err := os.Mkdir(configDir, 0755); err != nil {
return fmt.Errorf("abc: %s", err.Error())
}
}
// create the generated directory if it does not exist
if exists, _ := utils.DirExists(input.GeneratedLocation); !exists {
if err := os.Mkdir(input.GeneratedLocation, 0755); err != nil {

View file

@ -322,6 +322,7 @@ func (s *singleton) Generate(ctx context.Context, input models.GenerateMetadataI
Scene: *scene,
fileNamingAlgorithm: fileNamingAlgo,
txnManager: s.TxnManager,
Overwrite: overwrite,
}
wg.Add()
go progress.ExecuteTask(fmt.Sprintf("Generating phash for %s", scene.Path), func() {

View file

@ -29,11 +29,3 @@ func GetStashHomeDirectory() string {
func GetDefaultDatabaseFilePath() string {
return filepath.Join(GetStashHomeDirectory(), "stash-go.sqlite")
}
func GetSSLKey() string {
return filepath.Join(GetStashHomeDirectory(), "stash.key")
}
func GetSSLCert() string {
return filepath.Join(GetStashHomeDirectory(), "stash.crt")
}

View file

@ -13,6 +13,7 @@ import (
type GeneratePhashTask struct {
Scene models.Scene
Overwrite bool
fileNamingAlgorithm models.HashAlgorithm
txnManager models.TransactionManager
}
@ -58,5 +59,5 @@ func (t *GeneratePhashTask) Start(wg *sizedwaitgroup.SizedWaitGroup) {
}
func (t *GeneratePhashTask) shouldGenerate() bool {
return !t.Scene.Phash.Valid
return t.Overwrite || !t.Scene.Phash.Valid
}

View file

@ -1,65 +1,33 @@
package models
var resolutionMax = []int{
240,
360,
480,
540,
720,
1080,
1440,
1920,
2160,
2880,
3384,
4320,
0,
type ResolutionRange struct {
min, max int
}
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},
}
// GetMaxResolution returns the maximum width or height that media must be
// to qualify as this resolution. A return value of 0 means that there is no
// maximum.
// to qualify as this resolution.
func (r *ResolutionEnum) GetMaxResolution() int {
if !r.IsValid() {
return 0
}
// sanity check - length of arrays must be the same
if len(resolutionMax) != len(AllResolutionEnum) {
panic("resolutionMax array length != AllResolutionEnum array length")
}
for i, rr := range AllResolutionEnum {
if rr == *r {
return resolutionMax[i]
}
}
return 0
return resolutionRanges[*r].max
}
// GetMinResolution returns the minimum width or height that media must be
// to qualify as this resolution.
func (r *ResolutionEnum) GetMinResolution() int {
if !r.IsValid() {
return 0
}
// sanity check - length of arrays must be the same
if len(resolutionMax) != len(AllResolutionEnum) {
panic("resolutionMax array length != AllResolutionEnum array length")
}
// use the previous resolution max as this resolution min
for i, rr := range AllResolutionEnum {
if rr == *r {
if i > 0 {
return resolutionMax[i-1]
}
return 0
}
}
return 0
return resolutionRanges[*r].min
}

View file

@ -254,6 +254,27 @@ func (_m *SceneReaderWriter) DestroyCover(sceneID int) error {
return r0
}
// Duration provides a mock function with given fields:
func (_m *SceneReaderWriter) Duration() (float64, error) {
ret := _m.Called()
var r0 float64
if rf, ok := ret.Get(0).(func() float64); ok {
r0 = rf()
} else {
r0 = ret.Get(0).(float64)
}
var r1 error
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Find provides a mock function with given fields: id
func (_m *SceneReaderWriter) Find(id int) (*models.Scene, error) {
ret := _m.Called(id)

View file

@ -46,6 +46,7 @@ type ScrapedPerformer struct {
DeathDate *string `graphql:"death_date" json:"death_date"`
HairColor *string `graphql:"hair_color" json:"hair_color"`
Weight *string `graphql:"weight" json:"weight"`
RemoteSiteID *string `graphql:"remote_site_id" json:"remote_site_id"`
}
// this type has no Image field

View file

@ -15,6 +15,7 @@ type SceneReader interface {
CountByMovieID(movieID int) (int, error)
Count() (int, error)
Size() (float64, error)
Duration() (float64, error)
// SizeCount() (string, error)
CountByStudioID(studioID int) (int, error)
CountByTagID(tagID int) (int, error)

View file

@ -133,7 +133,7 @@ func (c Cache) makeServerConnection(ctx context.Context) common.StashServerConne
Dir: c.config.GetConfigPath(),
}
if config.HasTLSConfig() {
if c.config.HasTLSConfig() {
serverConnection.Scheme = "https"
}

View file

@ -5,10 +5,12 @@ import (
"errors"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"github.com/stashapp/stash/pkg/logger"
stash_config "github.com/stashapp/stash/pkg/manager/config"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/utils"
)
@ -239,12 +241,11 @@ func (c Cache) postScrapePerformer(ret *models.ScrapedPerformer) error {
if err := c.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
tqb := r.Tag()
for _, t := range ret.Tags {
err := MatchScrapedSceneTag(tqb, t)
if err != nil {
return err
}
tags, err := postProcessTags(tqb, ret.Tags)
if err != nil {
return err
}
ret.Tags = tags
return nil
}); err != nil {
@ -263,12 +264,11 @@ func (c Cache) postScrapeScenePerformer(ret *models.ScrapedScenePerformer) error
if err := c.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
tqb := r.Tag()
for _, t := range ret.Tags {
err := MatchScrapedSceneTag(tqb, t)
if err != nil {
return err
}
tags, err := postProcessTags(tqb, ret.Tags)
if err != nil {
return err
}
ret.Tags = tags
return nil
}); err != nil {
@ -302,12 +302,11 @@ func (c Cache) postScrapeScene(ret *models.ScrapedScene) error {
}
}
for _, t := range ret.Tags {
err := MatchScrapedSceneTag(tqb, t)
if err != nil {
return err
}
tags, err := postProcessTags(tqb, ret.Tags)
if err != nil {
return err
}
ret.Tags = tags
if ret.Studio != nil {
err := MatchScrapedSceneStudio(sqb, ret.Studio)
@ -342,12 +341,11 @@ func (c Cache) postScrapeGallery(ret *models.ScrapedGallery) error {
}
}
for _, t := range ret.Tags {
err := MatchScrapedSceneTag(tqb, t)
if err != nil {
return err
}
tags, err := postProcessTags(tqb, ret.Tags)
if err != nil {
return err
}
ret.Tags = tags
if ret.Studio != nil {
err := MatchScrapedSceneStudio(sqb, ret.Studio)
@ -509,3 +507,42 @@ func (c Cache) ScrapeMovieURL(url string) (*models.ScrapedMovie, error) {
return nil, nil
}
func postProcessTags(tqb models.TagReader, scrapedTags []*models.ScrapedSceneTag) ([]*models.ScrapedSceneTag, error) {
var ret []*models.ScrapedSceneTag
excludePatterns := stash_config.GetInstance().GetScraperExcludeTagPatterns()
var excludeRegexps []*regexp.Regexp
for _, excludePattern := range excludePatterns {
reg, err := regexp.Compile(strings.ToLower(excludePattern))
if err != nil {
logger.Errorf("Invalid tag exclusion pattern :%v", err)
} else {
excludeRegexps = append(excludeRegexps, reg)
}
}
var ignoredTags []string
ScrapeTag:
for _, t := range scrapedTags {
for _, reg := range excludeRegexps {
if reg.MatchString(strings.ToLower(t.Name)) {
ignoredTags = append(ignoredTags, t.Name)
continue ScrapeTag
}
}
err := MatchScrapedSceneTag(tqb, t)
if err != nil {
return nil, err
}
ret = append(ret, t)
}
if len(ignoredTags) > 0 {
logger.Infof("Scraping ignored tags: %s", strings.Join(ignoredTags, ", "))
}
return ret, nil
}

View file

@ -310,7 +310,7 @@ func (f *filterBuilder) andClauses(input []sqlClause) (string, []interface{}) {
}
if len(clauses) > 0 {
c := strings.Join(clauses, " AND ")
c := "(" + strings.Join(clauses, ") AND (") + ")"
if len(clauses) > 1 {
c = "(" + c + ")"
}
@ -368,13 +368,8 @@ func stringCriterionHandler(c *models.StringCriterionInput, column string) crite
func intCriterionHandler(c *models.IntCriterionInput, column string) criterionHandlerFunc {
return func(f *filterBuilder) {
if c != nil {
clause, count := getIntCriterionWhereClause(column, *c)
if count == 1 {
f.addWhere(clause, c.Value)
} else {
f.addWhere(clause)
}
clause, args := getIntCriterionWhereClause(column, *c)
f.addWhere(clause, args...)
}
}
}
@ -495,13 +490,9 @@ type countCriterionHandlerBuilder struct {
func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) {
if criterion != nil {
clause, count := getCountCriterionClause(m.primaryTable, m.joinTable, m.primaryFK, *criterion)
clause, args := getCountCriterionClause(m.primaryTable, m.joinTable, m.primaryFK, *criterion)
if count == 1 {
f.addWhere(clause, criterion.Value)
} else {
f.addWhere(clause)
}
f.addWhere(clause, args...)
}
}
}
@ -548,18 +539,27 @@ func addHierarchicalWithClause(f *filterBuilder, value []string, derivedTable, t
depthCondition = fmt.Sprintf("WHERE depth < %d", depth)
}
withClause := utils.StrFormat(`RECURSIVE {derivedTable} AS (
SELECT id as id, id as child_id, 0 as depth FROM {table}
WHERE id in {inBinding}
UNION SELECT p.id, c.id, depth + 1 FROM {table} as c
INNER JOIN {derivedTable} as p ON c.{parentFK} = p.child_id {depthCondition})
`, utils.StrFormatMap{
withClauseMap := utils.StrFormatMap{
"derivedTable": derivedTable,
"table": table,
"inBinding": getInBinding(inCount),
"parentFK": parentFK,
"depthCondition": depthCondition,
})
"unionClause": "",
}
if depth != 0 {
withClauseMap["unionClause"] = utils.StrFormat(`
UNION SELECT p.id, c.id, depth + 1 FROM {table} as c
INNER JOIN {derivedTable} as p ON c.{parentFK} = p.child_id {depthCondition}
`, withClauseMap)
}
withClause := utils.StrFormat(`RECURSIVE {derivedTable} AS (
SELECT id as id, id as child_id, 0 as depth FROM {table}
WHERE id in {inBinding}
{unionClause})
`, withClauseMap)
f.addWith(withClause, args...)
}

View file

@ -252,14 +252,14 @@ func TestGenerateWhereClauses(t *testing.T) {
// ensure single where clause is generated correctly
f.addWhere(clause1)
r, rArgs := f.generateWhereClauses()
assert.Equal(clause1, r)
assert.Equal(fmt.Sprintf("(%s)", clause1), r)
assert.Len(rArgs, 0)
// ensure multiple where clauses are surrounded with parenthesis and
// ANDed together
f.addWhere(clause2, arg1, arg2)
r, rArgs = f.generateWhereClauses()
assert.Equal(fmt.Sprintf("(%s AND %s)", clause1, clause2), r)
assert.Equal(fmt.Sprintf("((%s) AND (%s))", clause1, clause2), r)
assert.Len(rArgs, 2)
// ensure empty subfilter is not added to generated where clause
@ -267,13 +267,13 @@ func TestGenerateWhereClauses(t *testing.T) {
f.and(sf)
r, rArgs = f.generateWhereClauses()
assert.Equal(fmt.Sprintf("(%s AND %s)", clause1, clause2), r)
assert.Equal(fmt.Sprintf("((%s) AND (%s))", clause1, clause2), r)
assert.Len(rArgs, 2)
// ensure sub-filter is generated correctly
sf.addWhere(clause3, arg3)
r, rArgs = f.generateWhereClauses()
assert.Equal(fmt.Sprintf("(%s AND %s) AND (%s)", clause1, clause2, clause3), r)
assert.Equal(fmt.Sprintf("((%s) AND (%s)) AND ((%s))", clause1, clause2, clause3), r)
assert.Len(rArgs, 3)
// ensure OR sub-filter is generated correctly
@ -283,7 +283,7 @@ func TestGenerateWhereClauses(t *testing.T) {
f.or(sf)
r, rArgs = f.generateWhereClauses()
assert.Equal(fmt.Sprintf("(%s AND %s) OR (%s)", clause1, clause2, clause3), r)
assert.Equal(fmt.Sprintf("((%s) AND (%s)) OR ((%s))", clause1, clause2, clause3), r)
assert.Len(rArgs, 3)
// ensure NOT sub-filter is generated correctly
@ -293,7 +293,7 @@ func TestGenerateWhereClauses(t *testing.T) {
f.not(sf)
r, rArgs = f.generateWhereClauses()
assert.Equal(fmt.Sprintf("(%s AND %s) AND NOT (%s)", clause1, clause2, clause3), r)
assert.Equal(fmt.Sprintf("((%s) AND (%s)) AND NOT ((%s))", clause1, clause2, clause3), r)
assert.Len(rArgs, 3)
// ensure empty filter with ANDed sub-filter does not include AND
@ -301,7 +301,7 @@ func TestGenerateWhereClauses(t *testing.T) {
f.and(sf)
r, rArgs = f.generateWhereClauses()
assert.Equal(fmt.Sprintf("(%s)", clause3), r)
assert.Equal(fmt.Sprintf("((%s))", clause3), r)
assert.Len(rArgs, 1)
// ensure empty filter with ORed sub-filter does not include OR
@ -309,7 +309,7 @@ func TestGenerateWhereClauses(t *testing.T) {
f.or(sf)
r, rArgs = f.generateWhereClauses()
assert.Equal(fmt.Sprintf("(%s)", clause3), r)
assert.Equal(fmt.Sprintf("((%s))", clause3), r)
assert.Len(rArgs, 1)
// ensure empty filter with NOTed sub-filter does not include AND
@ -317,7 +317,7 @@ func TestGenerateWhereClauses(t *testing.T) {
f.not(sf)
r, rArgs = f.generateWhereClauses()
assert.Equal(fmt.Sprintf("NOT (%s)", clause3), r)
assert.Equal(fmt.Sprintf("NOT ((%s))", clause3), r)
assert.Len(rArgs, 1)
// (clause1) AND ((clause2) OR (clause3))
@ -328,7 +328,7 @@ func TestGenerateWhereClauses(t *testing.T) {
f.and(sf2)
sf2.or(sf)
r, rArgs = f.generateWhereClauses()
assert.Equal(fmt.Sprintf("%s AND (%s OR (%s))", clause1, clause2, clause3), r)
assert.Equal(fmt.Sprintf("(%s) AND ((%s) OR ((%s)))", clause1, clause2, clause3), r)
assert.Len(rArgs, 3)
}
@ -348,14 +348,14 @@ func TestGenerateHavingClauses(t *testing.T) {
// ensure single Having clause is generated correctly
f.addHaving(clause1)
r, rArgs := f.generateHavingClauses()
assert.Equal(clause1, r)
assert.Equal(fmt.Sprintf("(%s)", clause1), r)
assert.Len(rArgs, 0)
// ensure multiple Having clauses are surrounded with parenthesis and
// ANDed together
f.addHaving(clause2, arg1, arg2)
r, rArgs = f.generateHavingClauses()
assert.Equal("("+clause1+" AND "+clause2+")", r)
assert.Equal("(("+clause1+") AND ("+clause2+"))", r)
assert.Len(rArgs, 2)
// ensure empty subfilter is not added to generated Having clause
@ -363,13 +363,13 @@ func TestGenerateHavingClauses(t *testing.T) {
f.and(sf)
r, rArgs = f.generateHavingClauses()
assert.Equal("("+clause1+" AND "+clause2+")", r)
assert.Equal("(("+clause1+") AND ("+clause2+"))", r)
assert.Len(rArgs, 2)
// ensure sub-filter is generated correctly
sf.addHaving(clause3, arg3)
r, rArgs = f.generateHavingClauses()
assert.Equal("("+clause1+" AND "+clause2+") AND ("+clause3+")", r)
assert.Equal("(("+clause1+") AND ("+clause2+")) AND (("+clause3+"))", r)
assert.Len(rArgs, 3)
// ensure OR sub-filter is generated correctly
@ -379,7 +379,7 @@ func TestGenerateHavingClauses(t *testing.T) {
f.or(sf)
r, rArgs = f.generateHavingClauses()
assert.Equal("("+clause1+" AND "+clause2+") OR ("+clause3+")", r)
assert.Equal("(("+clause1+") AND ("+clause2+")) OR (("+clause3+"))", r)
assert.Len(rArgs, 3)
// ensure NOT sub-filter is generated correctly
@ -389,7 +389,7 @@ func TestGenerateHavingClauses(t *testing.T) {
f.not(sf)
r, rArgs = f.generateHavingClauses()
assert.Equal("("+clause1+" AND "+clause2+") AND NOT ("+clause3+")", r)
assert.Equal("(("+clause1+") AND ("+clause2+")) AND NOT (("+clause3+"))", r)
assert.Len(rArgs, 3)
}

View file

@ -3,7 +3,6 @@ package sqlite
import (
"database/sql"
"fmt"
"strconv"
"github.com/stashapp/stash/pkg/models"
)
@ -426,23 +425,25 @@ func galleryPerformerTagsCriterionHandler(qb *galleryQueryBuilder, performerTags
}
}
func galleryAverageResolutionCriterionHandler(qb *galleryQueryBuilder, resolution *models.ResolutionEnum) criterionHandlerFunc {
func galleryAverageResolutionCriterionHandler(qb *galleryQueryBuilder, resolution *models.ResolutionCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) {
if resolution != nil && resolution.IsValid() {
if resolution != nil && resolution.Value.IsValid() {
qb.imagesRepository().join(f, "images_join", "galleries.id")
f.addJoin("images", "", "images_join.image_id = images.id")
min := resolution.GetMinResolution()
max := resolution.GetMaxResolution()
min := resolution.Value.GetMinResolution()
max := resolution.Value.GetMaxResolution()
const widthHeight = "avg(MIN(images.width, images.height))"
if min > 0 {
f.addHaving(widthHeight + " >= " + strconv.Itoa(min))
}
if max > 0 {
f.addHaving(widthHeight + " < " + strconv.Itoa(max))
if resolution.Modifier == models.CriterionModifierEquals {
f.addHaving(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, min, max))
} else if resolution.Modifier == models.CriterionModifierNotEquals {
f.addHaving(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, min, max))
} else if resolution.Modifier == models.CriterionModifierLessThan {
f.addHaving(fmt.Sprintf("%s < %d", widthHeight, min))
} else if resolution.Modifier == models.CriterionModifierGreaterThan {
f.addHaving(fmt.Sprintf("%s > %d", widthHeight, max))
}
}
}

View file

@ -914,7 +914,10 @@ func TestGalleryQueryAverageResolution(t *testing.T) {
qb := r.Gallery()
resolution := models.ResolutionEnumLow
galleryFilter := models.GalleryFilterType{
AverageResolution: &resolution,
AverageResolution: &models.ResolutionCriterionInput{
Value: resolution,
Modifier: models.CriterionModifierEquals,
},
}
// not verifying average - just ensure we get at least one

View file

@ -389,7 +389,10 @@ func verifyImagesResolution(t *testing.T, resolution models.ResolutionEnum) {
withTxn(func(r models.Repository) error {
sqb := r.Image()
imageFilter := models.ImageFilterType{
Resolution: &resolution,
Resolution: &models.ResolutionCriterionInput{
Value: resolution,
Modifier: models.CriterionModifierEquals,
},
}
images, _, err := sqb.Query(&imageFilter, nil)

View file

@ -3,7 +3,6 @@ package sqlite
import (
"database/sql"
"fmt"
"strconv"
"strings"
"github.com/stashapp/stash/pkg/models"
@ -346,6 +345,9 @@ func performerIsMissingCriterionHandler(qb *performerQueryBuilder, isMissing *st
case "image":
f.addJoin(performersImageTable, "image_join", "image_join.performer_id = performers.id")
f.addWhere("image_join.performer_id IS NULL")
case "stash_id":
qb.stashIDRepository().join(f, "performer_stash_ids", "performers.id")
f.addWhere("performer_stash_ids.performer_id IS NULL")
default:
f.addWhere("(performers." + *isMissing + " IS NULL OR TRIM(performers." + *isMissing + ") = '')")
}
@ -356,25 +358,8 @@ func performerIsMissingCriterionHandler(qb *performerQueryBuilder, isMissing *st
func yearFilterCriterionHandler(year *models.IntCriterionInput, col string) criterionHandlerFunc {
return func(f *filterBuilder) {
if year != nil && year.Modifier.IsValid() {
yearStr := strconv.Itoa(year.Value)
startOfYear := yearStr + "-01-01"
endOfYear := yearStr + "-12-31"
switch year.Modifier {
case models.CriterionModifierEquals:
// between yyyy-01-01 and yyyy-12-31
f.addWhere(col+" >= ?", startOfYear)
f.addWhere(col+" <= ?", endOfYear)
case models.CriterionModifierNotEquals:
// outside of yyyy-01-01 to yyyy-12-31
f.addWhere(col+" < ? OR "+col+" > ?", startOfYear, endOfYear)
case models.CriterionModifierGreaterThan:
// > yyyy-12-31
f.addWhere(col+" > ?", endOfYear)
case models.CriterionModifierLessThan:
// < yyyy-01-01
f.addWhere(col+" < ?", startOfYear)
}
clause, args := getIntCriterionWhereClause("cast(strftime('%Y', "+col+") as int)", *year)
f.addWhere(clause, args...)
}
}
}
@ -382,22 +367,11 @@ func yearFilterCriterionHandler(year *models.IntCriterionInput, col string) crit
func performerAgeFilterCriterionHandler(age *models.IntCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) {
if age != nil && age.Modifier.IsValid() {
var op string
switch age.Modifier {
case models.CriterionModifierEquals:
op = "=="
case models.CriterionModifierNotEquals:
op = "!="
case models.CriterionModifierGreaterThan:
op = ">"
case models.CriterionModifierLessThan:
op = "<"
}
if op != "" {
f.addWhere("cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int) "+op+" ?", age.Value)
}
clause, args := getIntCriterionWhereClause(
"cast(IFNULL(strftime('%Y.%m%d', performers.death_date), strftime('%Y.%m%d', 'now')) - strftime('%Y.%m%d', performers.birthdate) as int)",
*age,
)
f.addWhere(clause, args...)
}
}
}
@ -529,6 +503,12 @@ func (qb *performerQueryBuilder) getPerformerSort(findFilter *models.FindFilterT
if sort == "scenes_count" {
return getCountSort(performerTable, performersScenesTable, performerIDColumn, direction)
}
if sort == "images_count" {
return getCountSort(performerTable, performersImagesTable, performerIDColumn, direction)
}
if sort == "galleries_count" {
return getCountSort(performerTable, performersGalleriesTable, performerIDColumn, direction)
}
return getSort(sort, direction, "performers")
}

View file

@ -135,11 +135,9 @@ func (qb *queryBuilder) addFilter(f *filterBuilder) {
func (qb *queryBuilder) handleIntCriterionInput(c *models.IntCriterionInput, column string) {
if c != nil {
clause, count := getIntCriterionWhereClause(column, *c)
clause, args := getIntCriterionWhereClause(column, *c)
qb.addWhere(clause)
if count == 1 {
qb.addArg(c.Value)
}
qb.addArg(args...)
}
}
@ -192,12 +190,9 @@ func (qb *queryBuilder) handleStringCriterionInput(c *models.StringCriterionInpu
func (qb *queryBuilder) handleCountCriterion(countFilter *models.IntCriterionInput, primaryTable, joinTable, primaryFK string) {
if countFilter != nil {
clause, count := getCountCriterionClause(primaryTable, joinTable, primaryFK, *countFilter)
if count == 1 {
qb.addArg(countFilter.Value)
}
clause, args := getCountCriterionClause(primaryTable, joinTable, primaryFK, *countFilter)
qb.addWhere(clause)
qb.addArg(args...)
}
}

View file

@ -68,13 +68,15 @@ SELECT GROUP_CONCAT(id) as ids
FROM scenes
WHERE phash IS NOT NULL
GROUP BY phash
HAVING COUNT(*) > 1;
HAVING COUNT(phash) > 1
ORDER BY SUM(size) DESC;
`
var findAllPhashesQuery = `
SELECT id, phash
FROM scenes
WHERE phash IS NOT NULL
ORDER BY size DESC
`
type sceneQueryBuilder struct {
@ -272,6 +274,10 @@ func (qb *sceneQueryBuilder) Size() (float64, error) {
return qb.runSumQuery("SELECT SUM(cast(size as double)) as sum FROM scenes", nil)
}
func (qb *sceneQueryBuilder) Duration() (float64, error) {
return qb.runSumQuery("SELECT SUM(cast(duration as double)) as sum FROM scenes", nil)
}
func (qb *sceneQueryBuilder) CountByStudioID(studioID int) (int, error) {
args := []interface{}{studioID}
return qb.runCountQuery(qb.buildCountQuery(scenesForStudioQuery), args)
@ -461,54 +467,28 @@ func phashCriterionHandler(phashFilter *models.StringCriterionInput) criterionHa
func durationCriterionHandler(durationFilter *models.IntCriterionInput, column string) criterionHandlerFunc {
return func(f *filterBuilder) {
if durationFilter != nil {
clause, thisArgs := getDurationWhereClause(*durationFilter, column)
f.addWhere(clause, thisArgs...)
clause, args := getIntCriterionWhereClause("cast("+column+" as int)", *durationFilter)
f.addWhere(clause, args...)
}
}
}
func getDurationWhereClause(durationFilter models.IntCriterionInput, column string) (string, []interface{}) {
// special case for duration. We accept duration as seconds as int but the
// field is floating point. Change the equals filter to return a range
// between x and x + 1
// likewise, not equals needs to be duration < x OR duration >= x
var clause string
args := []interface{}{}
value := durationFilter.Value
if durationFilter.Modifier == models.CriterionModifierEquals {
clause = fmt.Sprintf("%[1]s >= ? AND %[1]s < ?", column)
args = append(args, value)
args = append(args, value+1)
} else if durationFilter.Modifier == models.CriterionModifierNotEquals {
clause = fmt.Sprintf("(%[1]s < ? OR %[1]s >= ?)", column)
args = append(args, value)
args = append(args, value+1)
} else {
var count int
clause, count = getIntCriterionWhereClause(column, durationFilter)
if count == 1 {
args = append(args, value)
}
}
return clause, args
}
func resolutionCriterionHandler(resolution *models.ResolutionEnum, heightColumn string, widthColumn string) criterionHandlerFunc {
func resolutionCriterionHandler(resolution *models.ResolutionCriterionInput, heightColumn string, widthColumn string) criterionHandlerFunc {
return func(f *filterBuilder) {
if resolution != nil && resolution.IsValid() {
min := resolution.GetMinResolution()
max := resolution.GetMaxResolution()
if resolution != nil && resolution.Value.IsValid() {
min := resolution.Value.GetMinResolution()
max := resolution.Value.GetMaxResolution()
widthHeight := fmt.Sprintf("MIN(%s, %s)", widthColumn, heightColumn)
if min > 0 {
f.addWhere(widthHeight + " >= " + strconv.Itoa(min))
}
if max > 0 {
f.addWhere(widthHeight + " < " + strconv.Itoa(max))
if resolution.Modifier == models.CriterionModifierEquals {
f.addWhere(fmt.Sprintf("%s BETWEEN %d AND %d", widthHeight, min, max))
} else if resolution.Modifier == models.CriterionModifierNotEquals {
f.addWhere(fmt.Sprintf("%s NOT BETWEEN %d AND %d", widthHeight, min, max))
} else if resolution.Modifier == models.CriterionModifierLessThan {
f.addWhere(fmt.Sprintf("%s < %d", widthHeight, min))
} else if resolution.Modifier == models.CriterionModifierGreaterThan {
f.addWhere(fmt.Sprintf("%s > %d", widthHeight, max))
}
}
}

View file

@ -648,7 +648,10 @@ func verifyScenesResolution(t *testing.T, resolution models.ResolutionEnum) {
withTxn(func(r models.Repository) error {
sqb := r.Scene()
sceneFilter := models.SceneFilterType{
Resolution: &resolution,
Resolution: &models.ResolutionCriterionInput{
Value: resolution,
Modifier: models.CriterionModifierEquals,
},
}
scenes := queryScene(t, sqb, &sceneFilter, nil)
@ -679,6 +682,76 @@ func verifySceneResolution(t *testing.T, height sql.NullInt64, resolution models
}
}
func TestAllResolutionsHaveResolutionRange(t *testing.T) {
for _, resolution := range models.AllResolutionEnum {
assert.NotZero(t, resolution.GetMinResolution(), "Define resolution range for %s in extension_resolution.go", resolution)
assert.NotZero(t, resolution.GetMaxResolution(), "Define resolution range for %s in extension_resolution.go", resolution)
}
}
func TestSceneQueryResolutionModifiers(t *testing.T) {
if err := withRollbackTxn(func(r models.Repository) error {
qb := r.Scene()
sceneNoResolution, _ := createScene(qb, 0, 0)
firstScene540P, _ := createScene(qb, 960, 540)
secondScene540P, _ := createScene(qb, 1280, 719)
firstScene720P, _ := createScene(qb, 1280, 720)
secondScene720P, _ := createScene(qb, 1280, 721)
thirdScene720P, _ := createScene(qb, 1920, 1079)
scene1080P, _ := createScene(qb, 1920, 1080)
scenesEqualTo720P := queryScenes(t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierEquals)
scenesNotEqualTo720P := queryScenes(t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierNotEquals)
scenesGreaterThan720P := queryScenes(t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierGreaterThan)
scenesLessThan720P := queryScenes(t, qb, models.ResolutionEnumStandardHd, models.CriterionModifierLessThan)
assert.Subset(t, scenesEqualTo720P, []*models.Scene{firstScene720P, secondScene720P, thirdScene720P})
assert.NotSubset(t, scenesEqualTo720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P, scene1080P})
assert.Subset(t, scenesNotEqualTo720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P, scene1080P})
assert.NotSubset(t, scenesNotEqualTo720P, []*models.Scene{firstScene720P, secondScene720P, thirdScene720P})
assert.Subset(t, scenesGreaterThan720P, []*models.Scene{scene1080P})
assert.NotSubset(t, scenesGreaterThan720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P, firstScene720P, secondScene720P, thirdScene720P})
assert.Subset(t, scenesLessThan720P, []*models.Scene{sceneNoResolution, firstScene540P, secondScene540P})
assert.NotSubset(t, scenesLessThan720P, []*models.Scene{scene1080P, firstScene720P, secondScene720P, thirdScene720P})
return nil
}); err != nil {
t.Error(err.Error())
}
}
func queryScenes(t *testing.T, queryBuilder models.SceneReaderWriter, resolution models.ResolutionEnum, modifier models.CriterionModifier) []*models.Scene {
sceneFilter := models.SceneFilterType{
Resolution: &models.ResolutionCriterionInput{
Value: resolution,
Modifier: modifier,
},
}
return queryScene(t, queryBuilder, &sceneFilter, nil)
}
func createScene(queryBuilder models.SceneReaderWriter, width int64, height int64) (*models.Scene, error) {
name := fmt.Sprintf("TestSceneQueryResolutionModifiers %d %d", width, height)
scene := models.Scene{
Path: name,
Width: sql.NullInt64{
Int64: width,
Valid: true,
},
Height: sql.NullInt64{
Int64: height,
Valid: true,
},
Checksum: sql.NullString{String: utils.MD5FromString(name), Valid: true},
}
return queryBuilder.Create(scene)
}
func TestSceneQueryHasMarkers(t *testing.T) {
withTxn(func(r models.Repository) error {
sqb := r.Scene()

View file

@ -605,6 +605,7 @@ func createScenes(sqb models.SceneReaderWriter, n int) error {
OCounter: getOCounter(i),
Duration: getSceneDuration(i),
Height: getHeight(i),
Width: getWidth(i),
Date: getSceneDate(i),
}

View file

@ -160,7 +160,7 @@ func getCriterionModifierBinding(criterionModifier models.CriterionModifier, val
}
if modifier := criterionModifier.String(); criterionModifier.IsValid() {
switch modifier {
case "EQUALS", "NOT_EQUALS", "GREATER_THAN", "LESS_THAN", "IS_NULL", "NOT_NULL":
case "EQUALS", "NOT_EQUALS", "GREATER_THAN", "LESS_THAN", "IS_NULL", "NOT_NULL", "BETWEEN", "NOT_BETWEEN":
return getSimpleCriterionClause(criterionModifier, "?")
case "INCLUDES":
return "IN " + getInBinding(length), length // TODO?
@ -189,6 +189,10 @@ func getSimpleCriterionClause(criterionModifier models.CriterionModifier, rhs st
return "IS NULL", 0
case "NOT_NULL":
return "IS NOT NULL", 0
case "BETWEEN":
return "BETWEEN (" + rhs + ") AND (" + rhs + ")", 2
case "NOT_BETWEEN":
return "NOT BETWEEN (" + rhs + ") AND (" + rhs + ")", 2
default:
logger.Errorf("todo")
return "= ?", 1 // TODO
@ -198,9 +202,30 @@ func getSimpleCriterionClause(criterionModifier models.CriterionModifier, rhs st
return "= ?", 1 // TODO
}
func getIntCriterionWhereClause(column string, input models.IntCriterionInput) (string, int) {
binding, count := getCriterionModifierBinding(input.Modifier, input.Value)
return column + " " + binding, count
func getIntCriterionWhereClause(column string, input models.IntCriterionInput) (string, []interface{}) {
binding, _ := getSimpleCriterionClause(input.Modifier, "?")
var args []interface{}
switch input.Modifier {
case "EQUALS", "NOT_EQUALS":
args = []interface{}{input.Value}
break
case "LESS_THAN":
args = []interface{}{input.Value}
break
case "GREATER_THAN":
args = []interface{}{input.Value}
break
case "BETWEEN", "NOT_BETWEEN":
upper := 0
if input.Value2 != nil {
upper = *input.Value2
}
args = []interface{}{input.Value, upper}
break
}
return column + " " + binding, args
}
// returns where clause and having clause
@ -226,7 +251,7 @@ func getMultiCriterionClause(primaryTable, foreignTable, joinTable, primaryFK, f
return whereClause, havingClause
}
func getCountCriterionClause(primaryTable, joinTable, primaryFK string, criterion models.IntCriterionInput) (string, int) {
func getCountCriterionClause(primaryTable, joinTable, primaryFK string, criterion models.IntCriterionInput) (string, []interface{}) {
lhs := fmt.Sprintf("(SELECT COUNT(*) FROM %s s WHERE s.%s = %s.id)", joinTable, primaryFK, primaryTable)
return getIntCriterionWhereClause(lhs, criterion)
}

View file

@ -271,14 +271,6 @@ func (qb *tagQueryBuilder) makeFilter(tagFilter *models.TagFilterType) *filterBu
query.not(qb.makeFilter(tagFilter.Not))
}
// if markerCount := tagFilter.MarkerCount; markerCount != nil {
// clause, count := getIntCriterionWhereClause("count(distinct scene_markers.id)", *markerCount)
// query.addHaving(clause)
// if count == 1 {
// query.addArg(markerCount.Value)
// }
// }
query.handleCriterion(stringCriterionHandler(tagFilter.Name, tagTable+".name"))
query.handleCriterion(tagAliasCriterionHandler(qb, tagFilter.Aliases))
@ -287,6 +279,7 @@ func (qb *tagQueryBuilder) makeFilter(tagFilter *models.TagFilterType) *filterBu
query.handleCriterion(tagImageCountCriterionHandler(qb, tagFilter.ImageCount))
query.handleCriterion(tagGalleryCountCriterionHandler(qb, tagFilter.GalleryCount))
query.handleCriterion(tagPerformerCountCriterionHandler(qb, tagFilter.PerformerCount))
query.handleCriterion(tagMarkerCountCriterionHandler(qb, tagFilter.MarkerCount))
return query
}
@ -303,19 +296,6 @@ func (qb *tagQueryBuilder) Query(tagFilter *models.TagFilterType, findFilter *mo
query.body = selectDistinctIDs(tagTable)
/*
query.body += `
left join tags_image on tags_image.tag_id = tags.id
left join scenes_tags on scenes_tags.tag_id = tags.id
left join scene_markers_tags on scene_markers_tags.tag_id = tags.id
left join scene_markers on scene_markers.primary_tag_id = tags.id OR scene_markers.id = scene_markers_tags.scene_marker_id
left join scenes on scenes_tags.scene_id = scenes.id`
*/
// the presence of joining on scene_markers.primary_tag_id and scene_markers_tags.tag_id
// appears to confuse sqlite and causes serious performance issues.
// Disabling querying/sorting on marker count for now.
if q := findFilter.Q; q != nil && *q != "" {
query.join(tagAliasesTable, "", "tag_aliases.tag_id = tags.id")
searchColumns := []string{"tags.name", "tag_aliases.alias"}
@ -379,12 +359,7 @@ func tagSceneCountCriterionHandler(qb *tagQueryBuilder, sceneCount *models.IntCr
return func(f *filterBuilder) {
if sceneCount != nil {
f.addJoin("scenes_tags", "", "scenes_tags.tag_id = tags.id")
clause, count := getIntCriterionWhereClause("count(distinct scenes_tags.scene_id)", *sceneCount)
args := []interface{}{}
if count == 1 {
args = append(args, sceneCount.Value)
}
clause, args := getIntCriterionWhereClause("count(distinct scenes_tags.scene_id)", *sceneCount)
f.addHaving(clause, args...)
}
@ -395,12 +370,7 @@ func tagImageCountCriterionHandler(qb *tagQueryBuilder, imageCount *models.IntCr
return func(f *filterBuilder) {
if imageCount != nil {
f.addJoin("images_tags", "", "images_tags.tag_id = tags.id")
clause, count := getIntCriterionWhereClause("count(distinct images_tags.image_id)", *imageCount)
args := []interface{}{}
if count == 1 {
args = append(args, imageCount.Value)
}
clause, args := getIntCriterionWhereClause("count(distinct images_tags.image_id)", *imageCount)
f.addHaving(clause, args...)
}
@ -411,12 +381,7 @@ func tagGalleryCountCriterionHandler(qb *tagQueryBuilder, galleryCount *models.I
return func(f *filterBuilder) {
if galleryCount != nil {
f.addJoin("galleries_tags", "", "galleries_tags.tag_id = tags.id")
clause, count := getIntCriterionWhereClause("count(distinct galleries_tags.gallery_id)", *galleryCount)
args := []interface{}{}
if count == 1 {
args = append(args, galleryCount.Value)
}
clause, args := getIntCriterionWhereClause("count(distinct galleries_tags.gallery_id)", *galleryCount)
f.addHaving(clause, args...)
}
@ -427,12 +392,19 @@ func tagPerformerCountCriterionHandler(qb *tagQueryBuilder, performerCount *mode
return func(f *filterBuilder) {
if performerCount != nil {
f.addJoin("performers_tags", "", "performers_tags.tag_id = tags.id")
clause, count := getIntCriterionWhereClause("count(distinct performers_tags.performer_id)", *performerCount)
clause, args := getIntCriterionWhereClause("count(distinct performers_tags.performer_id)", *performerCount)
args := []interface{}{}
if count == 1 {
args = append(args, performerCount.Value)
}
f.addHaving(clause, args...)
}
}
}
func tagMarkerCountCriterionHandler(qb *tagQueryBuilder, markerCount *models.IntCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) {
if markerCount != nil {
f.addJoin("scene_markers_tags", "", "scene_markers_tags.tag_id = tags.id")
f.addJoin("scene_markers", "", "scene_markers_tags.scene_marker_id = scene_markers.id OR scene_markers.primary_tag_id = tags.id")
clause, args := getIntCriterionWhereClause("count(distinct scene_markers.id)", *markerCount)
f.addHaving(clause, args...)
}
@ -459,6 +431,9 @@ func (qb *tagQueryBuilder) getTagSort(query *queryBuilder, findFilter *models.Fi
case "scenes_count":
query.join("scenes_tags", "", "scenes_tags.tag_id = tags.id")
return " ORDER BY COUNT(distinct scenes_tags.scene_id) " + direction
case "scene_markers_count":
query.join("scene_markers_tags", "", "scene_markers_tags.tag_id = tags.id")
return " ORDER BY COUNT(distinct scene_markers_tags.scene_marker_id) " + direction
case "images_count":
query.join("images_tags", "", "images_tags.tag_id = tags.id")
return " ORDER BY COUNT(distinct images_tags.image_id) " + direction

View file

@ -320,26 +320,24 @@ func verifyTagSceneCount(t *testing.T, sceneCountCriterion models.IntCriterionIn
})
}
// disabled due to performance issues
func TestTagQueryMarkerCount(t *testing.T) {
countCriterion := models.IntCriterionInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
}
// func TestTagQueryMarkerCount(t *testing.T) {
// countCriterion := models.IntCriterionInput{
// Value: 1,
// Modifier: models.CriterionModifierEquals,
// }
verifyTagMarkerCount(t, countCriterion)
// verifyTagMarkerCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierNotEquals
verifyTagMarkerCount(t, countCriterion)
// countCriterion.Modifier = models.CriterionModifierNotEquals
// verifyTagMarkerCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierLessThan
verifyTagMarkerCount(t, countCriterion)
// countCriterion.Modifier = models.CriterionModifierLessThan
// verifyTagMarkerCount(t, countCriterion)
// countCriterion.Value = 0
// countCriterion.Modifier = models.CriterionModifierGreaterThan
// verifyTagMarkerCount(t, countCriterion)
// }
countCriterion.Value = 0
countCriterion.Modifier = models.CriterionModifierGreaterThan
verifyTagMarkerCount(t, countCriterion)
}
func verifyTagMarkerCount(t *testing.T, markerCountCriterion models.IntCriterionInput) {
withTxn(func(r models.Repository) error {

View file

@ -346,3 +346,14 @@ func IsFsPathCaseSensitive(path string) (bool, error) {
}
return false, fmt.Errorf("can not determine case sensitivity of path %s", path)
}
func FindInPaths(paths []string, baseName string) string {
for _, p := range paths {
filePath := filepath.Join(p, baseName)
if exists, _ := FileExists(filePath); exists {
return filePath
}
}
return ""
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 44 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -13,5 +13,14 @@
"src/locales"
],
"i18n-ally.keystyle": "nested",
"i18n-ally.sourceLanguage": "en-GB"
"i18n-ally.sourceLanguage": "en-GB",
"spellright.language": [
"en"
],
"spellright.documentTypes": [
"markdown",
"latex",
"plaintext",
"typescriptreact"
]
}

View file

@ -1,7 +1,7 @@
import React, { useEffect } from "react";
import { Route, Switch, useRouteMatch } from "react-router-dom";
import { IntlProvider } from "react-intl";
import { merge } from "lodash";
import { mergeWith } from "lodash";
import { ToastProvider } from "src/hooks/Toast";
import LightboxProvider from "src/hooks/Lightbox/context";
import { library } from "@fortawesome/fontawesome-svg-core";
@ -59,11 +59,16 @@ export const App: React.FC = () => {
const messageLanguage = languageMessageString(language);
// use en-GB as default messages if any messages aren't found in the chosen language
const mergedMessages = merge(
const mergedMessages = mergeWith(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(locales as any)[defaultMessageLanguage],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(locales as any)[messageLanguage]
(locales as any)[messageLanguage],
(objVal, srcVal) => {
if (srcVal === "") {
return objVal;
}
}
);
const messages = flattenMessages(mergedMessages);

View file

@ -11,6 +11,7 @@ import V050 from "./versions/v050.md";
import V060 from "./versions/v060.md";
import V070 from "./versions/v070.md";
import V080 from "./versions/v080.md";
import V090 from "./versions/v090.md";
import { MarkdownPage } from "../Shared/MarkdownPage";
// to avoid use of explicit any
@ -49,9 +50,9 @@ const Changelog: React.FC = () => {
// after new release:
// add entry to releases, using the current* fields
// then update the current fields.
const currentVersion = stashVersion || "v0.8.0";
const currentVersion = stashVersion || "v0.9.0";
const currentDate = buildDate;
const currentPage = V080;
const currentPage = V090;
const releases: IStashRelease[] = [
{
@ -60,6 +61,11 @@ const Changelog: React.FC = () => {
page: currentPage,
defaultOpen: true,
},
{
version: "v0.8.0",
date: "2021-07-02",
page: V080,
},
{
version: "v0.7.0",
date: "2021-05-15",

View file

@ -0,0 +1,45 @@
### ✨ New Features
* Support setting a fixed funscript offset/delay. ([#1573](https://github.com/stashapp/stash/pull/1573))
* Added sort by options for image and gallery count for performers. ([#1671](https://github.com/stashapp/stash/pull/1671))
* Added sort by options for date, duration and rating for movies. ([#1663](https://github.com/stashapp/stash/pull/1663))
* Allow saving query page zoom level in saved and default filters. ([#1636](https://github.com/stashapp/stash/pull/1636))
* Support custom page sizes in the query page size dropdown. ([#1636](https://github.com/stashapp/stash/pull/1636))
* Added between/not between modifiers for number criteria. ([#1559](https://github.com/stashapp/stash/pull/1559))
* Support excluding tag patterns when scraping. ([#1617](https://github.com/stashapp/stash/pull/1617))
* Support setting a custom directory for default performer images. ([#1489](https://github.com/stashapp/stash/pull/1489))
* Added filtering and sorting on scene marker count for tags. ([#1603](https://github.com/stashapp/stash/pull/1603))
* Support excluding fields and editing tags when saving from scene tagger view. ([#1605](https://github.com/stashapp/stash/pull/1605))
* Added not equals/greater than/less than modifiers for resolution criteria. ([#1568](https://github.com/stashapp/stash/pull/1568))
### 🎨 Improvements
* Added support for loading TLS/SSL configuration files from the configuration directory. ([#1678](https://github.com/stashapp/stash/pull/1678))
* Added total scenes duration to Stats page. ([#1626](https://github.com/stashapp/stash/pull/1626))
* Move Play Selected Scenes, and Add/Remove Gallery Image buttons to button toolbar. ([#1673](https://github.com/stashapp/stash/pull/1673))
* Added image and gallery counts to tag list view. ([#1672](https://github.com/stashapp/stash/pull/1672))
* Prompt when leaving gallery and image edit pages with unsaved changes. ([#1654](https://github.com/stashapp/stash/pull/1654), [#1669](https://github.com/stashapp/stash/pull/1669))
* Show largest duplicates first in scene duplicate checker. ([#1639](https://github.com/stashapp/stash/pull/1639))
* Added checkboxes to scene list view. ([#1642](https://github.com/stashapp/stash/pull/1642))
* Added keyboard shortcuts for scene queue navigation. ([#1635](https://github.com/stashapp/stash/pull/1635))
* Made performer scrape menu scrollable. ([#1634](https://github.com/stashapp/stash/pull/1634))
* Improve Studio UI. ([#1629](https://github.com/stashapp/stash/pull/1629))
* Improve link styling and ensure links open in a new tab. ([#1622](https://github.com/stashapp/stash/pull/1622))
* Added zh-CN language option. ([#1620](https://github.com/stashapp/stash/pull/1620))
* Moved scraping settings into the Scraping settings page. ([#1548](https://github.com/stashapp/stash/pull/1548))
* Show current scene details in tagger view. ([#1605](https://github.com/stashapp/stash/pull/1605))
* Removed stripes and added background colour to default performer images (old images can be downloaded from the PR link). ([#1609](https://github.com/stashapp/stash/pull/1609))
* Added pt-BR language option. ([#1587](https://github.com/stashapp/stash/pull/1587))
* Added de-DE language option. ([#1578](https://github.com/stashapp/stash/pull/1578))
### 🐛 Bug fixes
* Fix SQL error when filtering for Performers missing stash IDs. ([#1681](https://github.com/stashapp/stash/pull/1681))
* Fix Play Selected scene UI error when one scene is selected. ([#1674](https://github.com/stashapp/stash/pull/1674))
* Fix race condition panic when reading and writing config concurrently. ([#1645](https://github.com/stashapp/stash/issues/1343))
* Fix performance issue on Studios page getting studio image count. ([#1643](https://github.com/stashapp/stash/pull/1643))
* Regenerate scene phash if overwrite flag is set. ([#1633](https://github.com/stashapp/stash/pull/1633))
* Create .stash directory in $HOME only if required. ([#1623](https://github.com/stashapp/stash/pull/1623))
* Include stash id when scraping performer from stash-box. ([#1608](https://github.com/stashapp/stash/pull/1608))
* Fix infinity framerate values causing resolver error. ([#1607](https://github.com/stashapp/stash/pull/1607))
* Fix unsetting performer gender not working correctly. ([#1606](https://github.com/stashapp/stash/pull/1606))
* Fix is missing date scene criterion causing invalid SQL. ([#1577](https://github.com/stashapp/stash/pull/1577))
* Fix rendering of carousel images on Apple devices. ([#1562](https://github.com/stashapp/stash/pull/1562))
* Show New and Delete buttons in mobile view. ([#1539](https://github.com/stashapp/stash/pull/1539))

View file

@ -277,7 +277,7 @@ export const Gallery: React.FC = () => {
if (isNew)
return (
<div className="row new-view">
<div className="col-6">
<div className="col-md-6">
<h2>
<FormattedMessage
id="actions.create_entity"

View file

@ -8,6 +8,7 @@ import { mutateAddGalleryImages } from "src/core/StashService";
import { useToast } from "src/hooks";
import { TextUtils } from "src/utils";
import { useIntl } from "react-intl";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
interface IGalleryAddProps {
gallery: Partial<GQL.GalleryDataFragment>;
@ -87,6 +88,7 @@ export const GalleryAddPanel: React.FC<IGalleryAddProps> = ({ gallery }) => {
onClick: addImages,
isDisplayed: showWhenSelected,
postRefetch: true,
icon: "plus" as IconProp,
},
];

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