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:
Flashy78 2023-07-10 21:37:00 -07:00 committed by GitHub
parent ff22577ce0
commit cbdd4d3cbf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 581 additions and 136 deletions

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {
if !errors.As(err, &multipleMatchErr) {
return err return err
} }
}
if result == nil { if result == nil {
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) logger.Debugf("Unable to identify %s", scene.Path)
}
return nil return nil
} }
@ -62,50 +95,80 @@ 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 len(results) > 0 {
options := t.getOptions(source)
if len(results) > 1 && utils.IsTrue(options.SkipMultipleMatches) {
return nil, &MultipleMatchesFoundError{
Source: source,
}
} else {
// if results were found then return // if results were found then return
if scraped != nil {
return &scrapeResult{ return &scrapeResult{
result: scraped, result: results[0],
source: source, source: source,
}, nil }, 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
@ -113,12 +176,17 @@ func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene,
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,18 +198,20 @@ 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 {
if errors.Is(err, ErrSkipSingleNamePerformer) {
addSkipSingleNamePerformerTag = true
} else {
return nil, err return nil, err
} }
}
if performerIDs != nil { if performerIDs != nil {
ret.Partial.PerformerIDs = &models.UpdateIDs{ ret.Partial.PerformerIDs = &models.UpdateIDs{
IDs: performerIDs, IDs: performerIDs,
@ -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

View file

@ -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,39 +41,74 @@ 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,
}},
errUpdateID: {{
Title: &scrapedTitle,
}},
multiFoundID: {
{
Title: &scrapedTitle, Title: &scrapedTitle,
}, },
errUpdateID: { {
Title: &scrapedTitle2,
},
},
multiFound2ID: {
{
Title: &scrapedTitle, Title: &scrapedTitle,
}, },
{
Title: &scrapedTitle2,
},
},
}, },
}, },
}, },
} }
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",
multiFoundID,
nil,
false,
},
{
"multiple found - set tag",
multiFound2ID,
&MetadataOptions{
SkipMultipleMatches: &boolTrue,
SkipMultipleMatchTag: &skipMultipleTagIDStr,
},
false,
},
} }
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
identifier := SceneIdentifier{ identifier := SceneIdentifier{
SceneReaderUpdater: mockSceneReaderWriter, SceneReaderUpdater: mockSceneReaderWriter,
TagCreatorFinder: mockTagFinderCreator,
DefaultOptions: defaultOptions, DefaultOptions: defaultOptions,
Sources: sources, Sources: sources,
SceneUpdatePostHookExecutor: mockHookExecutor{}, SceneUpdatePostHookExecutor: mockHookExecutor{},
} }
for _, tt := range tests { if tt.options != nil {
t.Run(tt.name, func(t *testing.T) { 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,

View file

@ -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 {

View file

@ -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
} }

View file

@ -34,6 +34,7 @@ func Test_getPerformerID(t *testing.T) {
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))
} }
@ -241,6 +261,7 @@ func Test_scrapedToPerformerInput(t *testing.T) {
"set all", "set all",
&models.ScrapedPerformer{ &models.ScrapedPerformer{
Name: &name, Name: &name,
Disambiguation: nextVal(),
Birthdate: nextVal(), Birthdate: nextVal(),
DeathDate: nextVal(), DeathDate: nextVal(),
Gender: nextVal(), Gender: nextVal(),
@ -258,9 +279,12 @@ func Test_scrapedToPerformerInput(t *testing.T) {
Aliases: nextVal(), Aliases: nextVal(),
Twitter: nextVal(), Twitter: nextVal(),
Instagram: nextVal(), Instagram: nextVal(),
URL: nextVal(),
Details: nextVal(),
}, },
models.Performer{ models.Performer{
Name: name, Name: name,
Disambiguation: *nextVal(),
Birthdate: dateToDatePtr(models.NewDate(*nextVal())), Birthdate: dateToDatePtr(models.NewDate(*nextVal())),
DeathDate: dateToDatePtr(models.NewDate(*nextVal())), DeathDate: dateToDatePtr(models.NewDate(*nextVal())),
Gender: genderPtr(models.GenderEnum(*nextVal())), Gender: genderPtr(models.GenderEnum(*nextVal())),
@ -278,6 +302,8 @@ func Test_scrapedToPerformerInput(t *testing.T) {
Aliases: models.NewRelatedStrings([]string{*nextVal()}), Aliases: models.NewRelatedStrings([]string{*nextVal()}),
Twitter: *nextVal(), Twitter: *nextVal(),
Instagram: *nextVal(), Instagram: *nextVal(),
URL: *nextVal(),
Details: *nextVal(),
}, },
}, },
{ {

View file

@ -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)
} }

View file

@ -375,7 +375,7 @@ func Test_sceneRelationships_tags(t *testing.T) {
tr := sceneRelationships{ tr := sceneRelationships{
sceneReader: mockSceneReaderWriter, sceneReader: mockSceneReaderWriter,
tagCreator: mockTagReaderWriter, tagCreatorFinder: mockTagReaderWriter,
fieldOptions: make(map[string]*FieldOptions), fieldOptions: make(map[string]*FieldOptions),
} }

View file

@ -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{
{ {

View file

@ -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")

View file

@ -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 {

View file

@ -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>

View file

@ -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);
} }

View file

@ -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 ( return (
<Form.Group> <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 (
<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}

View file

@ -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)}

View file

@ -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;
}
}
}

View file

@ -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:

View file

@ -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.",