Merge pull request #1697 from stashapp/develop
Merge develop to master for 0.9
2
.github/workflows/build.yml
vendored
|
|
@ -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 }}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ fragment ScrapedPerformerData on ScrapedPerformer {
|
|||
death_date
|
||||
hair_color
|
||||
weight
|
||||
remote_site_id
|
||||
}
|
||||
|
||||
fragment ScrapedScenePerformerData on ScrapedScenePerformer {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ mutation ConfigureDLNA($input: ConfigDLNAInput!) {
|
|||
}
|
||||
}
|
||||
|
||||
mutation ConfigureScraping($input: ConfigScrapingInput!) {
|
||||
configureScraping(input: $input) {
|
||||
...ConfigScrapingData
|
||||
}
|
||||
}
|
||||
|
||||
mutation GenerateAPIKey($input: GenerateAPIKeyInput!) {
|
||||
generateAPIKey(input: $input)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ query Stats {
|
|||
stats {
|
||||
scene_count,
|
||||
scenes_size,
|
||||
scenes_duration,
|
||||
image_count,
|
||||
images_size,
|
||||
gallery_count,
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
|
|
|
|||
|
|
@ -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"""
|
||||
|
|
|
|||
|
|
@ -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!
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -125,7 +125,8 @@ type FindScenesResultType {
|
|||
input SceneParserInput {
|
||||
ignoreWords: [String!],
|
||||
whitespaceCharacters: String,
|
||||
capitalizeTitle: Boolean
|
||||
capitalizeTitle: Boolean,
|
||||
ignoreOrganized: Boolean
|
||||
}
|
||||
|
||||
type SceneMovieID {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
type StatsResultType {
|
||||
scene_count: Int!
|
||||
scenes_size: Float!
|
||||
scenes_duration: Float!
|
||||
image_count: Int!
|
||||
images_size: Float!
|
||||
gallery_count: Int!
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
100
pkg/manager/config/config_concurrency_test.go
Normal 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()
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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...)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 152 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 8.4 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 9.5 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 9.6 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 53 KiB After Width: | Height: | Size: 14 KiB |
11
ui/v2.5/.vscode/settings.json
vendored
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
45
ui/v2.5/src/components/Changelog/versions/v090.md
Normal 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))
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||