Feature Request: Bulk Add by StashID and Name (#6310)

This commit is contained in:
Gykes 2025-11-27 20:19:14 -06:00 committed by GitHub
parent 7e66ce8a49
commit e052a431d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 409 additions and 315 deletions

View file

@ -281,7 +281,10 @@ type StashBoxFingerprint {
duration: Int! duration: Int!
} }
"If neither ids nor names are set, tag all items" """
Accepts either ids, or a combination of names and stash_ids.
If none are set, then all existing items will be tagged.
"""
input StashBoxBatchTagInput { input StashBoxBatchTagInput {
"Stash endpoint to use for the tagging" "Stash endpoint to use for the tagging"
endpoint: Int @deprecated(reason: "use stash_box_endpoint") endpoint: Int @deprecated(reason: "use stash_box_endpoint")
@ -293,12 +296,17 @@ input StashBoxBatchTagInput {
refresh: Boolean! refresh: Boolean!
"If batch adding studios, should their parent studios also be created?" "If batch adding studios, should their parent studios also be created?"
createParent: Boolean! createParent: Boolean!
"If set, only tag these ids" """
IDs in stash of the items to update.
If set, names and stash_ids fields will be ignored.
"""
ids: [ID!] ids: [ID!]
"If set, only tag these names" "Names of the items in the stash-box instance to search for and create"
names: [String!] names: [String!]
"If set, only tag these performer ids" "Stash IDs of the items in the stash-box instance to search for and create"
stash_ids: [String!]
"IDs in stash of the performers to update"
performer_ids: [ID!] @deprecated(reason: "use ids") performer_ids: [ID!] @deprecated(reason: "use ids")
"If set, only tag these performer names" "Names of the performers in the stash-box instance to search for and create"
performer_names: [String!] @deprecated(reason: "use names") performer_names: [String!] @deprecated(reason: "use names")
} }

View file

@ -39,7 +39,7 @@ func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input
} }
func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) { func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) {
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck
if err != nil { if err != nil {
return "", err return "", err
} }
@ -49,7 +49,7 @@ func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input
} }
func (r *mutationResolver) StashBoxBatchStudioTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) { func (r *mutationResolver) StashBoxBatchStudioTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) {
b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) b, err := resolveStashBoxBatchTagInput(input.Endpoint, input.StashBoxEndpoint) //nolint:staticcheck
if err != nil { if err != nil {
return "", err return "", err
} }

View file

@ -365,9 +365,37 @@ func (s *Manager) MigrateHash(ctx context.Context) int {
return s.JobManager.Add(ctx, "Migrating scene hashes...", j) return s.JobManager.Add(ctx, "Migrating scene hashes...", j)
} }
// If neither ids nor names are set, tag all items // batchTagType indicates which batch tagging mode to use
type batchTagType int
const (
batchTagByIds batchTagType = iota
batchTagByNamesOrStashIds
batchTagAll
)
// getBatchTagType determines the batch tag mode based on the input
func (input StashBoxBatchTagInput) getBatchTagType(hasPerformerFields bool) batchTagType {
switch {
case len(input.Ids) > 0:
return batchTagByIds
case hasPerformerFields && len(input.PerformerIds) > 0:
return batchTagByIds
case len(input.StashIDs) > 0 || len(input.Names) > 0:
return batchTagByNamesOrStashIds
case hasPerformerFields && len(input.PerformerNames) > 0:
return batchTagByNamesOrStashIds
default:
return batchTagAll
}
}
// Accepts either ids, or a combination of names and stash_ids.
// If none are set, then all existing items will be tagged.
type StashBoxBatchTagInput struct { type StashBoxBatchTagInput struct {
// Stash endpoint to use for the tagging - deprecated - use StashBoxEndpoint // Stash endpoint to use for the tagging
//
// Deprecated: use StashBoxEndpoint
Endpoint *int `json:"endpoint"` Endpoint *int `json:"endpoint"`
StashBoxEndpoint *string `json:"stash_box_endpoint"` StashBoxEndpoint *string `json:"stash_box_endpoint"`
// Fields to exclude when executing the tagging // Fields to exclude when executing the tagging
@ -376,128 +404,143 @@ type StashBoxBatchTagInput struct {
Refresh bool `json:"refresh"` Refresh bool `json:"refresh"`
// If batch adding studios, should their parent studios also be created? // If batch adding studios, should their parent studios also be created?
CreateParent bool `json:"createParent"` CreateParent bool `json:"createParent"`
// If set, only tag these ids // IDs in stash of the items to update.
// If set, names and stash_ids fields will be ignored.
Ids []string `json:"ids"` Ids []string `json:"ids"`
// If set, only tag these names // Names of the items in the stash-box instance to search for and create
Names []string `json:"names"` Names []string `json:"names"`
// If set, only tag these performer ids // Stash IDs of the items in the stash-box instance to search for and create
StashIDs []string `json:"stash_ids"`
// IDs in stash of the performers to update
// //
// Deprecated: please use Ids // Deprecated: use Ids
PerformerIds []string `json:"performer_ids"` PerformerIds []string `json:"performer_ids"`
// If set, only tag these performer names // Names of the performers in the stash-box instance to search for and create
// //
// Deprecated: please use Names // Deprecated: use Names
PerformerNames []string `json:"performer_names"` PerformerNames []string `json:"performer_names"`
} }
func (s *Manager) batchTagPerformersByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
var tasks []Task
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
performerQuery := s.Repository.Performer
ids := input.Ids
if len(ids) == 0 {
ids = input.PerformerIds //nolint:staticcheck
}
for _, performerID := range ids {
if id, err := strconv.Atoi(performerID); err == nil {
performer, err := performerQuery.Find(ctx, id)
if err != nil {
return err
}
if err := performer.LoadStashIDs(ctx, performerQuery); err != nil {
return fmt.Errorf("loading performer stash ids: %w", err)
}
hasStashID := performer.StashIDs.ForEndpoint(box.Endpoint) != nil
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
tasks = append(tasks, &stashBoxBatchPerformerTagTask{
performer: performer,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
}
return nil
})
return tasks, err
}
func (s *Manager) batchTagPerformersByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task {
var tasks []Task
for i := range input.StashIDs {
stashID := input.StashIDs[i]
if len(stashID) > 0 {
tasks = append(tasks, &stashBoxBatchPerformerTagTask{
stashID: &stashID,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
names := input.Names
if len(names) == 0 {
names = input.PerformerNames //nolint:staticcheck
}
for i := range names {
name := names[i]
if len(name) > 0 {
tasks = append(tasks, &stashBoxBatchPerformerTagTask{
name: &name,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
return tasks
}
func (s *Manager) batchTagAllPerformers(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
var tasks []Task
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
performerQuery := s.Repository.Performer
var performers []*models.Performer
var err error
performers, err = performerQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint)
if err != nil {
return fmt.Errorf("error querying performers: %v", err)
}
for _, performer := range performers {
if err := performer.LoadStashIDs(ctx, performerQuery); err != nil {
return fmt.Errorf("error loading stash ids for performer %s: %v", performer.Name, err)
}
tasks = append(tasks, &stashBoxBatchPerformerTagTask{
performer: performer,
box: box,
excludedFields: input.ExcludeFields,
})
}
return nil
})
return tasks, err
}
func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int { func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {
logger.Infof("Initiating stash-box batch performer tag") logger.Infof("Initiating stash-box batch performer tag")
var tasks []StashBoxBatchTagTask var tasks []Task
var err error
// The gocritic linter wants to turn this ifElseChain into a switch. switch input.getBatchTagType(true) {
// however, such a switch would contain quite large blocks for each section case batchTagByIds:
// and would arguably be hard to read. tasks, err = s.batchTagPerformersByIds(ctx, input, box)
// case batchTagByNamesOrStashIds:
// This is why we mark this section nolint. In principle, we should look to tasks = s.batchTagPerformersByNamesOrStashIds(input, box)
// rewrite the section at some point, to avoid the linter warning. case batchTagAll:
if len(input.Ids) > 0 || len(input.PerformerIds) > 0 { //nolint:gocritic tasks, err = s.batchTagAllPerformers(ctx, input, box)
// The user has chosen only to tag the items on the current page }
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
performerQuery := s.Repository.Performer
idsToUse := input.PerformerIds if err != nil {
if len(input.Ids) > 0 { return err
idsToUse = input.Ids
}
for _, performerID := range idsToUse {
if id, err := strconv.Atoi(performerID); err == nil {
performer, err := performerQuery.Find(ctx, id)
if err == nil {
if err := performer.LoadStashIDs(ctx, performerQuery); err != nil {
return fmt.Errorf("loading performer stash ids: %w", err)
}
// Check if the user wants to refresh existing or new items
hasStashID := performer.StashIDs.ForEndpoint(box.Endpoint) != nil
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
tasks = append(tasks, StashBoxBatchTagTask{
performer: performer,
refresh: input.Refresh,
box: box,
excludedFields: input.ExcludeFields,
taskType: Performer,
})
}
} else {
return err
}
}
}
return nil
}); err != nil {
return err
}
} else if len(input.Names) > 0 || len(input.PerformerNames) > 0 {
// The user is batch adding performers
namesToUse := input.PerformerNames
if len(input.Names) > 0 {
namesToUse = input.Names
}
for i := range namesToUse {
name := namesToUse[i]
if len(name) > 0 {
tasks = append(tasks, StashBoxBatchTagTask{
name: &name,
refresh: false,
box: box,
excludedFields: input.ExcludeFields,
taskType: Performer,
})
}
}
} else { //nolint:gocritic
// The gocritic linter wants to fold this if-block into the else on the line above.
// However, this doesn't really help with readability of the current section. Mark it
// as nolint for now. In the future we'd like to rewrite this code by factoring some of
// this into separate functions.
// The user has chosen to tag every item in their database
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
performerQuery := s.Repository.Performer
var performers []*models.Performer
var err error
if input.Refresh {
performers, err = performerQuery.FindByStashIDStatus(ctx, true, box.Endpoint)
} else {
performers, err = performerQuery.FindByStashIDStatus(ctx, false, box.Endpoint)
}
if err != nil {
return fmt.Errorf("error querying performers: %v", err)
}
for _, performer := range performers {
if err := performer.LoadStashIDs(ctx, performerQuery); err != nil {
return fmt.Errorf("error loading stash ids for performer %s: %v", performer.Name, err)
}
tasks = append(tasks, StashBoxBatchTagTask{
performer: performer,
refresh: input.Refresh,
box: box,
excludedFields: input.ExcludeFields,
taskType: Performer,
})
}
return nil
}); err != nil {
return err
}
} }
if len(tasks) == 0 { if len(tasks) == 0 {
@ -509,7 +552,7 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, box *models.Sta
logger.Infof("Starting stash-box batch operation for %d performers", len(tasks)) logger.Infof("Starting stash-box batch operation for %d performers", len(tasks))
for _, task := range tasks { for _, task := range tasks {
progress.ExecuteTask(task.Description(), func() { progress.ExecuteTask(task.GetDescription(), func() {
task.Start(ctx) task.Start(ctx)
}) })
@ -522,103 +565,116 @@ func (s *Manager) StashBoxBatchPerformerTag(ctx context.Context, box *models.Sta
return s.JobManager.Add(ctx, "Batch stash-box performer tag...", j) return s.JobManager.Add(ctx, "Batch stash-box performer tag...", j)
} }
func (s *Manager) batchTagStudiosByIds(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
var tasks []Task
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
studioQuery := s.Repository.Studio
for _, studioID := range input.Ids {
if id, err := strconv.Atoi(studioID); err == nil {
studio, err := studioQuery.Find(ctx, id)
if err != nil {
return err
}
if err := studio.LoadStashIDs(ctx, studioQuery); err != nil {
return fmt.Errorf("loading studio stash ids: %w", err)
}
hasStashID := studio.StashIDs.ForEndpoint(box.Endpoint) != nil
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
tasks = append(tasks, &stashBoxBatchStudioTagTask{
studio: studio,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
}
return nil
})
return tasks, err
}
func (s *Manager) batchTagStudiosByNamesOrStashIds(input StashBoxBatchTagInput, box *models.StashBox) []Task {
var tasks []Task
for i := range input.StashIDs {
stashID := input.StashIDs[i]
if len(stashID) > 0 {
tasks = append(tasks, &stashBoxBatchStudioTagTask{
stashID: &stashID,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
for i := range input.Names {
name := input.Names[i]
if len(name) > 0 {
tasks = append(tasks, &stashBoxBatchStudioTagTask{
name: &name,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
}
}
return tasks
}
func (s *Manager) batchTagAllStudios(ctx context.Context, input StashBoxBatchTagInput, box *models.StashBox) ([]Task, error) {
var tasks []Task
err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
studioQuery := s.Repository.Studio
var studios []*models.Studio
var err error
studios, err = studioQuery.FindByStashIDStatus(ctx, input.Refresh, box.Endpoint)
if err != nil {
return fmt.Errorf("error querying studios: %v", err)
}
for _, studio := range studios {
tasks = append(tasks, &stashBoxBatchStudioTagTask{
studio: studio,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
})
}
return nil
})
return tasks, err
}
func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int { func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, box *models.StashBox, input StashBoxBatchTagInput) int {
j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error { j := job.MakeJobExec(func(ctx context.Context, progress *job.Progress) error {
logger.Infof("Initiating stash-box batch studio tag") logger.Infof("Initiating stash-box batch studio tag")
var tasks []StashBoxBatchTagTask var tasks []Task
var err error
// The gocritic linter wants to turn this ifElseChain into a switch. switch input.getBatchTagType(false) {
// however, such a switch would contain quite large blocks for each section case batchTagByIds:
// and would arguably be hard to read. tasks, err = s.batchTagStudiosByIds(ctx, input, box)
// case batchTagByNamesOrStashIds:
// This is why we mark this section nolint. In principle, we should look to tasks = s.batchTagStudiosByNamesOrStashIds(input, box)
// rewrite the section at some point, to avoid the linter warning. case batchTagAll:
if len(input.Ids) > 0 { //nolint:gocritic tasks, err = s.batchTagAllStudios(ctx, input, box)
// The user has chosen only to tag the items on the current page }
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
studioQuery := s.Repository.Studio
for _, studioID := range input.Ids { if err != nil {
if id, err := strconv.Atoi(studioID); err == nil { return err
studio, err := studioQuery.Find(ctx, id)
if err == nil {
if err := studio.LoadStashIDs(ctx, studioQuery); err != nil {
return fmt.Errorf("loading studio stash ids: %w", err)
}
// Check if the user wants to refresh existing or new items
hasStashID := studio.StashIDs.ForEndpoint(box.Endpoint) != nil
if (input.Refresh && hasStashID) || (!input.Refresh && !hasStashID) {
tasks = append(tasks, StashBoxBatchTagTask{
studio: studio,
refresh: input.Refresh,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
taskType: Studio,
})
}
} else {
return err
}
}
}
return nil
}); err != nil {
logger.Error(err.Error())
}
} else if len(input.Names) > 0 {
// The user is batch adding studios
for i := range input.Names {
name := input.Names[i]
if len(name) > 0 {
tasks = append(tasks, StashBoxBatchTagTask{
name: &name,
refresh: false,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
taskType: Studio,
})
}
}
} else { //nolint:gocritic
// The gocritic linter wants to fold this if-block into the else on the line above.
// However, this doesn't really help with readability of the current section. Mark it
// as nolint for now. In the future we'd like to rewrite this code by factoring some of
// this into separate functions.
// The user has chosen to tag every item in their database
if err := s.Repository.WithTxn(ctx, func(ctx context.Context) error {
studioQuery := s.Repository.Studio
var studios []*models.Studio
var err error
if input.Refresh {
studios, err = studioQuery.FindByStashIDStatus(ctx, true, box.Endpoint)
} else {
studios, err = studioQuery.FindByStashIDStatus(ctx, false, box.Endpoint)
}
if err != nil {
return fmt.Errorf("error querying studios: %v", err)
}
for _, studio := range studios {
tasks = append(tasks, StashBoxBatchTagTask{
studio: studio,
refresh: input.Refresh,
createParent: input.CreateParent,
box: box,
excludedFields: input.ExcludeFields,
taskType: Studio,
})
}
return nil
}); err != nil {
return err
}
} }
if len(tasks) == 0 { if len(tasks) == 0 {
@ -630,7 +686,7 @@ func (s *Manager) StashBoxBatchStudioTag(ctx context.Context, box *models.StashB
logger.Infof("Starting stash-box batch operation for %d studios", len(tasks)) logger.Infof("Starting stash-box batch operation for %d studios", len(tasks))
for _, task := range tasks { for _, task := range tasks {
progress.ExecuteTask(task.Description(), func() { progress.ExecuteTask(task.GetDescription(), func() {
task.Start(ctx) task.Start(ctx)
}) })

View file

@ -14,57 +14,33 @@ import (
"github.com/stashapp/stash/pkg/studio" "github.com/stashapp/stash/pkg/studio"
) )
type StashBoxTagTaskType int // stashBoxBatchPerformerTagTask is used to tag or create performers from stash-box.
//
const ( // Two modes of operation:
Performer StashBoxTagTaskType = iota // - Update existing performer: set performer to update from stash-box data
Studio // - Create new performer: set name or stashID to search stash-box and create locally
) type stashBoxBatchPerformerTagTask struct {
type StashBoxBatchTagTask struct {
box *models.StashBox box *models.StashBox
name *string name *string
stashID *string
performer *models.Performer performer *models.Performer
studio *models.Studio
refresh bool
createParent bool
excludedFields []string excludedFields []string
taskType StashBoxTagTaskType
} }
func (t *StashBoxBatchTagTask) Start(ctx context.Context) { func (t *stashBoxBatchPerformerTagTask) getName() string {
switch t.taskType { switch {
case Performer: case t.name != nil:
t.stashBoxPerformerTag(ctx) return *t.name
case Studio: case t.stashID != nil:
t.stashBoxStudioTag(ctx) return *t.stashID
case t.performer != nil:
return t.performer.Name
default: default:
logger.Errorf("Error starting batch task, unknown task_type %d", t.taskType) return ""
} }
} }
func (t *StashBoxBatchTagTask) Description() string { func (t *stashBoxBatchPerformerTagTask) Start(ctx context.Context) {
if t.taskType == Performer {
var name string
if t.name != nil {
name = *t.name
} else {
name = t.performer.Name
}
return fmt.Sprintf("Tagging performer %s from stash-box", name)
} else if t.taskType == Studio {
var name string
if t.name != nil {
name = *t.name
} else {
name = t.studio.Name
}
return fmt.Sprintf("Tagging studio %s from stash-box", name)
}
return fmt.Sprintf("Unknown tagging task type %d from stash-box", t.taskType)
}
func (t *StashBoxBatchTagTask) stashBoxPerformerTag(ctx context.Context) {
performer, err := t.findStashBoxPerformer(ctx) performer, err := t.findStashBoxPerformer(ctx)
if err != nil { if err != nil {
logger.Errorf("Error fetching performer data from stash-box: %v", err) logger.Errorf("Error fetching performer data from stash-box: %v", err)
@ -76,21 +52,18 @@ func (t *StashBoxBatchTagTask) stashBoxPerformerTag(ctx context.Context) {
excluded[field] = true excluded[field] = true
} }
// performer will have a value if pulling from Stash-box by Stash ID or name was successful
if performer != nil { if performer != nil {
t.processMatchedPerformer(ctx, performer, excluded) t.processMatchedPerformer(ctx, performer, excluded)
} else { } else {
var name string logger.Infof("No match found for %s", t.getName())
if t.name != nil {
name = *t.name
} else if t.performer != nil {
name = t.performer.Name
}
logger.Infof("No match found for %s", name)
} }
} }
func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*models.ScrapedPerformer, error) { func (t *stashBoxBatchPerformerTagTask) GetDescription() string {
return fmt.Sprintf("Tagging performer %s from stash-box", t.getName())
}
func (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Context) (*models.ScrapedPerformer, error) {
var performer *models.ScrapedPerformer var performer *models.ScrapedPerformer
var err error var err error
@ -98,7 +71,24 @@ func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*mode
client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns())) client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))
if t.refresh { switch {
case t.name != nil:
performer, err = client.FindPerformerByName(ctx, *t.name)
case t.stashID != nil:
performer, err = client.FindPerformerByID(ctx, *t.stashID)
if performer != nil && performer.RemoteMergedIntoId != nil {
mergedPerformer, err := t.handleMergedPerformer(ctx, performer, client)
if err != nil {
return nil, err
}
if mergedPerformer != nil {
logger.Infof("Performer id %s merged into %s, updating local performer", *t.stashID, *performer.RemoteMergedIntoId)
performer = mergedPerformer
}
}
case t.performer != nil:
var remoteID string var remoteID string
if err := r.WithReadTxn(ctx, func(ctx context.Context) error { if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
qb := r.Performer qb := r.Performer
@ -118,6 +108,7 @@ func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*mode
}); err != nil { }); err != nil {
return nil, err return nil, err
} }
if remoteID != "" { if remoteID != "" {
performer, err = client.FindPerformerByID(ctx, remoteID) performer, err = client.FindPerformerByID(ctx, remoteID)
@ -133,14 +124,6 @@ func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*mode
} }
} }
} }
} else {
var name string
if t.name != nil {
name = *t.name
} else {
name = t.performer.Name
}
performer, err = client.FindPerformerByName(ctx, name)
} }
if performer != nil { if performer != nil {
@ -154,7 +137,7 @@ func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*mode
return performer, err return performer, err
} }
func (t *StashBoxBatchTagTask) handleMergedPerformer(ctx context.Context, performer *models.ScrapedPerformer, client *stashbox.Client) (mergedPerformer *models.ScrapedPerformer, err error) { func (t *stashBoxBatchPerformerTagTask) handleMergedPerformer(ctx context.Context, performer *models.ScrapedPerformer, client *stashbox.Client) (mergedPerformer *models.ScrapedPerformer, err error) {
mergedPerformer, err = client.FindPerformerByID(ctx, *performer.RemoteMergedIntoId) mergedPerformer, err = client.FindPerformerByID(ctx, *performer.RemoteMergedIntoId)
if err != nil { if err != nil {
return nil, fmt.Errorf("loading merged performer %s from stashbox", *performer.RemoteMergedIntoId) return nil, fmt.Errorf("loading merged performer %s from stashbox", *performer.RemoteMergedIntoId)
@ -169,8 +152,7 @@ func (t *StashBoxBatchTagTask) handleMergedPerformer(ctx context.Context, perfor
return mergedPerformer, nil return mergedPerformer, nil
} }
func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool) { func (t *stashBoxBatchPerformerTagTask) processMatchedPerformer(ctx context.Context, p *models.ScrapedPerformer, excluded map[string]bool) {
// Refreshing an existing performer
if t.performer != nil { if t.performer != nil {
storedID, _ := strconv.Atoi(*p.StoredID) storedID, _ := strconv.Atoi(*p.StoredID)
@ -180,7 +162,6 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m
return return
} }
// Start the transaction and update the performer
r := instance.Repository r := instance.Repository
err = r.WithTxn(ctx, func(ctx context.Context) error { err = r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Performer qb := r.Performer
@ -226,8 +207,8 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m
} else { } else {
logger.Infof("Updated performer %s", *p.Name) logger.Infof("Updated performer %s", *p.Name)
} }
} else if t.name != nil && p.Name != nil { } else {
// Creating a new performer // no existing performer, create a new one
newPerformer := p.ToPerformer(t.box.Endpoint, excluded) newPerformer := p.ToPerformer(t.box.Endpoint, excluded)
image, err := p.GetImage(ctx, excluded) image, err := p.GetImage(ctx, excluded)
if err != nil { if err != nil {
@ -263,7 +244,34 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m
} }
} }
func (t *StashBoxBatchTagTask) stashBoxStudioTag(ctx context.Context) { // stashBoxBatchStudioTagTask is used to tag or create studios from stash-box.
//
// Two modes of operation:
// - Update existing studio: set studio to update from stash-box data
// - Create new studio: set name or stashID to search stash-box and create locally
type stashBoxBatchStudioTagTask struct {
box *models.StashBox
name *string
stashID *string
studio *models.Studio
createParent bool
excludedFields []string
}
func (t *stashBoxBatchStudioTagTask) getName() string {
switch {
case t.name != nil:
return *t.name
case t.stashID != nil:
return *t.stashID
case t.studio != nil:
return t.studio.Name
default:
return ""
}
}
func (t *stashBoxBatchStudioTagTask) Start(ctx context.Context) {
studio, err := t.findStashBoxStudio(ctx) studio, err := t.findStashBoxStudio(ctx)
if err != nil { if err != nil {
logger.Errorf("Error fetching studio data from stash-box: %v", err) logger.Errorf("Error fetching studio data from stash-box: %v", err)
@ -275,21 +283,18 @@ func (t *StashBoxBatchTagTask) stashBoxStudioTag(ctx context.Context) {
excluded[field] = true excluded[field] = true
} }
// studio will have a value if pulling from Stash-box by Stash ID or name was successful
if studio != nil { if studio != nil {
t.processMatchedStudio(ctx, studio, excluded) t.processMatchedStudio(ctx, studio, excluded)
} else { } else {
var name string logger.Infof("No match found for %s", t.getName())
if t.name != nil {
name = *t.name
} else if t.studio != nil {
name = t.studio.Name
}
logger.Infof("No match found for %s", name)
} }
} }
func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.ScrapedStudio, error) { func (t *stashBoxBatchStudioTagTask) GetDescription() string {
return fmt.Sprintf("Tagging studio %s from stash-box", t.getName())
}
func (t *stashBoxBatchStudioTagTask) findStashBoxStudio(ctx context.Context) (*models.ScrapedStudio, error) {
var studio *models.ScrapedStudio var studio *models.ScrapedStudio
var err error var err error
@ -297,7 +302,12 @@ func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.
client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns())) client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))
if t.refresh { switch {
case t.name != nil:
studio, err = client.FindStudio(ctx, *t.name)
case t.stashID != nil:
studio, err = client.FindStudio(ctx, *t.stashID)
case t.studio != nil:
var remoteID string var remoteID string
if err := r.WithReadTxn(ctx, func(ctx context.Context) error { if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
if !t.studio.StashIDs.Loaded() { if !t.studio.StashIDs.Loaded() {
@ -315,17 +325,10 @@ func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.
}); err != nil { }); err != nil {
return nil, err return nil, err
} }
if remoteID != "" { if remoteID != "" {
studio, err = client.FindStudio(ctx, remoteID) studio, err = client.FindStudio(ctx, remoteID)
} }
} else {
var name string
if t.name != nil {
name = *t.name
} else {
name = t.studio.Name
}
studio, err = client.FindStudio(ctx, name)
} }
if err := r.WithReadTxn(ctx, func(ctx context.Context) error { if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
@ -343,8 +346,7 @@ func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.
return studio, err return studio, err
} }
func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *models.ScrapedStudio, excluded map[string]bool) { func (t *stashBoxBatchStudioTagTask) processMatchedStudio(ctx context.Context, s *models.ScrapedStudio, excluded map[string]bool) {
// Refreshing an existing studio
if t.studio != nil { if t.studio != nil {
storedID, _ := strconv.Atoi(*s.StoredID) storedID, _ := strconv.Atoi(*s.StoredID)
@ -361,7 +363,6 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
return return
} }
// Start the transaction and update the studio
r := instance.Repository r := instance.Repository
err = r.WithTxn(ctx, func(ctx context.Context) error { err = r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Studio qb := r.Studio
@ -394,8 +395,8 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
} else { } else {
logger.Infof("Updated studio %s", s.Name) logger.Infof("Updated studio %s", s.Name)
} }
} else if t.name != nil && s.Name != "" { } else if s.Name != "" {
// Creating a new studio // no existing studio, create a new one
if s.Parent != nil && t.createParent { if s.Parent != nil && t.createParent {
err := t.processParentStudio(ctx, s.Parent, excluded) err := t.processParentStudio(ctx, s.Parent, excluded)
if err != nil { if err != nil {
@ -410,7 +411,6 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
return return
} }
// Start the transaction and save the studio
r := instance.Repository r := instance.Repository
err = r.WithTxn(ctx, func(ctx context.Context) error { err = r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Studio qb := r.Studio
@ -439,9 +439,8 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
} }
} }
func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *models.ScrapedStudio, excluded map[string]bool) error { func (t *stashBoxBatchStudioTagTask) processParentStudio(ctx context.Context, parent *models.ScrapedStudio, excluded map[string]bool) error {
if parent.StoredID == nil { if parent.StoredID == nil {
// The parent needs to be created
newParentStudio := parent.ToStudio(t.box.Endpoint, excluded) newParentStudio := parent.ToStudio(t.box.Endpoint, excluded)
image, err := parent.GetImage(ctx, excluded) image, err := parent.GetImage(ctx, excluded)
@ -450,7 +449,6 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *
return err return err
} }
// Start the transaction and save the studio
r := instance.Repository r := instance.Repository
err = r.WithTxn(ctx, func(ctx context.Context) error { err = r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Studio qb := r.Studio
@ -476,7 +474,6 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *
} }
return err return err
} else { } else {
// The parent studio matched an existing one and the user has chosen in the UI to link and/or update it
storedID, _ := strconv.Atoi(*parent.StoredID) storedID, _ := strconv.Atoi(*parent.StoredID)
image, err := parent.GetImage(ctx, excluded) image, err := parent.GetImage(ctx, excluded)
@ -485,7 +482,6 @@ func (t *StashBoxBatchTagTask) processParentStudio(ctx context.Context, parent *
return err return err
} }
// Start the transaction and update the studio
r := instance.Repository r := instance.Repository
err = r.WithTxn(ctx, func(ctx context.Context) error { err = r.WithTxn(ctx, func(ctx context.Context) error {
qb := r.Studio qb := r.Studio

View file

@ -25,6 +25,7 @@ import PerformerModal from "../PerformerModal";
import { useUpdatePerformer } from "../queries"; import { useUpdatePerformer } from "../queries";
import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; import { faStar, faTags } from "@fortawesome/free-solid-svg-icons";
import { mergeStashIDs } from "src/utils/stashbox"; import { mergeStashIDs } from "src/utils/stashbox";
import { separateNamesAndStashIds } from "src/utils/stashIds";
import { ExternalLink } from "src/components/Shared/ExternalLink"; import { ExternalLink } from "src/components/Shared/ExternalLink";
import { useTaggerConfig } from "../config"; import { useTaggerConfig } from "../config";
@ -222,7 +223,7 @@ const PerformerBatchAddModal: React.FC<IPerformerBatchAddModal> = ({
as="textarea" as="textarea"
ref={performerInput} ref={performerInput}
placeholder={intl.formatMessage({ placeholder={intl.formatMessage({
id: "performer_tagger.performer_names_separated_by_comma", id: "performer_tagger.performer_names_or_stashids_separated_by_comma",
})} })}
rows={6} rows={6}
/> />
@ -666,14 +667,17 @@ export const PerformerTagger: React.FC<ITaggerProps> = ({ performers }) => {
async function batchAdd(performerInput: string) { async function batchAdd(performerInput: string) {
if (performerInput && selectedEndpoint) { if (performerInput && selectedEndpoint) {
const names = performerInput const inputs = performerInput
.split(",") .split(",")
.map((n) => n.trim()) .map((n) => n.trim())
.filter((n) => n.length > 0); .filter((n) => n.length > 0);
if (names.length > 0) { const { names, stashIds } = separateNamesAndStashIds(inputs);
if (names.length > 0 || stashIds.length > 0) {
const ret = await mutateStashBoxBatchPerformerTag({ const ret = await mutateStashBoxBatchPerformerTag({
names: names, names: names.length > 0 ? names : undefined,
stash_ids: stashIds.length > 0 ? stashIds : undefined,
endpoint: selectedEndpointIndex, endpoint: selectedEndpointIndex,
refresh: false, refresh: false,
createParent: false, createParent: false,

View file

@ -28,6 +28,7 @@ import { apolloError } from "src/utils";
import { faStar, faTags } from "@fortawesome/free-solid-svg-icons"; import { faStar, faTags } from "@fortawesome/free-solid-svg-icons";
import { ExternalLink } from "src/components/Shared/ExternalLink"; import { ExternalLink } from "src/components/Shared/ExternalLink";
import { mergeStudioStashIDs } from "../utils"; import { mergeStudioStashIDs } from "../utils";
import { separateNamesAndStashIds } from "src/utils/stashIds";
import { useTaggerConfig } from "../config"; import { useTaggerConfig } from "../config";
type JobFragment = Pick< type JobFragment = Pick<
@ -242,7 +243,7 @@ const StudioBatchAddModal: React.FC<IStudioBatchAddModal> = ({
as="textarea" as="textarea"
ref={studioInput} ref={studioInput}
placeholder={intl.formatMessage({ placeholder={intl.formatMessage({
id: "studio_tagger.studio_names_separated_by_comma", id: "studio_tagger.studio_names_or_stashids_separated_by_comma",
})} })}
rows={6} rows={6}
/> />
@ -715,14 +716,17 @@ export const StudioTagger: React.FC<ITaggerProps> = ({ studios }) => {
async function batchAdd(studioInput: string, createParent: boolean) { async function batchAdd(studioInput: string, createParent: boolean) {
if (studioInput && selectedEndpoint) { if (studioInput && selectedEndpoint) {
const names = studioInput const inputs = studioInput
.split(",") .split(",")
.map((n) => n.trim()) .map((n) => n.trim())
.filter((n) => n.length > 0); .filter((n) => n.length > 0);
if (names.length > 0) { const { names, stashIds } = separateNamesAndStashIds(inputs);
if (names.length > 0 || stashIds.length > 0) {
const ret = await mutateStashBoxBatchStudioTag({ const ret = await mutateStashBoxBatchStudioTag({
names: names, names: names.length > 0 ? names : undefined,
stash_ids: stashIds.length > 0 ? stashIds : undefined,
endpoint: selectedEndpointIndex, endpoint: selectedEndpointIndex,
refresh: false, refresh: false,
exclude_fields: config?.excludedStudioFields ?? [], exclude_fields: config?.excludedStudioFields ?? [],

View file

@ -1295,7 +1295,7 @@
"no_results_found": "No results found.", "no_results_found": "No results found.",
"number_of_performers_will_be_processed": "{performer_count} performers will be processed", "number_of_performers_will_be_processed": "{performer_count} performers will be processed",
"performer_already_tagged": "Performer already tagged", "performer_already_tagged": "Performer already tagged",
"performer_names_separated_by_comma": "Performer names separated by comma", "performer_names_or_stashids_separated_by_comma": "Performer names or StashIDs separated by comma",
"performer_selection": "Performer selection", "performer_selection": "Performer selection",
"performer_successfully_tagged": "Performer successfully tagged:", "performer_successfully_tagged": "Performer successfully tagged:",
"query_all_performers_in_the_database": "All performers in the database", "query_all_performers_in_the_database": "All performers in the database",
@ -1510,7 +1510,7 @@
"status_tagging_job_queued": "Status: Tagging job queued", "status_tagging_job_queued": "Status: Tagging job queued",
"status_tagging_studios": "Status: Tagging studios", "status_tagging_studios": "Status: Tagging studios",
"studio_already_tagged": "Studio already tagged", "studio_already_tagged": "Studio already tagged",
"studio_names_separated_by_comma": "Studio names separated by comma", "studio_names_or_stashids_separated_by_comma": "Studio names or StashIDs separated by comma",
"studio_selection": "Studio selection", "studio_selection": "Studio selection",
"studio_successfully_tagged": "Studio successfully tagged", "studio_successfully_tagged": "Studio successfully tagged",
"tag_status": "Tag Status", "tag_status": "Tag Status",

View file

@ -6,3 +6,29 @@ export const getStashIDs = (
endpoint, endpoint,
updated_at, updated_at,
})); }));
// UUID regex pattern to detect StashIDs (supports v4 and v7)
const UUID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[47][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
/**
* Separates a list of inputs into names and StashIDs based on UUID pattern matching
* @param inputs - Array of strings that could be either names or StashIDs
* @returns Object containing separate arrays for names and stashIds
*/
export const separateNamesAndStashIds = (
inputs: string[]
): { names: string[]; stashIds: string[] } => {
const names: string[] = [];
const stashIds: string[] = [];
inputs.forEach((input) => {
if (UUID_PATTERN.test(input)) {
stashIds.push(input);
} else {
names.push(input);
}
});
return { names, stashIds };
};