Compare commits

...

5 Commits

Author SHA1 Message Date
WithoutPants
113f0b7d77 Update changelog for bugfix release 2023-08-21 09:51:23 +10:00
WithoutPants
58b6ca3f4b Show primary tag error on touch or submit (#4040) 2023-08-17 15:10:05 +10:00
WithoutPants
87e12319e4 Change drop location for dropdown menus (#4039) 2023-08-17 15:09:55 +10:00
WithoutPants
efc7b01cf6 Add explicit option to store blobs in database at setup (#4038) 2023-08-17 15:09:43 +10:00
DingDongSoLong4
1591180070 Fix bulk performer tagger (#4024)
* Fix tagger modal checkboxes
* Fix UNIQUE constraint detection
* Performer tagger cache invalidation
* Fix batch performer tagger
* Use ToPerformer in identify
* Add missing excluded fields
* Internationalize excluded fields
* Replace deprecated substr()
* Check RemoteSiteID nil
2023-08-17 10:21:24 +10:00
27 changed files with 815 additions and 703 deletions

View File

@@ -8,7 +8,8 @@ input SetupInput {
generatedLocation: String!
"Empty to indicate default"
cacheLocation: String!
"Empty to indicate database storage for blobs"
storeBlobsInDatabase: Boolean!
"Empty to indicate default - only applicable if storeBlobsInDatabase is false"
blobsLocation: String!
}

View File

@@ -5,11 +5,8 @@ import (
"fmt"
"strconv"
"strings"
"time"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/utils"
)
type PerformerCreator interface {
@@ -38,127 +35,23 @@ func getPerformerID(ctx context.Context, endpoint string, w PerformerCreator, p
}
func createMissingPerformer(ctx context.Context, endpoint string, w PerformerCreator, p *models.ScrapedPerformer) (*int, error) {
performerInput := scrapedToPerformerInput(p)
if endpoint != "" && p.RemoteSiteID != nil {
performerInput.StashIDs = models.NewRelatedStashIDs([]models.StashID{
{
Endpoint: endpoint,
StashID: *p.RemoteSiteID,
},
})
newPerformer := p.ToPerformer(endpoint, nil)
performerImage, err := p.GetImage(ctx, nil)
if err != nil {
return nil, err
}
err := w.Create(ctx, &performerInput)
err = w.Create(ctx, newPerformer)
if err != nil {
return nil, fmt.Errorf("error creating performer: %w", err)
}
// update image table
if p.Image != nil && len(*p.Image) > 0 {
imageData, err := utils.ReadImageFromURL(ctx, *p.Image)
if err != nil {
return nil, err
}
err = w.UpdateImage(ctx, performerInput.ID, imageData)
if err != nil {
if len(performerImage) > 0 {
if err := w.UpdateImage(ctx, newPerformer.ID, performerImage); err != nil {
return nil, err
}
}
return &performerInput.ID, nil
}
func scrapedToPerformerInput(performer *models.ScrapedPerformer) models.Performer {
currentTime := time.Now()
ret := models.Performer{
Name: *performer.Name,
CreatedAt: currentTime,
UpdatedAt: currentTime,
}
if performer.Disambiguation != nil {
ret.Disambiguation = *performer.Disambiguation
}
if performer.Birthdate != nil {
d, err := models.ParseDate(*performer.Birthdate)
if err == nil {
ret.Birthdate = &d
}
}
if performer.DeathDate != nil {
d, err := models.ParseDate(*performer.DeathDate)
if err == nil {
ret.DeathDate = &d
}
}
if performer.Gender != nil {
v := models.GenderEnum(*performer.Gender)
ret.Gender = &v
}
if performer.Ethnicity != nil {
ret.Ethnicity = *performer.Ethnicity
}
if performer.Country != nil {
ret.Country = *performer.Country
}
if performer.EyeColor != nil {
ret.EyeColor = *performer.EyeColor
}
if performer.HairColor != nil {
ret.HairColor = *performer.HairColor
}
if performer.Height != nil {
h, err := strconv.Atoi(*performer.Height) // height is stored as an int
if err == nil {
ret.Height = &h
}
}
if performer.Weight != nil {
h, err := strconv.Atoi(*performer.Weight)
if err == nil {
ret.Weight = &h
}
}
if performer.Measurements != nil {
ret.Measurements = *performer.Measurements
}
if performer.FakeTits != nil {
ret.FakeTits = *performer.FakeTits
}
if performer.PenisLength != nil {
h, err := strconv.ParseFloat(*performer.PenisLength, 64)
if err == nil {
ret.PenisLength = &h
}
}
if performer.Circumcised != nil {
v := models.CircumisedEnum(*performer.Circumcised)
ret.Circumcised = &v
}
if performer.CareerLength != nil {
ret.CareerLength = *performer.CareerLength
}
if performer.Tattoos != nil {
ret.Tattoos = *performer.Tattoos
}
if performer.Piercings != nil {
ret.Piercings = *performer.Piercings
}
if performer.Aliases != nil {
ret.Aliases = models.NewRelatedStrings(stringslice.FromString(*performer.Aliases, ","))
}
if performer.Twitter != nil {
ret.Twitter = *performer.Twitter
}
if performer.Instagram != nil {
ret.Instagram = *performer.Instagram
}
if performer.URL != nil {
ret.URL = *performer.URL
}
if performer.Details != nil {
ret.Details = *performer.Details
}
return ret
return &newPerformer.ID, nil
}

View File

@@ -3,14 +3,11 @@ package identify
import (
"errors"
"reflect"
"strconv"
"testing"
"time"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
@@ -22,6 +19,7 @@ func Test_getPerformerID(t *testing.T) {
invalidStoredID := "invalidStoredID"
validStoredIDStr := "1"
validStoredID := 1
remoteSiteID := "2"
name := "name"
mockPerformerReaderWriter := mocks.PerformerReaderWriter{}
@@ -121,7 +119,8 @@ func Test_getPerformerID(t *testing.T) {
args{
emptyEndpoint,
&models.ScrapedPerformer{
Name: &name,
Name: &name,
RemoteSiteID: &remoteSiteID,
},
true,
false,
@@ -179,7 +178,8 @@ func Test_createMissingPerformer(t *testing.T) {
args{
emptyEndpoint,
&models.ScrapedPerformer{
Name: &validName,
Name: &validName,
RemoteSiteID: &remoteSiteID,
},
},
&performerID,
@@ -190,7 +190,8 @@ func Test_createMissingPerformer(t *testing.T) {
args{
emptyEndpoint,
&models.ScrapedPerformer{
Name: &invalidName,
Name: &invalidName,
RemoteSiteID: &remoteSiteID,
},
},
nil,
@@ -222,120 +223,3 @@ func Test_createMissingPerformer(t *testing.T) {
})
}
}
func Test_scrapedToPerformerInput(t *testing.T) {
name := "name"
var stringValues []string
for i := 0; i < 20; i++ {
stringValues = append(stringValues, strconv.Itoa(i))
}
upTo := 0
nextVal := func() *string {
ret := stringValues[upTo]
upTo = (upTo + 1) % len(stringValues)
return &ret
}
nextIntVal := func() *int {
ret := upTo
upTo = (upTo + 1) % len(stringValues)
return &ret
}
dateFromInt := func(i int) *models.Date {
t := time.Date(2001, 1, i, 0, 0, 0, 0, time.UTC)
d := models.Date{Time: t}
return &d
}
dateStrFromInt := func(i int) *string {
s := dateFromInt(i).String()
return &s
}
genderFromInt := func(i int) *models.GenderEnum {
g := models.AllGenderEnum[i%len(models.AllGenderEnum)]
return &g
}
genderStrFromInt := func(i int) *string {
s := genderFromInt(i).String()
return &s
}
tests := []struct {
name string
performer *models.ScrapedPerformer
want models.Performer
}{
{
"set all",
&models.ScrapedPerformer{
Name: &name,
Disambiguation: nextVal(),
Birthdate: dateStrFromInt(*nextIntVal()),
DeathDate: dateStrFromInt(*nextIntVal()),
Gender: genderStrFromInt(*nextIntVal()),
Ethnicity: nextVal(),
Country: nextVal(),
EyeColor: nextVal(),
HairColor: nextVal(),
Height: nextVal(),
Weight: nextVal(),
Measurements: nextVal(),
FakeTits: nextVal(),
CareerLength: nextVal(),
Tattoos: nextVal(),
Piercings: nextVal(),
Aliases: nextVal(),
Twitter: nextVal(),
Instagram: nextVal(),
URL: nextVal(),
Details: nextVal(),
},
models.Performer{
Name: name,
Disambiguation: *nextVal(),
Birthdate: dateFromInt(*nextIntVal()),
DeathDate: dateFromInt(*nextIntVal()),
Gender: genderFromInt(*nextIntVal()),
Ethnicity: *nextVal(),
Country: *nextVal(),
EyeColor: *nextVal(),
HairColor: *nextVal(),
Height: nextIntVal(),
Weight: nextIntVal(),
Measurements: *nextVal(),
FakeTits: *nextVal(),
CareerLength: *nextVal(),
Tattoos: *nextVal(),
Piercings: *nextVal(),
Aliases: models.NewRelatedStrings([]string{*nextVal()}),
Twitter: *nextVal(),
Instagram: *nextVal(),
URL: *nextVal(),
Details: *nextVal(),
},
},
{
"set none",
&models.ScrapedPerformer{
Name: &name,
},
models.Performer{
Name: name,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := scrapedToPerformerInput(tt.performer)
// clear created/updated dates
got.CreatedAt = time.Time{}
got.UpdatedAt = got.CreatedAt
assert.Equal(t, tt.want, got)
})
}
}

View File

@@ -100,7 +100,9 @@ type SetupInput struct {
GeneratedLocation string `json:"generatedLocation"`
// Empty to indicate default
CacheLocation string `json:"cacheLocation"`
// Empty to indicate database storage for blobs
StoreBlobsInDatabase bool `json:"storeBlobsInDatabase"`
// Empty to indicate default
BlobsLocation string `json:"blobsLocation"`
}
@@ -596,6 +598,10 @@ func setSetupDefaults(input *SetupInput) {
if input.DatabaseFile == "" {
input.DatabaseFile = filepath.Join(configDir, "stash-go.sqlite")
}
if input.BlobsLocation == "" {
input.BlobsLocation = filepath.Join(configDir, "blobs")
}
}
func (s *Manager) Setup(ctx context.Context, input SetupInput) error {
@@ -648,20 +654,20 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error {
s.Config.Set(config.Cache, input.CacheLocation)
}
// if blobs path was provided then use filesystem based blob storage
if input.BlobsLocation != "" {
if input.StoreBlobsInDatabase {
s.Config.Set(config.BlobsStorage, config.BlobStorageTypeDatabase)
} else {
if !c.HasOverride(config.BlobsPath) {
if exists, _ := fsutil.DirExists(input.BlobsLocation); !exists {
if err := os.MkdirAll(input.BlobsLocation, 0755); err != nil {
return fmt.Errorf("error creating blobs directory: %v", err)
}
}
s.Config.Set(config.BlobsPath, input.BlobsLocation)
}
s.Config.Set(config.BlobsPath, input.BlobsLocation)
s.Config.Set(config.BlobsStorage, config.BlobStorageTypeFilesystem)
} else {
s.Config.Set(config.BlobsStorage, config.BlobStorageTypeDatabase)
}
// set the configuration

View File

@@ -410,13 +410,10 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB
}
for i := range namesToUse {
if len(namesToUse[i]) > 0 {
performer := models.Performer{
Name: namesToUse[i],
}
name := namesToUse[i]
if len(name) > 0 {
tasks = append(tasks, StashBoxBatchTagTask{
performer: &performer,
name: &name,
refresh: false,
box: box,
excludedFields: input.ExcludeFields,
@@ -435,6 +432,7 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB
performerQuery := s.Repository.Performer
var performers []*models.Performer
var err error
if input.Refresh {
performers, err = performerQuery.FindByStashIDStatus(ctx, true, box.Endpoint)
} else {
@@ -473,12 +471,9 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, input StashBoxB
logger.Infof("Starting stash-box batch operation for %d performers", len(tasks))
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1)
progress.ExecuteTask(task.Description(), func() {
task.Start(ctx)
wg.Done()
})
progress.Increment()
@@ -544,9 +539,10 @@ func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, input StashBoxBatc
} else if len(input.Names) > 0 {
// The user is batch adding studios
for i := range input.Names {
if len(input.Names[i]) > 0 {
name := input.Names[i]
if len(name) > 0 {
tasks = append(tasks, StashBoxBatchTagTask{
name: &input.Names[i],
name: &name,
refresh: false,
createParent: input.CreateParent,
box: box,
@@ -602,12 +598,9 @@ func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, input StashBoxBatc
logger.Infof("Starting stash-box batch operation for %d studios", len(tasks))
var wg sync.WaitGroup
for _, task := range tasks {
wg.Add(1)
progress.ExecuteTask(task.Description(), func() {
task.Start(ctx)
wg.Done()
})
progress.Increment()

View File

@@ -4,15 +4,12 @@ import (
"context"
"fmt"
"strconv"
"time"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper/stashbox"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/studio"
"github.com/stashapp/stash/pkg/txn"
"github.com/stashapp/stash/pkg/utils"
)
type StashBoxTagTaskType int
@@ -66,38 +63,9 @@ func (t *StashBoxBatchTagTask) Description() string {
}
func (t *StashBoxBatchTagTask) stashBoxPerformerTag(ctx context.Context) {
var performer *models.ScrapedPerformer
var err error
client := stashbox.NewClient(*t.box, instance.Repository, stashbox.Repository{
Scene: instance.Repository.Scene,
Performer: instance.Repository.Performer,
Tag: instance.Repository.Tag,
Studio: instance.Repository.Studio,
})
if t.refresh {
var performerID string
for _, id := range t.performer.StashIDs.List() {
if id.Endpoint == t.box.Endpoint {
performerID = id.StashID
}
}
if performerID != "" {
performer, err = client.FindStashBoxPerformerByID(ctx, performerID)
}
} else {
var name string
if t.name != nil {
name = *t.name
} else {
name = t.performer.Name
}
performer, err = client.FindStashBoxPerformerByName(ctx, name)
}
performer, err := t.findStashBoxPerformer(ctx)
if err != nil {
logger.Errorf("Error fetching performer data from stash-box: %s", err.Error())
logger.Errorf("Error fetching performer data from stash-box: %v", err)
return
}
@@ -106,104 +74,9 @@ func (t *StashBoxBatchTagTask) stashBoxPerformerTag(ctx context.Context) {
excluded[field] = true
}
// performer will have a value if pulling from Stash-box by Stash ID or name was successful
if performer != nil {
if t.performer != nil {
partial := t.getPartial(performer, excluded)
txnErr := txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error {
r := instance.Repository
_, err := r.Performer.UpdatePartial(ctx, t.performer.ID, partial)
if len(performer.Images) > 0 && !excluded["image"] {
image, err := utils.ReadImageFromURL(ctx, performer.Images[0])
if err == nil {
err = r.Performer.UpdateImage(ctx, t.performer.ID, image)
if err != nil {
return err
}
} else {
logger.Warnf("Failed to read performer image: %v", err)
}
}
if err == nil {
var name string
if performer.Name != nil {
name = *performer.Name
}
logger.Infof("Updated performer %s", name)
}
return err
})
if txnErr != nil {
logger.Warnf("failure to execute partial update of performer: %v", txnErr)
}
} else if t.name != nil && performer.Name != nil {
currentTime := time.Now()
var aliases []string
if performer.Aliases != nil {
aliases = stringslice.FromString(*performer.Aliases, ",")
} else {
aliases = []string{}
}
newPerformer := models.Performer{
Aliases: models.NewRelatedStrings(aliases),
Disambiguation: getString(performer.Disambiguation),
Details: getString(performer.Details),
Birthdate: getDate(performer.Birthdate),
DeathDate: getDate(performer.DeathDate),
CareerLength: getString(performer.CareerLength),
Country: getString(performer.Country),
CreatedAt: currentTime,
Ethnicity: getString(performer.Ethnicity),
EyeColor: getString(performer.EyeColor),
HairColor: getString(performer.HairColor),
FakeTits: getString(performer.FakeTits),
Height: getIntPtr(performer.Height),
Weight: getIntPtr(performer.Weight),
Instagram: getString(performer.Instagram),
Measurements: getString(performer.Measurements),
Name: *performer.Name,
Piercings: getString(performer.Piercings),
Tattoos: getString(performer.Tattoos),
Twitter: getString(performer.Twitter),
URL: getString(performer.URL),
StashIDs: models.NewRelatedStashIDs([]models.StashID{
{
Endpoint: t.box.Endpoint,
StashID: *performer.RemoteSiteID,
},
}),
UpdatedAt: currentTime,
}
if performer.Gender != nil {
v := models.GenderEnum(getString(performer.Gender))
newPerformer.Gender = &v
}
err := txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error {
r := instance.Repository
err := r.Performer.Create(ctx, &newPerformer)
if err != nil {
return err
}
if len(performer.Images) > 0 {
image, imageErr := utils.ReadImageFromURL(ctx, performer.Images[0])
if imageErr != nil {
return imageErr
}
err = r.Performer.UpdateImage(ctx, newPerformer.ID, image)
}
return err
})
if err != nil {
logger.Errorf("Failed to save performer %s: %s", *t.name, err.Error())
} else {
logger.Infof("Saved performer %s", *t.name)
}
}
t.processMatchedPerformer(ctx, performer, excluded)
} else {
var name string
if t.name != nil {
@@ -215,10 +88,131 @@ func (t *StashBoxBatchTagTask) stashBoxPerformerTag(ctx context.Context) {
}
}
func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*models.ScrapedPerformer, error) {
var performer *models.ScrapedPerformer
var err error
client := stashbox.NewClient(*t.box, instance.Repository, stashbox.Repository{
Scene: instance.Repository.Scene,
Performer: instance.Repository.Performer,
Tag: instance.Repository.Tag,
Studio: instance.Repository.Studio,
})
if t.refresh {
var remoteID string
if err := txn.WithReadTxn(ctx, instance.Repository, func(ctx context.Context) error {
if !t.performer.StashIDs.Loaded() {
err = t.performer.LoadStashIDs(ctx, instance.Repository.Performer)
if err != nil {
return err
}
}
for _, id := range t.performer.StashIDs.List() {
if id.Endpoint == t.box.Endpoint {
remoteID = id.StashID
}
}
return nil
}); err != nil {
return nil, err
}
if remoteID != "" {
performer, err = client.FindStashBoxPerformerByID(ctx, remoteID)
}
} else {
var name string
if t.name != nil {
name = *t.name
} else {
name = t.performer.Name
}
performer, err = client.FindStashBoxPerformerByName(ctx, name)
}
return performer, err
}
func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool) {
// Refreshing an existing performer
if t.performer != nil {
storedID, _ := strconv.Atoi(*p.StoredID)
existingStashIDs := getStashIDsForPerformer(ctx, storedID)
partial := p.ToPartial(t.box.Endpoint, excluded, existingStashIDs)
image, err := p.GetImage(ctx, excluded)
if err != nil {
logger.Errorf("Error processing scraped performer image for %s: %v", *p.Name, err)
return
}
// Start the transaction and update the performer
err = txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error {
qb := instance.Repository.Performer
if _, err := qb.UpdatePartial(ctx, t.performer.ID, partial); err != nil {
return err
}
if len(image) > 0 {
if err := qb.UpdateImage(ctx, t.performer.ID, image); err != nil {
return err
}
}
return nil
})
if err != nil {
logger.Errorf("Failed to update performer %s: %v", *p.Name, err)
} else {
logger.Infof("Updated performer %s", *p.Name)
}
} else if t.name != nil && p.Name != nil {
// Creating a new performer
newPerformer := p.ToPerformer(t.box.Endpoint, excluded)
image, err := p.GetImage(ctx, excluded)
if err != nil {
logger.Errorf("Error processing scraped performer image for %s: %v", *p.Name, err)
return
}
err = txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error {
qb := instance.Repository.Performer
if err := qb.Create(ctx, newPerformer); err != nil {
return err
}
if len(image) > 0 {
if err := qb.UpdateImage(ctx, newPerformer.ID, image); err != nil {
return err
}
}
return nil
})
if err != nil {
logger.Errorf("Failed to create performer %s: %v", *p.Name, err)
} else {
logger.Infof("Created performer %s", *p.Name)
}
}
}
func getStashIDsForPerformer(ctx context.Context, performerID int) []models.StashID {
tempPerformer := &models.Performer{ID: performerID}
err := tempPerformer.LoadStashIDs(ctx, instance.Repository.Performer)
if err != nil {
return nil
}
return tempPerformer.StashIDs.List()
}
func (t *StashBoxBatchTagTask) stashBoxStudioTag(ctx context.Context) {
studio, err := t.findStashBoxStudio(ctx)
if err != nil {
logger.Errorf("Error fetching studio data from stash-box: %s", err.Error())
logger.Errorf("Error fetching studio data from stash-box: %v", err)
return
}
@@ -254,24 +248,20 @@ func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.
if t.refresh {
var remoteID string
txnErr := txn.WithReadTxn(ctx, instance.Repository, func(ctx context.Context) error {
if err := txn.WithReadTxn(ctx, instance.Repository, func(ctx context.Context) error {
if !t.studio.StashIDs.Loaded() {
err = t.studio.LoadStashIDs(ctx, instance.Repository.Studio)
if err != nil {
return err
}
}
stashids := t.studio.StashIDs.List()
for _, id := range stashids {
for _, id := range t.studio.StashIDs.List() {
if id.Endpoint == t.box.Endpoint {
remoteID = id.StashID
}
}
return nil
})
if txnErr != nil {
logger.Warnf("error while executing read transaction: %v", err)
}); err != nil {
return nil, err
}
if remoteID != "" {
@@ -293,6 +283,8 @@ func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.
func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *models.ScrapedStudio, excluded map[string]bool) {
// Refreshing an existing studio
if t.studio != nil {
storedID, _ := strconv.Atoi(*s.StoredID)
if s.Parent != nil && t.createParent {
err := t.processParentStudio(ctx, s.Parent, excluded)
if err != nil {
@@ -300,11 +292,12 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
}
}
existingStashIDs := getStashIDsForStudio(ctx, *s.StoredID)
studioPartial := s.ToPartial(s.StoredID, t.box.Endpoint, excluded, existingStashIDs)
studioImage, err := s.GetImage(ctx, excluded)
existingStashIDs := getStashIDsForStudio(ctx, storedID)
partial := s.ToPartial(s.StoredID, t.box.Endpoint, excluded, existingStashIDs)
image, err := s.GetImage(ctx, excluded)
if err != nil {
logger.Errorf("Failed to make studio partial from scraped studio %s: %s", s.Name, err.Error())
logger.Errorf("Error processing scraped studio image for %s: %v", s.Name, err)
return
}
@@ -312,16 +305,16 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
err = txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error {
qb := instance.Repository.Studio
if err := studio.ValidateModify(ctx, *studioPartial, qb); err != nil {
if err := studio.ValidateModify(ctx, *partial, qb); err != nil {
return err
}
if _, err := qb.UpdatePartial(ctx, *studioPartial); err != nil {
if _, err := qb.UpdatePartial(ctx, *partial); err != nil {
return err
}
if len(studioImage) > 0 {
if err := qb.UpdateImage(ctx, studioPartial.ID, studioImage); err != nil {
if len(image) > 0 {
if err := qb.UpdateImage(ctx, partial.ID, image); err != nil {
return err
}
}
@@ -329,7 +322,7 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
return nil
})
if err != nil {
logger.Errorf("Failed to update studio %s: %s", s.Name, err.Error())
logger.Errorf("Failed to update studio %s: %v", s.Name, err)
} else {
logger.Infof("Updated studio %s", s.Name)
}
@@ -345,7 +338,7 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
newStudio := s.ToStudio(t.box.Endpoint, excluded)
studioImage, err := s.GetImage(ctx, excluded)
if err != nil {
logger.Errorf("Failed to make studio from scraped studio %s: %s", s.Name, err.Error())
logger.Errorf("Error processing scraped studio image for %s: %v", s.Name, err)
return
}
@@ -365,7 +358,7 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
return nil
})
if err != nil {
logger.Errorf("Failed to create studio %s: %s", s.Name, err.Error())
logger.Errorf("Failed to create studio %s: %v", s.Name, err)
} else {
logger.Infof("Created studio %s", s.Name)
}
@@ -376,9 +369,10 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *
if parent.StoredID == nil {
// The parent needs to be created
newParentStudio := parent.ToStudio(t.box.Endpoint, excluded)
studioImage, err := parent.GetImage(ctx, excluded)
image, err := parent.GetImage(ctx, excluded)
if err != nil {
logger.Errorf("Failed to make parent studio from scraped studio %s: %s", parent.Name, err.Error())
logger.Errorf("Error processing scraped studio image for %s: %v", parent.Name, err)
return err
}
@@ -389,8 +383,8 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *
return err
}
if len(studioImage) > 0 {
if err := qb.UpdateImage(ctx, newParentStudio.ID, studioImage); err != nil {
if len(image) > 0 {
if err := qb.UpdateImage(ctx, newParentStudio.ID, image); err != nil {
return err
}
}
@@ -400,17 +394,21 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *
return nil
})
if err != nil {
logger.Errorf("Failed to create studio %s: %s", parent.Name, err.Error())
return err
logger.Errorf("Failed to create studio %s: %v", parent.Name, err)
} else {
logger.Infof("Created studio %s", parent.Name)
}
logger.Infof("Created studio %s", parent.Name)
return err
} else {
storedID, _ := strconv.Atoi(*parent.StoredID)
// The parent studio matched an existing one and the user has chosen in the UI to link and/or update it
existingStashIDs := getStashIDsForStudio(ctx, *parent.StoredID)
studioPartial := parent.ToPartial(parent.StoredID, t.box.Endpoint, excluded, existingStashIDs)
studioImage, err := parent.GetImage(ctx, excluded)
existingStashIDs := getStashIDsForStudio(ctx, storedID)
partial := parent.ToPartial(parent.StoredID, t.box.Endpoint, excluded, existingStashIDs)
image, err := parent.GetImage(ctx, excluded)
if err != nil {
logger.Errorf("Failed to make parent studio partial from scraped studio %s: %s", parent.Name, err.Error())
logger.Errorf("Error processing scraped studio image for %s: %v", parent.Name, err)
return err
}
@@ -418,16 +416,16 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *
err = txn.WithTxn(ctx, instance.Repository, func(ctx context.Context) error {
qb := instance.Repository.Studio
if err := studio.ValidateModify(ctx, *studioPartial, instance.Repository.Studio); err != nil {
if err := studio.ValidateModify(ctx, *partial, instance.Repository.Studio); err != nil {
return err
}
if _, err := qb.UpdatePartial(ctx, *studioPartial); err != nil {
if _, err := qb.UpdatePartial(ctx, *partial); err != nil {
return err
}
if len(studioImage) > 0 {
if err := qb.UpdateImage(ctx, studioPartial.ID, studioImage); err != nil {
if len(image) > 0 {
if err := qb.UpdateImage(ctx, partial.ID, image); err != nil {
return err
}
}
@@ -435,17 +433,16 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *
return nil
})
if err != nil {
logger.Errorf("Failed to update studio %s: %s", parent.Name, err.Error())
return err
logger.Errorf("Failed to update studio %s: %v", parent.Name, err)
} else {
logger.Infof("Updated studio %s", parent.Name)
}
logger.Infof("Updated studio %s", parent.Name)
return err
}
return nil
}
func getStashIDsForStudio(ctx context.Context, studioID string) []models.StashID {
id, _ := strconv.Atoi(studioID)
tempStudio := &models.Studio{ID: id}
func getStashIDsForStudio(ctx context.Context, studioID int) []models.StashID {
tempStudio := &models.Studio{ID: studioID}
err := tempStudio.LoadStashIDs(ctx, instance.Repository.Studio)
if err != nil {
@@ -453,127 +450,3 @@ func getStashIDsForStudio(ctx context.Context, studioID string) []models.StashID
}
return tempStudio.StashIDs.List()
}
func (t *StashBoxBatchTagTask) getPartial(performer *models.ScrapedPerformer, excluded map[string]bool) models.PerformerPartial {
partial := models.NewPerformerPartial()
if performer.Aliases != nil && !excluded["aliases"] {
partial.Aliases = &models.UpdateStrings{
Values: stringslice.FromString(*performer.Aliases, ","),
Mode: models.RelationshipUpdateModeSet,
}
}
if performer.Birthdate != nil && *performer.Birthdate != "" && !excluded["birthdate"] {
value := getDate(performer.Birthdate)
partial.Birthdate = models.NewOptionalDate(*value)
}
if performer.DeathDate != nil && *performer.DeathDate != "" && !excluded["deathdate"] {
value := getDate(performer.DeathDate)
partial.DeathDate = models.NewOptionalDate(*value)
}
if performer.CareerLength != nil && !excluded["career_length"] {
partial.CareerLength = models.NewOptionalString(*performer.CareerLength)
}
if performer.Country != nil && !excluded["country"] {
partial.Country = models.NewOptionalString(*performer.Country)
}
if performer.Ethnicity != nil && !excluded["ethnicity"] {
partial.Ethnicity = models.NewOptionalString(*performer.Ethnicity)
}
if performer.EyeColor != nil && !excluded["eye_color"] {
partial.EyeColor = models.NewOptionalString(*performer.EyeColor)
}
if performer.HairColor != nil && !excluded["hair_color"] {
partial.HairColor = models.NewOptionalString(*performer.HairColor)
}
if performer.FakeTits != nil && !excluded["fake_tits"] {
partial.FakeTits = models.NewOptionalString(*performer.FakeTits)
}
if performer.Gender != nil && !excluded["gender"] {
partial.Gender = models.NewOptionalString(*performer.Gender)
}
if performer.Height != nil && !excluded["height"] {
h, err := strconv.Atoi(*performer.Height)
if err == nil {
partial.Height = models.NewOptionalInt(h)
}
}
if performer.Weight != nil && !excluded["weight"] {
w, err := strconv.Atoi(*performer.Weight)
if err == nil {
partial.Weight = models.NewOptionalInt(w)
}
}
if performer.Instagram != nil && !excluded["instagram"] {
partial.Instagram = models.NewOptionalString(*performer.Instagram)
}
if performer.Measurements != nil && !excluded["measurements"] {
partial.Measurements = models.NewOptionalString(*performer.Measurements)
}
if performer.Name != nil && !excluded["name"] {
partial.Name = models.NewOptionalString(*performer.Name)
}
if performer.Disambiguation != nil && !excluded["disambiguation"] {
partial.Disambiguation = models.NewOptionalString(*performer.Disambiguation)
}
if performer.Piercings != nil && !excluded["piercings"] {
partial.Piercings = models.NewOptionalString(*performer.Piercings)
}
if performer.Tattoos != nil && !excluded["tattoos"] {
partial.Tattoos = models.NewOptionalString(*performer.Tattoos)
}
if performer.Twitter != nil && !excluded["twitter"] {
partial.Twitter = models.NewOptionalString(*performer.Twitter)
}
if performer.URL != nil && !excluded["url"] {
partial.URL = models.NewOptionalString(*performer.URL)
}
if !t.refresh {
// #3547 - need to overwrite the stash id for the endpoint, but preserve
// existing stash ids for other endpoints
partial.StashIDs = &models.UpdateStashIDs{
StashIDs: t.performer.StashIDs.List(),
Mode: models.RelationshipUpdateModeSet,
}
partial.StashIDs.Set(models.StashID{
Endpoint: t.box.Endpoint,
StashID: *performer.RemoteSiteID,
})
}
return partial
}
func getDate(val *string) *models.Date {
if val == nil {
return nil
}
ret, err := models.ParseDate(*val)
if err != nil {
return nil
}
return &ret
}
func getString(val *string) string {
if val == nil {
return ""
} else {
return *val
}
}
func getIntPtr(val *string) *int {
if val == nil {
return nil
} else {
v, err := strconv.Atoi(*val)
if err != nil {
return nil
}
return &v
}
}

View File

@@ -5,6 +5,7 @@ import (
"strconv"
"time"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/utils"
)
@@ -26,22 +27,25 @@ func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Stu
// Populate a new studio from the input
newStudio := Studio{
Name: s.Name,
StashIDs: NewRelatedStashIDs([]StashID{
Name: s.Name,
CreatedAt: now,
UpdatedAt: now,
}
if s.RemoteSiteID != nil && endpoint != "" {
newStudio.StashIDs = NewRelatedStashIDs([]StashID{
{
Endpoint: endpoint,
StashID: *s.RemoteSiteID,
},
}),
CreatedAt: now,
UpdatedAt: now,
})
}
if s.URL != nil && !excluded["url"] {
newStudio.URL = *s.URL
}
if s.Parent != nil && s.Parent.StoredID != nil && !excluded["parent"] {
if s.Parent != nil && s.Parent.StoredID != nil && !excluded["parent"] && !excluded["parent_studio"] {
parentId, _ := strconv.Atoi(*s.Parent.StoredID)
newStudio.ParentID = &parentId
}
@@ -90,16 +94,17 @@ func (s *ScrapedStudio) ToPartial(id *string, endpoint string, excluded map[stri
partial.ParentID = NewOptionalIntPtr(nil)
}
partial.StashIDs = &UpdateStashIDs{
StashIDs: existingStashIDs,
Mode: RelationshipUpdateModeSet,
if s.RemoteSiteID != nil && endpoint != "" {
partial.StashIDs = &UpdateStashIDs{
StashIDs: existingStashIDs,
Mode: RelationshipUpdateModeSet,
}
partial.StashIDs.Set(StashID{
Endpoint: endpoint,
StashID: *s.RemoteSiteID,
})
}
partial.StashIDs.Set(StashID{
Endpoint: endpoint,
StashID: *s.RemoteSiteID,
})
return &partial
}
@@ -139,6 +144,220 @@ type ScrapedPerformer struct {
func (ScrapedPerformer) IsScrapedContent() {}
func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool) *Performer {
ret := NewPerformer(*p.Name)
if p.Aliases != nil && !excluded["aliases"] {
ret.Aliases = NewRelatedStrings(stringslice.FromString(*p.Aliases, ","))
}
if p.Birthdate != nil && !excluded["birthdate"] {
date, err := ParseDate(*p.Birthdate)
if err == nil {
ret.Birthdate = &date
}
}
if p.DeathDate != nil && !excluded["death_date"] {
date, err := ParseDate(*p.DeathDate)
if err == nil {
ret.DeathDate = &date
}
}
if p.CareerLength != nil && !excluded["career_length"] {
ret.CareerLength = *p.CareerLength
}
if p.Country != nil && !excluded["country"] {
ret.Country = *p.Country
}
if p.Ethnicity != nil && !excluded["ethnicity"] {
ret.Ethnicity = *p.Ethnicity
}
if p.EyeColor != nil && !excluded["eye_color"] {
ret.EyeColor = *p.EyeColor
}
if p.HairColor != nil && !excluded["hair_color"] {
ret.HairColor = *p.HairColor
}
if p.FakeTits != nil && !excluded["fake_tits"] {
ret.FakeTits = *p.FakeTits
}
if p.Gender != nil && !excluded["gender"] {
v := GenderEnum(*p.Gender)
if v.IsValid() {
ret.Gender = &v
}
}
if p.Height != nil && !excluded["height"] {
h, err := strconv.Atoi(*p.Height)
if err == nil {
ret.Height = &h
}
}
if p.Weight != nil && !excluded["weight"] {
w, err := strconv.Atoi(*p.Weight)
if err == nil {
ret.Weight = &w
}
}
if p.Instagram != nil && !excluded["instagram"] {
ret.Instagram = *p.Instagram
}
if p.Measurements != nil && !excluded["measurements"] {
ret.Measurements = *p.Measurements
}
if p.Disambiguation != nil && !excluded["disambiguation"] {
ret.Disambiguation = *p.Disambiguation
}
if p.Details != nil && !excluded["details"] {
ret.Details = *p.Details
}
if p.Piercings != nil && !excluded["piercings"] {
ret.Piercings = *p.Piercings
}
if p.Tattoos != nil && !excluded["tattoos"] {
ret.Tattoos = *p.Tattoos
}
if p.PenisLength != nil && !excluded["penis_length"] {
l, err := strconv.ParseFloat(*p.PenisLength, 64)
if err == nil {
ret.PenisLength = &l
}
}
if p.Circumcised != nil && !excluded["circumcised"] {
v := CircumisedEnum(*p.Circumcised)
if v.IsValid() {
ret.Circumcised = &v
}
}
if p.Twitter != nil && !excluded["twitter"] {
ret.Twitter = *p.Twitter
}
if p.URL != nil && !excluded["url"] {
ret.URL = *p.URL
}
if p.RemoteSiteID != nil && endpoint != "" {
ret.StashIDs = NewRelatedStashIDs([]StashID{
{
Endpoint: endpoint,
StashID: *p.RemoteSiteID,
},
})
}
return ret
}
func (p *ScrapedPerformer) GetImage(ctx context.Context, excluded map[string]bool) ([]byte, error) {
// Process the base 64 encoded image string
if len(p.Images) > 0 && !excluded["image"] {
var err error
img, err := utils.ProcessImageInput(ctx, p.Images[0])
if err != nil {
return nil, err
}
return img, nil
}
return nil, nil
}
func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, existingStashIDs []StashID) PerformerPartial {
partial := NewPerformerPartial()
if p.Aliases != nil && !excluded["aliases"] {
partial.Aliases = &UpdateStrings{
Values: stringslice.FromString(*p.Aliases, ","),
Mode: RelationshipUpdateModeSet,
}
}
if p.Birthdate != nil && !excluded["birthdate"] {
date, err := ParseDate(*p.Birthdate)
if err == nil {
partial.Birthdate = NewOptionalDate(date)
}
}
if p.DeathDate != nil && !excluded["death_date"] {
date, err := ParseDate(*p.DeathDate)
if err == nil {
partial.DeathDate = NewOptionalDate(date)
}
}
if p.CareerLength != nil && !excluded["career_length"] {
partial.CareerLength = NewOptionalString(*p.CareerLength)
}
if p.Country != nil && !excluded["country"] {
partial.Country = NewOptionalString(*p.Country)
}
if p.Ethnicity != nil && !excluded["ethnicity"] {
partial.Ethnicity = NewOptionalString(*p.Ethnicity)
}
if p.EyeColor != nil && !excluded["eye_color"] {
partial.EyeColor = NewOptionalString(*p.EyeColor)
}
if p.HairColor != nil && !excluded["hair_color"] {
partial.HairColor = NewOptionalString(*p.HairColor)
}
if p.FakeTits != nil && !excluded["fake_tits"] {
partial.FakeTits = NewOptionalString(*p.FakeTits)
}
if p.Gender != nil && !excluded["gender"] {
partial.Gender = NewOptionalString(*p.Gender)
}
if p.Height != nil && !excluded["height"] {
h, err := strconv.Atoi(*p.Height)
if err == nil {
partial.Height = NewOptionalInt(h)
}
}
if p.Weight != nil && !excluded["weight"] {
w, err := strconv.Atoi(*p.Weight)
if err == nil {
partial.Weight = NewOptionalInt(w)
}
}
if p.Instagram != nil && !excluded["instagram"] {
partial.Instagram = NewOptionalString(*p.Instagram)
}
if p.Measurements != nil && !excluded["measurements"] {
partial.Measurements = NewOptionalString(*p.Measurements)
}
if p.Name != nil && !excluded["name"] {
partial.Name = NewOptionalString(*p.Name)
}
if p.Disambiguation != nil && !excluded["disambiguation"] {
partial.Disambiguation = NewOptionalString(*p.Disambiguation)
}
if p.Details != nil && !excluded["details"] {
partial.Details = NewOptionalString(*p.Details)
}
if p.Piercings != nil && !excluded["piercings"] {
partial.Piercings = NewOptionalString(*p.Piercings)
}
if p.Tattoos != nil && !excluded["tattoos"] {
partial.Tattoos = NewOptionalString(*p.Tattoos)
}
if p.Twitter != nil && !excluded["twitter"] {
partial.Twitter = NewOptionalString(*p.Twitter)
}
if p.URL != nil && !excluded["url"] {
partial.URL = NewOptionalString(*p.URL)
}
if p.RemoteSiteID != nil && endpoint != "" {
partial.StashIDs = &UpdateStashIDs{
StashIDs: existingStashIDs,
Mode: RelationshipUpdateModeSet,
}
partial.StashIDs.Set(StashID{
Endpoint: endpoint,
StashID: *p.RemoteSiteID,
})
}
return partial
}
type ScrapedTag struct {
// Set if tag matched
StoredID *string `json:"stored_id"`

View File

@@ -1,6 +1,7 @@
package models
import (
"strconv"
"testing"
"time"
@@ -10,12 +11,15 @@ import (
func Test_scrapedToStudioInput(t *testing.T) {
const name = "name"
url := "url"
emptyEndpoint := ""
endpoint := "endpoint"
remoteSiteID := "remoteSiteID"
tests := []struct {
name string
studio *ScrapedStudio
want *Studio
name string
studio *ScrapedStudio
endpoint string
want *Studio
}{
{
"set all",
@@ -24,27 +28,51 @@ func Test_scrapedToStudioInput(t *testing.T) {
URL: &url,
RemoteSiteID: &remoteSiteID,
},
endpoint,
&Studio{
Name: name,
URL: url,
StashIDs: NewRelatedStashIDs([]StashID{
{
StashID: remoteSiteID,
Endpoint: endpoint,
StashID: remoteSiteID,
},
}),
},
},
{
"set none",
&ScrapedStudio{
Name: name,
},
emptyEndpoint,
&Studio{
Name: name,
},
},
{
"missing remoteSiteID",
&ScrapedStudio{
Name: name,
},
endpoint,
&Studio{
Name: name,
},
},
{
"set stashid",
&ScrapedStudio{
Name: name,
RemoteSiteID: &remoteSiteID,
},
endpoint,
&Studio{
Name: name,
StashIDs: NewRelatedStashIDs([]StashID{
{
StashID: remoteSiteID,
Endpoint: endpoint,
StashID: remoteSiteID,
},
}),
},
@@ -52,7 +80,165 @@ func Test_scrapedToStudioInput(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.studio.ToStudio("", nil)
got := tt.studio.ToStudio(tt.endpoint, nil)
assert.NotEqual(t, time.Time{}, got.CreatedAt)
assert.NotEqual(t, time.Time{}, got.UpdatedAt)
got.CreatedAt = time.Time{}
got.UpdatedAt = time.Time{}
assert.Equal(t, tt.want, got)
})
}
}
func Test_scrapedToPerformerInput(t *testing.T) {
name := "name"
emptyEndpoint := ""
endpoint := "endpoint"
remoteSiteID := "remoteSiteID"
var stringValues []string
for i := 0; i < 20; i++ {
stringValues = append(stringValues, strconv.Itoa(i))
}
upTo := 0
nextVal := func() *string {
ret := stringValues[upTo]
upTo = (upTo + 1) % len(stringValues)
return &ret
}
nextIntVal := func() *int {
ret := upTo
upTo = (upTo + 1) % len(stringValues)
return &ret
}
dateFromInt := func(i int) *Date {
t := time.Date(2001, 1, i, 0, 0, 0, 0, time.UTC)
d := Date{Time: t}
return &d
}
dateStrFromInt := func(i int) *string {
s := dateFromInt(i).String()
return &s
}
genderFromInt := func(i int) *GenderEnum {
g := AllGenderEnum[i%len(AllGenderEnum)]
return &g
}
genderStrFromInt := func(i int) *string {
s := genderFromInt(i).String()
return &s
}
tests := []struct {
name string
performer *ScrapedPerformer
endpoint string
want *Performer
}{
{
"set all",
&ScrapedPerformer{
Name: &name,
Disambiguation: nextVal(),
Birthdate: dateStrFromInt(*nextIntVal()),
DeathDate: dateStrFromInt(*nextIntVal()),
Gender: genderStrFromInt(*nextIntVal()),
Ethnicity: nextVal(),
Country: nextVal(),
EyeColor: nextVal(),
HairColor: nextVal(),
Height: nextVal(),
Weight: nextVal(),
Measurements: nextVal(),
FakeTits: nextVal(),
CareerLength: nextVal(),
Tattoos: nextVal(),
Piercings: nextVal(),
Aliases: nextVal(),
Twitter: nextVal(),
Instagram: nextVal(),
URL: nextVal(),
Details: nextVal(),
RemoteSiteID: &remoteSiteID,
},
endpoint,
&Performer{
Name: name,
Disambiguation: *nextVal(),
Birthdate: dateFromInt(*nextIntVal()),
DeathDate: dateFromInt(*nextIntVal()),
Gender: genderFromInt(*nextIntVal()),
Ethnicity: *nextVal(),
Country: *nextVal(),
EyeColor: *nextVal(),
HairColor: *nextVal(),
Height: nextIntVal(),
Weight: nextIntVal(),
Measurements: *nextVal(),
FakeTits: *nextVal(),
CareerLength: *nextVal(),
Tattoos: *nextVal(),
Piercings: *nextVal(),
Aliases: NewRelatedStrings([]string{*nextVal()}),
Twitter: *nextVal(),
Instagram: *nextVal(),
URL: *nextVal(),
Details: *nextVal(),
StashIDs: NewRelatedStashIDs([]StashID{
{
Endpoint: endpoint,
StashID: remoteSiteID,
},
}),
},
},
{
"set none",
&ScrapedPerformer{
Name: &name,
},
emptyEndpoint,
&Performer{
Name: name,
},
},
{
"missing remoteSiteID",
&ScrapedPerformer{
Name: &name,
},
endpoint,
&Performer{
Name: name,
},
},
{
"set stashid",
&ScrapedPerformer{
Name: &name,
RemoteSiteID: &remoteSiteID,
},
endpoint,
&Performer{
Name: name,
StashIDs: NewRelatedStashIDs([]StashID{
{
Endpoint: endpoint,
StashID: remoteSiteID,
},
}),
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.performer.ToPerformer(tt.endpoint, nil)
assert.NotEqual(t, time.Time{}, got.CreatedAt)
assert.NotEqual(t, time.Time{}, got.UpdatedAt)

View File

@@ -369,7 +369,7 @@ func (c Client) queryStashBoxPerformer(ctx context.Context, queryStr string) ([]
var ret []*models.ScrapedPerformer
for _, fragment := range performerFragments {
performer := performerFragmentToScrapedScenePerformer(*fragment)
performer := performerFragmentToScrapedPerformer(*fragment)
ret = append(ret, performer)
}
@@ -598,12 +598,12 @@ func fetchImage(ctx context.Context, client *http.Client, url string) (*string,
return &img, nil
}
func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *models.ScrapedPerformer {
id := p.ID
func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.ScrapedPerformer {
images := []string{}
for _, image := range p.Images {
images = append(images, image.URL)
}
sp := &models.ScrapedPerformer{
Name: &p.Name,
Disambiguation: p.Disambiguation,
@@ -613,7 +613,7 @@ func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *mode
Tattoos: formatBodyModifications(p.Tattoos),
Piercings: formatBodyModifications(p.Piercings),
Twitter: findURL(p.Urls, "TWITTER"),
RemoteSiteID: &id,
RemoteSiteID: &p.ID,
Images: images,
// TODO - tags not currently supported
// graphql schema change to accommodate this. Leave off for now.
@@ -772,7 +772,7 @@ func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.Scen
}
for _, p := range s.Performers {
sp := performerFragmentToScrapedScenePerformer(p.Performer)
sp := performerFragmentToScrapedPerformer(p.Performer)
err := match.ScrapedPerformer(ctx, pqb, sp, &c.box.Endpoint)
if err != nil {
@@ -809,7 +809,15 @@ func (c Client) FindStashBoxPerformerByID(ctx context.Context, id string) (*mode
return nil, err
}
ret := performerFragmentToScrapedScenePerformer(*performer.FindPerformer)
ret := performerFragmentToScrapedPerformer(*performer.FindPerformer)
if err := txn.WithReadTxn(ctx, c.txnManager, func(ctx context.Context) error {
err := match.ScrapedPerformer(ctx, c.repository.Performer, ret, &c.box.Endpoint)
return err
}); err != nil {
return nil, err
}
return ret, nil
}
@@ -822,10 +830,21 @@ func (c Client) FindStashBoxPerformerByName(ctx context.Context, name string) (*
var ret *models.ScrapedPerformer
for _, performer := range performers.SearchPerformer {
if strings.EqualFold(performer.Name, name) {
ret = performerFragmentToScrapedScenePerformer(*performer)
ret = performerFragmentToScrapedPerformer(*performer)
}
}
if ret == nil {
return nil, nil
}
if err := txn.WithReadTxn(ctx, c.txnManager, func(ctx context.Context) error {
err := match.ScrapedPerformer(ctx, c.repository.Performer, ret, &c.box.Endpoint)
return err
}); err != nil {
return nil, err
}
return ret, nil
}

View File

@@ -645,7 +645,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
);
return (
<Dropdown drop="up" className="d-inline-block">
<Dropdown className="d-inline-block">
<Dropdown.Toggle variant="secondary" className="mr-2">
<FormattedMessage id="actions.scrape_with" />
</Dropdown.Toggle>

View File

@@ -10,10 +10,13 @@ import {
useSceneMarkerDestroy,
} from "src/core/StashService";
import { DurationInput } from "src/components/Shared/DurationInput";
import { TagSelect, MarkerTitleSuggest } from "src/components/Shared/Select";
import {
TagSelect,
MarkerTitleSuggest,
SelectObject,
} from "src/components/Shared/Select";
import { getPlayerPosition } from "src/components/ScenePlayer/util";
import { useToast } from "src/hooks/Toast";
import isEqual from "lodash-es/isEqual";
interface ISceneMarkerForm {
sceneID: string;
@@ -97,6 +100,11 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
}
}
async function onSetPrimaryTagID(tags: SelectObject[]) {
await formik.setFieldValue("primary_tag_id", tags[0]?.id);
await formik.setFieldTouched("primary_tag_id", true);
}
const primaryTagId = formik.values.primary_tag_id;
return (
@@ -119,16 +127,16 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
</Form.Label>
<div className="col-sm-4 col-md-6 col-xl-12 mb-3 mb-sm-0 mb-xl-3">
<TagSelect
onSelect={(tags) =>
formik.setFieldValue("primary_tag_id", tags[0]?.id)
}
onSelect={onSetPrimaryTagID}
ids={primaryTagId ? [primaryTagId] : []}
noSelectionString="Select/create tag..."
hoverPlacement="right"
/>
<Form.Control.Feedback type="invalid">
{formik.errors.primary_tag_id}
</Form.Control.Feedback>
{formik.touched.primary_tag_id && (
<Form.Control.Feedback type="invalid">
{formik.errors.primary_tag_id}
</Form.Control.Feedback>
)}
</div>
<div className="col-sm-5 col-md-4 col-xl-12">
<div className="row">
@@ -175,7 +183,7 @@ export const SceneMarkerForm: React.FC<ISceneMarkerForm> = ({
<div className="col d-flex">
<Button
variant="primary"
disabled={(!isNew && !formik.dirty) || !isEqual(formik.errors, {})}
disabled={!isNew && !formik.dirty}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />

View File

@@ -40,7 +40,8 @@ export const Setup: React.FC = () => {
const [databaseFile, setDatabaseFile] = useState("");
const [generatedLocation, setGeneratedLocation] = useState("");
const [cacheLocation, setCacheLocation] = useState("");
const [blobsLocation, setBlobsLocation] = useState("blobs");
const [storeBlobsInDatabase, setStoreBlobsInDatabase] = useState(false);
const [blobsLocation, setBlobsLocation] = useState("");
const [loading, setLoading] = useState(false);
const [setupError, setSetupError] = useState("");
@@ -393,27 +394,43 @@ export const Setup: React.FC = () => {
}}
/>
</p>
<InputGroup>
<Form.Control
className="text-input"
value={blobsLocation}
placeholder={intl.formatMessage({
id: "setup.paths.path_to_blobs_directory_empty_for_database",
<p>
<Form.Check
id="store-blobs-in-database"
checked={storeBlobsInDatabase}
label={intl.formatMessage({
id: "setup.paths.store_blobs_in_database",
})}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setBlobsLocation(e.currentTarget.value)
}
onChange={() => setStoreBlobsInDatabase(!storeBlobsInDatabase)}
/>
<InputGroup.Append>
<Button
variant="secondary"
</p>
{!storeBlobsInDatabase && (
<InputGroup>
<Form.Control
className="text-input"
onClick={() => setShowBlobsDialog(true)}
>
<Icon icon={faEllipsisH} />
</Button>
</InputGroup.Append>
</InputGroup>
value={blobsLocation}
placeholder={intl.formatMessage({
id: "setup.paths.path_to_blobs_directory_empty_for_default",
})}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setBlobsLocation(e.currentTarget.value)
}
disabled={storeBlobsInDatabase}
/>
<InputGroup.Append>
<Button
variant="secondary"
className="text-input"
onClick={() => setShowBlobsDialog(true)}
disabled={storeBlobsInDatabase}
>
<Icon icon={faEllipsisH} />
</Button>
</InputGroup.Append>
</InputGroup>
)}
</Form.Group>
);
}
@@ -543,6 +560,7 @@ export const Setup: React.FC = () => {
databaseFile,
generatedLocation,
cacheLocation,
storeBlobsInDatabase,
blobsLocation,
stashes,
});
@@ -631,7 +649,11 @@ export const Setup: React.FC = () => {
</dt>
<dd>
<code>
{blobsLocation !== ""
{storeBlobsInDatabase
? intl.formatMessage({
id: "setup.confirm.blobs_use_database",
})
: blobsLocation !== ""
? blobsLocation
: intl.formatMessage({
id: "setup.confirm.default_blobs_location",

View File

@@ -5,17 +5,15 @@ import { useIntl } from "react-intl";
import { ModalComponent } from "../Shared/Modal";
import { Icon } from "../Shared/Icon";
import TextUtils from "src/utils/text";
import { PERFORMER_FIELDS } from "./constants";
interface IProps {
fields: string[];
show: boolean;
excludedFields: string[];
onSelect: (fields: string[]) => void;
}
const PerformerFieldSelect: React.FC<IProps> = ({
fields,
show,
excludedFields,
onSelect,
@@ -25,22 +23,22 @@ const PerformerFieldSelect: React.FC<IProps> = ({
excludedFields.reduce((dict, field) => ({ ...dict, [field]: true }), {})
);
const toggleField = (name: string) =>
const toggleField = (field: string) =>
setExcluded({
...excluded,
[name]: !excluded[name],
[field]: !excluded[field],
});
const renderField = (name: string) => (
<Col xs={6} className="mb-1" key={name}>
const renderField = (field: string) => (
<Col xs={6} className="mb-1" key={field}>
<Button
onClick={() => toggleField(name)}
onClick={() => toggleField(field)}
variant="secondary"
className={excluded[name] ? "text-muted" : "text-success"}
className={excluded[field] ? "text-muted" : "text-success"}
>
<Icon icon={excluded[name] ? faTimes : faCheck} />
<Icon icon={excluded[field] ? faTimes : faCheck} />
</Button>
<span className="ml-3">{TextUtils.capitalize(name)}</span>
<span className="ml-3">{intl.formatMessage({ id: field })}</span>
</Col>
);
@@ -59,7 +57,7 @@ const PerformerFieldSelect: React.FC<IProps> = ({
<div className="mb-2">
These fields will be tagged by default. Click the button to toggle.
</div>
<Row>{fields.map((f) => renderField(f))}</Row>
<Row>{PERFORMER_FIELDS.map((f) => renderField(f))}</Row>
</ModalComponent>
);
};

View File

@@ -58,26 +58,29 @@ export interface ITaggerConfig {
export const PERFORMER_FIELDS = [
"name",
"aliases",
"image",
"disambiguation",
"aliases",
"gender",
"birthdate",
"ethnicity",
"death_date",
"country",
"eye_color",
"ethnicity",
"hair_color",
"eye_color",
"height",
"weight",
"penis_length",
"circumcised",
"measurements",
"fake_tits",
"career_length",
"tattoos",
"piercings",
"career_length",
"url",
"twitter",
"instagram",
"details",
"death_date",
"weight",
];
export const STUDIO_FIELDS = ["name", "image", "url", "parent"];
export const STUDIO_FIELDS = ["name", "image", "url", "parent_studio"];

View File

@@ -3,8 +3,7 @@ import { Badge, Button, Card, Collapse, Form } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import { ConfigurationContext } from "src/hooks/Config";
import TextUtils from "src/utils/text";
import { ITaggerConfig, PERFORMER_FIELDS } from "../constants";
import { ITaggerConfig } from "../constants";
import PerformerFieldSelector from "../PerformerFieldSelector";
interface IConfigProps {
@@ -52,7 +51,7 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
{excludedFields.length > 0 ? (
excludedFields.map((f) => (
<Badge variant="secondary" className="tag-item" key={f}>
{TextUtils.capitalize(f)}
<FormattedMessage id={f} />
</Badge>
))
) : (
@@ -100,7 +99,6 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
</Card>
</Collapse>
<PerformerFieldSelector
fields={PERFORMER_FIELDS}
show={showExclusionModal}
onSelect={handleFieldSelect}
excludedFields={excludedFields}

View File

@@ -12,6 +12,9 @@ import {
stashBoxPerformerQuery,
useJobsSubscribe,
mutateStashBoxBatchPerformerTag,
getClient,
evictQueries,
performerMutationImpactedQueries,
} from "src/core/StashService";
import { Manual } from "src/components/Help/Manual";
import { ConfigurationContext } from "src/hooks/Config";
@@ -112,7 +115,7 @@ const PerformerBatchUpdateModal: React.FC<IPerformerBatchUpdateModal> = ({
type="radio"
name="performer-query"
label={<FormattedMessage id="performer_tagger.current_page" />}
defaultChecked={!queryAll}
checked={!queryAll}
onChange={() => setQueryAll(false)}
/>
<Form.Check
@@ -122,8 +125,8 @@ const PerformerBatchUpdateModal: React.FC<IPerformerBatchUpdateModal> = ({
label={intl.formatMessage({
id: "performer_tagger.query_all_performers_in_the_database",
})}
defaultChecked={false}
onChange={() => setQueryAll(queryAll)}
checked={queryAll}
onChange={() => setQueryAll(true)}
/>
</Form.Group>
<Form.Group>
@@ -139,7 +142,7 @@ const PerformerBatchUpdateModal: React.FC<IPerformerBatchUpdateModal> = ({
label={intl.formatMessage({
id: "performer_tagger.untagged_performers",
})}
defaultChecked={!refresh}
checked={!refresh}
onChange={() => setRefresh(false)}
/>
<Form.Text>
@@ -152,8 +155,8 @@ const PerformerBatchUpdateModal: React.FC<IPerformerBatchUpdateModal> = ({
label={intl.formatMessage({
id: "performer_tagger.refresh_tagged_performers",
})}
defaultChecked={false}
onChange={() => setRefresh(refresh)}
checked={refresh}
onChange={() => setRefresh(true)}
/>
<Form.Text>
<FormattedMessage id="performer_tagger.refreshing_will_update_the_data" />
@@ -346,6 +349,24 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
const updatePerformer = useUpdatePerformer();
function handleSaveError(performerID: string, name: string, message: string) {
setError({
...error,
[performerID]: {
message: intl.formatMessage(
{ id: "performer_tagger.failed_to_save_performer" },
{ studio: modalPerformer?.name }
),
details:
message === "UNIQUE constraint failed: performers.name"
? intl.formatMessage({
id: "performer_tagger.name_already_exists",
})
: message,
},
});
}
const handlePerformerUpdate = async (input: GQL.PerformerCreateInput) => {
setModalPerformer(undefined);
const performerID = modalPerformer?.stored_id;
@@ -357,22 +378,11 @@ const PerformerTaggerList: React.FC<IPerformerTaggerListProps> = ({
const res = await updatePerformer(updateData);
if (!res.data?.performerUpdate)
setError({
...error,
[performerID]: {
message: intl.formatMessage(
{ id: "performer_tagger.failed_to_save_performer" },
{ performer: modalPerformer?.name }
),
details:
res?.errors?.[0].message ===
"UNIQUE constraint failed: performers.checksum"
? intl.formatMessage({
id: "performer_tagger.name_already_exists",
})
: res?.errors?.[0].message,
},
});
handleSaveError(
performerID,
modalPerformer?.name ?? "",
res?.errors?.[0]?.message ?? ""
);
}
};
@@ -631,6 +641,10 @@ export const PerformerTagger: React.FC<ITaggerProps> = ({ performers }) => {
} else {
setBatchJob(undefined);
setBatchJobID(undefined);
// Once the performer batch is complete, refresh all local performer data
const ac = getClient();
evictQueries(ac.cache, performerMutationImpactedQueries);
}
}, [jobsSubscribe, batchJobID]);

View File

@@ -24,9 +24,8 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
excludedPerformerFields,
endpoint,
}) => {
const [modalPerformer, setModalPerformer] = useState<
GQL.ScrapedPerformerDataFragment | undefined
>();
const [modalPerformer, setModalPerformer] =
useState<GQL.ScrapedPerformerDataFragment>();
const [saveState, setSaveState] = useState<string>("");
const [error, setError] = useState<{ message?: string; details?: string }>(
{}
@@ -51,7 +50,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
message: `Failed to save performer "${performer.name}"`,
details:
res?.errors?.[0].message ===
"UNIQUE constraint failed: performers.checksum"
"UNIQUE constraint failed: performers.name"
? "Name already exists"
: res?.errors?.[0].message,
});

View File

@@ -3,8 +3,7 @@ import { Badge, Button, Card, Collapse, Form } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import { ConfigurationContext } from "src/hooks/Config";
import TextUtils from "src/utils/text";
import { ITaggerConfig, STUDIO_FIELDS } from "../constants";
import { ITaggerConfig } from "../constants";
import StudioFieldSelector from "./StudioFieldSelector";
interface IConfigProps {
@@ -72,7 +71,7 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
{excludedFields.length > 0 ? (
excludedFields.map((f) => (
<Badge variant="secondary" className="tag-item" key={f}>
{TextUtils.capitalize(f)}
<FormattedMessage id={f} />
</Badge>
))
) : (
@@ -120,7 +119,6 @@ const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
</Card>
</Collapse>
<StudioFieldSelector
fields={STUDIO_FIELDS}
show={showExclusionModal}
onSelect={handleFieldSelect}
excludedFields={excludedFields}

View File

@@ -28,9 +28,8 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
}) => {
const intl = useIntl();
const [modalStudio, setModalStudio] = useState<
GQL.ScrapedStudioDataFragment | undefined
>();
const [modalStudio, setModalStudio] =
useState<GQL.ScrapedStudioDataFragment>();
const [saveState, setSaveState] = useState<string>("");
const [error, setError] = useState<{ message?: string; details?: string }>(
{}
@@ -46,7 +45,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
{ studio: name }
),
details:
message === "UNIQUE constraint failed: studios.checksum"
message === "UNIQUE constraint failed: studios.name"
? "Name already exists"
: message,
});

View File

@@ -5,17 +5,15 @@ import { useIntl } from "react-intl";
import { ModalComponent } from "../../Shared/Modal";
import { Icon } from "../../Shared/Icon";
import TextUtils from "src/utils/text";
import { STUDIO_FIELDS } from "../constants";
interface IProps {
fields: string[];
show: boolean;
excludedFields: string[];
onSelect: (fields: string[]) => void;
}
const StudioFieldSelect: React.FC<IProps> = ({
fields,
show,
excludedFields,
onSelect,
@@ -25,22 +23,22 @@ const StudioFieldSelect: React.FC<IProps> = ({
excludedFields.reduce((dict, field) => ({ ...dict, [field]: true }), {})
);
const toggleField = (name: string) =>
const toggleField = (field: string) =>
setExcluded({
...excluded,
[name]: !excluded[name],
[field]: !excluded[field],
});
const renderField = (name: string) => (
<Col xs={6} className="mb-1" key={name}>
const renderField = (field: string) => (
<Col xs={6} className="mb-1" key={field}>
<Button
onClick={() => toggleField(name)}
onClick={() => toggleField(field)}
variant="secondary"
className={excluded[name] ? "text-muted" : "text-success"}
className={excluded[field] ? "text-muted" : "text-success"}
>
<Icon icon={excluded[name] ? faTimes : faCheck} />
<Icon icon={excluded[field] ? faTimes : faCheck} />
</Button>
<span className="ml-3">{TextUtils.capitalize(name)}</span>
<span className="ml-3">{intl.formatMessage({ id: field })}</span>
</Col>
);
@@ -59,7 +57,7 @@ const StudioFieldSelect: React.FC<IProps> = ({
<div className="mb-2">
These fields will be tagged by default. Click the button to toggle.
</div>
<Row>{fields.map((f) => renderField(f))}</Row>
<Row>{STUDIO_FIELDS.map((f) => renderField(f))}</Row>
</ModalComponent>
);
};

View File

@@ -120,7 +120,7 @@ const StudioBatchUpdateModal: React.FC<IStudioBatchUpdateModal> = ({
type="radio"
name="studio-query"
label={<FormattedMessage id="studio_tagger.current_page" />}
defaultChecked={!queryAll}
checked={!queryAll}
onChange={() => setQueryAll(false)}
/>
<Form.Check
@@ -130,7 +130,7 @@ const StudioBatchUpdateModal: React.FC<IStudioBatchUpdateModal> = ({
label={intl.formatMessage({
id: "studio_tagger.query_all_studios_in_the_database",
})}
defaultChecked={queryAll}
checked={queryAll}
onChange={() => setQueryAll(true)}
/>
</Form.Group>
@@ -147,7 +147,7 @@ const StudioBatchUpdateModal: React.FC<IStudioBatchUpdateModal> = ({
label={intl.formatMessage({
id: "studio_tagger.untagged_studios",
})}
defaultChecked={!refresh}
checked={!refresh}
onChange={() => setRefresh(false)}
/>
<Form.Text>
@@ -160,7 +160,7 @@ const StudioBatchUpdateModal: React.FC<IStudioBatchUpdateModal> = ({
label={intl.formatMessage({
id: "studio_tagger.refresh_tagged_studios",
})}
defaultChecked={refresh}
checked={refresh}
onChange={() => setRefresh(true)}
/>
<Form.Text>
@@ -400,7 +400,7 @@ const StudioTaggerList: React.FC<IStudioTaggerListProps> = ({
{ studio: modalStudio?.name }
),
details:
message === "UNIQUE constraint failed: studios.checksum"
message === "UNIQUE constraint failed: studios.name"
? intl.formatMessage({
id: "studio_tagger.name_already_exists",
})

View File

@@ -280,7 +280,7 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
function renderMergeButton() {
return (
<Dropdown drop="up">
<Dropdown>
<Dropdown.Toggle variant="secondary">
<FormattedMessage id="actions.merge" />
...

View File

@@ -1358,7 +1358,7 @@ const performerMutationImpactedTypeFields = {
Tag: ["performer_count"],
};
const performerMutationImpactedQueries = [
export const performerMutationImpactedQueries = [
GQL.FindScenesDocument, // filter by performer tags
GQL.FindImagesDocument, // filter by performer tags
GQL.FindGalleriesDocument, // filter by performer tags

View File

@@ -20,6 +20,11 @@
* Added support for `-v/--version` command line flag. ([#3883](https://github.com/stashapp/stash/pull/3883))
### 🐛 Bug fixes
* **[0.22.1]** Fixed Batch Update Performers not working correctly. ([#4024](https://github.com/stashapp/stash/pull/4024))
* **[0.22.1]** Fixed panic when creating Studios during Identify task. ([#4024](https://github.com/stashapp/stash/pull/4024))
* **[0.22.1]** Added explicit option to store blobs in database at setup, and fixed default blobs path. ([#4038](https://github.com/stashapp/stash/pull/4038))
* **[0.22.1]** Fixed dropdown appearing beneath other controls on the Performer and Tag pages. ([#4039](https://github.com/stashapp/stash/pull/4039))
* **[0.22.1]** Fixed buttons moving around when setting marker time when creating a new marker. ([#4040](https://github.com/stashapp/stash/pull/4040))
* Fixing sorting of performer tags. ([#4018](https://github.com/stashapp/stash/pull/4018))
* Fixed scene URLs being cleared when merging scenes. ([#4005](https://github.com/stashapp/stash/pull/4005))
* Fixed setting the Create Missing flag in the Identify dialog not working. ([#4008](https://github.com/stashapp/stash/pull/4008))

View File

@@ -1149,10 +1149,11 @@
"confirm": {
"almost_ready": "We're almost ready to complete the configuration. Please confirm the following settings. You can click back to change anything incorrect. If everything looks good, click Confirm to create your system.",
"blobs_directory": "Binary data directory",
"blobs_use_database": "<use database>",
"cache_directory": "Cache directory",
"configuration_file_location": "Configuration file location:",
"database_file_path": "Database file path",
"default_blobs_location": "<use database>",
"default_blobs_location": "<path containing configuration file>/blobs",
"default_cache_location": "<path containing configuration file>/cache",
"default_db_location": "<path containing configuration file>/stash-go.sqlite",
"default_generated_content_location": "<path containing configuration file>/generated",
@@ -1190,14 +1191,15 @@
"paths": {
"database_filename_empty_for_default": "database filename (empty for default)",
"description": "Next up, we need to determine where to find your porn collection, and where to store the Stash database, generated files and cache files. These settings can be changed later if needed.",
"path_to_blobs_directory_empty_for_database": "path to blobs directory (empty to use database)",
"path_to_blobs_directory_empty_for_default": "path to blobs directory (empty for default)",
"path_to_cache_directory_empty_for_default": "path to cache directory (empty for default)",
"path_to_generated_directory_empty_for_default": "path to generated directory (empty for default)",
"set_up_your_paths": "Set up your paths",
"stash_alert": "No library paths have been selected. No media will be able to be scanned into Stash. Are you sure?",
"store_blobs_in_database": "Store blobs in database",
"where_can_stash_store_blobs": "Where can Stash store database binary data?",
"where_can_stash_store_blobs_description": "Stash can store binary data such as scene covers, performer, studio and tag images either in the database or in the filesystem. By default, it will store this data in the filesystem in the subdirectory <code>blobs</code>. If you want to change this, please enter an absolute or relative (to the current working directory) path. Stash will create this directory if it does not already exist.",
"where_can_stash_store_blobs_description_addendum": "Alternatively, if you want to store this data in the database, you can leave this field blank. <strong>Note:</strong> This will increase the size of your database file, and will increase database migration times.",
"where_can_stash_store_blobs_description": "Stash can store binary data such as scene covers, performer, studio and tag images either in the database or in the filesystem. By default, it will store this data in the filesystem in the subdirectory <code>blobs</code> within the directory containing your config file. If you want to change this, please enter an absolute or relative (to the current working directory) path. Stash will create this directory if it does not already exist.",
"where_can_stash_store_blobs_description_addendum": "Alternatively, you can store this data in the database. <strong>Note:</strong> This will increase the size of your database file, and will increase database migration times.",
"where_can_stash_store_cache_files": "Where can Stash store cache files?",
"where_can_stash_store_cache_files_description": "In order for some functionality like HLS/DASH live transcoding to function, Stash requires a cache directory for temporary files. By default, Stash will create a <code>cache</code> directory within the directory containing your config file. If you want to change this, please enter an absolute or relative (to the current working directory) path. Stash will create this directory if it does not already exist.",
"where_can_stash_store_its_database": "Where can Stash store its database?",

View File

@@ -4,10 +4,10 @@ const secondsToString = (seconds: number) => {
let ret = TextUtils.secondsToTimestamp(seconds);
if (ret.startsWith("00:")) {
ret = ret.substr(3);
ret = ret.substring(3);
if (ret.startsWith("0")) {
ret = ret.substr(1);
ret = ret.substring(1);
}
}

View File

@@ -157,15 +157,15 @@ const fileSizeFractionalDigits = (unit: Unit) => {
};
const secondsToTimestamp = (seconds: number) => {
let ret = new Date(seconds * 1000).toISOString().substr(11, 8);
let ret = new Date(seconds * 1000).toISOString().substring(11, 19);
if (ret.startsWith("00")) {
// strip hours if under one hour
ret = ret.substr(3);
ret = ret.substring(3);
}
if (ret.startsWith("0")) {
// for duration under a minute, leave one leading zero
ret = ret.substr(1);
ret = ret.substring(1);
}
return ret;
};
@@ -387,11 +387,6 @@ const formatDateTime = (intl: IntlShape, dateTime?: string, utc = false) =>
timeZone: utc ? "utc" : undefined,
})}`;
const capitalize = (val: string) =>
val
.replace(/^[-_]*(.)/, (_, c) => c.toUpperCase())
.replace(/[-_]+(.)/g, (_, c) => ` ${c.toUpperCase()}`);
type CountUnit = "" | "K" | "M" | "B";
const CountUnits: CountUnit[] = ["", "K", "M", "B"];
@@ -435,7 +430,6 @@ const TextUtils = {
instagramURL,
formatDate,
formatDateTime,
capitalize,
secondsAsTimeString,
abbreviateCounter,
};