mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Identify: Options to skip multiple results and single name performers (#3707)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
parent
ff22577ce0
commit
cbdd4d3cbf
19 changed files with 581 additions and 136 deletions
|
|
@ -124,6 +124,10 @@ fragment IdentifyMetadataOptionsData on IdentifyMetadataOptions {
|
||||||
setCoverImage
|
setCoverImage
|
||||||
setOrganized
|
setOrganized
|
||||||
includeMalePerformers
|
includeMalePerformers
|
||||||
|
skipMultipleMatches
|
||||||
|
skipMultipleMatchTag
|
||||||
|
skipSingleNamePerformers
|
||||||
|
skipSingleNamePerformerTag
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment ScraperSourceData on ScraperSource {
|
fragment ScraperSourceData on ScraperSource {
|
||||||
|
|
|
||||||
|
|
@ -185,6 +185,14 @@ input IdentifyMetadataOptionsInput {
|
||||||
setOrganized: Boolean
|
setOrganized: Boolean
|
||||||
"""defaults to true if not provided"""
|
"""defaults to true if not provided"""
|
||||||
includeMalePerformers: Boolean
|
includeMalePerformers: Boolean
|
||||||
|
"""defaults to true if not provided"""
|
||||||
|
skipMultipleMatches: Boolean
|
||||||
|
"""tag to tag skipped multiple matches with"""
|
||||||
|
skipMultipleMatchTag: String
|
||||||
|
"""defaults to true if not provided"""
|
||||||
|
skipSingleNamePerformers: Boolean
|
||||||
|
"""tag to tag skipped single name performers with"""
|
||||||
|
skipSingleNamePerformerTag: String
|
||||||
}
|
}
|
||||||
|
|
||||||
input IdentifySourceInput {
|
input IdentifySourceInput {
|
||||||
|
|
@ -222,6 +230,14 @@ type IdentifyMetadataOptions {
|
||||||
setOrganized: Boolean
|
setOrganized: Boolean
|
||||||
"""defaults to true if not provided"""
|
"""defaults to true if not provided"""
|
||||||
includeMalePerformers: Boolean
|
includeMalePerformers: Boolean
|
||||||
|
"""defaults to true if not provided"""
|
||||||
|
skipMultipleMatches: Boolean
|
||||||
|
"""tag to tag skipped multiple matches with"""
|
||||||
|
skipMultipleMatchTag: String
|
||||||
|
"""defaults to true if not provided"""
|
||||||
|
skipSingleNamePerformers: Boolean
|
||||||
|
"""tag to tag skipped single name performers with"""
|
||||||
|
skipSingleNamePerformerTag: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type IdentifySource {
|
type IdentifySource {
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,33 @@ package identify
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/logger"
|
"github.com/stashapp/stash/pkg/logger"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
"github.com/stashapp/stash/pkg/scene"
|
"github.com/stashapp/stash/pkg/scene"
|
||||||
"github.com/stashapp/stash/pkg/scraper"
|
"github.com/stashapp/stash/pkg/scraper"
|
||||||
|
"github.com/stashapp/stash/pkg/sliceutil/intslice"
|
||||||
"github.com/stashapp/stash/pkg/txn"
|
"github.com/stashapp/stash/pkg/txn"
|
||||||
"github.com/stashapp/stash/pkg/utils"
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrSkipSingleNamePerformer = errors.New("a performer was skipped because they only had a single name and no disambiguation")
|
||||||
|
)
|
||||||
|
|
||||||
|
type MultipleMatchesFoundError struct {
|
||||||
|
Source ScraperSource
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *MultipleMatchesFoundError) Error() string {
|
||||||
|
return fmt.Sprintf("multiple matches found for %s", e.Source.Name)
|
||||||
|
}
|
||||||
|
|
||||||
type SceneScraper interface {
|
type SceneScraper interface {
|
||||||
ScrapeScene(ctx context.Context, sceneID int) (*scraper.ScrapedScene, error)
|
ScrapeScenes(ctx context.Context, sceneID int) ([]*scraper.ScrapedScene, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type SceneUpdatePostHookExecutor interface {
|
type SceneUpdatePostHookExecutor interface {
|
||||||
|
|
@ -31,7 +46,7 @@ type SceneIdentifier struct {
|
||||||
SceneReaderUpdater SceneReaderUpdater
|
SceneReaderUpdater SceneReaderUpdater
|
||||||
StudioCreator StudioCreator
|
StudioCreator StudioCreator
|
||||||
PerformerCreator PerformerCreator
|
PerformerCreator PerformerCreator
|
||||||
TagCreator TagCreator
|
TagCreatorFinder TagCreatorFinder
|
||||||
|
|
||||||
DefaultOptions *MetadataOptions
|
DefaultOptions *MetadataOptions
|
||||||
Sources []ScraperSource
|
Sources []ScraperSource
|
||||||
|
|
@ -39,13 +54,31 @@ type SceneIdentifier struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *SceneIdentifier) Identify(ctx context.Context, txnManager txn.Manager, scene *models.Scene) error {
|
func (t *SceneIdentifier) Identify(ctx context.Context, txnManager txn.Manager, scene *models.Scene) error {
|
||||||
result, err := t.scrapeScene(ctx, scene)
|
result, err := t.scrapeScene(ctx, txnManager, scene)
|
||||||
|
var multipleMatchErr *MultipleMatchesFoundError
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
if !errors.As(err, &multipleMatchErr) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if result == nil {
|
if result == nil {
|
||||||
logger.Debugf("Unable to identify %s", scene.Path)
|
if multipleMatchErr != nil {
|
||||||
|
logger.Debugf("Identify skipped because multiple results returned for %s", scene.Path)
|
||||||
|
|
||||||
|
// find if the scene should be tagged for multiple results
|
||||||
|
options := t.getOptions(multipleMatchErr.Source)
|
||||||
|
if options.SkipMultipleMatchTag != nil && len(*options.SkipMultipleMatchTag) > 0 {
|
||||||
|
// Tag it with the multiple results tag
|
||||||
|
err := t.addTagToScene(ctx, txnManager, scene, *options.SkipMultipleMatchTag)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
logger.Debugf("Unable to identify %s", scene.Path)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,63 +95,98 @@ type scrapeResult struct {
|
||||||
source ScraperSource
|
source ScraperSource
|
||||||
}
|
}
|
||||||
|
|
||||||
func (t *SceneIdentifier) scrapeScene(ctx context.Context, scene *models.Scene) (*scrapeResult, error) {
|
func (t *SceneIdentifier) scrapeScene(ctx context.Context, txnManager txn.Manager, scene *models.Scene) (*scrapeResult, error) {
|
||||||
// iterate through the input sources
|
// iterate through the input sources
|
||||||
for _, source := range t.Sources {
|
for _, source := range t.Sources {
|
||||||
// scrape using the source
|
// scrape using the source
|
||||||
scraped, err := source.Scraper.ScrapeScene(ctx, scene.ID)
|
results, err := source.Scraper.ScrapeScenes(ctx, scene.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Errorf("error scraping from %v: %v", source.Scraper, err)
|
logger.Errorf("error scraping from %v: %v", source.Scraper, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// if results were found then return
|
if len(results) > 0 {
|
||||||
if scraped != nil {
|
options := t.getOptions(source)
|
||||||
return &scrapeResult{
|
if len(results) > 1 && utils.IsTrue(options.SkipMultipleMatches) {
|
||||||
result: scraped,
|
return nil, &MultipleMatchesFoundError{
|
||||||
source: source,
|
Source: source,
|
||||||
}, nil
|
}
|
||||||
|
} else {
|
||||||
|
// if results were found then return
|
||||||
|
return &scrapeResult{
|
||||||
|
result: results[0],
|
||||||
|
source: source,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Returns a MetadataOptions object with any default options overwritten by source specific options
|
||||||
|
func (t *SceneIdentifier) getOptions(source ScraperSource) MetadataOptions {
|
||||||
|
options := *t.DefaultOptions
|
||||||
|
if source.Options == nil {
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
if source.Options.SetCoverImage != nil {
|
||||||
|
options.SetCoverImage = source.Options.SetCoverImage
|
||||||
|
}
|
||||||
|
if source.Options.SetOrganized != nil {
|
||||||
|
options.SetOrganized = source.Options.SetOrganized
|
||||||
|
}
|
||||||
|
if source.Options.IncludeMalePerformers != nil {
|
||||||
|
options.IncludeMalePerformers = source.Options.IncludeMalePerformers
|
||||||
|
}
|
||||||
|
if source.Options.SkipMultipleMatches != nil {
|
||||||
|
options.SkipMultipleMatches = source.Options.SkipMultipleMatches
|
||||||
|
}
|
||||||
|
if source.Options.SkipMultipleMatchTag != nil && len(*source.Options.SkipMultipleMatchTag) > 0 {
|
||||||
|
options.SkipMultipleMatchTag = source.Options.SkipMultipleMatchTag
|
||||||
|
}
|
||||||
|
if source.Options.SkipSingleNamePerformers != nil {
|
||||||
|
options.SkipSingleNamePerformers = source.Options.SkipSingleNamePerformers
|
||||||
|
}
|
||||||
|
if source.Options.SkipSingleNamePerformerTag != nil && len(*source.Options.SkipSingleNamePerformerTag) > 0 {
|
||||||
|
options.SkipSingleNamePerformerTag = source.Options.SkipSingleNamePerformerTag
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
}
|
||||||
|
|
||||||
func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene, result *scrapeResult) (*scene.UpdateSet, error) {
|
func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene, result *scrapeResult) (*scene.UpdateSet, error) {
|
||||||
ret := &scene.UpdateSet{
|
ret := &scene.UpdateSet{
|
||||||
ID: s.ID,
|
ID: s.ID,
|
||||||
}
|
}
|
||||||
|
|
||||||
options := []MetadataOptions{}
|
allOptions := []MetadataOptions{}
|
||||||
if result.source.Options != nil {
|
if result.source.Options != nil {
|
||||||
options = append(options, *result.source.Options)
|
allOptions = append(allOptions, *result.source.Options)
|
||||||
}
|
}
|
||||||
if t.DefaultOptions != nil {
|
if t.DefaultOptions != nil {
|
||||||
options = append(options, *t.DefaultOptions)
|
allOptions = append(allOptions, *t.DefaultOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldOptions := getFieldOptions(options)
|
fieldOptions := getFieldOptions(allOptions)
|
||||||
|
options := t.getOptions(result.source)
|
||||||
setOrganized := false
|
|
||||||
for _, o := range options {
|
|
||||||
if o.SetOrganized != nil {
|
|
||||||
setOrganized = *o.SetOrganized
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
scraped := result.result
|
scraped := result.result
|
||||||
|
|
||||||
rel := sceneRelationships{
|
rel := sceneRelationships{
|
||||||
sceneReader: t.SceneReaderUpdater,
|
sceneReader: t.SceneReaderUpdater,
|
||||||
studioCreator: t.StudioCreator,
|
studioCreator: t.StudioCreator,
|
||||||
performerCreator: t.PerformerCreator,
|
performerCreator: t.PerformerCreator,
|
||||||
tagCreator: t.TagCreator,
|
tagCreatorFinder: t.TagCreatorFinder,
|
||||||
scene: s,
|
scene: s,
|
||||||
result: result,
|
result: result,
|
||||||
fieldOptions: fieldOptions,
|
fieldOptions: fieldOptions,
|
||||||
|
skipSingleNamePerformers: *options.SkipSingleNamePerformers,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setOrganized := false
|
||||||
|
if options.SetOrganized != nil {
|
||||||
|
setOrganized = *options.SetOrganized
|
||||||
|
}
|
||||||
ret.Partial = getScenePartial(s, scraped, fieldOptions, setOrganized)
|
ret.Partial = getScenePartial(s, scraped, fieldOptions, setOrganized)
|
||||||
|
|
||||||
studioID, err := rel.studio(ctx)
|
studioID, err := rel.studio(ctx)
|
||||||
|
|
@ -130,17 +198,19 @@ func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene,
|
||||||
ret.Partial.StudioID = models.NewOptionalInt(*studioID)
|
ret.Partial.StudioID = models.NewOptionalInt(*studioID)
|
||||||
}
|
}
|
||||||
|
|
||||||
ignoreMale := false
|
includeMalePerformers := true
|
||||||
for _, o := range options {
|
if options.IncludeMalePerformers != nil {
|
||||||
if o.IncludeMalePerformers != nil {
|
includeMalePerformers = *options.IncludeMalePerformers
|
||||||
ignoreMale = !*o.IncludeMalePerformers
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
performerIDs, err := rel.performers(ctx, ignoreMale)
|
addSkipSingleNamePerformerTag := false
|
||||||
|
performerIDs, err := rel.performers(ctx, !includeMalePerformers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
if errors.Is(err, ErrSkipSingleNamePerformer) {
|
||||||
|
addSkipSingleNamePerformerTag = true
|
||||||
|
} else {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if performerIDs != nil {
|
if performerIDs != nil {
|
||||||
ret.Partial.PerformerIDs = &models.UpdateIDs{
|
ret.Partial.PerformerIDs = &models.UpdateIDs{
|
||||||
|
|
@ -153,6 +223,14 @@ func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene,
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if addSkipSingleNamePerformerTag {
|
||||||
|
tagID, err := strconv.ParseInt(*options.SkipSingleNamePerformerTag, 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("error converting tag ID %s: %w", *options.SkipSingleNamePerformerTag, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagIDs = intslice.IntAppendUnique(tagIDs, int(tagID))
|
||||||
|
}
|
||||||
if tagIDs != nil {
|
if tagIDs != nil {
|
||||||
ret.Partial.TagIDs = &models.UpdateIDs{
|
ret.Partial.TagIDs = &models.UpdateIDs{
|
||||||
IDs: tagIDs,
|
IDs: tagIDs,
|
||||||
|
|
@ -171,15 +249,7 @@ func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setCoverImage := false
|
if options.SetCoverImage != nil && *options.SetCoverImage {
|
||||||
for _, o := range options {
|
|
||||||
if o.SetCoverImage != nil {
|
|
||||||
setCoverImage = *o.SetCoverImage
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if setCoverImage {
|
|
||||||
ret.CoverImage, err = rel.cover(ctx)
|
ret.CoverImage, err = rel.cover(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -241,6 +311,41 @@ func (t *SceneIdentifier) modifyScene(ctx context.Context, txnManager txn.Manage
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (t *SceneIdentifier) addTagToScene(ctx context.Context, txnManager txn.Manager, s *models.Scene, tagToAdd string) error {
|
||||||
|
if err := txn.WithTxn(ctx, txnManager, func(ctx context.Context) error {
|
||||||
|
tagID, err := strconv.Atoi(tagToAdd)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error converting tag ID %s: %w", tagToAdd, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.LoadTagIDs(ctx, t.SceneReaderUpdater); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
existing := s.TagIDs.List()
|
||||||
|
|
||||||
|
if intslice.IntInclude(existing, tagID) {
|
||||||
|
// skip if the scene was already tagged
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := scene.AddTag(ctx, t.SceneReaderUpdater, s, tagID); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ret, err := t.TagCreatorFinder.Find(ctx, tagID)
|
||||||
|
if err != nil {
|
||||||
|
logger.Infof("Added tag id %s to skipped scene %s", tagToAdd, s.Path)
|
||||||
|
} else {
|
||||||
|
logger.Infof("Added tag %s to skipped scene %s", ret.Name, s.Path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func getFieldOptions(options []MetadataOptions) map[string]*FieldOptions {
|
func getFieldOptions(options []MetadataOptions) map[string]*FieldOptions {
|
||||||
// prefer source-specific field strategies, then the defaults
|
// prefer source-specific field strategies, then the defaults
|
||||||
ret := make(map[string]*FieldOptions)
|
ret := make(map[string]*FieldOptions)
|
||||||
|
|
@ -291,8 +396,7 @@ func getScenePartial(scene *models.Scene, scraped *scraper.ScrapedScene, fieldOp
|
||||||
}
|
}
|
||||||
|
|
||||||
if setOrganized && !scene.Organized {
|
if setOrganized && !scene.Organized {
|
||||||
// just reuse the boolean since we know it's true
|
partial.Organized = models.NewOptionalBool(true)
|
||||||
partial.Organized = models.NewOptionalBool(setOrganized)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return partial
|
return partial
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strconv"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
|
@ -17,10 +18,10 @@ var testCtx = context.Background()
|
||||||
|
|
||||||
type mockSceneScraper struct {
|
type mockSceneScraper struct {
|
||||||
errIDs []int
|
errIDs []int
|
||||||
results map[int]*scraper.ScrapedScene
|
results map[int][]*scraper.ScrapedScene
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s mockSceneScraper) ScrapeScene(ctx context.Context, sceneID int) (*scraper.ScrapedScene, error) {
|
func (s mockSceneScraper) ScrapeScenes(ctx context.Context, sceneID int) ([]*scraper.ScrapedScene, error) {
|
||||||
if intslice.IntInclude(s.errIDs, sceneID) {
|
if intslice.IntInclude(s.errIDs, sceneID) {
|
||||||
return nil, errors.New("scrape scene error")
|
return nil, errors.New("scrape scene error")
|
||||||
}
|
}
|
||||||
|
|
@ -40,32 +41,66 @@ func TestSceneIdentifier_Identify(t *testing.T) {
|
||||||
missingID
|
missingID
|
||||||
found1ID
|
found1ID
|
||||||
found2ID
|
found2ID
|
||||||
|
multiFoundID
|
||||||
|
multiFound2ID
|
||||||
errUpdateID
|
errUpdateID
|
||||||
)
|
)
|
||||||
|
|
||||||
var scrapedTitle = "scrapedTitle"
|
var (
|
||||||
|
skipMultipleTagID = 1
|
||||||
|
skipMultipleTagIDStr = strconv.Itoa(skipMultipleTagID)
|
||||||
|
)
|
||||||
|
|
||||||
defaultOptions := &MetadataOptions{}
|
var (
|
||||||
|
scrapedTitle = "scrapedTitle"
|
||||||
|
scrapedTitle2 = "scrapedTitle2"
|
||||||
|
|
||||||
|
boolFalse = false
|
||||||
|
boolTrue = true
|
||||||
|
)
|
||||||
|
|
||||||
|
defaultOptions := &MetadataOptions{
|
||||||
|
SetOrganized: &boolFalse,
|
||||||
|
SetCoverImage: &boolFalse,
|
||||||
|
IncludeMalePerformers: &boolFalse,
|
||||||
|
SkipSingleNamePerformers: &boolFalse,
|
||||||
|
}
|
||||||
sources := []ScraperSource{
|
sources := []ScraperSource{
|
||||||
{
|
{
|
||||||
Scraper: mockSceneScraper{
|
Scraper: mockSceneScraper{
|
||||||
errIDs: []int{errID1},
|
errIDs: []int{errID1},
|
||||||
results: map[int]*scraper.ScrapedScene{
|
results: map[int][]*scraper.ScrapedScene{
|
||||||
found1ID: {
|
found1ID: {{
|
||||||
Title: &scrapedTitle,
|
Title: &scrapedTitle,
|
||||||
},
|
}},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Scraper: mockSceneScraper{
|
Scraper: mockSceneScraper{
|
||||||
errIDs: []int{errID2},
|
errIDs: []int{errID2},
|
||||||
results: map[int]*scraper.ScrapedScene{
|
results: map[int][]*scraper.ScrapedScene{
|
||||||
found2ID: {
|
found2ID: {{
|
||||||
Title: &scrapedTitle,
|
Title: &scrapedTitle,
|
||||||
|
}},
|
||||||
|
errUpdateID: {{
|
||||||
|
Title: &scrapedTitle,
|
||||||
|
}},
|
||||||
|
multiFoundID: {
|
||||||
|
{
|
||||||
|
Title: &scrapedTitle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: &scrapedTitle2,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
errUpdateID: {
|
multiFound2ID: {
|
||||||
Title: &scrapedTitle,
|
{
|
||||||
|
Title: &scrapedTitle,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Title: &scrapedTitle2,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -73,6 +108,7 @@ func TestSceneIdentifier_Identify(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
mockSceneReaderWriter := &mocks.SceneReaderWriter{}
|
mockSceneReaderWriter := &mocks.SceneReaderWriter{}
|
||||||
|
mockTagFinderCreator := &mocks.TagReaderWriter{}
|
||||||
|
|
||||||
mockSceneReaderWriter.On("UpdatePartial", mock.Anything, mock.MatchedBy(func(id int) bool {
|
mockSceneReaderWriter.On("UpdatePartial", mock.Anything, mock.MatchedBy(func(id int) bool {
|
||||||
return id == errUpdateID
|
return id == errUpdateID
|
||||||
|
|
@ -81,52 +117,84 @@ func TestSceneIdentifier_Identify(t *testing.T) {
|
||||||
return id != errUpdateID
|
return id != errUpdateID
|
||||||
}), mock.Anything).Return(nil, nil)
|
}), mock.Anything).Return(nil, nil)
|
||||||
|
|
||||||
|
mockTagFinderCreator.On("Find", mock.Anything, skipMultipleTagID).Return(&models.Tag{
|
||||||
|
ID: skipMultipleTagID,
|
||||||
|
Name: skipMultipleTagIDStr,
|
||||||
|
}, nil)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
sceneID int
|
sceneID int
|
||||||
|
options *MetadataOptions
|
||||||
wantErr bool
|
wantErr bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
"error scraping",
|
"error scraping",
|
||||||
errID1,
|
errID1,
|
||||||
|
nil,
|
||||||
false,
|
false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"error scraping from second",
|
"error scraping from second",
|
||||||
errID2,
|
errID2,
|
||||||
|
nil,
|
||||||
false,
|
false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"found in first scraper",
|
"found in first scraper",
|
||||||
found1ID,
|
found1ID,
|
||||||
|
nil,
|
||||||
false,
|
false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"found in second scraper",
|
"found in second scraper",
|
||||||
found2ID,
|
found2ID,
|
||||||
|
nil,
|
||||||
false,
|
false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"not found",
|
"not found",
|
||||||
missingID,
|
missingID,
|
||||||
|
nil,
|
||||||
false,
|
false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"error modifying",
|
"error modifying",
|
||||||
errUpdateID,
|
errUpdateID,
|
||||||
|
nil,
|
||||||
true,
|
true,
|
||||||
},
|
},
|
||||||
}
|
{
|
||||||
|
"multiple found",
|
||||||
identifier := SceneIdentifier{
|
multiFoundID,
|
||||||
SceneReaderUpdater: mockSceneReaderWriter,
|
nil,
|
||||||
DefaultOptions: defaultOptions,
|
false,
|
||||||
Sources: sources,
|
},
|
||||||
SceneUpdatePostHookExecutor: mockHookExecutor{},
|
{
|
||||||
|
"multiple found - set tag",
|
||||||
|
multiFound2ID,
|
||||||
|
&MetadataOptions{
|
||||||
|
SkipMultipleMatches: &boolTrue,
|
||||||
|
SkipMultipleMatchTag: &skipMultipleTagIDStr,
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
identifier := SceneIdentifier{
|
||||||
|
SceneReaderUpdater: mockSceneReaderWriter,
|
||||||
|
TagCreatorFinder: mockTagFinderCreator,
|
||||||
|
DefaultOptions: defaultOptions,
|
||||||
|
Sources: sources,
|
||||||
|
SceneUpdatePostHookExecutor: mockHookExecutor{},
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.options != nil {
|
||||||
|
identifier.DefaultOptions = tt.options
|
||||||
|
}
|
||||||
|
|
||||||
scene := &models.Scene{
|
scene := &models.Scene{
|
||||||
ID: tt.sceneID,
|
ID: tt.sceneID,
|
||||||
PerformerIDs: models.NewRelatedIDs([]int{}),
|
PerformerIDs: models.NewRelatedIDs([]int{}),
|
||||||
|
|
@ -144,7 +212,16 @@ func TestSceneIdentifier_modifyScene(t *testing.T) {
|
||||||
repo := models.Repository{
|
repo := models.Repository{
|
||||||
TxnManager: &mocks.TxnManager{},
|
TxnManager: &mocks.TxnManager{},
|
||||||
}
|
}
|
||||||
tr := &SceneIdentifier{}
|
boolFalse := false
|
||||||
|
defaultOptions := &MetadataOptions{
|
||||||
|
SetOrganized: &boolFalse,
|
||||||
|
SetCoverImage: &boolFalse,
|
||||||
|
IncludeMalePerformers: &boolFalse,
|
||||||
|
SkipSingleNamePerformers: &boolFalse,
|
||||||
|
}
|
||||||
|
tr := &SceneIdentifier{
|
||||||
|
DefaultOptions: defaultOptions,
|
||||||
|
}
|
||||||
|
|
||||||
type args struct {
|
type args struct {
|
||||||
scene *models.Scene
|
scene *models.Scene
|
||||||
|
|
@ -165,6 +242,9 @@ func TestSceneIdentifier_modifyScene(t *testing.T) {
|
||||||
},
|
},
|
||||||
&scrapeResult{
|
&scrapeResult{
|
||||||
result: &scraper.ScrapedScene{},
|
result: &scraper.ScrapedScene{},
|
||||||
|
source: ScraperSource{
|
||||||
|
Options: defaultOptions,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,14 @@ type MetadataOptions struct {
|
||||||
SetOrganized *bool `json:"setOrganized"`
|
SetOrganized *bool `json:"setOrganized"`
|
||||||
// defaults to true if not provided
|
// defaults to true if not provided
|
||||||
IncludeMalePerformers *bool `json:"includeMalePerformers"`
|
IncludeMalePerformers *bool `json:"includeMalePerformers"`
|
||||||
|
// defaults to true if not provided
|
||||||
|
SkipMultipleMatches *bool `json:"skipMultipleMatches"`
|
||||||
|
// ID of tag to tag skipped multiple matches with
|
||||||
|
SkipMultipleMatchTag *string `json:"skipMultipleMatchTag"`
|
||||||
|
// defaults to true if not provided
|
||||||
|
SkipSingleNamePerformers *bool `json:"skipSingleNamePerformers"`
|
||||||
|
// ID of tag to tag skipped single name performers with
|
||||||
|
SkipSingleNamePerformerTag *string `json:"skipSingleNamePerformerTag"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type FieldOptions struct {
|
type FieldOptions struct {
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,20 @@ import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||||
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type PerformerCreator interface {
|
type PerformerCreator interface {
|
||||||
Create(ctx context.Context, newPerformer *models.Performer) error
|
Create(ctx context.Context, newPerformer *models.Performer) error
|
||||||
|
UpdateImage(ctx context.Context, performerID int, image []byte) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPerformerID(ctx context.Context, endpoint string, w PerformerCreator, p *models.ScrapedPerformer, createMissing bool) (*int, error) {
|
func getPerformerID(ctx context.Context, endpoint string, w PerformerCreator, p *models.ScrapedPerformer, createMissing bool, skipSingleNamePerformers bool) (*int, error) {
|
||||||
if p.StoredID != nil {
|
if p.StoredID != nil {
|
||||||
// existing performer, just add it
|
// existing performer, just add it
|
||||||
performerID, err := strconv.Atoi(*p.StoredID)
|
performerID, err := strconv.Atoi(*p.StoredID)
|
||||||
|
|
@ -24,6 +27,10 @@ func getPerformerID(ctx context.Context, endpoint string, w PerformerCreator, p
|
||||||
|
|
||||||
return &performerID, nil
|
return &performerID, nil
|
||||||
} else if createMissing && p.Name != nil { // name is mandatory
|
} else if createMissing && p.Name != nil { // name is mandatory
|
||||||
|
// skip single name performers with no disambiguation
|
||||||
|
if skipSingleNamePerformers && !strings.Contains(*p.Name, " ") && (p.Disambiguation == nil || len(*p.Disambiguation) == 0) {
|
||||||
|
return nil, ErrSkipSingleNamePerformer
|
||||||
|
}
|
||||||
return createMissingPerformer(ctx, endpoint, w, p)
|
return createMissingPerformer(ctx, endpoint, w, p)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -46,6 +53,19 @@ func createMissingPerformer(ctx context.Context, endpoint string, w PerformerCre
|
||||||
return nil, fmt.Errorf("error creating performer: %w", err)
|
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 {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return &performerInput.ID, nil
|
return &performerInput.ID, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -56,6 +76,9 @@ func scrapedToPerformerInput(performer *models.ScrapedPerformer) models.Performe
|
||||||
CreatedAt: currentTime,
|
CreatedAt: currentTime,
|
||||||
UpdatedAt: currentTime,
|
UpdatedAt: currentTime,
|
||||||
}
|
}
|
||||||
|
if performer.Disambiguation != nil {
|
||||||
|
ret.Disambiguation = *performer.Disambiguation
|
||||||
|
}
|
||||||
if performer.Birthdate != nil {
|
if performer.Birthdate != nil {
|
||||||
d := models.NewDate(*performer.Birthdate)
|
d := models.NewDate(*performer.Birthdate)
|
||||||
ret.Birthdate = &d
|
ret.Birthdate = &d
|
||||||
|
|
@ -126,6 +149,12 @@ func scrapedToPerformerInput(performer *models.ScrapedPerformer) models.Performe
|
||||||
if performer.Instagram != nil {
|
if performer.Instagram != nil {
|
||||||
ret.Instagram = *performer.Instagram
|
ret.Instagram = *performer.Instagram
|
||||||
}
|
}
|
||||||
|
if performer.URL != nil {
|
||||||
|
ret.URL = *performer.URL
|
||||||
|
}
|
||||||
|
if performer.Details != nil {
|
||||||
|
ret.Details = *performer.Details
|
||||||
|
}
|
||||||
|
|
||||||
return ret
|
return ret
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,10 @@ func Test_getPerformerID(t *testing.T) {
|
||||||
}).Return(nil)
|
}).Return(nil)
|
||||||
|
|
||||||
type args struct {
|
type args struct {
|
||||||
endpoint string
|
endpoint string
|
||||||
p *models.ScrapedPerformer
|
p *models.ScrapedPerformer
|
||||||
createMissing bool
|
createMissing bool
|
||||||
|
skipSingleName bool
|
||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
|
|
@ -47,6 +48,7 @@ func Test_getPerformerID(t *testing.T) {
|
||||||
emptyEndpoint,
|
emptyEndpoint,
|
||||||
&models.ScrapedPerformer{},
|
&models.ScrapedPerformer{},
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
},
|
},
|
||||||
nil,
|
nil,
|
||||||
false,
|
false,
|
||||||
|
|
@ -59,6 +61,7 @@ func Test_getPerformerID(t *testing.T) {
|
||||||
StoredID: &invalidStoredID,
|
StoredID: &invalidStoredID,
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
},
|
},
|
||||||
nil,
|
nil,
|
||||||
true,
|
true,
|
||||||
|
|
@ -71,6 +74,7 @@ func Test_getPerformerID(t *testing.T) {
|
||||||
StoredID: &validStoredIDStr,
|
StoredID: &validStoredIDStr,
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
},
|
},
|
||||||
&validStoredID,
|
&validStoredID,
|
||||||
false,
|
false,
|
||||||
|
|
@ -83,6 +87,7 @@ func Test_getPerformerID(t *testing.T) {
|
||||||
Name: &name,
|
Name: &name,
|
||||||
},
|
},
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
},
|
},
|
||||||
nil,
|
nil,
|
||||||
false,
|
false,
|
||||||
|
|
@ -93,10 +98,24 @@ func Test_getPerformerID(t *testing.T) {
|
||||||
emptyEndpoint,
|
emptyEndpoint,
|
||||||
&models.ScrapedPerformer{},
|
&models.ScrapedPerformer{},
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
},
|
},
|
||||||
nil,
|
nil,
|
||||||
false,
|
false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"single name no disambig creating",
|
||||||
|
args{
|
||||||
|
emptyEndpoint,
|
||||||
|
&models.ScrapedPerformer{
|
||||||
|
Name: &name,
|
||||||
|
},
|
||||||
|
true,
|
||||||
|
true,
|
||||||
|
},
|
||||||
|
nil,
|
||||||
|
true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"valid name creating",
|
"valid name creating",
|
||||||
args{
|
args{
|
||||||
|
|
@ -105,6 +124,7 @@ func Test_getPerformerID(t *testing.T) {
|
||||||
Name: &name,
|
Name: &name,
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
|
false,
|
||||||
},
|
},
|
||||||
&validStoredID,
|
&validStoredID,
|
||||||
false,
|
false,
|
||||||
|
|
@ -112,7 +132,7 @@ func Test_getPerformerID(t *testing.T) {
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
got, err := getPerformerID(testCtx, tt.args.endpoint, &mockPerformerReaderWriter, tt.args.p, tt.args.createMissing)
|
got, err := getPerformerID(testCtx, tt.args.endpoint, &mockPerformerReaderWriter, tt.args.p, tt.args.createMissing, tt.args.skipSingleName)
|
||||||
if (err != nil) != tt.wantErr {
|
if (err != nil) != tt.wantErr {
|
||||||
t.Errorf("getPerformerID() error = %v, wantErr %v", err, tt.wantErr)
|
t.Errorf("getPerformerID() error = %v, wantErr %v", err, tt.wantErr)
|
||||||
return
|
return
|
||||||
|
|
@ -207,7 +227,7 @@ func Test_scrapedToPerformerInput(t *testing.T) {
|
||||||
name := "name"
|
name := "name"
|
||||||
|
|
||||||
var stringValues []string
|
var stringValues []string
|
||||||
for i := 0; i < 17; i++ {
|
for i := 0; i < 20; i++ {
|
||||||
stringValues = append(stringValues, strconv.Itoa(i))
|
stringValues = append(stringValues, strconv.Itoa(i))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,44 +260,50 @@ func Test_scrapedToPerformerInput(t *testing.T) {
|
||||||
{
|
{
|
||||||
"set all",
|
"set all",
|
||||||
&models.ScrapedPerformer{
|
&models.ScrapedPerformer{
|
||||||
Name: &name,
|
Name: &name,
|
||||||
Birthdate: nextVal(),
|
Disambiguation: nextVal(),
|
||||||
DeathDate: nextVal(),
|
Birthdate: nextVal(),
|
||||||
Gender: nextVal(),
|
DeathDate: nextVal(),
|
||||||
Ethnicity: nextVal(),
|
Gender: nextVal(),
|
||||||
Country: nextVal(),
|
Ethnicity: nextVal(),
|
||||||
EyeColor: nextVal(),
|
Country: nextVal(),
|
||||||
HairColor: nextVal(),
|
EyeColor: nextVal(),
|
||||||
Height: nextVal(),
|
HairColor: nextVal(),
|
||||||
Weight: nextVal(),
|
Height: nextVal(),
|
||||||
Measurements: nextVal(),
|
Weight: nextVal(),
|
||||||
FakeTits: nextVal(),
|
Measurements: nextVal(),
|
||||||
CareerLength: nextVal(),
|
FakeTits: nextVal(),
|
||||||
Tattoos: nextVal(),
|
CareerLength: nextVal(),
|
||||||
Piercings: nextVal(),
|
Tattoos: nextVal(),
|
||||||
Aliases: nextVal(),
|
Piercings: nextVal(),
|
||||||
Twitter: nextVal(),
|
Aliases: nextVal(),
|
||||||
Instagram: nextVal(),
|
Twitter: nextVal(),
|
||||||
|
Instagram: nextVal(),
|
||||||
|
URL: nextVal(),
|
||||||
|
Details: nextVal(),
|
||||||
},
|
},
|
||||||
models.Performer{
|
models.Performer{
|
||||||
Name: name,
|
Name: name,
|
||||||
Birthdate: dateToDatePtr(models.NewDate(*nextVal())),
|
Disambiguation: *nextVal(),
|
||||||
DeathDate: dateToDatePtr(models.NewDate(*nextVal())),
|
Birthdate: dateToDatePtr(models.NewDate(*nextVal())),
|
||||||
Gender: genderPtr(models.GenderEnum(*nextVal())),
|
DeathDate: dateToDatePtr(models.NewDate(*nextVal())),
|
||||||
Ethnicity: *nextVal(),
|
Gender: genderPtr(models.GenderEnum(*nextVal())),
|
||||||
Country: *nextVal(),
|
Ethnicity: *nextVal(),
|
||||||
EyeColor: *nextVal(),
|
Country: *nextVal(),
|
||||||
HairColor: *nextVal(),
|
EyeColor: *nextVal(),
|
||||||
Height: nextIntVal(),
|
HairColor: *nextVal(),
|
||||||
Weight: nextIntVal(),
|
Height: nextIntVal(),
|
||||||
Measurements: *nextVal(),
|
Weight: nextIntVal(),
|
||||||
FakeTits: *nextVal(),
|
Measurements: *nextVal(),
|
||||||
CareerLength: *nextVal(),
|
FakeTits: *nextVal(),
|
||||||
Tattoos: *nextVal(),
|
CareerLength: *nextVal(),
|
||||||
Piercings: *nextVal(),
|
Tattoos: *nextVal(),
|
||||||
Aliases: models.NewRelatedStrings([]string{*nextVal()}),
|
Piercings: *nextVal(),
|
||||||
Twitter: *nextVal(),
|
Aliases: models.NewRelatedStrings([]string{*nextVal()}),
|
||||||
Instagram: *nextVal(),
|
Twitter: *nextVal(),
|
||||||
|
Instagram: *nextVal(),
|
||||||
|
URL: *nextVal(),
|
||||||
|
Details: *nextVal(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package identify
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -13,6 +14,7 @@ import (
|
||||||
"github.com/stashapp/stash/pkg/scene"
|
"github.com/stashapp/stash/pkg/scene"
|
||||||
"github.com/stashapp/stash/pkg/sliceutil"
|
"github.com/stashapp/stash/pkg/sliceutil"
|
||||||
"github.com/stashapp/stash/pkg/sliceutil/intslice"
|
"github.com/stashapp/stash/pkg/sliceutil/intslice"
|
||||||
|
"github.com/stashapp/stash/pkg/tag"
|
||||||
"github.com/stashapp/stash/pkg/utils"
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -24,18 +26,20 @@ type SceneReaderUpdater interface {
|
||||||
models.StashIDLoader
|
models.StashIDLoader
|
||||||
}
|
}
|
||||||
|
|
||||||
type TagCreator interface {
|
type TagCreatorFinder interface {
|
||||||
Create(ctx context.Context, newTag *models.Tag) error
|
Create(ctx context.Context, newTag *models.Tag) error
|
||||||
|
tag.Finder
|
||||||
}
|
}
|
||||||
|
|
||||||
type sceneRelationships struct {
|
type sceneRelationships struct {
|
||||||
sceneReader SceneReaderUpdater
|
sceneReader SceneReaderUpdater
|
||||||
studioCreator StudioCreator
|
studioCreator StudioCreator
|
||||||
performerCreator PerformerCreator
|
performerCreator PerformerCreator
|
||||||
tagCreator TagCreator
|
tagCreatorFinder TagCreatorFinder
|
||||||
scene *models.Scene
|
scene *models.Scene
|
||||||
result *scrapeResult
|
result *scrapeResult
|
||||||
fieldOptions map[string]*FieldOptions
|
fieldOptions map[string]*FieldOptions
|
||||||
|
skipSingleNamePerformers bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g sceneRelationships) studio(ctx context.Context) (*int, error) {
|
func (g sceneRelationships) studio(ctx context.Context) (*int, error) {
|
||||||
|
|
@ -93,13 +97,19 @@ func (g sceneRelationships) performers(ctx context.Context, ignoreMale bool) ([]
|
||||||
performerIDs = originalPerformerIDs
|
performerIDs = originalPerformerIDs
|
||||||
}
|
}
|
||||||
|
|
||||||
|
singleNamePerformerSkipped := false
|
||||||
|
|
||||||
for _, p := range scraped {
|
for _, p := range scraped {
|
||||||
if ignoreMale && p.Gender != nil && strings.EqualFold(*p.Gender, models.GenderEnumMale.String()) {
|
if ignoreMale && p.Gender != nil && strings.EqualFold(*p.Gender, models.GenderEnumMale.String()) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
performerID, err := getPerformerID(ctx, endpoint, g.performerCreator, p, createMissing)
|
performerID, err := getPerformerID(ctx, endpoint, g.performerCreator, p, createMissing, g.skipSingleNamePerformers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
if errors.Is(err, ErrSkipSingleNamePerformer) {
|
||||||
|
singleNamePerformerSkipped = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,9 +120,15 @@ func (g sceneRelationships) performers(ctx context.Context, ignoreMale bool) ([]
|
||||||
|
|
||||||
// don't return if nothing was added
|
// don't return if nothing was added
|
||||||
if sliceutil.SliceSame(originalPerformerIDs, performerIDs) {
|
if sliceutil.SliceSame(originalPerformerIDs, performerIDs) {
|
||||||
|
if singleNamePerformerSkipped {
|
||||||
|
return nil, ErrSkipSingleNamePerformer
|
||||||
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if singleNamePerformerSkipped {
|
||||||
|
return performerIDs, ErrSkipSingleNamePerformer
|
||||||
|
}
|
||||||
return performerIDs, nil
|
return performerIDs, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,7 +172,7 @@ func (g sceneRelationships) tags(ctx context.Context) ([]int, error) {
|
||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
err := g.tagCreator.Create(ctx, &newTag)
|
err := g.tagCreatorFinder.Create(ctx, &newTag)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error creating tag: %w", err)
|
return nil, fmt.Errorf("error creating tag: %w", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -374,9 +374,9 @@ func Test_sceneRelationships_tags(t *testing.T) {
|
||||||
})).Return(errors.New("error creating tag"))
|
})).Return(errors.New("error creating tag"))
|
||||||
|
|
||||||
tr := sceneRelationships{
|
tr := sceneRelationships{
|
||||||
sceneReader: mockSceneReaderWriter,
|
sceneReader: mockSceneReaderWriter,
|
||||||
tagCreator: mockTagReaderWriter,
|
tagCreatorFinder: mockTagReaderWriter,
|
||||||
fieldOptions: make(map[string]*FieldOptions),
|
fieldOptions: make(map[string]*FieldOptions),
|
||||||
}
|
}
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
|
|
|
||||||
|
|
@ -7,11 +7,13 @@ import (
|
||||||
|
|
||||||
"github.com/stashapp/stash/pkg/hash/md5"
|
"github.com/stashapp/stash/pkg/hash/md5"
|
||||||
"github.com/stashapp/stash/pkg/models"
|
"github.com/stashapp/stash/pkg/models"
|
||||||
|
"github.com/stashapp/stash/pkg/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
type StudioCreator interface {
|
type StudioCreator interface {
|
||||||
Create(ctx context.Context, newStudio *models.Studio) error
|
Create(ctx context.Context, newStudio *models.Studio) error
|
||||||
UpdateStashIDs(ctx context.Context, studioID int, stashIDs []models.StashID) error
|
UpdateStashIDs(ctx context.Context, studioID int, stashIDs []models.StashID) error
|
||||||
|
UpdateImage(ctx context.Context, studioID int, image []byte) error
|
||||||
}
|
}
|
||||||
|
|
||||||
func createMissingStudio(ctx context.Context, endpoint string, w StudioCreator, studio *models.ScrapedStudio) (*int, error) {
|
func createMissingStudio(ctx context.Context, endpoint string, w StudioCreator, studio *models.ScrapedStudio) (*int, error) {
|
||||||
|
|
@ -21,6 +23,19 @@ func createMissingStudio(ctx context.Context, endpoint string, w StudioCreator,
|
||||||
return nil, fmt.Errorf("error creating studio: %w", err)
|
return nil, fmt.Errorf("error creating studio: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// update image table
|
||||||
|
if studio.Image != nil && len(*studio.Image) > 0 {
|
||||||
|
imageData, err := utils.ReadImageFromURL(ctx, *studio.Image)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = w.UpdateImage(ctx, studioInput.ID, imageData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if endpoint != "" && studio.RemoteSiteID != nil {
|
if endpoint != "" && studio.RemoteSiteID != nil {
|
||||||
if err := w.UpdateStashIDs(ctx, studioInput.ID, []models.StashID{
|
if err := w.UpdateStashIDs(ctx, studioInput.ID, []models.StashID{
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -136,7 +136,7 @@ func (j *IdentifyJob) identifyScene(ctx context.Context, s *models.Scene, source
|
||||||
SceneReaderUpdater: instance.Repository.Scene,
|
SceneReaderUpdater: instance.Repository.Scene,
|
||||||
StudioCreator: instance.Repository.Studio,
|
StudioCreator: instance.Repository.Studio,
|
||||||
PerformerCreator: instance.Repository.Performer,
|
PerformerCreator: instance.Repository.Performer,
|
||||||
TagCreator: instance.Repository.Tag,
|
TagCreatorFinder: instance.Repository.Tag,
|
||||||
|
|
||||||
DefaultOptions: j.input.Options,
|
DefaultOptions: j.input.Options,
|
||||||
Sources: sources,
|
Sources: sources,
|
||||||
|
|
@ -248,14 +248,14 @@ type stashboxSource struct {
|
||||||
endpoint string
|
endpoint string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s stashboxSource) ScrapeScene(ctx context.Context, sceneID int) (*scraper.ScrapedScene, error) {
|
func (s stashboxSource) ScrapeScenes(ctx context.Context, sceneID int) ([]*scraper.ScrapedScene, error) {
|
||||||
results, err := s.FindStashBoxSceneByFingerprints(ctx, sceneID)
|
results, err := s.FindStashBoxSceneByFingerprints(ctx, sceneID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("error querying stash-box using scene ID %d: %w", sceneID, err)
|
return nil, fmt.Errorf("error querying stash-box using scene ID %d: %w", sceneID, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(results) > 0 {
|
if len(results) > 0 {
|
||||||
return results[0], nil
|
return results, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|
@ -270,7 +270,7 @@ type scraperSource struct {
|
||||||
scraperID string
|
scraperID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s scraperSource) ScrapeScene(ctx context.Context, sceneID int) (*scraper.ScrapedScene, error) {
|
func (s scraperSource) ScrapeScenes(ctx context.Context, sceneID int) ([]*scraper.ScrapedScene, error) {
|
||||||
content, err := s.cache.ScrapeID(ctx, s.scraperID, sceneID, scraper.ScrapeContentTypeScene)
|
content, err := s.cache.ScrapeID(ctx, s.scraperID, sceneID, scraper.ScrapeContentTypeScene)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|
@ -282,7 +282,7 @@ func (s scraperSource) ScrapeScene(ctx context.Context, sceneID int) (*scraper.S
|
||||||
}
|
}
|
||||||
|
|
||||||
if scene, ok := content.(scraper.ScrapedScene); ok {
|
if scene, ok := content.(scraper.ScrapedScene); ok {
|
||||||
return &scene, nil
|
return []*scraper.ScrapedScene{&scene}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil, errors.New("could not convert content to scene")
|
return nil, errors.New("could not convert content to scene")
|
||||||
|
|
|
||||||
|
|
@ -722,6 +722,9 @@ func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.Scen
|
||||||
URL: findURL(s.Studio.Urls, "HOME"),
|
URL: findURL(s.Studio.Urls, "HOME"),
|
||||||
RemoteSiteID: &studioID,
|
RemoteSiteID: &studioID,
|
||||||
}
|
}
|
||||||
|
if s.Studio.Images != nil && len(s.Studio.Images) > 0 {
|
||||||
|
ss.Studio.Image = &s.Studio.Images[0].URL
|
||||||
|
}
|
||||||
|
|
||||||
err := match.ScrapedStudio(ctx, c.repository.Studio, ss.Studio, &c.box.Endpoint)
|
err := match.ScrapedStudio(ctx, c.repository.Studio, ss.Studio, &c.box.Endpoint)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,7 @@ export const FieldOptionsList: React.FC<IFieldOptionsList> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Group className="scraper-sources">
|
<Form.Group className="scraper-sources mt-3">
|
||||||
<h5>
|
<h5>
|
||||||
<FormattedMessage id="config.tasks.identify.field_options" />
|
<FormattedMessage id="config.tasks.identify.field_options" />
|
||||||
</h5>
|
</h5>
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,10 @@ export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
|
||||||
includeMalePerformers: true,
|
includeMalePerformers: true,
|
||||||
setCoverImage: true,
|
setCoverImage: true,
|
||||||
setOrganized: false,
|
setOrganized: false,
|
||||||
|
skipMultipleMatches: true,
|
||||||
|
skipMultipleMatchTag: undefined,
|
||||||
|
skipSingleNamePerformers: true,
|
||||||
|
skipSingleNamePerformerTag: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,6 +244,8 @@ export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
|
||||||
const autoTagCopy = { ...autoTag };
|
const autoTagCopy = { ...autoTag };
|
||||||
autoTagCopy.options = {
|
autoTagCopy.options = {
|
||||||
setOrganized: false,
|
setOrganized: false,
|
||||||
|
skipMultipleMatches: true,
|
||||||
|
skipSingleNamePerformers: true,
|
||||||
};
|
};
|
||||||
newSources.push(autoTagCopy);
|
newSources.push(autoTagCopy);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import { Form } from "react-bootstrap";
|
import { Col, Form, Row } from "react-bootstrap";
|
||||||
import * as GQL from "src/core/generated-graphql";
|
import * as GQL from "src/core/generated-graphql";
|
||||||
import { FormattedMessage, useIntl } from "react-intl";
|
import { FormattedMessage, useIntl } from "react-intl";
|
||||||
import { IScraperSource } from "./constants";
|
import { IScraperSource } from "./constants";
|
||||||
import { FieldOptionsList } from "./FieldOptions";
|
import { FieldOptionsList } from "./FieldOptions";
|
||||||
import { ThreeStateBoolean } from "./ThreeStateBoolean";
|
import { ThreeStateBoolean } from "./ThreeStateBoolean";
|
||||||
|
import { TagSelect } from "src/components/Shared/Select";
|
||||||
|
|
||||||
interface IOptionsEditor {
|
interface IOptionsEditor {
|
||||||
options: GQL.IdentifyMetadataOptionsInput;
|
options: GQL.IdentifyMetadataOptionsInput;
|
||||||
|
|
@ -35,8 +36,76 @@ export const OptionsEditor: React.FC<IOptionsEditor> = ({
|
||||||
indeterminateClassname: "text-muted",
|
indeterminateClassname: "text-muted",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function maybeRenderMultipleMatchesTag() {
|
||||||
|
if (!options.skipMultipleMatches) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form.Group controlId="match_tags" className="ml-3 mt-1 mb-0" as={Row}>
|
||||||
|
<Form.Label
|
||||||
|
column
|
||||||
|
sm={{ span: 4, offset: 1 }}
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: "config.tasks.identify.tag_skipped_matches_tooltip",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="config.tasks.identify.tag_skipped_matches" />
|
||||||
|
</Form.Label>
|
||||||
|
<Col sm>
|
||||||
|
<TagSelect
|
||||||
|
onSelect={(tags) =>
|
||||||
|
setOptions({
|
||||||
|
skipMultipleMatchTag: tags[0]?.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ids={
|
||||||
|
options.skipMultipleMatchTag ? [options.skipMultipleMatchTag] : []
|
||||||
|
}
|
||||||
|
noSelectionString="Select/create tag..."
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Form.Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeRenderPerformersTag() {
|
||||||
|
if (!options.skipSingleNamePerformers) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form.Group controlId="match_tags" className="ml-3 mt-1 mb-0" as={Row}>
|
||||||
|
<Form.Label
|
||||||
|
column
|
||||||
|
sm={{ span: 4, offset: 1 }}
|
||||||
|
title={intl.formatMessage({
|
||||||
|
id: "config.tasks.identify.tag_skipped_performer_tooltip",
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<FormattedMessage id="config.tasks.identify.tag_skipped_performers" />
|
||||||
|
</Form.Label>
|
||||||
|
<Col sm>
|
||||||
|
<TagSelect
|
||||||
|
onSelect={(tags) =>
|
||||||
|
setOptions({
|
||||||
|
skipSingleNamePerformerTag: tags[0]?.id,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
ids={
|
||||||
|
options.skipSingleNamePerformerTag
|
||||||
|
? [options.skipSingleNamePerformerTag]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
noSelectionString="Select/create tag..."
|
||||||
|
/>
|
||||||
|
</Col>
|
||||||
|
</Form.Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Group>
|
<Form.Group className="mb-0">
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<h5>
|
<h5>
|
||||||
<FormattedMessage
|
<FormattedMessage
|
||||||
|
|
@ -52,7 +121,7 @@ export const OptionsEditor: React.FC<IOptionsEditor> = ({
|
||||||
</Form.Text>
|
</Form.Text>
|
||||||
)}
|
)}
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
<Form.Group>
|
<Form.Group className="mb-0">
|
||||||
<ThreeStateBoolean
|
<ThreeStateBoolean
|
||||||
id="include-male-performers"
|
id="include-male-performers"
|
||||||
value={
|
value={
|
||||||
|
|
@ -104,6 +173,50 @@ export const OptionsEditor: React.FC<IOptionsEditor> = ({
|
||||||
{...checkboxProps}
|
{...checkboxProps}
|
||||||
/>
|
/>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
<ThreeStateBoolean
|
||||||
|
id="skip-multiple-match"
|
||||||
|
value={
|
||||||
|
options.skipMultipleMatches === null
|
||||||
|
? undefined
|
||||||
|
: options.skipMultipleMatches
|
||||||
|
}
|
||||||
|
setValue={(v) =>
|
||||||
|
setOptions({
|
||||||
|
skipMultipleMatches: v,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: "config.tasks.identify.skip_multiple_matches",
|
||||||
|
})}
|
||||||
|
defaultValue={defaultOptions?.skipMultipleMatches ?? undefined}
|
||||||
|
tooltip={intl.formatMessage({
|
||||||
|
id: "config.tasks.identify.skip_multiple_matches_tooltip",
|
||||||
|
})}
|
||||||
|
{...checkboxProps}
|
||||||
|
/>
|
||||||
|
{maybeRenderMultipleMatchesTag()}
|
||||||
|
<ThreeStateBoolean
|
||||||
|
id="skip-single-name-performers"
|
||||||
|
value={
|
||||||
|
options.skipSingleNamePerformers === null
|
||||||
|
? undefined
|
||||||
|
: options.skipSingleNamePerformers
|
||||||
|
}
|
||||||
|
setValue={(v) =>
|
||||||
|
setOptions({
|
||||||
|
skipSingleNamePerformers: v,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
label={intl.formatMessage({
|
||||||
|
id: "config.tasks.identify.skip_single_name_performers",
|
||||||
|
})}
|
||||||
|
defaultValue={defaultOptions?.skipSingleNamePerformers ?? undefined}
|
||||||
|
tooltip={intl.formatMessage({
|
||||||
|
id: "config.tasks.identify.skip_single_name_performers_tooltip",
|
||||||
|
})}
|
||||||
|
{...checkboxProps}
|
||||||
|
/>
|
||||||
|
{maybeRenderPerformersTag()}
|
||||||
|
|
||||||
<FieldOptionsList
|
<FieldOptionsList
|
||||||
fieldOptions={options.fieldOptions ?? undefined}
|
fieldOptions={options.fieldOptions ?? undefined}
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ interface IThreeStateBoolean {
|
||||||
label?: React.ReactNode;
|
label?: React.ReactNode;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
defaultValue?: boolean;
|
defaultValue?: boolean;
|
||||||
|
tooltip?: string | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ThreeStateBoolean: React.FC<IThreeStateBoolean> = ({
|
export const ThreeStateBoolean: React.FC<IThreeStateBoolean> = ({
|
||||||
|
|
@ -20,6 +21,7 @@ export const ThreeStateBoolean: React.FC<IThreeStateBoolean> = ({
|
||||||
label,
|
label,
|
||||||
disabled,
|
disabled,
|
||||||
defaultValue,
|
defaultValue,
|
||||||
|
tooltip,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
|
|
||||||
|
|
@ -31,6 +33,7 @@ export const ThreeStateBoolean: React.FC<IThreeStateBoolean> = ({
|
||||||
checked={value}
|
checked={value}
|
||||||
label={label}
|
label={label}
|
||||||
onChange={() => setValue(!value)}
|
onChange={() => setValue(!value)}
|
||||||
|
title={tooltip}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -79,7 +82,7 @@ export const ThreeStateBoolean: React.FC<IThreeStateBoolean> = ({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
<h6>{label}</h6>
|
<h6 title={tooltip}>{label}</h6>
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
{renderModeButton(undefined)}
|
{renderModeButton(undefined)}
|
||||||
{renderModeButton(false)}
|
{renderModeButton(false)}
|
||||||
|
|
|
||||||
|
|
@ -6,3 +6,13 @@
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
h6,
|
||||||
|
label {
|
||||||
|
&[title]:not([title=""]) {
|
||||||
|
cursor: help;
|
||||||
|
text-decoration: underline dotted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,10 @@ The following options can be set:
|
||||||
| Include male performers | If false, then male performers will not be created or set on scenes. |
|
| Include male performers | If false, then male performers will not be created or set on scenes. |
|
||||||
| Set cover images | If false, then scene cover images will not be modified. |
|
| Set cover images | If false, then scene cover images will not be modified. |
|
||||||
| Set organised flag | If true, the organised flag is set to true when a scene is organised. |
|
| Set organised flag | If true, the organised flag is set to true when a scene is organised. |
|
||||||
|
| Skip matches that have more than one result | If this is not enabled and more than one result is returned, one will be randomly chosen to match |
|
||||||
|
| Tag skipped matches with | If the above option is set and a scene is skipped, this will add the tag so that you can filter for it in the Scene Tagger view and choose the correct match by hand |
|
||||||
|
| Skip single name performers with no disambiguation | If this is not enabled, performers that are often generic like Samantha or Olga will be matched |
|
||||||
|
| Tag skipped performers with | If the above options is set and a performer is skipped, this will add the tag so that you can filter for in it the Scene Tagger view and choose how you want to handle those performers |
|
||||||
|
|
||||||
Field specific options may be set as well. Each field may have a Strategy. The behaviour for each strategy value is as follows:
|
Field specific options may be set as well. Each field may have a Strategy. The behaviour for each strategy value is as follows:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -455,10 +455,18 @@
|
||||||
"include_male_performers": "Include male performers",
|
"include_male_performers": "Include male performers",
|
||||||
"set_cover_images": "Set cover images",
|
"set_cover_images": "Set cover images",
|
||||||
"set_organized": "Set organised flag",
|
"set_organized": "Set organised flag",
|
||||||
|
"skip_multiple_matches": "Skip matches that have more than one result",
|
||||||
|
"skip_multiple_matches_tooltip": "If this is not enabled and more than one result is returned, one will be randomly chosen to match",
|
||||||
|
"skip_single_name_performers": "Skip single name performers with no disambiguation",
|
||||||
|
"skip_single_name_performers_tooltip": "If this is not enabled, performers that are often generic like Samantha or Olga will be matched",
|
||||||
"source": "Source",
|
"source": "Source",
|
||||||
"source_options": "{source} Options",
|
"source_options": "{source} Options",
|
||||||
"sources": "Sources",
|
"sources": "Sources",
|
||||||
"strategy": "Strategy"
|
"strategy": "Strategy",
|
||||||
|
"tag_skipped_matches": "Tag skipped matches with",
|
||||||
|
"tag_skipped_matches_tooltip": "Create a tag like 'Identify: Multiple Matches' that you can filter for in the Scene Tagger view and choose the correct match by hand",
|
||||||
|
"tag_skipped_performers": "Tag skipped performers with",
|
||||||
|
"tag_skipped_performer_tooltip": "Create a tag like 'Identify: Single Name Performer' that you can filter for in the Scene Tagger view and choose how you want to handle these performers"
|
||||||
},
|
},
|
||||||
"import_from_exported_json": "Import from exported JSON in the metadata directory. Wipes the existing database.",
|
"import_from_exported_json": "Import from exported JSON in the metadata directory. Wipes the existing database.",
|
||||||
"incremental_import": "Incremental import from a supplied export zip file.",
|
"incremental_import": "Incremental import from a supplied export zip file.",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue