mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Identify task (#1839)
* Add identify task * Change type naming * Debounce folder select text input * Add generic slice comparison function
This commit is contained in:
parent
c93b5e12b7
commit
0f64954e5b
70 changed files with 5882 additions and 291 deletions
|
|
@ -81,6 +81,43 @@ fragment ConfigScrapingData on ConfigScrapingResult {
|
|||
excludeTagPatterns
|
||||
}
|
||||
|
||||
fragment IdentifyFieldOptionsData on IdentifyFieldOptions {
|
||||
field
|
||||
strategy
|
||||
createMissing
|
||||
}
|
||||
|
||||
fragment IdentifyMetadataOptionsData on IdentifyMetadataOptions {
|
||||
fieldOptions {
|
||||
...IdentifyFieldOptionsData
|
||||
}
|
||||
setCoverImage
|
||||
setOrganized
|
||||
includeMalePerformers
|
||||
}
|
||||
|
||||
fragment ScraperSourceData on ScraperSource {
|
||||
stash_box_index
|
||||
stash_box_endpoint
|
||||
scraper_id
|
||||
}
|
||||
|
||||
fragment ConfigDefaultSettingsData on ConfigDefaultSettingsResult {
|
||||
identify {
|
||||
sources {
|
||||
source {
|
||||
...ScraperSourceData
|
||||
}
|
||||
options {
|
||||
...IdentifyMetadataOptionsData
|
||||
}
|
||||
}
|
||||
options {
|
||||
...IdentifyMetadataOptionsData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fragment ConfigData on ConfigResult {
|
||||
general {
|
||||
...ConfigGeneralData
|
||||
|
|
@ -94,4 +131,7 @@ fragment ConfigData on ConfigResult {
|
|||
scraping {
|
||||
...ConfigScrapingData
|
||||
}
|
||||
defaults {
|
||||
...ConfigDefaultSettingsData
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,12 @@ mutation ConfigureScraping($input: ConfigScrapingInput!) {
|
|||
}
|
||||
}
|
||||
|
||||
mutation ConfigureDefaults($input: ConfigDefaultSettingsInput!) {
|
||||
configureDefaults(input: $input) {
|
||||
...ConfigDefaultSettingsData
|
||||
}
|
||||
}
|
||||
|
||||
mutation GenerateAPIKey($input: GenerateAPIKeyInput!) {
|
||||
generateAPIKey(input: $input)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ mutation MetadataAutoTag($input: AutoTagMetadataInput!) {
|
|||
metadataAutoTag(input: $input)
|
||||
}
|
||||
|
||||
mutation MetadataIdentify($input: IdentifyMetadataInput!) {
|
||||
metadataIdentify(input: $input)
|
||||
}
|
||||
|
||||
mutation MetadataClean($input: CleanMetadataInput!) {
|
||||
metadataClean(input: $input)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -237,6 +237,7 @@ type Mutation {
|
|||
configureInterface(input: ConfigInterfaceInput!): ConfigInterfaceResult!
|
||||
configureDLNA(input: ConfigDLNAInput!): ConfigDLNAResult!
|
||||
configureScraping(input: ConfigScrapingInput!): ConfigScrapingResult!
|
||||
configureDefaults(input: ConfigDefaultSettingsInput!): ConfigDefaultSettingsResult!
|
||||
|
||||
"""Generate and set (or clear) API key"""
|
||||
generateAPIKey(input: GenerateAPIKeyInput!): String!
|
||||
|
|
@ -259,6 +260,8 @@ type Mutation {
|
|||
metadataAutoTag(input: AutoTagMetadataInput!): ID!
|
||||
"""Clean metadata. Returns the job ID"""
|
||||
metadataClean(input: CleanMetadataInput!): ID!
|
||||
"""Identifies scenes using scrapers. Returns the job ID"""
|
||||
metadataIdentify(input: IdentifyMetadataInput!): ID!
|
||||
"""Migrate generated files for the current hash naming"""
|
||||
migrateHashNaming: ID!
|
||||
|
||||
|
|
|
|||
|
|
@ -302,12 +302,21 @@ type ConfigScrapingResult {
|
|||
excludeTagPatterns: [String!]!
|
||||
}
|
||||
|
||||
type ConfigDefaultSettingsResult {
|
||||
identify: IdentifyMetadataTaskOptions
|
||||
}
|
||||
|
||||
input ConfigDefaultSettingsInput {
|
||||
identify: IdentifyMetadataInput
|
||||
}
|
||||
|
||||
"""All configuration settings"""
|
||||
type ConfigResult {
|
||||
general: ConfigGeneralResult!
|
||||
interface: ConfigInterfaceResult!
|
||||
dlna: ConfigDLNAResult!
|
||||
scraping: ConfigScrapingResult!
|
||||
defaults: ConfigDefaultSettingsResult!
|
||||
}
|
||||
|
||||
"""Directory structure of a path"""
|
||||
|
|
|
|||
|
|
@ -67,6 +67,88 @@ input AutoTagMetadataInput {
|
|||
tags: [String!]
|
||||
}
|
||||
|
||||
enum IdentifyFieldStrategy {
|
||||
"""Never sets the field value"""
|
||||
IGNORE
|
||||
"""
|
||||
For multi-value fields, merge with existing.
|
||||
For single-value fields, ignore if already set
|
||||
"""
|
||||
MERGE
|
||||
"""Always replaces the value if a value is found.
|
||||
For multi-value fields, any existing values are removed and replaced with the
|
||||
scraped values.
|
||||
"""
|
||||
OVERWRITE
|
||||
}
|
||||
|
||||
input IdentifyFieldOptionsInput {
|
||||
field: String!
|
||||
strategy: IdentifyFieldStrategy!
|
||||
"""creates missing objects if needed - only applicable for performers, tags and studios"""
|
||||
createMissing: Boolean
|
||||
}
|
||||
|
||||
input IdentifyMetadataOptionsInput {
|
||||
"""any fields missing from here are defaulted to MERGE and createMissing false"""
|
||||
fieldOptions: [IdentifyFieldOptionsInput!]
|
||||
"""defaults to true if not provided"""
|
||||
setCoverImage: Boolean
|
||||
setOrganized: Boolean
|
||||
"""defaults to true if not provided"""
|
||||
includeMalePerformers: Boolean
|
||||
}
|
||||
|
||||
input IdentifySourceInput {
|
||||
source: ScraperSourceInput!
|
||||
"""Options defined for a source override the defaults"""
|
||||
options: IdentifyMetadataOptionsInput
|
||||
}
|
||||
|
||||
input IdentifyMetadataInput {
|
||||
"""An ordered list of sources to identify items with. Only the first source that finds a match is used."""
|
||||
sources: [IdentifySourceInput!]!
|
||||
"""Options defined here override the configured defaults"""
|
||||
options: IdentifyMetadataOptionsInput
|
||||
|
||||
"""scene ids to identify"""
|
||||
sceneIDs: [ID!]
|
||||
|
||||
"""paths of scenes to identify - ignored if scene ids are set"""
|
||||
paths: [String!]
|
||||
}
|
||||
|
||||
# types for default options
|
||||
type IdentifyFieldOptions {
|
||||
field: String!
|
||||
strategy: IdentifyFieldStrategy!
|
||||
"""creates missing objects if needed - only applicable for performers, tags and studios"""
|
||||
createMissing: Boolean
|
||||
}
|
||||
|
||||
type IdentifyMetadataOptions {
|
||||
"""any fields missing from here are defaulted to MERGE and createMissing false"""
|
||||
fieldOptions: [IdentifyFieldOptions!]
|
||||
"""defaults to true if not provided"""
|
||||
setCoverImage: Boolean
|
||||
setOrganized: Boolean
|
||||
"""defaults to true if not provided"""
|
||||
includeMalePerformers: Boolean
|
||||
}
|
||||
|
||||
type IdentifySource {
|
||||
source: ScraperSource!
|
||||
"""Options defined for a source override the defaults"""
|
||||
options: IdentifyMetadataOptions
|
||||
}
|
||||
|
||||
type IdentifyMetadataTaskOptions {
|
||||
"""An ordered list of sources to identify items with. Only the first source that finds a match is used."""
|
||||
sources: [IdentifySource!]!
|
||||
"""Options defined here override the configured defaults"""
|
||||
options: IdentifyMetadataOptions
|
||||
}
|
||||
|
||||
input ExportObjectTypeInput {
|
||||
ids: [String!]
|
||||
all: Boolean
|
||||
|
|
|
|||
|
|
@ -96,7 +96,18 @@ input ScrapedGalleryInput {
|
|||
|
||||
input ScraperSourceInput {
|
||||
"""Index of the configured stash-box instance to use. Should be unset if scraper_id is set"""
|
||||
stash_box_index: Int
|
||||
stash_box_index: Int @deprecated(reason: "use stash_box_endpoint")
|
||||
"""Stash-box endpoint"""
|
||||
stash_box_endpoint: String
|
||||
"""Scraper ID to scrape with. Should be unset if stash_box_index is set"""
|
||||
scraper_id: ID
|
||||
}
|
||||
|
||||
type ScraperSource {
|
||||
"""Index of the configured stash-box instance to use. Should be unset if scraper_id is set"""
|
||||
stash_box_index: Int @deprecated(reason: "use stash_box_endpoint")
|
||||
"""Stash-box endpoint"""
|
||||
stash_box_endpoint: String
|
||||
"""Scraper ID to scrape with. Should be unset if stash_box_index is set"""
|
||||
scraper_id: ID
|
||||
}
|
||||
|
|
|
|||
|
|
@ -352,6 +352,20 @@ func (r *mutationResolver) ConfigureScraping(ctx context.Context, input models.C
|
|||
return makeConfigScrapingResult(), nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) ConfigureDefaults(ctx context.Context, input models.ConfigDefaultSettingsInput) (*models.ConfigDefaultSettingsResult, error) {
|
||||
c := config.GetInstance()
|
||||
|
||||
if input.Identify != nil {
|
||||
c.Set(config.DefaultIdentifySettings, input.Identify)
|
||||
}
|
||||
|
||||
if err := c.Write(); err != nil {
|
||||
return makeConfigDefaultsResult(), err
|
||||
}
|
||||
|
||||
return makeConfigDefaultsResult(), nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) GenerateAPIKey(ctx context.Context, input models.GenerateAPIKeyInput) (string, error) {
|
||||
c := config.GetInstance()
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,13 @@ func (r *mutationResolver) MetadataAutoTag(ctx context.Context, input models.Aut
|
|||
return strconv.Itoa(jobID), nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) MetadataIdentify(ctx context.Context, input models.IdentifyMetadataInput) (string, error) {
|
||||
t := manager.CreateIdentifyJob(input)
|
||||
jobID := manager.GetInstance().JobManager.Add(ctx, "Identifying...", t)
|
||||
|
||||
return strconv.Itoa(jobID), nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) MetadataClean(ctx context.Context, input models.CleanMetadataInput) (string, error) {
|
||||
jobID := manager.GetInstance().Clean(ctx, input)
|
||||
return strconv.Itoa(jobID), nil
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/stashapp/stash/pkg/manager/config"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/plugin"
|
||||
"github.com/stashapp/stash/pkg/scene"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
|
|
@ -119,7 +120,7 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
|
|||
}
|
||||
|
||||
qb := repo.Scene()
|
||||
scene, err := qb.Update(updatedScene)
|
||||
s, err := qb.Update(updatedScene)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -169,13 +170,13 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
|
|||
|
||||
// only update the cover image if provided and everything else was successful
|
||||
if coverImageData != nil {
|
||||
err = manager.SetSceneScreenshot(scene.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), coverImageData)
|
||||
err = scene.SetScreenshot(manager.GetInstance().Paths, s.GetHash(config.GetInstance().GetVideoFileNamingAlgorithm()), coverImageData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return scene, nil
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (r *mutationResolver) updateScenePerformers(qb models.SceneReaderWriter, sceneID int, performerIDs []string) error {
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ func makeConfigResult() *models.ConfigResult {
|
|||
Interface: makeConfigInterfaceResult(),
|
||||
Dlna: makeConfigDLNAResult(),
|
||||
Scraping: makeConfigScrapingResult(),
|
||||
Defaults: makeConfigDefaultsResult(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -159,3 +160,11 @@ func makeConfigScrapingResult() *models.ConfigScrapingResult {
|
|||
ExcludeTagPatterns: config.GetScraperExcludeTagPatterns(),
|
||||
}
|
||||
}
|
||||
|
||||
func makeConfigDefaultsResult() *models.ConfigDefaultSettingsResult {
|
||||
config := config.GetInstance()
|
||||
|
||||
return &models.ConfigDefaultSettingsResult{
|
||||
Identify: config.GetDefaultIdentifySettings(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
264
pkg/identify/identify.go
Normal file
264
pkg/identify/identify.go
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
package identify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/scene"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
type SceneScraper interface {
|
||||
ScrapeScene(sceneID int) (*models.ScrapedScene, error)
|
||||
}
|
||||
|
||||
type SceneUpdatePostHookExecutor interface {
|
||||
ExecuteSceneUpdatePostHooks(ctx context.Context, input models.SceneUpdateInput, inputFields []string)
|
||||
}
|
||||
|
||||
type ScraperSource struct {
|
||||
Name string
|
||||
Options *models.IdentifyMetadataOptionsInput
|
||||
Scraper SceneScraper
|
||||
RemoteSite string
|
||||
}
|
||||
|
||||
type SceneIdentifier struct {
|
||||
DefaultOptions *models.IdentifyMetadataOptionsInput
|
||||
Sources []ScraperSource
|
||||
ScreenshotSetter scene.ScreenshotSetter
|
||||
SceneUpdatePostHookExecutor SceneUpdatePostHookExecutor
|
||||
}
|
||||
|
||||
func (t *SceneIdentifier) Identify(ctx context.Context, repo models.Repository, scene *models.Scene) error {
|
||||
result, err := t.scrapeScene(scene)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if result == nil {
|
||||
logger.Infof("Unable to identify %s", scene.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// results were found, modify the scene
|
||||
if err := t.modifyScene(ctx, repo, scene, result); err != nil {
|
||||
return fmt.Errorf("error modifying scene: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type scrapeResult struct {
|
||||
result *models.ScrapedScene
|
||||
source ScraperSource
|
||||
}
|
||||
|
||||
func (t *SceneIdentifier) scrapeScene(scene *models.Scene) (*scrapeResult, error) {
|
||||
// iterate through the input sources
|
||||
for _, source := range t.Sources {
|
||||
// scrape using the source
|
||||
scraped, err := source.Scraper.ScrapeScene(scene.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error scraping from %v: %v", source.Scraper, err)
|
||||
}
|
||||
|
||||
// if results were found then return
|
||||
if scraped != nil {
|
||||
return &scrapeResult{
|
||||
result: scraped,
|
||||
source: source,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene, result *scrapeResult, repo models.Repository) (*scene.UpdateSet, error) {
|
||||
ret := &scene.UpdateSet{
|
||||
ID: s.ID,
|
||||
}
|
||||
|
||||
options := []models.IdentifyMetadataOptionsInput{}
|
||||
if result.source.Options != nil {
|
||||
options = append(options, *result.source.Options)
|
||||
}
|
||||
if t.DefaultOptions != nil {
|
||||
options = append(options, *t.DefaultOptions)
|
||||
}
|
||||
|
||||
fieldOptions := getFieldOptions(options)
|
||||
|
||||
setOrganized := false
|
||||
for _, o := range options {
|
||||
if o.SetOrganized != nil {
|
||||
setOrganized = *o.SetOrganized
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
scraped := result.result
|
||||
|
||||
rel := sceneRelationships{
|
||||
repo: repo,
|
||||
scene: s,
|
||||
result: result,
|
||||
fieldOptions: fieldOptions,
|
||||
}
|
||||
|
||||
ret.Partial = getScenePartial(s, scraped, fieldOptions, setOrganized)
|
||||
|
||||
studioID, err := rel.studio()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting studio: %w", err)
|
||||
}
|
||||
|
||||
if studioID != nil {
|
||||
ret.Partial.StudioID = &sql.NullInt64{
|
||||
Int64: *studioID,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
ignoreMale := false
|
||||
for _, o := range options {
|
||||
if o.IncludeMalePerformers != nil {
|
||||
ignoreMale = !*o.IncludeMalePerformers
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
ret.PerformerIDs, err = rel.performers(ignoreMale)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret.TagIDs, err = rel.tags()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret.StashIDs, err = rel.stashIDs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
setCoverImage := false
|
||||
for _, o := range options {
|
||||
if o.SetCoverImage != nil {
|
||||
setCoverImage = *o.SetCoverImage
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if setCoverImage {
|
||||
ret.CoverImage, err = rel.cover(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (t *SceneIdentifier) modifyScene(ctx context.Context, repo models.Repository, scene *models.Scene, result *scrapeResult) error {
|
||||
updater, err := t.getSceneUpdater(ctx, scene, result, repo)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// don't update anything if nothing was set
|
||||
if updater.IsEmpty() {
|
||||
logger.Infof("Nothing to set for %s", scene.Path)
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = updater.Update(repo.Scene(), t.ScreenshotSetter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error updating scene: %w", err)
|
||||
}
|
||||
|
||||
// fire post-update hooks
|
||||
updateInput := updater.UpdateInput()
|
||||
fields := utils.NotNilFields(updateInput, "json")
|
||||
t.SceneUpdatePostHookExecutor.ExecuteSceneUpdatePostHooks(ctx, updateInput, fields)
|
||||
|
||||
as := ""
|
||||
title := updater.Partial.Title
|
||||
if title != nil {
|
||||
as = fmt.Sprintf(" as %s", title.String)
|
||||
}
|
||||
logger.Infof("Successfully identified %s%s using %s", scene.Path, as, result.source.Name)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getFieldOptions(options []models.IdentifyMetadataOptionsInput) map[string]*models.IdentifyFieldOptionsInput {
|
||||
// prefer source-specific field strategies, then the defaults
|
||||
ret := make(map[string]*models.IdentifyFieldOptionsInput)
|
||||
for _, oo := range options {
|
||||
for _, f := range oo.FieldOptions {
|
||||
if _, found := ret[f.Field]; !found {
|
||||
ret[f.Field] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func getScenePartial(scene *models.Scene, scraped *models.ScrapedScene, fieldOptions map[string]*models.IdentifyFieldOptionsInput, setOrganized bool) models.ScenePartial {
|
||||
partial := models.ScenePartial{
|
||||
ID: scene.ID,
|
||||
}
|
||||
|
||||
if scraped.Title != nil && scene.Title.String != *scraped.Title {
|
||||
if shouldSetSingleValueField(fieldOptions["title"], scene.Title.String != "") {
|
||||
partial.Title = models.NullStringPtr(*scraped.Title)
|
||||
}
|
||||
}
|
||||
if scraped.Date != nil && scene.Date.String != *scraped.Date {
|
||||
if shouldSetSingleValueField(fieldOptions["date"], scene.Date.Valid) {
|
||||
partial.Date = &models.SQLiteDate{
|
||||
String: *scraped.Date,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
if scraped.Details != nil && scene.Details.String != *scraped.Details {
|
||||
if shouldSetSingleValueField(fieldOptions["details"], scene.Details.String != "") {
|
||||
partial.Details = models.NullStringPtr(*scraped.Details)
|
||||
}
|
||||
}
|
||||
if scraped.URL != nil && scene.URL.String != *scraped.URL {
|
||||
if shouldSetSingleValueField(fieldOptions["url"], scene.URL.String != "") {
|
||||
partial.URL = models.NullStringPtr(*scraped.URL)
|
||||
}
|
||||
}
|
||||
|
||||
if setOrganized && !scene.Organized {
|
||||
// just reuse the boolean since we know it's true
|
||||
partial.Organized = &setOrganized
|
||||
}
|
||||
|
||||
return partial
|
||||
}
|
||||
|
||||
func shouldSetSingleValueField(strategy *models.IdentifyFieldOptionsInput, hasExistingValue bool) bool {
|
||||
// if unset then default to MERGE
|
||||
fs := models.IdentifyFieldStrategyMerge
|
||||
|
||||
if strategy != nil && strategy.Strategy.IsValid() {
|
||||
fs = strategy.Strategy
|
||||
}
|
||||
|
||||
if fs == models.IdentifyFieldStrategyIgnore {
|
||||
return false
|
||||
}
|
||||
|
||||
return !hasExistingValue || fs == models.IdentifyFieldStrategyOverwrite
|
||||
}
|
||||
502
pkg/identify/identify_test.go
Normal file
502
pkg/identify/identify_test.go
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
package identify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/mocks"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type mockSceneScraper struct {
|
||||
errIDs []int
|
||||
results map[int]*models.ScrapedScene
|
||||
}
|
||||
|
||||
func (s mockSceneScraper) ScrapeScene(sceneID int) (*models.ScrapedScene, error) {
|
||||
if utils.IntInclude(s.errIDs, sceneID) {
|
||||
return nil, errors.New("scrape scene error")
|
||||
}
|
||||
return s.results[sceneID], nil
|
||||
}
|
||||
|
||||
type mockHookExecutor struct {
|
||||
}
|
||||
|
||||
func (s mockHookExecutor) ExecuteSceneUpdatePostHooks(ctx context.Context, input models.SceneUpdateInput, inputFields []string) {
|
||||
}
|
||||
|
||||
func TestSceneIdentifier_Identify(t *testing.T) {
|
||||
const (
|
||||
errID1 = iota
|
||||
errID2
|
||||
missingID
|
||||
found1ID
|
||||
found2ID
|
||||
errUpdateID
|
||||
)
|
||||
|
||||
var scrapedTitle = "scrapedTitle"
|
||||
|
||||
defaultOptions := &models.IdentifyMetadataOptionsInput{}
|
||||
sources := []ScraperSource{
|
||||
{
|
||||
Scraper: mockSceneScraper{
|
||||
errIDs: []int{errID1},
|
||||
results: map[int]*models.ScrapedScene{
|
||||
found1ID: {
|
||||
Title: &scrapedTitle,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Scraper: mockSceneScraper{
|
||||
errIDs: []int{errID2},
|
||||
results: map[int]*models.ScrapedScene{
|
||||
found2ID: {
|
||||
Title: &scrapedTitle,
|
||||
},
|
||||
errUpdateID: {
|
||||
Title: &scrapedTitle,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
repo := mocks.NewTransactionManager()
|
||||
repo.Scene().(*mocks.SceneReaderWriter).On("Update", mock.MatchedBy(func(partial models.ScenePartial) bool {
|
||||
return partial.ID != errUpdateID
|
||||
})).Return(nil, nil)
|
||||
repo.Scene().(*mocks.SceneReaderWriter).On("Update", mock.MatchedBy(func(partial models.ScenePartial) bool {
|
||||
return partial.ID == errUpdateID
|
||||
})).Return(nil, errors.New("update error"))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sceneID int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"error scraping",
|
||||
errID1,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"error scraping from second",
|
||||
errID2,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"found in first scraper",
|
||||
found1ID,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"found in second scraper",
|
||||
found2ID,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"not found",
|
||||
missingID,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"error modifying",
|
||||
errUpdateID,
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
identifier := SceneIdentifier{
|
||||
DefaultOptions: defaultOptions,
|
||||
Sources: sources,
|
||||
SceneUpdatePostHookExecutor: mockHookExecutor{},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
scene := &models.Scene{
|
||||
ID: tt.sceneID,
|
||||
}
|
||||
if err := identifier.Identify(context.TODO(), repo, scene); (err != nil) != tt.wantErr {
|
||||
t.Errorf("SceneIdentifier.Identify() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSceneIdentifier_modifyScene(t *testing.T) {
|
||||
repo := mocks.NewTransactionManager()
|
||||
tr := &SceneIdentifier{}
|
||||
|
||||
type args struct {
|
||||
scene *models.Scene
|
||||
result *scrapeResult
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"empty update",
|
||||
args{
|
||||
&models.Scene{},
|
||||
&scrapeResult{
|
||||
result: &models.ScrapedScene{},
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if err := tr.modifyScene(context.TODO(), repo, tt.args.scene, tt.args.result); (err != nil) != tt.wantErr {
|
||||
t.Errorf("SceneIdentifier.modifyScene() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getFieldOptions(t *testing.T) {
|
||||
const (
|
||||
inFirst = "inFirst"
|
||||
inSecond = "inSecond"
|
||||
inBoth = "inBoth"
|
||||
)
|
||||
|
||||
type args struct {
|
||||
options []models.IdentifyMetadataOptionsInput
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want map[string]*models.IdentifyFieldOptionsInput
|
||||
}{
|
||||
{
|
||||
"simple",
|
||||
args{
|
||||
[]models.IdentifyMetadataOptionsInput{
|
||||
{
|
||||
FieldOptions: []*models.IdentifyFieldOptionsInput{
|
||||
{
|
||||
Field: inFirst,
|
||||
Strategy: models.IdentifyFieldStrategyIgnore,
|
||||
},
|
||||
{
|
||||
Field: inBoth,
|
||||
Strategy: models.IdentifyFieldStrategyIgnore,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
FieldOptions: []*models.IdentifyFieldOptionsInput{
|
||||
{
|
||||
Field: inSecond,
|
||||
Strategy: models.IdentifyFieldStrategyMerge,
|
||||
},
|
||||
{
|
||||
Field: inBoth,
|
||||
Strategy: models.IdentifyFieldStrategyMerge,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
map[string]*models.IdentifyFieldOptionsInput{
|
||||
inFirst: {
|
||||
Field: inFirst,
|
||||
Strategy: models.IdentifyFieldStrategyIgnore,
|
||||
},
|
||||
inSecond: {
|
||||
Field: inSecond,
|
||||
Strategy: models.IdentifyFieldStrategyMerge,
|
||||
},
|
||||
inBoth: {
|
||||
Field: inBoth,
|
||||
Strategy: models.IdentifyFieldStrategyIgnore,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := getFieldOptions(tt.args.options); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("getFieldOptions() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getScenePartial(t *testing.T) {
|
||||
var (
|
||||
originalTitle = "originalTitle"
|
||||
originalDate = "originalDate"
|
||||
originalDetails = "originalDetails"
|
||||
originalURL = "originalURL"
|
||||
)
|
||||
|
||||
var (
|
||||
scrapedTitle = "scrapedTitle"
|
||||
scrapedDate = "scrapedDate"
|
||||
scrapedDetails = "scrapedDetails"
|
||||
scrapedURL = "scrapedURL"
|
||||
)
|
||||
|
||||
originalScene := &models.Scene{
|
||||
Title: models.NullString(originalTitle),
|
||||
Date: models.SQLiteDate{
|
||||
String: originalDate,
|
||||
Valid: true,
|
||||
},
|
||||
Details: models.NullString(originalDetails),
|
||||
URL: models.NullString(originalURL),
|
||||
}
|
||||
|
||||
organisedScene := *originalScene
|
||||
organisedScene.Organized = true
|
||||
|
||||
emptyScene := &models.Scene{}
|
||||
|
||||
postPartial := models.ScenePartial{
|
||||
Title: models.NullStringPtr(scrapedTitle),
|
||||
Date: &models.SQLiteDate{
|
||||
String: scrapedDate,
|
||||
Valid: true,
|
||||
},
|
||||
Details: models.NullStringPtr(scrapedDetails),
|
||||
URL: models.NullStringPtr(scrapedURL),
|
||||
}
|
||||
|
||||
scrapedScene := &models.ScrapedScene{
|
||||
Title: &scrapedTitle,
|
||||
Date: &scrapedDate,
|
||||
Details: &scrapedDetails,
|
||||
URL: &scrapedURL,
|
||||
}
|
||||
|
||||
scrapedUnchangedScene := &models.ScrapedScene{
|
||||
Title: &originalTitle,
|
||||
Date: &originalDate,
|
||||
Details: &originalDetails,
|
||||
URL: &originalURL,
|
||||
}
|
||||
|
||||
makeFieldOptions := func(input *models.IdentifyFieldOptionsInput) map[string]*models.IdentifyFieldOptionsInput {
|
||||
return map[string]*models.IdentifyFieldOptionsInput{
|
||||
"title": input,
|
||||
"date": input,
|
||||
"details": input,
|
||||
"url": input,
|
||||
}
|
||||
}
|
||||
|
||||
overwriteAll := makeFieldOptions(&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyOverwrite,
|
||||
})
|
||||
ignoreAll := makeFieldOptions(&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyIgnore,
|
||||
})
|
||||
mergeAll := makeFieldOptions(&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyMerge,
|
||||
})
|
||||
|
||||
setOrganised := true
|
||||
|
||||
type args struct {
|
||||
scene *models.Scene
|
||||
scraped *models.ScrapedScene
|
||||
fieldOptions map[string]*models.IdentifyFieldOptionsInput
|
||||
setOrganized bool
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want models.ScenePartial
|
||||
}{
|
||||
{
|
||||
"overwrite all",
|
||||
args{
|
||||
originalScene,
|
||||
scrapedScene,
|
||||
overwriteAll,
|
||||
false,
|
||||
},
|
||||
postPartial,
|
||||
},
|
||||
{
|
||||
"ignore all",
|
||||
args{
|
||||
originalScene,
|
||||
scrapedScene,
|
||||
ignoreAll,
|
||||
false,
|
||||
},
|
||||
models.ScenePartial{},
|
||||
},
|
||||
{
|
||||
"merge (existing values)",
|
||||
args{
|
||||
originalScene,
|
||||
scrapedScene,
|
||||
mergeAll,
|
||||
false,
|
||||
},
|
||||
models.ScenePartial{},
|
||||
},
|
||||
{
|
||||
"merge (empty values)",
|
||||
args{
|
||||
emptyScene,
|
||||
scrapedScene,
|
||||
mergeAll,
|
||||
false,
|
||||
},
|
||||
postPartial,
|
||||
},
|
||||
{
|
||||
"unchanged",
|
||||
args{
|
||||
originalScene,
|
||||
scrapedUnchangedScene,
|
||||
overwriteAll,
|
||||
false,
|
||||
},
|
||||
models.ScenePartial{},
|
||||
},
|
||||
{
|
||||
"set organized",
|
||||
args{
|
||||
originalScene,
|
||||
scrapedUnchangedScene,
|
||||
overwriteAll,
|
||||
true,
|
||||
},
|
||||
models.ScenePartial{
|
||||
Organized: &setOrganised,
|
||||
},
|
||||
},
|
||||
{
|
||||
"set organized unchanged",
|
||||
args{
|
||||
&organisedScene,
|
||||
scrapedUnchangedScene,
|
||||
overwriteAll,
|
||||
true,
|
||||
},
|
||||
models.ScenePartial{},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := getScenePartial(tt.args.scene, tt.args.scraped, tt.args.fieldOptions, tt.args.setOrganized); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("getScenePartial() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_shouldSetSingleValueField(t *testing.T) {
|
||||
const invalid = "invalid"
|
||||
|
||||
type args struct {
|
||||
strategy *models.IdentifyFieldOptionsInput
|
||||
hasExistingValue bool
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
"ignore",
|
||||
args{
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyIgnore,
|
||||
},
|
||||
false,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"merge existing",
|
||||
args{
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyMerge,
|
||||
},
|
||||
true,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"merge absent",
|
||||
args{
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyMerge,
|
||||
},
|
||||
false,
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"overwrite",
|
||||
args{
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyOverwrite,
|
||||
},
|
||||
true,
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"nil (merge) existing",
|
||||
args{
|
||||
&models.IdentifyFieldOptionsInput{},
|
||||
true,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"nil (merge) absent",
|
||||
args{
|
||||
&models.IdentifyFieldOptionsInput{},
|
||||
false,
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid (merge) existing",
|
||||
args{
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: invalid,
|
||||
},
|
||||
true,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid (merge) absent",
|
||||
args{
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: invalid,
|
||||
},
|
||||
false,
|
||||
},
|
||||
true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := shouldSetSingleValueField(tt.args.strategy, tt.args.hasExistingValue); got != tt.want {
|
||||
t.Errorf("shouldSetSingleValueField() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
108
pkg/identify/performer.go
Normal file
108
pkg/identify/performer.go
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
package identify
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
func getPerformerID(endpoint string, r models.Repository, p *models.ScrapedPerformer, createMissing bool) (*int, error) {
|
||||
if p.StoredID != nil {
|
||||
// existing performer, just add it
|
||||
performerID, err := strconv.Atoi(*p.StoredID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting performer ID %s: %w", *p.StoredID, err)
|
||||
}
|
||||
|
||||
return &performerID, nil
|
||||
} else if createMissing && p.Name != nil { // name is mandatory
|
||||
return createMissingPerformer(endpoint, r, p)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func createMissingPerformer(endpoint string, r models.Repository, p *models.ScrapedPerformer) (*int, error) {
|
||||
created, err := r.Performer().Create(scrapedToPerformerInput(p))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating performer: %w", err)
|
||||
}
|
||||
|
||||
if endpoint != "" && p.RemoteSiteID != nil {
|
||||
if err := r.Performer().UpdateStashIDs(created.ID, []models.StashID{
|
||||
{
|
||||
Endpoint: endpoint,
|
||||
StashID: *p.RemoteSiteID,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("error setting performer stash id: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &created.ID, nil
|
||||
}
|
||||
|
||||
func scrapedToPerformerInput(performer *models.ScrapedPerformer) models.Performer {
|
||||
currentTime := time.Now()
|
||||
ret := models.Performer{
|
||||
Name: sql.NullString{String: *performer.Name, Valid: true},
|
||||
Checksum: utils.MD5FromString(*performer.Name),
|
||||
CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
Favorite: sql.NullBool{Bool: false, Valid: true},
|
||||
}
|
||||
if performer.Birthdate != nil {
|
||||
ret.Birthdate = models.SQLiteDate{String: *performer.Birthdate, Valid: true}
|
||||
}
|
||||
if performer.DeathDate != nil {
|
||||
ret.DeathDate = models.SQLiteDate{String: *performer.DeathDate, Valid: true}
|
||||
}
|
||||
if performer.Gender != nil {
|
||||
ret.Gender = sql.NullString{String: *performer.Gender, Valid: true}
|
||||
}
|
||||
if performer.Ethnicity != nil {
|
||||
ret.Ethnicity = sql.NullString{String: *performer.Ethnicity, Valid: true}
|
||||
}
|
||||
if performer.Country != nil {
|
||||
ret.Country = sql.NullString{String: *performer.Country, Valid: true}
|
||||
}
|
||||
if performer.EyeColor != nil {
|
||||
ret.EyeColor = sql.NullString{String: *performer.EyeColor, Valid: true}
|
||||
}
|
||||
if performer.HairColor != nil {
|
||||
ret.HairColor = sql.NullString{String: *performer.HairColor, Valid: true}
|
||||
}
|
||||
if performer.Height != nil {
|
||||
ret.Height = sql.NullString{String: *performer.Height, Valid: true}
|
||||
}
|
||||
if performer.Measurements != nil {
|
||||
ret.Measurements = sql.NullString{String: *performer.Measurements, Valid: true}
|
||||
}
|
||||
if performer.FakeTits != nil {
|
||||
ret.FakeTits = sql.NullString{String: *performer.FakeTits, Valid: true}
|
||||
}
|
||||
if performer.CareerLength != nil {
|
||||
ret.CareerLength = sql.NullString{String: *performer.CareerLength, Valid: true}
|
||||
}
|
||||
if performer.Tattoos != nil {
|
||||
ret.Tattoos = sql.NullString{String: *performer.Tattoos, Valid: true}
|
||||
}
|
||||
if performer.Piercings != nil {
|
||||
ret.Piercings = sql.NullString{String: *performer.Piercings, Valid: true}
|
||||
}
|
||||
if performer.Aliases != nil {
|
||||
ret.Aliases = sql.NullString{String: *performer.Aliases, Valid: true}
|
||||
}
|
||||
if performer.Twitter != nil {
|
||||
ret.Twitter = sql.NullString{String: *performer.Twitter, Valid: true}
|
||||
}
|
||||
if performer.Instagram != nil {
|
||||
ret.Instagram = sql.NullString{String: *performer.Instagram, Valid: true}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
329
pkg/identify/performer_test.go
Normal file
329
pkg/identify/performer_test.go
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
package identify
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/mocks"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func Test_getPerformerID(t *testing.T) {
|
||||
const (
|
||||
emptyEndpoint = ""
|
||||
endpoint = "endpoint"
|
||||
)
|
||||
invalidStoredID := "invalidStoredID"
|
||||
validStoredIDStr := "1"
|
||||
validStoredID := 1
|
||||
name := "name"
|
||||
|
||||
repo := mocks.NewTransactionManager()
|
||||
repo.PerformerMock().On("Create", mock.Anything).Return(&models.Performer{
|
||||
ID: validStoredID,
|
||||
}, nil)
|
||||
|
||||
type args struct {
|
||||
endpoint string
|
||||
p *models.ScrapedPerformer
|
||||
createMissing bool
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"no performer",
|
||||
args{
|
||||
emptyEndpoint,
|
||||
&models.ScrapedPerformer{},
|
||||
false,
|
||||
},
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid stored id",
|
||||
args{
|
||||
emptyEndpoint,
|
||||
&models.ScrapedPerformer{
|
||||
StoredID: &invalidStoredID,
|
||||
},
|
||||
false,
|
||||
},
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"valid stored id",
|
||||
args{
|
||||
emptyEndpoint,
|
||||
&models.ScrapedPerformer{
|
||||
StoredID: &validStoredIDStr,
|
||||
},
|
||||
false,
|
||||
},
|
||||
&validStoredID,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"nil stored not creating",
|
||||
args{
|
||||
emptyEndpoint,
|
||||
&models.ScrapedPerformer{
|
||||
Name: &name,
|
||||
},
|
||||
false,
|
||||
},
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"nil name creating",
|
||||
args{
|
||||
emptyEndpoint,
|
||||
&models.ScrapedPerformer{},
|
||||
true,
|
||||
},
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"valid name creating",
|
||||
args{
|
||||
emptyEndpoint,
|
||||
&models.ScrapedPerformer{
|
||||
Name: &name,
|
||||
},
|
||||
true,
|
||||
},
|
||||
&validStoredID,
|
||||
false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := getPerformerID(tt.args.endpoint, repo, tt.args.p, tt.args.createMissing)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("getPerformerID() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("getPerformerID() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_createMissingPerformer(t *testing.T) {
|
||||
emptyEndpoint := ""
|
||||
validEndpoint := "validEndpoint"
|
||||
invalidEndpoint := "invalidEndpoint"
|
||||
remoteSiteID := "remoteSiteID"
|
||||
validName := "validName"
|
||||
invalidName := "invalidName"
|
||||
performerID := 1
|
||||
|
||||
repo := mocks.NewTransactionManager()
|
||||
repo.PerformerMock().On("Create", mock.MatchedBy(func(p models.Performer) bool {
|
||||
return p.Name.String == validName
|
||||
})).Return(&models.Performer{
|
||||
ID: performerID,
|
||||
}, nil)
|
||||
repo.PerformerMock().On("Create", mock.MatchedBy(func(p models.Performer) bool {
|
||||
return p.Name.String == invalidName
|
||||
})).Return(nil, errors.New("error creating performer"))
|
||||
|
||||
repo.PerformerMock().On("UpdateStashIDs", performerID, []models.StashID{
|
||||
{
|
||||
Endpoint: invalidEndpoint,
|
||||
StashID: remoteSiteID,
|
||||
},
|
||||
}).Return(errors.New("error updating stash ids"))
|
||||
repo.PerformerMock().On("UpdateStashIDs", performerID, []models.StashID{
|
||||
{
|
||||
Endpoint: validEndpoint,
|
||||
StashID: remoteSiteID,
|
||||
},
|
||||
}).Return(nil)
|
||||
|
||||
type args struct {
|
||||
endpoint string
|
||||
p *models.ScrapedPerformer
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"simple",
|
||||
args{
|
||||
emptyEndpoint,
|
||||
&models.ScrapedPerformer{
|
||||
Name: &validName,
|
||||
},
|
||||
},
|
||||
&performerID,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"error creating",
|
||||
args{
|
||||
emptyEndpoint,
|
||||
&models.ScrapedPerformer{
|
||||
Name: &invalidName,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"valid stash id",
|
||||
args{
|
||||
validEndpoint,
|
||||
&models.ScrapedPerformer{
|
||||
Name: &validName,
|
||||
RemoteSiteID: &remoteSiteID,
|
||||
},
|
||||
},
|
||||
&performerID,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid stash id",
|
||||
args{
|
||||
invalidEndpoint,
|
||||
&models.ScrapedPerformer{
|
||||
Name: &validName,
|
||||
RemoteSiteID: &remoteSiteID,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := createMissingPerformer(tt.args.endpoint, repo, tt.args.p)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("createMissingPerformer() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("createMissingPerformer() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_scrapedToPerformerInput(t *testing.T) {
|
||||
name := "name"
|
||||
md5 := "b068931cc450442b63f5b3d276ea4297"
|
||||
|
||||
var stringValues []string
|
||||
for i := 0; i < 16; i++ {
|
||||
stringValues = append(stringValues, strconv.Itoa(i))
|
||||
}
|
||||
|
||||
upTo := 0
|
||||
nextVal := func() *string {
|
||||
ret := stringValues[upTo]
|
||||
upTo = (upTo + 1) % len(stringValues)
|
||||
return &ret
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
performer *models.ScrapedPerformer
|
||||
want models.Performer
|
||||
}{
|
||||
{
|
||||
"set all",
|
||||
&models.ScrapedPerformer{
|
||||
Name: &name,
|
||||
Birthdate: nextVal(),
|
||||
DeathDate: nextVal(),
|
||||
Gender: nextVal(),
|
||||
Ethnicity: nextVal(),
|
||||
Country: nextVal(),
|
||||
EyeColor: nextVal(),
|
||||
HairColor: nextVal(),
|
||||
Height: nextVal(),
|
||||
Measurements: nextVal(),
|
||||
FakeTits: nextVal(),
|
||||
CareerLength: nextVal(),
|
||||
Tattoos: nextVal(),
|
||||
Piercings: nextVal(),
|
||||
Aliases: nextVal(),
|
||||
Twitter: nextVal(),
|
||||
Instagram: nextVal(),
|
||||
},
|
||||
models.Performer{
|
||||
Name: models.NullString(name),
|
||||
Checksum: md5,
|
||||
Favorite: sql.NullBool{
|
||||
Bool: false,
|
||||
Valid: true,
|
||||
},
|
||||
Birthdate: models.SQLiteDate{
|
||||
String: *nextVal(),
|
||||
Valid: true,
|
||||
},
|
||||
DeathDate: models.SQLiteDate{
|
||||
String: *nextVal(),
|
||||
Valid: true,
|
||||
},
|
||||
Gender: models.NullString(*nextVal()),
|
||||
Ethnicity: models.NullString(*nextVal()),
|
||||
Country: models.NullString(*nextVal()),
|
||||
EyeColor: models.NullString(*nextVal()),
|
||||
HairColor: models.NullString(*nextVal()),
|
||||
Height: models.NullString(*nextVal()),
|
||||
Measurements: models.NullString(*nextVal()),
|
||||
FakeTits: models.NullString(*nextVal()),
|
||||
CareerLength: models.NullString(*nextVal()),
|
||||
Tattoos: models.NullString(*nextVal()),
|
||||
Piercings: models.NullString(*nextVal()),
|
||||
Aliases: models.NullString(*nextVal()),
|
||||
Twitter: models.NullString(*nextVal()),
|
||||
Instagram: models.NullString(*nextVal()),
|
||||
},
|
||||
},
|
||||
{
|
||||
"set none",
|
||||
&models.ScrapedPerformer{
|
||||
Name: &name,
|
||||
},
|
||||
models.Performer{
|
||||
Name: models.NullString(name),
|
||||
Checksum: md5,
|
||||
Favorite: sql.NullBool{
|
||||
Bool: false,
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := scrapedToPerformerInput(tt.performer)
|
||||
|
||||
// clear created/updated dates
|
||||
got.CreatedAt = models.SQLiteTimestamp{}
|
||||
got.UpdatedAt = got.CreatedAt
|
||||
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("scrapedToPerformerInput() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
251
pkg/identify/scene.go
Normal file
251
pkg/identify/scene.go
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
package identify
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
type sceneRelationships struct {
|
||||
repo models.Repository
|
||||
scene *models.Scene
|
||||
result *scrapeResult
|
||||
fieldOptions map[string]*models.IdentifyFieldOptionsInput
|
||||
}
|
||||
|
||||
func (g sceneRelationships) studio() (*int64, error) {
|
||||
existingID := g.scene.StudioID
|
||||
fieldStrategy := g.fieldOptions["studio"]
|
||||
createMissing := fieldStrategy != nil && utils.IsTrue(fieldStrategy.CreateMissing)
|
||||
|
||||
scraped := g.result.result.Studio
|
||||
endpoint := g.result.source.RemoteSite
|
||||
|
||||
if scraped == nil || !shouldSetSingleValueField(fieldStrategy, existingID.Valid) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if scraped.StoredID != nil {
|
||||
// existing studio, just set it
|
||||
studioID, err := strconv.ParseInt(*scraped.StoredID, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting studio ID %s: %w", *scraped.StoredID, err)
|
||||
}
|
||||
|
||||
// only return value if different to current
|
||||
if existingID.Int64 != studioID {
|
||||
return &studioID, nil
|
||||
}
|
||||
} else if createMissing {
|
||||
return createMissingStudio(endpoint, g.repo, scraped)
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (g sceneRelationships) performers(ignoreMale bool) ([]int, error) {
|
||||
fieldStrategy := g.fieldOptions["performers"]
|
||||
scraped := g.result.result.Performers
|
||||
|
||||
// just check if ignored
|
||||
if len(scraped) == 0 || !shouldSetSingleValueField(fieldStrategy, false) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
createMissing := fieldStrategy != nil && utils.IsTrue(fieldStrategy.CreateMissing)
|
||||
strategy := models.IdentifyFieldStrategyMerge
|
||||
if fieldStrategy != nil {
|
||||
strategy = fieldStrategy.Strategy
|
||||
}
|
||||
|
||||
repo := g.repo
|
||||
endpoint := g.result.source.RemoteSite
|
||||
|
||||
var performerIDs []int
|
||||
originalPerformerIDs, err := repo.Scene().GetPerformerIDs(g.scene.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting scene performers: %w", err)
|
||||
}
|
||||
|
||||
if strategy == models.IdentifyFieldStrategyMerge {
|
||||
// add to existing
|
||||
performerIDs = originalPerformerIDs
|
||||
}
|
||||
|
||||
for _, p := range scraped {
|
||||
if ignoreMale && p.Gender != nil && strings.EqualFold(*p.Gender, models.GenderEnumMale.String()) {
|
||||
continue
|
||||
}
|
||||
|
||||
performerID, err := getPerformerID(endpoint, repo, p, createMissing)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if performerID != nil {
|
||||
performerIDs = utils.IntAppendUnique(performerIDs, *performerID)
|
||||
}
|
||||
}
|
||||
|
||||
// don't return if nothing was added
|
||||
if utils.SliceSame(originalPerformerIDs, performerIDs) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return performerIDs, nil
|
||||
}
|
||||
|
||||
func (g sceneRelationships) tags() ([]int, error) {
|
||||
fieldStrategy := g.fieldOptions["tags"]
|
||||
scraped := g.result.result.Tags
|
||||
target := g.scene
|
||||
r := g.repo
|
||||
|
||||
// just check if ignored
|
||||
if len(scraped) == 0 || !shouldSetSingleValueField(fieldStrategy, false) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
createMissing := fieldStrategy != nil && utils.IsTrue(fieldStrategy.CreateMissing)
|
||||
strategy := models.IdentifyFieldStrategyMerge
|
||||
if fieldStrategy != nil {
|
||||
strategy = fieldStrategy.Strategy
|
||||
}
|
||||
|
||||
var tagIDs []int
|
||||
originalTagIDs, err := r.Scene().GetTagIDs(target.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting scene tags: %w", err)
|
||||
}
|
||||
|
||||
if strategy == models.IdentifyFieldStrategyMerge {
|
||||
// add to existing
|
||||
tagIDs = originalTagIDs
|
||||
}
|
||||
|
||||
for _, t := range scraped {
|
||||
if t.StoredID != nil {
|
||||
// existing tag, just add it
|
||||
tagID, err := strconv.ParseInt(*t.StoredID, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error converting tag ID %s: %w", *t.StoredID, err)
|
||||
}
|
||||
|
||||
tagIDs = utils.IntAppendUnique(tagIDs, int(tagID))
|
||||
} else if createMissing {
|
||||
now := time.Now()
|
||||
created, err := r.Tag().Create(models.Tag{
|
||||
Name: t.Name,
|
||||
CreatedAt: models.SQLiteTimestamp{Timestamp: now},
|
||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: now},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating tag: %w", err)
|
||||
}
|
||||
|
||||
tagIDs = append(tagIDs, created.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// don't return if nothing was added
|
||||
if utils.SliceSame(originalTagIDs, tagIDs) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return tagIDs, nil
|
||||
}
|
||||
|
||||
func (g sceneRelationships) stashIDs() ([]models.StashID, error) {
|
||||
remoteSiteID := g.result.result.RemoteSiteID
|
||||
fieldStrategy := g.fieldOptions["stash_ids"]
|
||||
target := g.scene
|
||||
r := g.repo
|
||||
|
||||
endpoint := g.result.source.RemoteSite
|
||||
|
||||
// just check if ignored
|
||||
if remoteSiteID == nil || endpoint == "" || !shouldSetSingleValueField(fieldStrategy, false) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
strategy := models.IdentifyFieldStrategyMerge
|
||||
if fieldStrategy != nil {
|
||||
strategy = fieldStrategy.Strategy
|
||||
}
|
||||
|
||||
var originalStashIDs []models.StashID
|
||||
var stashIDs []models.StashID
|
||||
stashIDPtrs, err := r.Scene().GetStashIDs(target.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting scene tag: %w", err)
|
||||
}
|
||||
|
||||
// convert existing to non-pointer types
|
||||
for _, stashID := range stashIDPtrs {
|
||||
originalStashIDs = append(originalStashIDs, *stashID)
|
||||
}
|
||||
|
||||
if strategy == models.IdentifyFieldStrategyMerge {
|
||||
// add to existing
|
||||
stashIDs = originalStashIDs
|
||||
}
|
||||
|
||||
for i, stashID := range stashIDs {
|
||||
if endpoint == stashID.Endpoint {
|
||||
// if stashID is the same, then don't set
|
||||
if stashID.StashID == *remoteSiteID {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// replace the stash id and return
|
||||
stashID.StashID = *remoteSiteID
|
||||
stashIDs[i] = stashID
|
||||
return stashIDs, nil
|
||||
}
|
||||
}
|
||||
|
||||
// not found, create new entry
|
||||
stashIDs = append(stashIDs, models.StashID{
|
||||
StashID: *remoteSiteID,
|
||||
Endpoint: endpoint,
|
||||
})
|
||||
|
||||
if utils.SliceSame(originalStashIDs, stashIDs) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return stashIDs, nil
|
||||
}
|
||||
|
||||
func (g sceneRelationships) cover(ctx context.Context) ([]byte, error) {
|
||||
scraped := g.result.result.Image
|
||||
r := g.repo
|
||||
|
||||
if scraped == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// always overwrite if present
|
||||
existingCover, err := r.Scene().GetCover(g.scene.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting scene cover: %w", err)
|
||||
}
|
||||
|
||||
data, err := utils.ProcessImageInput(ctx, *scraped)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error processing image input: %w", err)
|
||||
}
|
||||
|
||||
// only return if different
|
||||
if !bytes.Equal(existingCover, data) {
|
||||
return data, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
782
pkg/identify/scene_test.go
Normal file
782
pkg/identify/scene_test.go
Normal file
|
|
@ -0,0 +1,782 @@
|
|||
package identify
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/mocks"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func Test_sceneRelationships_studio(t *testing.T) {
|
||||
validStoredID := "1"
|
||||
var validStoredIDInt int64 = 1
|
||||
invalidStoredID := "invalidStoredID"
|
||||
createMissing := true
|
||||
|
||||
defaultOptions := &models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyMerge,
|
||||
}
|
||||
|
||||
repo := mocks.NewTransactionManager()
|
||||
repo.StudioMock().On("Create", mock.Anything).Return(&models.Studio{
|
||||
ID: int(validStoredIDInt),
|
||||
}, nil)
|
||||
|
||||
tr := sceneRelationships{
|
||||
repo: repo,
|
||||
fieldOptions: make(map[string]*models.IdentifyFieldOptionsInput),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
scene *models.Scene
|
||||
fieldOptions *models.IdentifyFieldOptionsInput
|
||||
result *models.ScrapedStudio
|
||||
want *int64
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"nil studio",
|
||||
&models.Scene{},
|
||||
defaultOptions,
|
||||
nil,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"ignore",
|
||||
&models.Scene{},
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyIgnore,
|
||||
},
|
||||
&models.ScrapedStudio{
|
||||
StoredID: &validStoredID,
|
||||
},
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid stored id",
|
||||
&models.Scene{},
|
||||
defaultOptions,
|
||||
&models.ScrapedStudio{
|
||||
StoredID: &invalidStoredID,
|
||||
},
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"same stored id",
|
||||
&models.Scene{
|
||||
StudioID: models.NullInt64(validStoredIDInt),
|
||||
},
|
||||
defaultOptions,
|
||||
&models.ScrapedStudio{
|
||||
StoredID: &validStoredID,
|
||||
},
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"different stored id",
|
||||
&models.Scene{},
|
||||
defaultOptions,
|
||||
&models.ScrapedStudio{
|
||||
StoredID: &validStoredID,
|
||||
},
|
||||
&validStoredIDInt,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"no create missing",
|
||||
&models.Scene{},
|
||||
defaultOptions,
|
||||
&models.ScrapedStudio{},
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"create missing",
|
||||
&models.Scene{},
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyMerge,
|
||||
CreateMissing: &createMissing,
|
||||
},
|
||||
&models.ScrapedStudio{},
|
||||
&validStoredIDInt,
|
||||
false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tr.scene = tt.scene
|
||||
tr.fieldOptions["studio"] = tt.fieldOptions
|
||||
tr.result = &scrapeResult{
|
||||
result: &models.ScrapedScene{
|
||||
Studio: tt.result,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := tr.studio()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("sceneRelationships.studio() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("sceneRelationships.studio() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_sceneRelationships_performers(t *testing.T) {
|
||||
const (
|
||||
sceneID = iota
|
||||
sceneWithPerformerID
|
||||
errSceneID
|
||||
existingPerformerID
|
||||
validStoredIDInt
|
||||
)
|
||||
validStoredID := strconv.Itoa(validStoredIDInt)
|
||||
invalidStoredID := "invalidStoredID"
|
||||
createMissing := true
|
||||
existingPerformerStr := strconv.Itoa(existingPerformerID)
|
||||
validName := "validName"
|
||||
female := models.GenderEnumFemale.String()
|
||||
male := models.GenderEnumMale.String()
|
||||
|
||||
defaultOptions := &models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyMerge,
|
||||
}
|
||||
|
||||
repo := mocks.NewTransactionManager()
|
||||
repo.SceneMock().On("GetPerformerIDs", sceneID).Return(nil, nil)
|
||||
repo.SceneMock().On("GetPerformerIDs", sceneWithPerformerID).Return([]int{existingPerformerID}, nil)
|
||||
repo.SceneMock().On("GetPerformerIDs", errSceneID).Return(nil, errors.New("error getting IDs"))
|
||||
|
||||
tr := sceneRelationships{
|
||||
repo: repo,
|
||||
fieldOptions: make(map[string]*models.IdentifyFieldOptionsInput),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sceneID int
|
||||
fieldOptions *models.IdentifyFieldOptionsInput
|
||||
scraped []*models.ScrapedPerformer
|
||||
ignoreMale bool
|
||||
want []int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"ignore",
|
||||
sceneID,
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyIgnore,
|
||||
},
|
||||
[]*models.ScrapedPerformer{
|
||||
{
|
||||
StoredID: &validStoredID,
|
||||
},
|
||||
},
|
||||
false,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"none",
|
||||
sceneID,
|
||||
defaultOptions,
|
||||
[]*models.ScrapedPerformer{},
|
||||
false,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"error getting ids",
|
||||
errSceneID,
|
||||
defaultOptions,
|
||||
[]*models.ScrapedPerformer{
|
||||
{},
|
||||
},
|
||||
false,
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"merge existing",
|
||||
sceneWithPerformerID,
|
||||
defaultOptions,
|
||||
[]*models.ScrapedPerformer{
|
||||
{
|
||||
Name: &validName,
|
||||
StoredID: &existingPerformerStr,
|
||||
},
|
||||
},
|
||||
false,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"merge add",
|
||||
sceneWithPerformerID,
|
||||
defaultOptions,
|
||||
[]*models.ScrapedPerformer{
|
||||
{
|
||||
Name: &validName,
|
||||
StoredID: &validStoredID,
|
||||
},
|
||||
},
|
||||
false,
|
||||
[]int{existingPerformerID, validStoredIDInt},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"ignore male",
|
||||
sceneID,
|
||||
defaultOptions,
|
||||
[]*models.ScrapedPerformer{
|
||||
{
|
||||
Name: &validName,
|
||||
StoredID: &validStoredID,
|
||||
Gender: &male,
|
||||
},
|
||||
},
|
||||
true,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"overwrite",
|
||||
sceneWithPerformerID,
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyOverwrite,
|
||||
},
|
||||
[]*models.ScrapedPerformer{
|
||||
{
|
||||
Name: &validName,
|
||||
StoredID: &validStoredID,
|
||||
},
|
||||
},
|
||||
false,
|
||||
[]int{validStoredIDInt},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"ignore male (not male)",
|
||||
sceneWithPerformerID,
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyOverwrite,
|
||||
},
|
||||
[]*models.ScrapedPerformer{
|
||||
{
|
||||
Name: &validName,
|
||||
StoredID: &validStoredID,
|
||||
Gender: &female,
|
||||
},
|
||||
},
|
||||
true,
|
||||
[]int{validStoredIDInt},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"error getting tag ID",
|
||||
sceneID,
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyOverwrite,
|
||||
CreateMissing: &createMissing,
|
||||
},
|
||||
[]*models.ScrapedPerformer{
|
||||
{
|
||||
Name: &validName,
|
||||
StoredID: &invalidStoredID,
|
||||
},
|
||||
},
|
||||
false,
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tr.scene = &models.Scene{
|
||||
ID: tt.sceneID,
|
||||
}
|
||||
tr.fieldOptions["performers"] = tt.fieldOptions
|
||||
tr.result = &scrapeResult{
|
||||
result: &models.ScrapedScene{
|
||||
Performers: tt.scraped,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := tr.performers(tt.ignoreMale)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("sceneRelationships.performers() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("sceneRelationships.performers() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_sceneRelationships_tags(t *testing.T) {
|
||||
const (
|
||||
sceneID = iota
|
||||
sceneWithTagID
|
||||
errSceneID
|
||||
existingID
|
||||
validStoredIDInt
|
||||
)
|
||||
validStoredID := strconv.Itoa(validStoredIDInt)
|
||||
invalidStoredID := "invalidStoredID"
|
||||
createMissing := true
|
||||
existingIDStr := strconv.Itoa(existingID)
|
||||
validName := "validName"
|
||||
invalidName := "invalidName"
|
||||
|
||||
defaultOptions := &models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyMerge,
|
||||
}
|
||||
|
||||
repo := mocks.NewTransactionManager()
|
||||
repo.SceneMock().On("GetTagIDs", sceneID).Return(nil, nil)
|
||||
repo.SceneMock().On("GetTagIDs", sceneWithTagID).Return([]int{existingID}, nil)
|
||||
repo.SceneMock().On("GetTagIDs", errSceneID).Return(nil, errors.New("error getting IDs"))
|
||||
|
||||
repo.TagMock().On("Create", mock.MatchedBy(func(p models.Tag) bool {
|
||||
return p.Name == validName
|
||||
})).Return(&models.Tag{
|
||||
ID: validStoredIDInt,
|
||||
}, nil)
|
||||
repo.TagMock().On("Create", mock.MatchedBy(func(p models.Tag) bool {
|
||||
return p.Name == invalidName
|
||||
})).Return(nil, errors.New("error creating tag"))
|
||||
|
||||
tr := sceneRelationships{
|
||||
repo: repo,
|
||||
fieldOptions: make(map[string]*models.IdentifyFieldOptionsInput),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sceneID int
|
||||
fieldOptions *models.IdentifyFieldOptionsInput
|
||||
scraped []*models.ScrapedTag
|
||||
want []int
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"ignore",
|
||||
sceneID,
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyIgnore,
|
||||
},
|
||||
[]*models.ScrapedTag{
|
||||
{
|
||||
StoredID: &validStoredID,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"none",
|
||||
sceneID,
|
||||
defaultOptions,
|
||||
[]*models.ScrapedTag{},
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"error getting ids",
|
||||
errSceneID,
|
||||
defaultOptions,
|
||||
[]*models.ScrapedTag{
|
||||
{},
|
||||
},
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"merge existing",
|
||||
sceneWithTagID,
|
||||
defaultOptions,
|
||||
[]*models.ScrapedTag{
|
||||
{
|
||||
Name: validName,
|
||||
StoredID: &existingIDStr,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"merge add",
|
||||
sceneWithTagID,
|
||||
defaultOptions,
|
||||
[]*models.ScrapedTag{
|
||||
{
|
||||
Name: validName,
|
||||
StoredID: &validStoredID,
|
||||
},
|
||||
},
|
||||
[]int{existingID, validStoredIDInt},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"overwrite",
|
||||
sceneWithTagID,
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyOverwrite,
|
||||
},
|
||||
[]*models.ScrapedTag{
|
||||
{
|
||||
Name: validName,
|
||||
StoredID: &validStoredID,
|
||||
},
|
||||
},
|
||||
[]int{validStoredIDInt},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"error getting tag ID",
|
||||
sceneID,
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyOverwrite,
|
||||
},
|
||||
[]*models.ScrapedTag{
|
||||
{
|
||||
Name: validName,
|
||||
StoredID: &invalidStoredID,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"create missing",
|
||||
sceneID,
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyOverwrite,
|
||||
CreateMissing: &createMissing,
|
||||
},
|
||||
[]*models.ScrapedTag{
|
||||
{
|
||||
Name: validName,
|
||||
},
|
||||
},
|
||||
[]int{validStoredIDInt},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"error creating",
|
||||
sceneID,
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyOverwrite,
|
||||
CreateMissing: &createMissing,
|
||||
},
|
||||
[]*models.ScrapedTag{
|
||||
{
|
||||
Name: invalidName,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tr.scene = &models.Scene{
|
||||
ID: tt.sceneID,
|
||||
}
|
||||
tr.fieldOptions["tags"] = tt.fieldOptions
|
||||
tr.result = &scrapeResult{
|
||||
result: &models.ScrapedScene{
|
||||
Tags: tt.scraped,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := tr.tags()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("sceneRelationships.tags() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("sceneRelationships.tags() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_sceneRelationships_stashIDs(t *testing.T) {
|
||||
const (
|
||||
sceneID = iota
|
||||
sceneWithStashID
|
||||
errSceneID
|
||||
existingID
|
||||
validStoredIDInt
|
||||
)
|
||||
existingEndpoint := "existingEndpoint"
|
||||
newEndpoint := "newEndpoint"
|
||||
remoteSiteID := "remoteSiteID"
|
||||
newRemoteSiteID := "newRemoteSiteID"
|
||||
|
||||
defaultOptions := &models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyMerge,
|
||||
}
|
||||
|
||||
repo := mocks.NewTransactionManager()
|
||||
repo.SceneMock().On("GetStashIDs", sceneID).Return(nil, nil)
|
||||
repo.SceneMock().On("GetStashIDs", sceneWithStashID).Return([]*models.StashID{
|
||||
{
|
||||
StashID: remoteSiteID,
|
||||
Endpoint: existingEndpoint,
|
||||
},
|
||||
}, nil)
|
||||
repo.SceneMock().On("GetStashIDs", errSceneID).Return(nil, errors.New("error getting IDs"))
|
||||
|
||||
tr := sceneRelationships{
|
||||
repo: repo,
|
||||
fieldOptions: make(map[string]*models.IdentifyFieldOptionsInput),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sceneID int
|
||||
fieldOptions *models.IdentifyFieldOptionsInput
|
||||
endpoint string
|
||||
remoteSiteID *string
|
||||
want []models.StashID
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"ignore",
|
||||
sceneID,
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyIgnore,
|
||||
},
|
||||
newEndpoint,
|
||||
&remoteSiteID,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"no endpoint",
|
||||
sceneID,
|
||||
defaultOptions,
|
||||
"",
|
||||
&remoteSiteID,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"no site id",
|
||||
sceneID,
|
||||
defaultOptions,
|
||||
newEndpoint,
|
||||
nil,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"error getting ids",
|
||||
errSceneID,
|
||||
defaultOptions,
|
||||
newEndpoint,
|
||||
&remoteSiteID,
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"merge existing",
|
||||
sceneWithStashID,
|
||||
defaultOptions,
|
||||
existingEndpoint,
|
||||
&remoteSiteID,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"merge existing new value",
|
||||
sceneWithStashID,
|
||||
defaultOptions,
|
||||
existingEndpoint,
|
||||
&newRemoteSiteID,
|
||||
[]models.StashID{
|
||||
{
|
||||
StashID: newRemoteSiteID,
|
||||
Endpoint: existingEndpoint,
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"merge add",
|
||||
sceneWithStashID,
|
||||
defaultOptions,
|
||||
newEndpoint,
|
||||
&newRemoteSiteID,
|
||||
[]models.StashID{
|
||||
{
|
||||
StashID: remoteSiteID,
|
||||
Endpoint: existingEndpoint,
|
||||
},
|
||||
{
|
||||
StashID: newRemoteSiteID,
|
||||
Endpoint: newEndpoint,
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"overwrite",
|
||||
sceneWithStashID,
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyOverwrite,
|
||||
},
|
||||
newEndpoint,
|
||||
&newRemoteSiteID,
|
||||
[]models.StashID{
|
||||
{
|
||||
StashID: newRemoteSiteID,
|
||||
Endpoint: newEndpoint,
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"overwrite same",
|
||||
sceneWithStashID,
|
||||
&models.IdentifyFieldOptionsInput{
|
||||
Strategy: models.IdentifyFieldStrategyOverwrite,
|
||||
},
|
||||
existingEndpoint,
|
||||
&remoteSiteID,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tr.scene = &models.Scene{
|
||||
ID: tt.sceneID,
|
||||
}
|
||||
tr.fieldOptions["stash_ids"] = tt.fieldOptions
|
||||
tr.result = &scrapeResult{
|
||||
source: ScraperSource{
|
||||
RemoteSite: tt.endpoint,
|
||||
},
|
||||
result: &models.ScrapedScene{
|
||||
RemoteSiteID: tt.remoteSiteID,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := tr.stashIDs()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("sceneRelationships.stashIDs() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("sceneRelationships.stashIDs() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_sceneRelationships_cover(t *testing.T) {
|
||||
const (
|
||||
sceneID = iota
|
||||
sceneWithStashID
|
||||
errSceneID
|
||||
existingID
|
||||
validStoredIDInt
|
||||
)
|
||||
existingData := []byte("existingData")
|
||||
newData := []byte("newData")
|
||||
const base64Prefix = "data:image/png;base64,"
|
||||
existingDataEncoded := base64Prefix + utils.GetBase64StringFromData(existingData)
|
||||
newDataEncoded := base64Prefix + utils.GetBase64StringFromData(newData)
|
||||
invalidData := newDataEncoded + "!!!"
|
||||
|
||||
repo := mocks.NewTransactionManager()
|
||||
repo.SceneMock().On("GetCover", sceneID).Return(existingData, nil)
|
||||
repo.SceneMock().On("GetCover", errSceneID).Return(nil, errors.New("error getting cover"))
|
||||
|
||||
tr := sceneRelationships{
|
||||
repo: repo,
|
||||
fieldOptions: make(map[string]*models.IdentifyFieldOptionsInput),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sceneID int
|
||||
image *string
|
||||
want []byte
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"nil image",
|
||||
sceneID,
|
||||
nil,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"different image",
|
||||
sceneID,
|
||||
&newDataEncoded,
|
||||
newData,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"same image",
|
||||
sceneID,
|
||||
&existingDataEncoded,
|
||||
nil,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"error getting scene cover",
|
||||
errSceneID,
|
||||
&newDataEncoded,
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid data",
|
||||
sceneID,
|
||||
&invalidData,
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tr.scene = &models.Scene{
|
||||
ID: tt.sceneID,
|
||||
}
|
||||
tr.result = &scrapeResult{
|
||||
result: &models.ScrapedScene{
|
||||
Image: tt.image,
|
||||
},
|
||||
}
|
||||
|
||||
got, err := tr.cover(context.TODO())
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("sceneRelationships.cover() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("sceneRelationships.cover() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
47
pkg/identify/studio.go
Normal file
47
pkg/identify/studio.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package identify
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
func createMissingStudio(endpoint string, repo models.Repository, studio *models.ScrapedStudio) (*int64, error) {
|
||||
created, err := repo.Studio().Create(scrapedToStudioInput(studio))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating studio: %w", err)
|
||||
}
|
||||
|
||||
if endpoint != "" && studio.RemoteSiteID != nil {
|
||||
if err := repo.Studio().UpdateStashIDs(created.ID, []models.StashID{
|
||||
{
|
||||
Endpoint: endpoint,
|
||||
StashID: *studio.RemoteSiteID,
|
||||
},
|
||||
}); err != nil {
|
||||
return nil, fmt.Errorf("error setting studio stash id: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
createdID := int64(created.ID)
|
||||
return &createdID, nil
|
||||
}
|
||||
|
||||
func scrapedToStudioInput(studio *models.ScrapedStudio) models.Studio {
|
||||
currentTime := time.Now()
|
||||
ret := models.Studio{
|
||||
Name: sql.NullString{String: studio.Name, Valid: true},
|
||||
Checksum: utils.MD5FromString(studio.Name),
|
||||
CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
|
||||
}
|
||||
|
||||
if studio.URL != nil {
|
||||
ret.URL = sql.NullString{String: *studio.URL, Valid: true}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
163
pkg/identify/studio_test.go
Normal file
163
pkg/identify/studio_test.go
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
package identify
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/mocks"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func Test_createMissingStudio(t *testing.T) {
|
||||
emptyEndpoint := ""
|
||||
validEndpoint := "validEndpoint"
|
||||
invalidEndpoint := "invalidEndpoint"
|
||||
remoteSiteID := "remoteSiteID"
|
||||
validName := "validName"
|
||||
invalidName := "invalidName"
|
||||
createdID := 1
|
||||
createdID64 := int64(createdID)
|
||||
|
||||
repo := mocks.NewTransactionManager()
|
||||
repo.StudioMock().On("Create", mock.MatchedBy(func(p models.Studio) bool {
|
||||
return p.Name.String == validName
|
||||
})).Return(&models.Studio{
|
||||
ID: createdID,
|
||||
}, nil)
|
||||
repo.StudioMock().On("Create", mock.MatchedBy(func(p models.Studio) bool {
|
||||
return p.Name.String == invalidName
|
||||
})).Return(nil, errors.New("error creating performer"))
|
||||
|
||||
repo.StudioMock().On("UpdateStashIDs", createdID, []models.StashID{
|
||||
{
|
||||
Endpoint: invalidEndpoint,
|
||||
StashID: remoteSiteID,
|
||||
},
|
||||
}).Return(errors.New("error updating stash ids"))
|
||||
repo.StudioMock().On("UpdateStashIDs", createdID, []models.StashID{
|
||||
{
|
||||
Endpoint: validEndpoint,
|
||||
StashID: remoteSiteID,
|
||||
},
|
||||
}).Return(nil)
|
||||
|
||||
type args struct {
|
||||
endpoint string
|
||||
studio *models.ScrapedStudio
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want *int64
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"simple",
|
||||
args{
|
||||
emptyEndpoint,
|
||||
&models.ScrapedStudio{
|
||||
Name: validName,
|
||||
},
|
||||
},
|
||||
&createdID64,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"error creating",
|
||||
args{
|
||||
emptyEndpoint,
|
||||
&models.ScrapedStudio{
|
||||
Name: invalidName,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"valid stash id",
|
||||
args{
|
||||
validEndpoint,
|
||||
&models.ScrapedStudio{
|
||||
Name: validName,
|
||||
RemoteSiteID: &remoteSiteID,
|
||||
},
|
||||
},
|
||||
&createdID64,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid stash id",
|
||||
args{
|
||||
invalidEndpoint,
|
||||
&models.ScrapedStudio{
|
||||
Name: validName,
|
||||
RemoteSiteID: &remoteSiteID,
|
||||
},
|
||||
},
|
||||
nil,
|
||||
true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := createMissingStudio(tt.args.endpoint, repo, tt.args.studio)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("createMissingStudio() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("createMissingStudio() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_scrapedToStudioInput(t *testing.T) {
|
||||
const name = "name"
|
||||
const md5 = "b068931cc450442b63f5b3d276ea4297"
|
||||
url := "url"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
studio *models.ScrapedStudio
|
||||
want models.Studio
|
||||
}{
|
||||
{
|
||||
"set all",
|
||||
&models.ScrapedStudio{
|
||||
Name: name,
|
||||
URL: &url,
|
||||
},
|
||||
models.Studio{
|
||||
Name: models.NullString(name),
|
||||
Checksum: md5,
|
||||
URL: models.NullString(url),
|
||||
},
|
||||
},
|
||||
{
|
||||
"set none",
|
||||
&models.ScrapedStudio{
|
||||
Name: name,
|
||||
},
|
||||
models.Studio{
|
||||
Name: models.NullString(name),
|
||||
Checksum: md5,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := scrapedToStudioInput(tt.studio)
|
||||
|
||||
// clear created/updated dates
|
||||
got.CreatedAt = models.SQLiteTimestamp{}
|
||||
got.UpdatedAt = got.CreatedAt
|
||||
|
||||
if !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("scrapedToStudioInput() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -144,6 +144,11 @@ const (
|
|||
const HandyKey = "handy_key"
|
||||
const FunscriptOffset = "funscript_offset"
|
||||
|
||||
// Default settings
|
||||
const (
|
||||
DefaultIdentifySettings = "defaults.identify_task"
|
||||
)
|
||||
|
||||
// Security
|
||||
const TrustedProxies = "trusted_proxies"
|
||||
const dangerousAllowPublicWithoutAuth = "dangerous_allow_public_without_auth"
|
||||
|
|
@ -476,10 +481,10 @@ func (i *Instance) GetScraperExcludeTagPatterns() []string {
|
|||
return ret
|
||||
}
|
||||
|
||||
func (i *Instance) GetStashBoxes() []*models.StashBox {
|
||||
func (i *Instance) GetStashBoxes() models.StashBoxes {
|
||||
i.RLock()
|
||||
defer i.RUnlock()
|
||||
var boxes []*models.StashBox
|
||||
var boxes models.StashBoxes
|
||||
if err := viper.UnmarshalKey(StashBoxes, &boxes); err != nil {
|
||||
logger.Warnf("error in unmarshalkey: %v", err)
|
||||
}
|
||||
|
|
@ -869,10 +874,30 @@ func (i *Instance) GetHandyKey() string {
|
|||
}
|
||||
|
||||
func (i *Instance) GetFunscriptOffset() int {
|
||||
i.Lock()
|
||||
defer i.Unlock()
|
||||
viper.SetDefault(FunscriptOffset, 0)
|
||||
return viper.GetInt(FunscriptOffset)
|
||||
}
|
||||
|
||||
// GetDefaultIdentifySettings returns the default Identify task settings.
|
||||
// Returns nil if the settings could not be unmarshalled, or if it
|
||||
// has not been set.
|
||||
func (i *Instance) GetDefaultIdentifySettings() *models.IdentifyMetadataTaskOptions {
|
||||
i.RLock()
|
||||
defer i.RUnlock()
|
||||
|
||||
if viper.IsSet(DefaultIdentifySettings) {
|
||||
var ret models.IdentifyMetadataTaskOptions
|
||||
if err := viper.UnmarshalKey(DefaultIdentifySettings, &ret); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &ret
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTrustedProxies returns a comma separated list of ip addresses that should allow proxying.
|
||||
// When empty, allow from any private network
|
||||
func (i *Instance) GetTrustedProxies() []string {
|
||||
|
|
|
|||
|
|
@ -92,6 +92,8 @@ func TestConcurrentConfigAccess(t *testing.T) {
|
|||
i.Set(LogLevel, i.GetLogLevel())
|
||||
i.Set(LogAccess, i.GetLogAccess())
|
||||
i.Set(MaxUploadSize, i.GetMaxUploadSize())
|
||||
i.Set(FunscriptOffset, i.GetFunscriptOffset())
|
||||
i.Set(DefaultIdentifySettings, i.GetDefaultIdentifySettings())
|
||||
}
|
||||
wg.Done()
|
||||
}(k)
|
||||
|
|
|
|||
|
|
@ -1,61 +0,0 @@
|
|||
package manager
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
|
||||
// needed to decode other image formats
|
||||
_ "image/gif"
|
||||
_ "image/png"
|
||||
)
|
||||
|
||||
func writeImage(path string, imageData []byte) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = f.Write(imageData)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeThumbnail(path string, thumbnail image.Image) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
return jpeg.Encode(f, thumbnail, nil)
|
||||
}
|
||||
|
||||
func SetSceneScreenshot(checksum string, imageData []byte) error {
|
||||
thumbPath := instance.Paths.Scene.GetThumbnailScreenshotPath(checksum)
|
||||
normalPath := instance.Paths.Scene.GetScreenshotPath(checksum)
|
||||
|
||||
img, _, err := image.Decode(bytes.NewReader(imageData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// resize to 320 width maintaining aspect ratio, for the thumbnail
|
||||
const width = 320
|
||||
origWidth := img.Bounds().Max.X
|
||||
origHeight := img.Bounds().Max.Y
|
||||
height := width / origWidth * origHeight
|
||||
|
||||
thumbnail := imaging.Resize(img, width, height, imaging.Lanczos)
|
||||
err = writeThumbnail(thumbPath, thumbnail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = writeImage(normalPath, imageData)
|
||||
|
||||
return err
|
||||
}
|
||||
|
|
@ -326,28 +326,7 @@ type autoTagFilesTask struct {
|
|||
}
|
||||
|
||||
func (t *autoTagFilesTask) makeSceneFilter() *models.SceneFilterType {
|
||||
ret := &models.SceneFilterType{}
|
||||
or := ret
|
||||
sep := string(filepath.Separator)
|
||||
|
||||
for _, p := range t.paths {
|
||||
if !strings.HasSuffix(p, sep) {
|
||||
p += sep
|
||||
}
|
||||
|
||||
if ret.Path == nil {
|
||||
or = ret
|
||||
} else {
|
||||
newOr := &models.SceneFilterType{}
|
||||
or.Or = newOr
|
||||
or = newOr
|
||||
}
|
||||
|
||||
or.Path = &models.StringCriterionInput{
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
Value: p + "%",
|
||||
}
|
||||
}
|
||||
ret := scene.FilterFromPaths(t.paths)
|
||||
|
||||
organized := false
|
||||
ret.Organized = &organized
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import (
|
|||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/scene"
|
||||
)
|
||||
|
||||
type GenerateScreenshotTask struct {
|
||||
|
|
@ -66,7 +67,7 @@ func (t *GenerateScreenshotTask) Start(ctx context.Context) {
|
|||
UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime},
|
||||
}
|
||||
|
||||
if err := SetSceneScreenshot(checksum, coverImageData); err != nil {
|
||||
if err := scene.SetScreenshot(instance.Paths, checksum, coverImageData); err != nil {
|
||||
return fmt.Errorf("error writing screenshot: %v", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
244
pkg/manager/task_identify.go
Normal file
244
pkg/manager/task_identify.go
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/identify"
|
||||
"github.com/stashapp/stash/pkg/job"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/scene"
|
||||
"github.com/stashapp/stash/pkg/scraper"
|
||||
"github.com/stashapp/stash/pkg/scraper/stashbox"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
var ErrInput = errors.New("invalid request input")
|
||||
|
||||
type IdentifyJob struct {
|
||||
txnManager models.TransactionManager
|
||||
postHookExecutor identify.SceneUpdatePostHookExecutor
|
||||
input models.IdentifyMetadataInput
|
||||
|
||||
stashBoxes models.StashBoxes
|
||||
progress *job.Progress
|
||||
}
|
||||
|
||||
func CreateIdentifyJob(input models.IdentifyMetadataInput) *IdentifyJob {
|
||||
return &IdentifyJob{
|
||||
txnManager: instance.TxnManager,
|
||||
postHookExecutor: instance.PluginCache,
|
||||
input: input,
|
||||
stashBoxes: instance.Config.GetStashBoxes(),
|
||||
}
|
||||
}
|
||||
|
||||
func (j *IdentifyJob) Execute(ctx context.Context, progress *job.Progress) {
|
||||
j.progress = progress
|
||||
|
||||
// if no sources provided - just return
|
||||
if len(j.input.Sources) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
sources, err := j.getSources()
|
||||
if err != nil {
|
||||
logger.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// if scene ids provided, use those
|
||||
// otherwise, batch query for all scenes - ordering by path
|
||||
if err := j.txnManager.WithReadTxn(ctx, func(r models.ReaderRepository) error {
|
||||
if len(j.input.SceneIDs) == 0 {
|
||||
return j.identifyAllScenes(ctx, r, sources)
|
||||
}
|
||||
|
||||
sceneIDs, err := utils.StringSliceToIntSlice(j.input.SceneIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid scene IDs: %w", err)
|
||||
}
|
||||
|
||||
progress.SetTotal(len(sceneIDs))
|
||||
for _, id := range sceneIDs {
|
||||
if job.IsCancelled(ctx) {
|
||||
break
|
||||
}
|
||||
|
||||
// find the scene
|
||||
var err error
|
||||
scene, err := r.Scene().Find(id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding scene with id %d: %w", id, err)
|
||||
}
|
||||
|
||||
if scene == nil {
|
||||
return fmt.Errorf("%w: scene with id %d", models.ErrNotFound, id)
|
||||
}
|
||||
|
||||
j.identifyScene(ctx, scene, sources)
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
logger.Errorf("Error encountered while identifying scenes: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (j *IdentifyJob) identifyAllScenes(ctx context.Context, r models.ReaderRepository, sources []identify.ScraperSource) error {
|
||||
// exclude organised
|
||||
organised := false
|
||||
sceneFilter := scene.FilterFromPaths(j.input.Paths)
|
||||
sceneFilter.Organized = &organised
|
||||
|
||||
sort := "path"
|
||||
findFilter := &models.FindFilterType{
|
||||
Sort: &sort,
|
||||
}
|
||||
|
||||
// get the count
|
||||
pp := 0
|
||||
findFilter.PerPage = &pp
|
||||
countResult, err := r.Scene().Query(models.SceneQueryOptions{
|
||||
QueryOptions: models.QueryOptions{
|
||||
FindFilter: findFilter,
|
||||
Count: true,
|
||||
},
|
||||
SceneFilter: sceneFilter,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error getting scene count: %w", err)
|
||||
}
|
||||
|
||||
j.progress.SetTotal(countResult.Count)
|
||||
|
||||
return scene.BatchProcess(ctx, r.Scene(), sceneFilter, findFilter, func(scene *models.Scene) error {
|
||||
if job.IsCancelled(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
j.identifyScene(ctx, scene, sources)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func (j *IdentifyJob) identifyScene(ctx context.Context, s *models.Scene, sources []identify.ScraperSource) {
|
||||
if job.IsCancelled(ctx) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := j.txnManager.WithTxn(context.TODO(), func(r models.Repository) error {
|
||||
var taskError error
|
||||
j.progress.ExecuteTask("Identifying "+s.Path, func() {
|
||||
task := identify.SceneIdentifier{
|
||||
DefaultOptions: j.input.Options,
|
||||
Sources: sources,
|
||||
ScreenshotSetter: &scene.PathsScreenshotSetter{
|
||||
Paths: instance.Paths,
|
||||
FileNamingAlgorithm: instance.Config.GetVideoFileNamingAlgorithm(),
|
||||
},
|
||||
SceneUpdatePostHookExecutor: j.postHookExecutor,
|
||||
}
|
||||
|
||||
taskError = task.Identify(ctx, r, s)
|
||||
})
|
||||
|
||||
return taskError
|
||||
}); err != nil {
|
||||
logger.Errorf("Error encountered identifying %s: %v", s.Path, err)
|
||||
}
|
||||
|
||||
j.progress.Increment()
|
||||
}
|
||||
|
||||
func (j *IdentifyJob) getSources() ([]identify.ScraperSource, error) {
|
||||
var ret []identify.ScraperSource
|
||||
for _, source := range j.input.Sources {
|
||||
// get scraper source
|
||||
stashBox, err := j.getStashBox(source.Source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var src identify.ScraperSource
|
||||
if stashBox != nil {
|
||||
src = identify.ScraperSource{
|
||||
Name: "stash-box: " + stashBox.Endpoint,
|
||||
Scraper: stashboxSource{
|
||||
stashbox.NewClient(*stashBox, j.txnManager),
|
||||
stashBox.Endpoint,
|
||||
},
|
||||
RemoteSite: stashBox.Endpoint,
|
||||
}
|
||||
} else {
|
||||
scraperID := *source.Source.ScraperID
|
||||
s := instance.ScraperCache.GetScraper(scraperID)
|
||||
if s == nil {
|
||||
return nil, fmt.Errorf("%w: scraper with id %q", models.ErrNotFound, scraperID)
|
||||
}
|
||||
src = identify.ScraperSource{
|
||||
Name: s.Name,
|
||||
Scraper: scraperSource{
|
||||
cache: instance.ScraperCache,
|
||||
scraperID: scraperID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
src.Options = source.Options
|
||||
ret = append(ret, src)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (j *IdentifyJob) getStashBox(src *models.ScraperSourceInput) (*models.StashBox, error) {
|
||||
if src.ScraperID != nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// must be stash-box
|
||||
if src.StashBoxIndex == nil && src.StashBoxEndpoint == nil {
|
||||
return nil, fmt.Errorf("%w: stash_box_index or stash_box_endpoint or scraper_id must be set", ErrInput)
|
||||
}
|
||||
|
||||
return j.stashBoxes.ResolveStashBox(*src)
|
||||
}
|
||||
|
||||
type stashboxSource struct {
|
||||
*stashbox.Client
|
||||
endpoint string
|
||||
}
|
||||
|
||||
func (s stashboxSource) ScrapeScene(sceneID int) (*models.ScrapedScene, error) {
|
||||
results, err := s.FindStashBoxScenesByFingerprintsFlat([]string{strconv.Itoa(sceneID)})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error querying stash-box using scene ID %d: %w", sceneID, err)
|
||||
}
|
||||
|
||||
if len(results) > 0 {
|
||||
return results[0], nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s stashboxSource) String() string {
|
||||
return fmt.Sprintf("stash-box %s", s.endpoint)
|
||||
}
|
||||
|
||||
type scraperSource struct {
|
||||
cache *scraper.Cache
|
||||
scraperID string
|
||||
}
|
||||
|
||||
func (s scraperSource) ScrapeScene(sceneID int) (*models.ScrapedScene, error) {
|
||||
return s.cache.ScrapeScene(s.scraperID, sceneID)
|
||||
}
|
||||
|
||||
func (s scraperSource) String() string {
|
||||
return fmt.Sprintf("scraper %s", s.scraperID)
|
||||
}
|
||||
5
pkg/models/errors.go
Normal file
5
pkg/models/errors.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package models
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
|
@ -7,16 +7,16 @@ import (
|
|||
)
|
||||
|
||||
type TransactionManager struct {
|
||||
gallery models.GalleryReaderWriter
|
||||
image models.ImageReaderWriter
|
||||
movie models.MovieReaderWriter
|
||||
performer models.PerformerReaderWriter
|
||||
scene models.SceneReaderWriter
|
||||
sceneMarker models.SceneMarkerReaderWriter
|
||||
scrapedItem models.ScrapedItemReaderWriter
|
||||
studio models.StudioReaderWriter
|
||||
tag models.TagReaderWriter
|
||||
savedFilter models.SavedFilterReaderWriter
|
||||
gallery *GalleryReaderWriter
|
||||
image *ImageReaderWriter
|
||||
movie *MovieReaderWriter
|
||||
performer *PerformerReaderWriter
|
||||
scene *SceneReaderWriter
|
||||
sceneMarker *SceneMarkerReaderWriter
|
||||
scrapedItem *ScrapedItemReaderWriter
|
||||
studio *StudioReaderWriter
|
||||
tag *TagReaderWriter
|
||||
savedFilter *SavedFilterReaderWriter
|
||||
}
|
||||
|
||||
func NewTransactionManager() *TransactionManager {
|
||||
|
|
@ -38,90 +38,130 @@ func (t *TransactionManager) WithTxn(ctx context.Context, fn func(r models.Repos
|
|||
return fn(t)
|
||||
}
|
||||
|
||||
func (t *TransactionManager) Gallery() models.GalleryReaderWriter {
|
||||
func (t *TransactionManager) GalleryMock() *GalleryReaderWriter {
|
||||
return t.gallery
|
||||
}
|
||||
|
||||
func (t *TransactionManager) Image() models.ImageReaderWriter {
|
||||
func (t *TransactionManager) ImageMock() *ImageReaderWriter {
|
||||
return t.image
|
||||
}
|
||||
|
||||
func (t *TransactionManager) Movie() models.MovieReaderWriter {
|
||||
func (t *TransactionManager) MovieMock() *MovieReaderWriter {
|
||||
return t.movie
|
||||
}
|
||||
|
||||
func (t *TransactionManager) Performer() models.PerformerReaderWriter {
|
||||
func (t *TransactionManager) PerformerMock() *PerformerReaderWriter {
|
||||
return t.performer
|
||||
}
|
||||
|
||||
func (t *TransactionManager) SceneMarker() models.SceneMarkerReaderWriter {
|
||||
func (t *TransactionManager) SceneMarkerMock() *SceneMarkerReaderWriter {
|
||||
return t.sceneMarker
|
||||
}
|
||||
|
||||
func (t *TransactionManager) Scene() models.SceneReaderWriter {
|
||||
func (t *TransactionManager) SceneMock() *SceneReaderWriter {
|
||||
return t.scene
|
||||
}
|
||||
|
||||
func (t *TransactionManager) ScrapedItem() models.ScrapedItemReaderWriter {
|
||||
func (t *TransactionManager) ScrapedItemMock() *ScrapedItemReaderWriter {
|
||||
return t.scrapedItem
|
||||
}
|
||||
|
||||
func (t *TransactionManager) Studio() models.StudioReaderWriter {
|
||||
func (t *TransactionManager) StudioMock() *StudioReaderWriter {
|
||||
return t.studio
|
||||
}
|
||||
|
||||
func (t *TransactionManager) Tag() models.TagReaderWriter {
|
||||
func (t *TransactionManager) TagMock() *TagReaderWriter {
|
||||
return t.tag
|
||||
}
|
||||
|
||||
func (t *TransactionManager) SavedFilter() models.SavedFilterReaderWriter {
|
||||
func (t *TransactionManager) SavedFilterMock() *SavedFilterReaderWriter {
|
||||
return t.savedFilter
|
||||
}
|
||||
|
||||
func (t *TransactionManager) Gallery() models.GalleryReaderWriter {
|
||||
return t.GalleryMock()
|
||||
}
|
||||
|
||||
func (t *TransactionManager) Image() models.ImageReaderWriter {
|
||||
return t.ImageMock()
|
||||
}
|
||||
|
||||
func (t *TransactionManager) Movie() models.MovieReaderWriter {
|
||||
return t.MovieMock()
|
||||
}
|
||||
|
||||
func (t *TransactionManager) Performer() models.PerformerReaderWriter {
|
||||
return t.PerformerMock()
|
||||
}
|
||||
|
||||
func (t *TransactionManager) SceneMarker() models.SceneMarkerReaderWriter {
|
||||
return t.SceneMarkerMock()
|
||||
}
|
||||
|
||||
func (t *TransactionManager) Scene() models.SceneReaderWriter {
|
||||
return t.SceneMock()
|
||||
}
|
||||
|
||||
func (t *TransactionManager) ScrapedItem() models.ScrapedItemReaderWriter {
|
||||
return t.ScrapedItemMock()
|
||||
}
|
||||
|
||||
func (t *TransactionManager) Studio() models.StudioReaderWriter {
|
||||
return t.StudioMock()
|
||||
}
|
||||
|
||||
func (t *TransactionManager) Tag() models.TagReaderWriter {
|
||||
return t.TagMock()
|
||||
}
|
||||
|
||||
func (t *TransactionManager) SavedFilter() models.SavedFilterReaderWriter {
|
||||
return t.SavedFilterMock()
|
||||
}
|
||||
|
||||
type ReadTransaction struct {
|
||||
t *TransactionManager
|
||||
*TransactionManager
|
||||
}
|
||||
|
||||
func (t *TransactionManager) WithReadTxn(ctx context.Context, fn func(r models.ReaderRepository) error) error {
|
||||
return fn(&ReadTransaction{t: t})
|
||||
return fn(&ReadTransaction{t})
|
||||
}
|
||||
|
||||
func (r *ReadTransaction) Gallery() models.GalleryReader {
|
||||
return r.t.gallery
|
||||
return r.GalleryMock()
|
||||
}
|
||||
|
||||
func (r *ReadTransaction) Image() models.ImageReader {
|
||||
return r.t.image
|
||||
return r.ImageMock()
|
||||
}
|
||||
|
||||
func (r *ReadTransaction) Movie() models.MovieReader {
|
||||
return r.t.movie
|
||||
return r.MovieMock()
|
||||
}
|
||||
|
||||
func (r *ReadTransaction) Performer() models.PerformerReader {
|
||||
return r.t.performer
|
||||
return r.PerformerMock()
|
||||
}
|
||||
|
||||
func (r *ReadTransaction) SceneMarker() models.SceneMarkerReader {
|
||||
return r.t.sceneMarker
|
||||
return r.SceneMarkerMock()
|
||||
}
|
||||
|
||||
func (r *ReadTransaction) Scene() models.SceneReader {
|
||||
return r.t.scene
|
||||
return r.SceneMock()
|
||||
}
|
||||
|
||||
func (r *ReadTransaction) ScrapedItem() models.ScrapedItemReader {
|
||||
return r.t.scrapedItem
|
||||
return r.ScrapedItemMock()
|
||||
}
|
||||
|
||||
func (r *ReadTransaction) Studio() models.StudioReader {
|
||||
return r.t.studio
|
||||
return r.StudioMock()
|
||||
}
|
||||
|
||||
func (r *ReadTransaction) Tag() models.TagReader {
|
||||
return r.t.tag
|
||||
return r.TagMock()
|
||||
}
|
||||
|
||||
func (r *ReadTransaction) SavedFilter() models.SavedFilterReader {
|
||||
return r.t.savedFilter
|
||||
return r.SavedFilterMock()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,3 +12,10 @@ type StashID struct {
|
|||
StashID string `db:"stash_id" json:"stash_id"`
|
||||
Endpoint string `db:"endpoint" json:"endpoint"`
|
||||
}
|
||||
|
||||
func (s StashID) StashIDInput() StashIDInput {
|
||||
return StashIDInput{
|
||||
Endpoint: s.Endpoint,
|
||||
StashID: s.StashID,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package models
|
|||
import (
|
||||
"database/sql"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
|
|
@ -119,6 +120,29 @@ type ScenePartial struct {
|
|||
Interactive *bool `db:"interactive" json:"interactive"`
|
||||
}
|
||||
|
||||
// UpdateInput constructs a SceneUpdateInput using the populated fields in the ScenePartial object.
|
||||
func (s ScenePartial) UpdateInput() SceneUpdateInput {
|
||||
boolPtrCopy := func(v *bool) *bool {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
vv := *v
|
||||
return &vv
|
||||
}
|
||||
|
||||
return SceneUpdateInput{
|
||||
ID: strconv.Itoa(s.ID),
|
||||
Title: nullStringPtrToStringPtr(s.Title),
|
||||
Details: nullStringPtrToStringPtr(s.Details),
|
||||
URL: nullStringPtrToStringPtr(s.URL),
|
||||
Date: s.Date.StringPtr(),
|
||||
Rating: nullInt64PtrToIntPtr(s.Rating),
|
||||
Organized: boolPtrCopy(s.Organized),
|
||||
StudioID: nullInt64PtrToStringPtr(s.StudioID),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ScenePartial) SetFile(f File) {
|
||||
path := f.Path
|
||||
s.Path = &path
|
||||
|
|
|
|||
80
pkg/models/model_scene_test.go
Normal file
80
pkg/models/model_scene_test.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScenePartial_UpdateInput(t *testing.T) {
|
||||
const (
|
||||
id = 1
|
||||
idStr = "1"
|
||||
)
|
||||
|
||||
var (
|
||||
title = "title"
|
||||
details = "details"
|
||||
url = "url"
|
||||
date = "2001-02-03"
|
||||
rating = 4
|
||||
organized = true
|
||||
studioID = 2
|
||||
studioIDStr = "2"
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
s ScenePartial
|
||||
want SceneUpdateInput
|
||||
}{
|
||||
{
|
||||
"full",
|
||||
ScenePartial{
|
||||
ID: id,
|
||||
Title: NullStringPtr(title),
|
||||
Details: NullStringPtr(details),
|
||||
URL: NullStringPtr(url),
|
||||
Date: &SQLiteDate{
|
||||
String: date,
|
||||
Valid: true,
|
||||
},
|
||||
Rating: &sql.NullInt64{
|
||||
Int64: int64(rating),
|
||||
Valid: true,
|
||||
},
|
||||
Organized: &organized,
|
||||
StudioID: &sql.NullInt64{
|
||||
Int64: int64(studioID),
|
||||
Valid: true,
|
||||
},
|
||||
},
|
||||
SceneUpdateInput{
|
||||
ID: idStr,
|
||||
Title: &title,
|
||||
Details: &details,
|
||||
URL: &url,
|
||||
Date: &date,
|
||||
Rating: &rating,
|
||||
Organized: &organized,
|
||||
StudioID: &studioIDStr,
|
||||
},
|
||||
},
|
||||
{
|
||||
"empty",
|
||||
ScenePartial{
|
||||
ID: id,
|
||||
},
|
||||
SceneUpdateInput{
|
||||
ID: idStr,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.s.UpdateInput(); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("ScenePartial.UpdateInput() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
package models
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrScraperSource = errors.New("invalid ScraperSource")
|
||||
|
||||
type ScrapedItemReader interface {
|
||||
All() ([]*ScrapedItem, error)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package models
|
||||
|
||||
import "database/sql"
|
||||
import (
|
||||
"database/sql"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
func NullString(v string) sql.NullString {
|
||||
return sql.NullString{
|
||||
|
|
@ -9,9 +12,43 @@ func NullString(v string) sql.NullString {
|
|||
}
|
||||
}
|
||||
|
||||
func NullStringPtr(v string) *sql.NullString {
|
||||
return &sql.NullString{
|
||||
String: v,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
func NullInt64(v int64) sql.NullInt64 {
|
||||
return sql.NullInt64{
|
||||
Int64: v,
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
|
||||
func nullStringPtrToStringPtr(v *sql.NullString) *string {
|
||||
if v == nil || !v.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
vv := v.String
|
||||
return &vv
|
||||
}
|
||||
|
||||
func nullInt64PtrToIntPtr(v *sql.NullInt64) *int {
|
||||
if v == nil || !v.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
vv := int(v.Int64)
|
||||
return &vv
|
||||
}
|
||||
|
||||
func nullInt64PtrToStringPtr(v *sql.NullInt64) *string {
|
||||
if v == nil || !v.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
vv := strconv.FormatInt(v.Int64, 10)
|
||||
return &vv
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,3 +44,12 @@ func (t SQLiteDate) Value() (driver.Value, error) {
|
|||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (t *SQLiteDate) StringPtr() *string {
|
||||
if t == nil || !t.Valid {
|
||||
return nil
|
||||
}
|
||||
|
||||
vv := t.String
|
||||
return &vv
|
||||
}
|
||||
|
|
|
|||
39
pkg/models/stash_box.go
Normal file
39
pkg/models/stash_box.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package models
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type StashBoxes []*StashBox
|
||||
|
||||
func (sb StashBoxes) ResolveStashBox(source ScraperSourceInput) (*StashBox, error) {
|
||||
if source.StashBoxIndex != nil {
|
||||
index := source.StashBoxIndex
|
||||
if *index < 0 || *index >= len(sb) {
|
||||
return nil, fmt.Errorf("%w: invalid stash_box_index: %d", ErrScraperSource, index)
|
||||
}
|
||||
|
||||
return sb[*index], nil
|
||||
}
|
||||
|
||||
if source.StashBoxEndpoint != nil {
|
||||
var ret *StashBox
|
||||
endpoint := *source.StashBoxEndpoint
|
||||
for _, b := range sb {
|
||||
if strings.EqualFold(endpoint, b.Endpoint) {
|
||||
ret = b
|
||||
}
|
||||
}
|
||||
|
||||
if ret == nil {
|
||||
return nil, fmt.Errorf(`%w: stash-box with endpoint "%s"`, ErrNotFound, endpoint)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// neither stash-box inputs were provided, so assume it is a scraper
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
|
@ -13,6 +13,7 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/manager/config"
|
||||
|
|
@ -179,6 +180,15 @@ func (c Cache) ExecutePostHooks(ctx context.Context, id int, hookType HookTrigge
|
|||
}
|
||||
}
|
||||
|
||||
func (c Cache) ExecuteSceneUpdatePostHooks(ctx context.Context, input models.SceneUpdateInput, inputFields []string) {
|
||||
id, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
logger.Errorf("error converting id in SceneUpdatePostHooks: %v", err)
|
||||
return
|
||||
}
|
||||
c.ExecutePostHooks(ctx, id, SceneUpdatePost, input, inputFields)
|
||||
}
|
||||
|
||||
func (c Cache) executePostHooks(ctx context.Context, hookType HookTriggerEnum, hookContext common.HookContext) error {
|
||||
visitedPlugins := session.GetVisitedPlugins(ctx)
|
||||
|
||||
|
|
|
|||
|
|
@ -85,7 +85,7 @@ var names = []string{
|
|||
|
||||
var imageBytes = []byte("imageBytes")
|
||||
|
||||
const image = "aW1hZ2VCeXRlcw=="
|
||||
const imageBase64 = "aW1hZ2VCeXRlcw=="
|
||||
|
||||
var (
|
||||
createTime = time.Date(2001, 01, 01, 0, 0, 0, 0, time.UTC)
|
||||
|
|
@ -198,7 +198,7 @@ type basicTestScenario struct {
|
|||
var scenarios = []basicTestScenario{
|
||||
{
|
||||
createFullScene(sceneID),
|
||||
createFullJSONScene(image),
|
||||
createFullJSONScene(imageBase64),
|
||||
false,
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ func TestImporterPreImport(t *testing.T) {
|
|||
err := i.PreImport()
|
||||
assert.NotNil(t, err)
|
||||
|
||||
i.Input.Cover = image
|
||||
i.Input.Cover = imageBase64
|
||||
|
||||
err = i.PreImport()
|
||||
assert.Nil(t, err)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
package scene
|
||||
|
||||
import "github.com/stashapp/stash/pkg/models"
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/job"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
type Queryer interface {
|
||||
Query(options models.SceneQueryOptions) (*models.SceneQueryResult, error)
|
||||
|
|
@ -48,3 +56,70 @@ func Query(qb Queryer, sceneFilter *models.SceneFilterType, findFilter *models.F
|
|||
|
||||
return scenes, nil
|
||||
}
|
||||
|
||||
func BatchProcess(ctx context.Context, reader models.SceneReader, sceneFilter *models.SceneFilterType, findFilter *models.FindFilterType, fn func(scene *models.Scene) error) error {
|
||||
const batchSize = 1000
|
||||
|
||||
if findFilter == nil {
|
||||
findFilter = &models.FindFilterType{}
|
||||
}
|
||||
|
||||
page := 1
|
||||
perPage := batchSize
|
||||
findFilter.Page = &page
|
||||
findFilter.PerPage = &perPage
|
||||
|
||||
for more := true; more; {
|
||||
if job.IsCancelled(ctx) {
|
||||
return nil
|
||||
}
|
||||
|
||||
scenes, err := Query(reader, sceneFilter, findFilter)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error querying for scenes: %w", err)
|
||||
}
|
||||
|
||||
for _, scene := range scenes {
|
||||
if err := fn(scene); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(scenes) != batchSize {
|
||||
more = false
|
||||
} else {
|
||||
*findFilter.Page++
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FilterFromPaths creates a SceneFilterType that filters using the provided
|
||||
// paths.
|
||||
func FilterFromPaths(paths []string) *models.SceneFilterType {
|
||||
ret := &models.SceneFilterType{}
|
||||
or := ret
|
||||
sep := string(filepath.Separator)
|
||||
|
||||
for _, p := range paths {
|
||||
if !strings.HasSuffix(p, sep) {
|
||||
p += sep
|
||||
}
|
||||
|
||||
if ret.Path == nil {
|
||||
or = ret
|
||||
} else {
|
||||
newOr := &models.SceneFilterType{}
|
||||
or.Or = newOr
|
||||
or = newOr
|
||||
}
|
||||
|
||||
or.Path = &models.StringCriterionInput{
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
Value: p + "%",
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,21 @@
|
|||
package scene
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"os"
|
||||
|
||||
"github.com/stashapp/stash/pkg/ffmpeg"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/manager/paths"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
|
||||
"github.com/disintegration/imaging"
|
||||
|
||||
// needed to decode other image formats
|
||||
_ "image/gif"
|
||||
_ "image/png"
|
||||
)
|
||||
|
||||
type screenshotter interface {
|
||||
|
|
@ -21,3 +34,64 @@ func makeScreenshot(encoder screenshotter, probeResult ffmpeg.VideoFile, outputP
|
|||
logger.Warnf("[encoder] failure to generate screenshot: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type ScreenshotSetter interface {
|
||||
SetScreenshot(scene *models.Scene, imageData []byte) error
|
||||
}
|
||||
|
||||
type PathsScreenshotSetter struct {
|
||||
Paths *paths.Paths
|
||||
FileNamingAlgorithm models.HashAlgorithm
|
||||
}
|
||||
|
||||
func (ss *PathsScreenshotSetter) SetScreenshot(scene *models.Scene, imageData []byte) error {
|
||||
checksum := scene.GetHash(ss.FileNamingAlgorithm)
|
||||
return SetScreenshot(ss.Paths, checksum, imageData)
|
||||
}
|
||||
|
||||
func writeImage(path string, imageData []byte) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
_, err = f.Write(imageData)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeThumbnail(path string, thumbnail image.Image) error {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
return jpeg.Encode(f, thumbnail, nil)
|
||||
}
|
||||
|
||||
func SetScreenshot(paths *paths.Paths, checksum string, imageData []byte) error {
|
||||
thumbPath := paths.Scene.GetThumbnailScreenshotPath(checksum)
|
||||
normalPath := paths.Scene.GetScreenshotPath(checksum)
|
||||
|
||||
img, _, err := image.Decode(bytes.NewReader(imageData))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// resize to 320 width maintaining aspect ratio, for the thumbnail
|
||||
const width = 320
|
||||
origWidth := img.Bounds().Max.X
|
||||
origHeight := img.Bounds().Max.Y
|
||||
height := width / origWidth * origHeight
|
||||
|
||||
thumbnail := imaging.Resize(img, width, height, imaging.Lanczos)
|
||||
err = writeThumbnail(thumbPath, thumbnail)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = writeImage(normalPath, imageData)
|
||||
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,11 +2,127 @@ package scene
|
|||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
var ErrEmptyUpdater = errors.New("no fields have been set")
|
||||
|
||||
// UpdateSet is used to update a scene and its relationships.
|
||||
type UpdateSet struct {
|
||||
ID int
|
||||
|
||||
Partial models.ScenePartial
|
||||
|
||||
// in future these could be moved into a separate struct and reused
|
||||
// for a Creator struct
|
||||
|
||||
// Not set if nil. Set to []int{} to clear existing
|
||||
PerformerIDs []int
|
||||
// Not set if nil. Set to []int{} to clear existing
|
||||
TagIDs []int
|
||||
// Not set if nil. Set to []int{} to clear existing
|
||||
StashIDs []models.StashID
|
||||
// Not set if nil. Set to []byte{} to clear existing
|
||||
CoverImage []byte
|
||||
}
|
||||
|
||||
// IsEmpty returns true if there is nothing to update.
|
||||
func (u *UpdateSet) IsEmpty() bool {
|
||||
withoutID := u.Partial
|
||||
withoutID.ID = 0
|
||||
|
||||
return withoutID == models.ScenePartial{} &&
|
||||
u.PerformerIDs == nil &&
|
||||
u.TagIDs == nil &&
|
||||
u.StashIDs == nil &&
|
||||
u.CoverImage == nil
|
||||
}
|
||||
|
||||
// Update updates a scene by updating the fields in the Partial field, then
|
||||
// updates non-nil relationships. Returns an error if there is no work to
|
||||
// be done.
|
||||
func (u *UpdateSet) Update(qb models.SceneWriter, screenshotSetter ScreenshotSetter) (*models.Scene, error) {
|
||||
if u.IsEmpty() {
|
||||
return nil, ErrEmptyUpdater
|
||||
}
|
||||
|
||||
partial := u.Partial
|
||||
partial.ID = u.ID
|
||||
partial.UpdatedAt = &models.SQLiteTimestamp{
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
|
||||
ret, err := qb.Update(partial)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error updating scene: %w", err)
|
||||
}
|
||||
|
||||
if u.PerformerIDs != nil {
|
||||
if err := qb.UpdatePerformers(u.ID, u.PerformerIDs); err != nil {
|
||||
return nil, fmt.Errorf("error updating scene performers: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if u.TagIDs != nil {
|
||||
if err := qb.UpdateTags(u.ID, u.TagIDs); err != nil {
|
||||
return nil, fmt.Errorf("error updating scene tags: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if u.StashIDs != nil {
|
||||
if err := qb.UpdateStashIDs(u.ID, u.StashIDs); err != nil {
|
||||
return nil, fmt.Errorf("error updating scene stash_ids: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if u.CoverImage != nil {
|
||||
if err := qb.UpdateCover(u.ID, u.CoverImage); err != nil {
|
||||
return nil, fmt.Errorf("error updating scene cover: %w", err)
|
||||
}
|
||||
|
||||
if err := screenshotSetter.SetScreenshot(ret, u.CoverImage); err != nil {
|
||||
return nil, fmt.Errorf("error setting scene screenshot: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// UpdateInput converts the UpdateSet into SceneUpdateInput for hook firing purposes.
|
||||
func (u UpdateSet) UpdateInput() models.SceneUpdateInput {
|
||||
// ensure the partial ID is set
|
||||
u.Partial.ID = u.ID
|
||||
ret := u.Partial.UpdateInput()
|
||||
|
||||
if u.PerformerIDs != nil {
|
||||
ret.PerformerIds = utils.IntSliceToStringSlice(u.PerformerIDs)
|
||||
}
|
||||
|
||||
if u.TagIDs != nil {
|
||||
ret.TagIds = utils.IntSliceToStringSlice(u.TagIDs)
|
||||
}
|
||||
|
||||
if u.StashIDs != nil {
|
||||
for _, s := range u.StashIDs {
|
||||
ss := s.StashIDInput()
|
||||
ret.StashIds = append(ret.StashIds, &ss)
|
||||
}
|
||||
}
|
||||
|
||||
if u.CoverImage != nil {
|
||||
// convert back to base64
|
||||
data := utils.GetBase64StringFromData(u.CoverImage)
|
||||
ret.CoverImage = &data
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
func UpdateFormat(qb models.SceneWriter, id int, format string) (*models.Scene, error) {
|
||||
return qb.Update(models.ScenePartial{
|
||||
ID: id,
|
||||
|
|
|
|||
337
pkg/scene/update_test.go
Normal file
337
pkg/scene/update_test.go
Normal file
|
|
@ -0,0 +1,337 @@
|
|||
package scene
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/mocks"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func TestUpdater_IsEmpty(t *testing.T) {
|
||||
organized := true
|
||||
ids := []int{1}
|
||||
stashIDs := []models.StashID{
|
||||
{},
|
||||
}
|
||||
cover := []byte{1}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
u *UpdateSet
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
"empty",
|
||||
&UpdateSet{},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"id only",
|
||||
&UpdateSet{
|
||||
Partial: models.ScenePartial{
|
||||
ID: 1,
|
||||
},
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"partial set",
|
||||
&UpdateSet{
|
||||
Partial: models.ScenePartial{
|
||||
Organized: &organized,
|
||||
},
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"performer set",
|
||||
&UpdateSet{
|
||||
PerformerIDs: ids,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"tags set",
|
||||
&UpdateSet{
|
||||
TagIDs: ids,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"performer set",
|
||||
&UpdateSet{
|
||||
StashIDs: stashIDs,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"cover set",
|
||||
&UpdateSet{
|
||||
CoverImage: cover,
|
||||
},
|
||||
false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.u.IsEmpty(); got != tt.want {
|
||||
t.Errorf("Updater.IsEmpty() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type mockScreenshotSetter struct{}
|
||||
|
||||
func (s *mockScreenshotSetter) SetScreenshot(scene *models.Scene, imageData []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUpdater_Update(t *testing.T) {
|
||||
const (
|
||||
sceneID = iota + 1
|
||||
badUpdateID
|
||||
badPerformersID
|
||||
badTagsID
|
||||
badStashIDsID
|
||||
badCoverID
|
||||
performerID
|
||||
tagID
|
||||
)
|
||||
|
||||
performerIDs := []int{performerID}
|
||||
tagIDs := []int{tagID}
|
||||
stashID := "stashID"
|
||||
endpoint := "endpoint"
|
||||
stashIDs := []models.StashID{
|
||||
{
|
||||
StashID: stashID,
|
||||
Endpoint: endpoint,
|
||||
},
|
||||
}
|
||||
|
||||
title := "title"
|
||||
cover := []byte("cover")
|
||||
|
||||
validScene := &models.Scene{}
|
||||
|
||||
updateErr := errors.New("error updating")
|
||||
|
||||
qb := mocks.SceneReaderWriter{}
|
||||
qb.On("Update", mock.MatchedBy(func(s models.ScenePartial) bool {
|
||||
return s.ID != badUpdateID
|
||||
})).Return(validScene, nil)
|
||||
qb.On("Update", mock.MatchedBy(func(s models.ScenePartial) bool {
|
||||
return s.ID == badUpdateID
|
||||
})).Return(nil, updateErr)
|
||||
|
||||
qb.On("UpdatePerformers", sceneID, performerIDs).Return(nil).Once()
|
||||
qb.On("UpdateTags", sceneID, tagIDs).Return(nil).Once()
|
||||
qb.On("UpdateStashIDs", sceneID, stashIDs).Return(nil).Once()
|
||||
qb.On("UpdateCover", sceneID, cover).Return(nil).Once()
|
||||
|
||||
qb.On("UpdatePerformers", badPerformersID, performerIDs).Return(updateErr).Once()
|
||||
qb.On("UpdateTags", badTagsID, tagIDs).Return(updateErr).Once()
|
||||
qb.On("UpdateStashIDs", badStashIDsID, stashIDs).Return(updateErr).Once()
|
||||
qb.On("UpdateCover", badCoverID, cover).Return(updateErr).Once()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
u *UpdateSet
|
||||
wantNil bool
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"empty",
|
||||
&UpdateSet{
|
||||
ID: sceneID,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"update all",
|
||||
&UpdateSet{
|
||||
ID: sceneID,
|
||||
PerformerIDs: performerIDs,
|
||||
TagIDs: tagIDs,
|
||||
StashIDs: []models.StashID{
|
||||
{
|
||||
StashID: stashID,
|
||||
Endpoint: endpoint,
|
||||
},
|
||||
},
|
||||
CoverImage: cover,
|
||||
},
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"update fields only",
|
||||
&UpdateSet{
|
||||
ID: sceneID,
|
||||
Partial: models.ScenePartial{
|
||||
Title: models.NullStringPtr(title),
|
||||
},
|
||||
},
|
||||
false,
|
||||
false,
|
||||
},
|
||||
{
|
||||
"error updating scene",
|
||||
&UpdateSet{
|
||||
ID: badUpdateID,
|
||||
Partial: models.ScenePartial{
|
||||
Title: models.NullStringPtr(title),
|
||||
},
|
||||
},
|
||||
true,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"error updating performers",
|
||||
&UpdateSet{
|
||||
ID: badPerformersID,
|
||||
PerformerIDs: performerIDs,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"error updating tags",
|
||||
&UpdateSet{
|
||||
ID: badTagsID,
|
||||
TagIDs: tagIDs,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"error updating stash IDs",
|
||||
&UpdateSet{
|
||||
ID: badStashIDsID,
|
||||
StashIDs: stashIDs,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"error updating cover",
|
||||
&UpdateSet{
|
||||
ID: badCoverID,
|
||||
CoverImage: cover,
|
||||
},
|
||||
true,
|
||||
true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := tt.u.Update(&qb, &mockScreenshotSetter{})
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Updater.Update() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if (got == nil) != tt.wantNil {
|
||||
t.Errorf("Updater.Update() = %v, want %v", got, tt.wantNil)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
qb.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestUpdateSet_UpdateInput(t *testing.T) {
|
||||
const (
|
||||
sceneID = iota + 1
|
||||
badUpdateID
|
||||
badPerformersID
|
||||
badTagsID
|
||||
badStashIDsID
|
||||
badCoverID
|
||||
performerID
|
||||
tagID
|
||||
)
|
||||
|
||||
sceneIDStr := strconv.Itoa(sceneID)
|
||||
|
||||
performerIDs := []int{performerID}
|
||||
performerIDStrs := utils.IntSliceToStringSlice(performerIDs)
|
||||
tagIDs := []int{tagID}
|
||||
tagIDStrs := utils.IntSliceToStringSlice(tagIDs)
|
||||
stashID := "stashID"
|
||||
endpoint := "endpoint"
|
||||
stashIDs := []models.StashID{
|
||||
{
|
||||
StashID: stashID,
|
||||
Endpoint: endpoint,
|
||||
},
|
||||
}
|
||||
stashIDInputs := []*models.StashIDInput{
|
||||
{
|
||||
StashID: stashID,
|
||||
Endpoint: endpoint,
|
||||
},
|
||||
}
|
||||
|
||||
title := "title"
|
||||
cover := []byte("cover")
|
||||
coverB64 := "Y292ZXI="
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
u UpdateSet
|
||||
want models.SceneUpdateInput
|
||||
}{
|
||||
{
|
||||
"empty",
|
||||
UpdateSet{
|
||||
ID: sceneID,
|
||||
},
|
||||
models.SceneUpdateInput{
|
||||
ID: sceneIDStr,
|
||||
},
|
||||
},
|
||||
{
|
||||
"update all",
|
||||
UpdateSet{
|
||||
ID: sceneID,
|
||||
PerformerIDs: performerIDs,
|
||||
TagIDs: tagIDs,
|
||||
StashIDs: stashIDs,
|
||||
CoverImage: cover,
|
||||
},
|
||||
models.SceneUpdateInput{
|
||||
ID: sceneIDStr,
|
||||
PerformerIds: performerIDStrs,
|
||||
TagIds: tagIDStrs,
|
||||
StashIds: stashIDInputs,
|
||||
CoverImage: &coverB64,
|
||||
},
|
||||
},
|
||||
{
|
||||
"update fields only",
|
||||
UpdateSet{
|
||||
ID: sceneID,
|
||||
Partial: models.ScenePartial{
|
||||
Title: models.NullStringPtr(title),
|
||||
},
|
||||
},
|
||||
models.SceneUpdateInput{
|
||||
ID: sceneIDStr,
|
||||
Title: &title,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := tt.u.UpdateInput()
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -212,6 +212,16 @@ func (c Cache) ListMovieScrapers() []*models.Scraper {
|
|||
return ret
|
||||
}
|
||||
|
||||
// GetScraper returns the scraper matching the provided id.
|
||||
func (c Cache) GetScraper(scraperID string) *models.Scraper {
|
||||
ret := c.findScraper(scraperID)
|
||||
if ret != nil {
|
||||
return ret.Spec
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Cache) findScraper(scraperID string) *scraper {
|
||||
for _, s := range c.scrapers {
|
||||
if s.ID == scraperID {
|
||||
|
|
|
|||
60
pkg/utils/collections.go
Normal file
60
pkg/utils/collections.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package utils
|
||||
|
||||
import "reflect"
|
||||
|
||||
// SliceSame returns true if the two provided lists have the same elements,
|
||||
// regardless of order. Panics if either parameter is not a slice.
|
||||
func SliceSame(a, b interface{}) bool {
|
||||
v1 := reflect.ValueOf(a)
|
||||
v2 := reflect.ValueOf(b)
|
||||
|
||||
if (v1.IsValid() && v1.Kind() != reflect.Slice) || (v2.IsValid() && v2.Kind() != reflect.Slice) {
|
||||
panic("not a slice")
|
||||
}
|
||||
|
||||
v1Len := 0
|
||||
v2Len := 0
|
||||
|
||||
v1Valid := v1.IsValid()
|
||||
v2Valid := v2.IsValid()
|
||||
|
||||
if v1Valid {
|
||||
v1Len = v1.Len()
|
||||
}
|
||||
if v2Valid {
|
||||
v2Len = v2.Len()
|
||||
}
|
||||
|
||||
if !v1Valid || !v2Valid {
|
||||
return v1Len == v2Len
|
||||
}
|
||||
|
||||
if v1Len != v2Len {
|
||||
return false
|
||||
}
|
||||
|
||||
if v1.Type() != v2.Type() {
|
||||
return false
|
||||
}
|
||||
|
||||
visited := make(map[int]bool)
|
||||
for i := 0; i < v1.Len(); i++ {
|
||||
found := false
|
||||
for j := 0; j < v2.Len(); j++ {
|
||||
if visited[j] {
|
||||
continue
|
||||
}
|
||||
if reflect.DeepEqual(v1.Index(i).Interface(), v2.Index(j).Interface()) {
|
||||
found = true
|
||||
visited[j] = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
92
pkg/utils/collections_test.go
Normal file
92
pkg/utils/collections_test.go
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
package utils
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSliceSame(t *testing.T) {
|
||||
objs := []struct {
|
||||
a string
|
||||
b int
|
||||
}{
|
||||
{"1", 2},
|
||||
{"1", 2},
|
||||
{"2", 1},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
a interface{}
|
||||
b interface{}
|
||||
want bool
|
||||
}{
|
||||
{"nil values", nil, nil, true},
|
||||
{"empty", []int{}, []int{}, true},
|
||||
{"nil and empty", nil, []int{}, true},
|
||||
{
|
||||
"different type",
|
||||
[]string{"1"},
|
||||
[]int{1},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"different length",
|
||||
[]int{1, 2, 3},
|
||||
[]int{1, 2},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"equal",
|
||||
[]int{1, 2, 3, 4, 5},
|
||||
[]int{1, 2, 3, 4, 5},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"different order",
|
||||
[]int{5, 4, 3, 2, 1},
|
||||
[]int{1, 2, 3, 4, 5},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"different",
|
||||
[]int{5, 4, 3, 2, 6},
|
||||
[]int{1, 2, 3, 4, 5},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"same with duplicates",
|
||||
[]int{1, 1, 2, 3, 4},
|
||||
[]int{1, 2, 3, 4, 1},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"subset",
|
||||
[]int{1, 1, 2, 2, 3},
|
||||
[]int{1, 2, 3, 4, 5},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"superset",
|
||||
[]int{1, 2, 3, 4, 5},
|
||||
[]int{1, 1, 2, 2, 3},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"structs equal",
|
||||
objs[0:1],
|
||||
objs[0:1],
|
||||
true,
|
||||
},
|
||||
{
|
||||
"structs not equal",
|
||||
objs[0:2],
|
||||
objs[1:3],
|
||||
false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := SliceSame(tt.a, tt.b); got != tt.want {
|
||||
t.Errorf("SliceSame() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
package utils
|
||||
|
||||
import "strconv"
|
||||
|
||||
// IntIndex returns the first index of the provided int value in the provided
|
||||
// int slice. It returns -1 if it is not found.
|
||||
func IntIndex(vs []int, t int) int {
|
||||
|
|
@ -50,3 +52,13 @@ func IntExclude(vs []int, toExclude []int) []int {
|
|||
|
||||
return ret
|
||||
}
|
||||
|
||||
// IntSliceToStringSlice converts a slice of ints to a slice of strings.
|
||||
func IntSliceToStringSlice(ss []int) []string {
|
||||
ret := make([]string, len(ss))
|
||||
for i, v := range ss {
|
||||
ret[i] = strconv.Itoa(v)
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
|
|
|||
30
pkg/utils/reflect.go
Normal file
30
pkg/utils/reflect.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package utils
|
||||
|
||||
import "reflect"
|
||||
|
||||
// NotNilFields returns the matching tag values of fields from an object that are not nil.
|
||||
// Panics if the provided object is not a struct.
|
||||
func NotNilFields(subject interface{}, tag string) []string {
|
||||
value := reflect.ValueOf(subject)
|
||||
structType := value.Type()
|
||||
|
||||
if structType.Kind() != reflect.Struct {
|
||||
panic("subject must be struct")
|
||||
}
|
||||
|
||||
var ret []string
|
||||
|
||||
for i := 0; i < value.NumField(); i++ {
|
||||
field := value.Field(i)
|
||||
|
||||
kind := field.Type().Kind()
|
||||
if (kind == reflect.Ptr || kind == reflect.Slice) && !field.IsNil() {
|
||||
tagValue := structType.Field(i).Tag.Get(tag)
|
||||
if tagValue != "" {
|
||||
ret = append(ret, tagValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
83
pkg/utils/reflect_test.go
Normal file
83
pkg/utils/reflect_test.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package utils
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNotNilFields(t *testing.T) {
|
||||
v := "value"
|
||||
var zeroStr string
|
||||
|
||||
type testObject struct {
|
||||
ptrField *string `tag:"ptrField"`
|
||||
noTagField *string
|
||||
otherTagField *string `otherTag:"otherTagField"`
|
||||
sliceField []string `tag:"sliceField"`
|
||||
}
|
||||
|
||||
type args struct {
|
||||
subject interface{}
|
||||
tag string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
"basic",
|
||||
args{
|
||||
testObject{
|
||||
ptrField: &v,
|
||||
noTagField: &v,
|
||||
otherTagField: &v,
|
||||
sliceField: []string{v},
|
||||
},
|
||||
"tag",
|
||||
},
|
||||
[]string{"ptrField", "sliceField"},
|
||||
},
|
||||
{
|
||||
"empty",
|
||||
args{
|
||||
testObject{},
|
||||
"tag",
|
||||
},
|
||||
nil,
|
||||
},
|
||||
{
|
||||
"zero values",
|
||||
args{
|
||||
testObject{
|
||||
ptrField: &zeroStr,
|
||||
noTagField: &zeroStr,
|
||||
otherTagField: &zeroStr,
|
||||
sliceField: []string{},
|
||||
},
|
||||
"tag",
|
||||
},
|
||||
[]string{"ptrField", "sliceField"},
|
||||
},
|
||||
{
|
||||
"other tag",
|
||||
args{
|
||||
testObject{
|
||||
ptrField: &v,
|
||||
noTagField: &v,
|
||||
otherTagField: &v,
|
||||
sliceField: []string{v},
|
||||
},
|
||||
"otherTag",
|
||||
},
|
||||
[]string{"otherTagField"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := NotNilFields(tt.args.subject, tt.args.tag); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("NotNilFields() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
### ✨ New Features
|
||||
* Added Identify task to automatically identify scenes from stash-box/scraper sources. See manual entry for details. ([#1839](https://github.com/stashapp/stash/pull/1839))
|
||||
* Added support for matching scenes using perceptual hashes when querying stash-box. ([#1858](https://github.com/stashapp/stash/pull/1858))
|
||||
* Generalised Tagger view to support tagging using supported scene scrapers. ([#1812](https://github.com/stashapp/stash/pull/1812))
|
||||
* Added built-in `Auto Tag` scene scraper to match performers, studio and tags from filename - using AutoTag logic. ([#1817](https://github.com/stashapp/stash/pull/1817))
|
||||
|
|
|
|||
338
ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx
Normal file
338
ui/v2.5/src/components/Dialogs/IdentifyDialog/FieldOptions.tsx
Normal file
|
|
@ -0,0 +1,338 @@
|
|||
import React, { useState, useEffect, useCallback } from "react";
|
||||
import { Form, Button, Table } from "react-bootstrap";
|
||||
import { Icon } from "src/components/Shared";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { multiValueSceneFields, SceneField, sceneFields } from "./constants";
|
||||
import { ThreeStateBoolean } from "./ThreeStateBoolean";
|
||||
|
||||
interface IFieldOptionsEditor {
|
||||
options: GQL.IdentifyFieldOptions | undefined;
|
||||
field: string;
|
||||
editField: () => void;
|
||||
editOptions: (o?: GQL.IdentifyFieldOptions | null) => void;
|
||||
editing: boolean;
|
||||
allowSetDefault: boolean;
|
||||
defaultOptions?: GQL.IdentifyMetadataOptionsInput;
|
||||
}
|
||||
|
||||
interface IFieldOptions {
|
||||
field: string;
|
||||
strategy: GQL.IdentifyFieldStrategy | undefined;
|
||||
createMissing?: GQL.Maybe<boolean> | undefined;
|
||||
}
|
||||
|
||||
const FieldOptionsEditor: React.FC<IFieldOptionsEditor> = ({
|
||||
options,
|
||||
field,
|
||||
editField,
|
||||
editOptions,
|
||||
editing,
|
||||
allowSetDefault,
|
||||
defaultOptions,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [localOptions, setLocalOptions] = useState<IFieldOptions>();
|
||||
|
||||
const resetOptions = useCallback(() => {
|
||||
let toSet: IFieldOptions;
|
||||
if (!options) {
|
||||
// unset - use default values
|
||||
toSet = {
|
||||
field,
|
||||
strategy: undefined,
|
||||
createMissing: undefined,
|
||||
};
|
||||
} else {
|
||||
toSet = {
|
||||
field,
|
||||
strategy: options.strategy,
|
||||
createMissing: options.createMissing,
|
||||
};
|
||||
}
|
||||
setLocalOptions(toSet);
|
||||
}, [options, field]);
|
||||
|
||||
useEffect(() => {
|
||||
resetOptions();
|
||||
}, [resetOptions]);
|
||||
|
||||
function renderField() {
|
||||
return intl.formatMessage({ id: field });
|
||||
}
|
||||
|
||||
function renderStrategy() {
|
||||
if (!localOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
const strategies = Object.entries(GQL.IdentifyFieldStrategy);
|
||||
let { strategy } = localOptions;
|
||||
if (strategy === undefined) {
|
||||
if (!allowSetDefault) {
|
||||
strategy = GQL.IdentifyFieldStrategy.Merge;
|
||||
}
|
||||
}
|
||||
|
||||
if (!editing) {
|
||||
if (strategy === undefined) {
|
||||
return intl.formatMessage({ id: "use_default" });
|
||||
}
|
||||
|
||||
const f = strategies.find((s) => s[1] === strategy);
|
||||
return intl.formatMessage({
|
||||
id: `config.tasks.identify.field_strategies.${f![0].toLowerCase()}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (!localOptions) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group>
|
||||
{allowSetDefault ? (
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id={`${field}-strategy-default`}
|
||||
checked={localOptions.strategy === undefined}
|
||||
onChange={() =>
|
||||
setLocalOptions({
|
||||
...localOptions,
|
||||
strategy: undefined,
|
||||
})
|
||||
}
|
||||
disabled={!editing}
|
||||
label={intl.formatMessage({ id: "use_default" })}
|
||||
/>
|
||||
) : undefined}
|
||||
{strategies.map((f) => (
|
||||
<Form.Check
|
||||
type="radio"
|
||||
key={f[0]}
|
||||
id={`${field}-strategy-${f[0]}`}
|
||||
checked={localOptions.strategy === f[1]}
|
||||
onChange={() =>
|
||||
setLocalOptions({
|
||||
...localOptions,
|
||||
strategy: f[1],
|
||||
})
|
||||
}
|
||||
disabled={!editing}
|
||||
label={intl.formatMessage({
|
||||
id: `config.tasks.identify.field_strategies.${f[0].toLowerCase()}`,
|
||||
})}
|
||||
/>
|
||||
))}
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderCreateMissing() {
|
||||
if (!localOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
multiValueSceneFields.includes(localOptions.field as SceneField) &&
|
||||
localOptions.strategy !== GQL.IdentifyFieldStrategy.Ignore
|
||||
) {
|
||||
const value =
|
||||
localOptions.createMissing === null
|
||||
? undefined
|
||||
: localOptions.createMissing;
|
||||
|
||||
if (!editing) {
|
||||
if (value === undefined && allowSetDefault) {
|
||||
return intl.formatMessage({ id: "use_default" });
|
||||
}
|
||||
if (value) {
|
||||
return <Icon icon="check" className="text-success" />;
|
||||
}
|
||||
|
||||
return <Icon icon="times" className="text-danger" />;
|
||||
}
|
||||
|
||||
const defaultVal = defaultOptions?.fieldOptions?.find(
|
||||
(f) => f.field === localOptions.field
|
||||
)?.createMissing;
|
||||
|
||||
if (localOptions.strategy === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThreeStateBoolean
|
||||
id="create-missing"
|
||||
disabled={!editing}
|
||||
allowUndefined={allowSetDefault}
|
||||
value={value}
|
||||
setValue={(v) =>
|
||||
setLocalOptions({ ...localOptions, createMissing: v })
|
||||
}
|
||||
defaultValue={defaultVal ?? undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function onEditOptions() {
|
||||
if (!localOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
// send null if strategy is undefined
|
||||
if (localOptions.strategy === undefined) {
|
||||
editOptions(null);
|
||||
resetOptions();
|
||||
} else {
|
||||
let { createMissing } = localOptions;
|
||||
if (createMissing === undefined && !allowSetDefault) {
|
||||
createMissing = false;
|
||||
}
|
||||
|
||||
editOptions({
|
||||
...localOptions,
|
||||
strategy: localOptions.strategy,
|
||||
createMissing,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<tr>
|
||||
<td>{renderField()}</td>
|
||||
<td>{renderStrategy()}</td>
|
||||
<td>{maybeRenderCreateMissing()}</td>
|
||||
<td className="text-right">
|
||||
{editing ? (
|
||||
<>
|
||||
<Button
|
||||
className="minimal text-success"
|
||||
onClick={() => onEditOptions()}
|
||||
>
|
||||
<Icon icon="check" />
|
||||
</Button>
|
||||
<Button
|
||||
className="minimal text-danger"
|
||||
onClick={() => {
|
||||
editOptions();
|
||||
resetOptions();
|
||||
}}
|
||||
>
|
||||
<Icon icon="times" />
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button className="minimal" onClick={() => editField()}>
|
||||
<Icon icon="pencil-alt" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
interface IFieldOptionsList {
|
||||
fieldOptions?: GQL.IdentifyFieldOptions[];
|
||||
setFieldOptions: (o: GQL.IdentifyFieldOptions[]) => void;
|
||||
setEditingField: (v: boolean) => void;
|
||||
allowSetDefault?: boolean;
|
||||
defaultOptions?: GQL.IdentifyMetadataOptionsInput;
|
||||
}
|
||||
|
||||
export const FieldOptionsList: React.FC<IFieldOptionsList> = ({
|
||||
fieldOptions,
|
||||
setFieldOptions,
|
||||
setEditingField,
|
||||
allowSetDefault = true,
|
||||
defaultOptions,
|
||||
}) => {
|
||||
const [localFieldOptions, setLocalFieldOptions] = useState<
|
||||
GQL.IdentifyFieldOptions[]
|
||||
>();
|
||||
const [editField, setEditField] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (fieldOptions) {
|
||||
setLocalFieldOptions([...fieldOptions]);
|
||||
} else {
|
||||
setLocalFieldOptions([]);
|
||||
}
|
||||
}, [fieldOptions]);
|
||||
|
||||
function handleEditOptions(o?: GQL.IdentifyFieldOptions | null) {
|
||||
if (!localFieldOptions) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (o !== undefined) {
|
||||
const newOptions = [...localFieldOptions];
|
||||
const index = newOptions.findIndex(
|
||||
(option) => option.field === editField
|
||||
);
|
||||
if (index !== -1) {
|
||||
// if null, then we're removing
|
||||
if (o === null) {
|
||||
newOptions.splice(index, 1);
|
||||
} else {
|
||||
// replace in list
|
||||
newOptions.splice(index, 1, o);
|
||||
}
|
||||
} else if (o !== null) {
|
||||
// don't add if null
|
||||
newOptions.push(o);
|
||||
}
|
||||
|
||||
setFieldOptions(newOptions);
|
||||
}
|
||||
|
||||
setEditField(undefined);
|
||||
setEditingField(false);
|
||||
}
|
||||
|
||||
function onEditField(field: string) {
|
||||
setEditField(field);
|
||||
setEditingField(true);
|
||||
}
|
||||
|
||||
if (!localFieldOptions) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group className="scraper-sources">
|
||||
<h5>
|
||||
<FormattedMessage id="config.tasks.identify.field_options" />
|
||||
</h5>
|
||||
<Table responsive className="field-options-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-25">Field</th>
|
||||
<th className="w-25">Strategy</th>
|
||||
<th className="w-25">Create missing</th>
|
||||
{/* eslint-disable-next-line jsx-a11y/control-has-associated-label */}
|
||||
<th className="w-25" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sceneFields.map((f) => (
|
||||
<FieldOptionsEditor
|
||||
key={f}
|
||||
field={f}
|
||||
allowSetDefault={allowSetDefault}
|
||||
options={localFieldOptions.find((o) => o.field === f)}
|
||||
editField={() => onEditField(f)}
|
||||
editOptions={handleEditOptions}
|
||||
editing={f === editField}
|
||||
defaultOptions={defaultOptions}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</Table>
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
451
ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx
Normal file
451
ui/v2.5/src/components/Dialogs/IdentifyDialog/IdentifyDialog.tsx
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
import React, { useState, useEffect, useMemo } from "react";
|
||||
import { Button, Form, Spinner } from "react-bootstrap";
|
||||
import {
|
||||
mutateMetadataIdentify,
|
||||
useConfiguration,
|
||||
useConfigureDefaults,
|
||||
useListSceneScrapers,
|
||||
} from "src/core/StashService";
|
||||
import { Icon, Modal } from "src/components/Shared";
|
||||
import { useToast } from "src/hooks";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { withoutTypename } from "src/utils";
|
||||
import {
|
||||
SCRAPER_PREFIX,
|
||||
STASH_BOX_PREFIX,
|
||||
} from "src/components/Tagger/constants";
|
||||
import { DirectorySelectionDialog } from "src/components/Settings/SettingsTasksPanel/DirectorySelectionDialog";
|
||||
import { Manual } from "src/components/Help/Manual";
|
||||
import { IScraperSource } from "./constants";
|
||||
import { OptionsEditor } from "./Options";
|
||||
import { SourcesEditor, SourcesList } from "./Sources";
|
||||
|
||||
const autoTagScraperID = "builtin_autotag";
|
||||
|
||||
interface IIdentifyDialogProps {
|
||||
selectedIds?: string[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const IdentifyDialog: React.FC<IIdentifyDialogProps> = ({
|
||||
selectedIds,
|
||||
onClose,
|
||||
}) => {
|
||||
function getDefaultOptions(): GQL.IdentifyMetadataOptionsInput {
|
||||
return {
|
||||
fieldOptions: [
|
||||
{
|
||||
field: "title",
|
||||
strategy: GQL.IdentifyFieldStrategy.Overwrite,
|
||||
},
|
||||
],
|
||||
includeMalePerformers: true,
|
||||
setCoverImage: true,
|
||||
setOrganized: false,
|
||||
};
|
||||
}
|
||||
|
||||
const [configureDefaults] = useConfigureDefaults();
|
||||
|
||||
const [options, setOptions] = useState<GQL.IdentifyMetadataOptionsInput>(
|
||||
getDefaultOptions()
|
||||
);
|
||||
const [sources, setSources] = useState<IScraperSource[]>([]);
|
||||
const [editingSource, setEditingSource] = useState<
|
||||
IScraperSource | undefined
|
||||
>();
|
||||
const [paths, setPaths] = useState<string[]>([]);
|
||||
const [showManual, setShowManual] = useState(false);
|
||||
const [settingPaths, setSettingPaths] = useState(false);
|
||||
const [animation, setAnimation] = useState(true);
|
||||
const [editingField, setEditingField] = useState(false);
|
||||
const [savingDefaults, setSavingDefaults] = useState(false);
|
||||
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
|
||||
const { data: configData, error: configError } = useConfiguration();
|
||||
const { data: scraperData, error: scraperError } = useListSceneScrapers();
|
||||
|
||||
const allSources = useMemo(() => {
|
||||
if (!configData || !scraperData) return;
|
||||
|
||||
const ret: IScraperSource[] = [];
|
||||
|
||||
ret.push(
|
||||
...configData.configuration.general.stashBoxes.map((b, i) => {
|
||||
return {
|
||||
id: `${STASH_BOX_PREFIX}${i}`,
|
||||
displayName: `stash-box: ${b.name}`,
|
||||
stash_box_endpoint: b.endpoint,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
const scrapers = scraperData.listSceneScrapers;
|
||||
|
||||
const fragmentScrapers = scrapers.filter((s) =>
|
||||
s.scene?.supported_scrapes.includes(GQL.ScrapeType.Fragment)
|
||||
);
|
||||
|
||||
ret.push(
|
||||
...fragmentScrapers.map((s) => {
|
||||
return {
|
||||
id: `${SCRAPER_PREFIX}${s.id}`,
|
||||
displayName: s.name,
|
||||
scraper_id: s.id,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return ret;
|
||||
}, [configData, scraperData]);
|
||||
|
||||
const selectionStatus = useMemo(() => {
|
||||
if (selectedIds) {
|
||||
return (
|
||||
<Form.Group id="selected-identify-ids">
|
||||
<FormattedMessage
|
||||
id="config.tasks.identify.identifying_scenes"
|
||||
values={{
|
||||
num: selectedIds.length,
|
||||
scene: intl.formatMessage(
|
||||
{
|
||||
id: "countables.scenes",
|
||||
},
|
||||
{
|
||||
count: selectedIds.length,
|
||||
}
|
||||
),
|
||||
}}
|
||||
/>
|
||||
.
|
||||
</Form.Group>
|
||||
);
|
||||
}
|
||||
const message = paths.length ? (
|
||||
<div>
|
||||
<FormattedMessage id="config.tasks.identify.identifying_from_paths" />:
|
||||
<ul>
|
||||
{paths.map((p) => (
|
||||
<li key={p}>{p}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<span>
|
||||
<FormattedMessage
|
||||
id="config.tasks.identify.identifying_scenes"
|
||||
values={{
|
||||
num: intl.formatMessage({ id: "all" }),
|
||||
scene: intl.formatMessage(
|
||||
{
|
||||
id: "countables.scenes",
|
||||
},
|
||||
{
|
||||
count: 0,
|
||||
}
|
||||
),
|
||||
}}
|
||||
/>
|
||||
.
|
||||
</span>
|
||||
);
|
||||
|
||||
function onClick() {
|
||||
setAnimation(false);
|
||||
setSettingPaths(true);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group id="selected-identify-folders">
|
||||
<div>
|
||||
{message}
|
||||
<div>
|
||||
<Button
|
||||
title={intl.formatMessage({ id: "actions.select_folders" })}
|
||||
onClick={() => onClick()}
|
||||
>
|
||||
<Icon icon="folder-open" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form.Group>
|
||||
);
|
||||
}, [selectedIds, intl, paths]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!configData || !allSources) return;
|
||||
|
||||
const { identify: identifyDefaults } = configData.configuration.defaults;
|
||||
|
||||
if (identifyDefaults) {
|
||||
const mappedSources = identifyDefaults.sources
|
||||
.map((s) => {
|
||||
const found = allSources.find(
|
||||
(ss) =>
|
||||
ss.scraper_id === s.source.scraper_id ||
|
||||
ss.stash_box_endpoint === s.source.stash_box_endpoint
|
||||
);
|
||||
|
||||
if (!found) return;
|
||||
|
||||
const ret: IScraperSource = {
|
||||
...found,
|
||||
};
|
||||
|
||||
if (s.options) {
|
||||
const sourceOptions = withoutTypename(s.options);
|
||||
sourceOptions.fieldOptions = sourceOptions.fieldOptions?.map(
|
||||
withoutTypename
|
||||
);
|
||||
ret.options = sourceOptions;
|
||||
}
|
||||
|
||||
return ret;
|
||||
})
|
||||
.filter((s) => s) as IScraperSource[];
|
||||
|
||||
setSources(mappedSources);
|
||||
if (identifyDefaults.options) {
|
||||
const defaultOptions = withoutTypename(identifyDefaults.options);
|
||||
defaultOptions.fieldOptions = defaultOptions.fieldOptions?.map(
|
||||
withoutTypename
|
||||
);
|
||||
setOptions(defaultOptions);
|
||||
}
|
||||
} else {
|
||||
// default to first stash-box instance only
|
||||
const stashBox = allSources.find((s) => s.stash_box_endpoint);
|
||||
|
||||
// add auto-tag as well
|
||||
const autoTag = allSources.find(
|
||||
(s) => s.id === `${SCRAPER_PREFIX}${autoTagScraperID}`
|
||||
);
|
||||
|
||||
const newSources: IScraperSource[] = [];
|
||||
if (stashBox) {
|
||||
newSources.push(stashBox);
|
||||
}
|
||||
|
||||
// sanity check - this should always be true
|
||||
if (autoTag) {
|
||||
// don't set organised by default
|
||||
const autoTagCopy = { ...autoTag };
|
||||
autoTagCopy.options = {
|
||||
setOrganized: false,
|
||||
};
|
||||
newSources.push(autoTagCopy);
|
||||
}
|
||||
|
||||
setSources(newSources);
|
||||
}
|
||||
}, [allSources, configData]);
|
||||
|
||||
if (configError || scraperError)
|
||||
return <div>{configError ?? scraperError}</div>;
|
||||
if (!allSources || !configData) return <div />;
|
||||
|
||||
function makeIdentifyInput(): GQL.IdentifyMetadataInput {
|
||||
return {
|
||||
sources: sources.map((s) => {
|
||||
return {
|
||||
source: {
|
||||
scraper_id: s.scraper_id,
|
||||
stash_box_endpoint: s.stash_box_endpoint,
|
||||
},
|
||||
options: s.options,
|
||||
};
|
||||
}),
|
||||
options,
|
||||
sceneIDs: selectedIds,
|
||||
paths,
|
||||
};
|
||||
}
|
||||
|
||||
function makeDefaultIdentifyInput() {
|
||||
const ret = makeIdentifyInput();
|
||||
const { sceneIDs, paths: _paths, ...withoutSpecifics } = ret;
|
||||
return withoutSpecifics;
|
||||
}
|
||||
|
||||
async function onIdentify() {
|
||||
try {
|
||||
await mutateMetadataIdentify(makeIdentifyInput());
|
||||
|
||||
Toast.success({
|
||||
content: intl.formatMessage(
|
||||
{ id: "config.tasks.added_job_to_queue" },
|
||||
{ operation_name: intl.formatMessage({ id: "actions.identify" }) }
|
||||
),
|
||||
});
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
function getAvailableSources() {
|
||||
// only include scrapers not already present
|
||||
return !editingSource?.id === undefined
|
||||
? []
|
||||
: allSources?.filter((s) => {
|
||||
return !sources.some((ss) => ss.id === s.id);
|
||||
}) ?? [];
|
||||
}
|
||||
|
||||
function onEditSource(s?: IScraperSource) {
|
||||
setAnimation(false);
|
||||
|
||||
// if undefined, then set a dummy source to create a new one
|
||||
if (!s) {
|
||||
setEditingSource(getAvailableSources()[0]);
|
||||
} else {
|
||||
setEditingSource(s);
|
||||
}
|
||||
}
|
||||
|
||||
function onShowManual() {
|
||||
setAnimation(false);
|
||||
setShowManual(true);
|
||||
}
|
||||
|
||||
function isNewSource() {
|
||||
return !!editingSource && !sources.includes(editingSource);
|
||||
}
|
||||
|
||||
function onSaveSource(s?: IScraperSource) {
|
||||
if (s) {
|
||||
let found = false;
|
||||
const newSources = sources.map((ss) => {
|
||||
if (ss.id === s.id) {
|
||||
found = true;
|
||||
return s;
|
||||
}
|
||||
return ss;
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
newSources.push(s);
|
||||
}
|
||||
|
||||
setSources(newSources);
|
||||
}
|
||||
setEditingSource(undefined);
|
||||
}
|
||||
|
||||
async function setAsDefault() {
|
||||
try {
|
||||
setSavingDefaults(true);
|
||||
await configureDefaults({
|
||||
variables: {
|
||||
input: {
|
||||
identify: makeDefaultIdentifyInput(),
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
} finally {
|
||||
setSavingDefaults(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (editingSource) {
|
||||
return (
|
||||
<SourcesEditor
|
||||
availableSources={getAvailableSources()}
|
||||
source={editingSource}
|
||||
saveSource={onSaveSource}
|
||||
isNew={isNewSource()}
|
||||
defaultOptions={options}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (settingPaths) {
|
||||
return (
|
||||
<DirectorySelectionDialog
|
||||
animation={false}
|
||||
allowEmpty
|
||||
initialPaths={paths}
|
||||
onClose={(p) => {
|
||||
if (p) {
|
||||
setPaths(p);
|
||||
}
|
||||
setSettingPaths(false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (showManual) {
|
||||
return (
|
||||
<Manual
|
||||
animation={false}
|
||||
show
|
||||
onClose={() => setShowManual(false)}
|
||||
defaultActiveTab="Identify.md"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
modalProps={{ animation, size: "lg" }}
|
||||
show
|
||||
icon="cogs"
|
||||
header={intl.formatMessage({ id: "actions.identify" })}
|
||||
accept={{
|
||||
onClick: onIdentify,
|
||||
text: intl.formatMessage({ id: "actions.identify" }),
|
||||
}}
|
||||
cancel={{
|
||||
onClick: () => onClose(),
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
variant: "secondary",
|
||||
}}
|
||||
disabled={editingField || savingDefaults || sources.length === 0}
|
||||
footerButtons={
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={editingField || savingDefaults}
|
||||
onClick={() => setAsDefault()}
|
||||
>
|
||||
{savingDefaults && (
|
||||
<Spinner animation="border" role="status" size="sm" />
|
||||
)}
|
||||
<FormattedMessage id="actions.set_as_default" />
|
||||
</Button>
|
||||
}
|
||||
leftFooterButtons={
|
||||
<Button
|
||||
title="Help"
|
||||
className="minimal help-button"
|
||||
onClick={() => onShowManual()}
|
||||
>
|
||||
<Icon icon="question-circle" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form>
|
||||
{selectionStatus}
|
||||
<SourcesList
|
||||
sources={sources}
|
||||
setSources={(s) => setSources(s)}
|
||||
editSource={onEditSource}
|
||||
canAdd={sources.length < allSources.length}
|
||||
/>
|
||||
<OptionsEditor
|
||||
options={options}
|
||||
setOptions={(o) => setOptions(o)}
|
||||
setEditingField={(v) => setEditingField(v)}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default IdentifyDialog;
|
||||
117
ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx
Normal file
117
ui/v2.5/src/components/Dialogs/IdentifyDialog/Options.tsx
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import React from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import { IScraperSource } from "./constants";
|
||||
import { FieldOptionsList } from "./FieldOptions";
|
||||
import { ThreeStateBoolean } from "./ThreeStateBoolean";
|
||||
|
||||
interface IOptionsEditor {
|
||||
options: GQL.IdentifyMetadataOptionsInput;
|
||||
setOptions: (s: GQL.IdentifyMetadataOptionsInput) => void;
|
||||
source?: IScraperSource;
|
||||
defaultOptions?: GQL.IdentifyMetadataOptionsInput;
|
||||
setEditingField: (v: boolean) => void;
|
||||
}
|
||||
|
||||
export const OptionsEditor: React.FC<IOptionsEditor> = ({
|
||||
options,
|
||||
setOptions: setOptionsState,
|
||||
source,
|
||||
setEditingField,
|
||||
defaultOptions,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
function setOptions(v: Partial<GQL.IdentifyMetadataOptionsInput>) {
|
||||
setOptionsState({ ...options, ...v });
|
||||
}
|
||||
|
||||
const headingID = !source
|
||||
? "config.tasks.identify.default_options"
|
||||
: "config.tasks.identify.source_options";
|
||||
const checkboxProps = {
|
||||
allowUndefined: !!source,
|
||||
indeterminateClassname: "text-muted",
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Group>
|
||||
<Form.Group>
|
||||
<h5>
|
||||
<FormattedMessage
|
||||
id={headingID}
|
||||
values={{ source: source?.displayName }}
|
||||
/>
|
||||
</h5>
|
||||
{!source && (
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({
|
||||
id: "config.tasks.identify.explicit_set_description",
|
||||
})}
|
||||
</Form.Text>
|
||||
)}
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<ThreeStateBoolean
|
||||
id="include-male-performers"
|
||||
value={
|
||||
options.includeMalePerformers === null
|
||||
? undefined
|
||||
: options.includeMalePerformers
|
||||
}
|
||||
setValue={(v) =>
|
||||
setOptions({
|
||||
includeMalePerformers: v,
|
||||
})
|
||||
}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.identify.include_male_performers",
|
||||
})}
|
||||
defaultValue={defaultOptions?.includeMalePerformers ?? undefined}
|
||||
{...checkboxProps}
|
||||
/>
|
||||
<ThreeStateBoolean
|
||||
id="set-cover-image"
|
||||
value={
|
||||
options.setCoverImage === null ? undefined : options.setCoverImage
|
||||
}
|
||||
setValue={(v) =>
|
||||
setOptions({
|
||||
setCoverImage: v,
|
||||
})
|
||||
}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.identify.set_cover_images",
|
||||
})}
|
||||
defaultValue={defaultOptions?.setCoverImage ?? undefined}
|
||||
{...checkboxProps}
|
||||
/>
|
||||
<ThreeStateBoolean
|
||||
id="set-organized"
|
||||
value={
|
||||
options.setOrganized === null ? undefined : options.setOrganized
|
||||
}
|
||||
setValue={(v) =>
|
||||
setOptions({
|
||||
setOrganized: v,
|
||||
})
|
||||
}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.identify.set_organized",
|
||||
})}
|
||||
defaultValue={defaultOptions?.setOrganized ?? undefined}
|
||||
{...checkboxProps}
|
||||
/>
|
||||
</Form.Group>
|
||||
|
||||
<FieldOptionsList
|
||||
fieldOptions={options.fieldOptions ?? undefined}
|
||||
setFieldOptions={(o) => setOptions({ fieldOptions: o })}
|
||||
setEditingField={setEditingField}
|
||||
allowSetDefault={!!source}
|
||||
defaultOptions={defaultOptions}
|
||||
/>
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
216
ui/v2.5/src/components/Dialogs/IdentifyDialog/Sources.tsx
Normal file
216
ui/v2.5/src/components/Dialogs/IdentifyDialog/Sources.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
import React, { useState, useEffect } from "react";
|
||||
import { Form, Button, ListGroup } from "react-bootstrap";
|
||||
import { Modal, Icon } from "src/components/Shared";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { IScraperSource } from "./constants";
|
||||
import { OptionsEditor } from "./Options";
|
||||
|
||||
interface ISourceEditor {
|
||||
isNew: boolean;
|
||||
availableSources: IScraperSource[];
|
||||
source: IScraperSource;
|
||||
saveSource: (s?: IScraperSource) => void;
|
||||
defaultOptions: GQL.IdentifyMetadataOptionsInput;
|
||||
}
|
||||
|
||||
export const SourcesEditor: React.FC<ISourceEditor> = ({
|
||||
isNew,
|
||||
availableSources,
|
||||
source: initialSource,
|
||||
saveSource,
|
||||
defaultOptions,
|
||||
}) => {
|
||||
const [source, setSource] = useState<IScraperSource>(initialSource);
|
||||
const [editingField, setEditingField] = useState(false);
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
// if id is empty, then we are adding a new source
|
||||
const headerMsgId = isNew ? "actions.add" : "dialogs.edit_entity_title";
|
||||
const acceptMsgId = isNew ? "actions.add" : "actions.confirm";
|
||||
|
||||
function handleSourceSelect(e: React.ChangeEvent<HTMLSelectElement>) {
|
||||
const selectedSource = availableSources.find(
|
||||
(s) => s.id === e.currentTarget.value
|
||||
);
|
||||
if (!selectedSource) return;
|
||||
|
||||
setSource({
|
||||
...source,
|
||||
id: selectedSource.id,
|
||||
displayName: selectedSource.displayName,
|
||||
scraper_id: selectedSource.scraper_id,
|
||||
stash_box_endpoint: selectedSource.stash_box_endpoint,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
dialogClassName="identify-source-editor"
|
||||
modalProps={{ animation: false, size: "lg" }}
|
||||
show
|
||||
icon={isNew ? "plus" : "pencil-alt"}
|
||||
header={intl.formatMessage(
|
||||
{ id: headerMsgId },
|
||||
{
|
||||
count: 1,
|
||||
singularEntity: source?.displayName,
|
||||
}
|
||||
)}
|
||||
accept={{
|
||||
onClick: () => saveSource(source),
|
||||
text: intl.formatMessage({ id: acceptMsgId }),
|
||||
}}
|
||||
cancel={{
|
||||
onClick: () => saveSource(),
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
variant: "secondary",
|
||||
}}
|
||||
disabled={
|
||||
(!source.scraper_id && !source.stash_box_endpoint) || editingField
|
||||
}
|
||||
>
|
||||
<Form>
|
||||
{isNew && (
|
||||
<Form.Group>
|
||||
<h5>
|
||||
<FormattedMessage id="config.tasks.identify.source" />
|
||||
</h5>
|
||||
<Form.Control
|
||||
as="select"
|
||||
value={source.id}
|
||||
className="input-control"
|
||||
onChange={handleSourceSelect}
|
||||
>
|
||||
{availableSources.map((i) => (
|
||||
<option value={i.id} key={i.id}>
|
||||
{i.displayName}
|
||||
</option>
|
||||
))}
|
||||
</Form.Control>
|
||||
</Form.Group>
|
||||
)}
|
||||
<OptionsEditor
|
||||
options={source.options ?? {}}
|
||||
setOptions={(o) => setSource({ ...source, options: o })}
|
||||
source={source}
|
||||
setEditingField={(v) => setEditingField(v)}
|
||||
defaultOptions={defaultOptions}
|
||||
/>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
interface ISourcesList {
|
||||
sources: IScraperSource[];
|
||||
setSources: (s: IScraperSource[]) => void;
|
||||
editSource: (s?: IScraperSource) => void;
|
||||
canAdd: boolean;
|
||||
}
|
||||
|
||||
export const SourcesList: React.FC<ISourcesList> = ({
|
||||
sources,
|
||||
setSources,
|
||||
editSource,
|
||||
canAdd,
|
||||
}) => {
|
||||
const [tempSources, setTempSources] = useState(sources);
|
||||
const [dragIndex, setDragIndex] = useState<number | undefined>();
|
||||
const [mouseOverIndex, setMouseOverIndex] = useState<number | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
setTempSources([...sources]);
|
||||
}, [sources]);
|
||||
|
||||
function removeSource(index: number) {
|
||||
const newSources = [...sources];
|
||||
newSources.splice(index, 1);
|
||||
setSources(newSources);
|
||||
}
|
||||
|
||||
function onDragStart(event: React.DragEvent<HTMLElement>, index: number) {
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
setDragIndex(index);
|
||||
}
|
||||
|
||||
function onDragOver(event: React.DragEvent<HTMLElement>, index?: number) {
|
||||
if (dragIndex !== undefined && index !== undefined && index !== dragIndex) {
|
||||
const newSources = [...tempSources];
|
||||
const moved = newSources.splice(dragIndex, 1);
|
||||
newSources.splice(index, 0, moved[0]);
|
||||
setTempSources(newSources);
|
||||
setDragIndex(index);
|
||||
}
|
||||
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function onDragOverDefault(event: React.DragEvent<HTMLDivElement>) {
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
function onDrop() {
|
||||
// assume we've already set the temp source list
|
||||
// feed it up
|
||||
setSources(tempSources);
|
||||
setDragIndex(undefined);
|
||||
setMouseOverIndex(undefined);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group className="scraper-sources" onDragOver={onDragOverDefault}>
|
||||
<h5>
|
||||
<FormattedMessage id="config.tasks.identify.sources" />
|
||||
</h5>
|
||||
<ListGroup as="ul" className="scraper-source-list">
|
||||
{tempSources.map((s, index) => (
|
||||
<ListGroup.Item
|
||||
as="li"
|
||||
key={s.id}
|
||||
className="d-flex justify-content-between align-items-center"
|
||||
draggable={mouseOverIndex === index}
|
||||
onDragStart={(e) => onDragStart(e, index)}
|
||||
onDragEnter={(e) => onDragOver(e, index)}
|
||||
onDrop={() => onDrop()}
|
||||
>
|
||||
<div>
|
||||
<div
|
||||
className="minimal text-muted drag-handle"
|
||||
onMouseEnter={() => setMouseOverIndex(index)}
|
||||
onMouseLeave={() => setMouseOverIndex(undefined)}
|
||||
>
|
||||
<Icon icon="grip-vertical" />
|
||||
</div>
|
||||
{s.displayName}
|
||||
</div>
|
||||
<div>
|
||||
<Button className="minimal" onClick={() => editSource(s)}>
|
||||
<Icon icon="cog" />
|
||||
</Button>
|
||||
<Button
|
||||
className="minimal text-danger"
|
||||
onClick={() => removeSource(index)}
|
||||
>
|
||||
<Icon icon="minus" />
|
||||
</Button>
|
||||
</div>
|
||||
</ListGroup.Item>
|
||||
))}
|
||||
</ListGroup>
|
||||
{canAdd && (
|
||||
<div className="text-right">
|
||||
<Button
|
||||
className="minimal add-scraper-source-button"
|
||||
onClick={() => editSource()}
|
||||
>
|
||||
<Icon icon="plus" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
import React from "react";
|
||||
import { Form } from "react-bootstrap";
|
||||
import { useIntl } from "react-intl";
|
||||
|
||||
interface IThreeStateBoolean {
|
||||
id: string;
|
||||
value: boolean | undefined;
|
||||
setValue: (v: boolean | undefined) => void;
|
||||
allowUndefined?: boolean;
|
||||
label?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
defaultValue?: boolean;
|
||||
}
|
||||
|
||||
export const ThreeStateBoolean: React.FC<IThreeStateBoolean> = ({
|
||||
id,
|
||||
value,
|
||||
setValue,
|
||||
allowUndefined = true,
|
||||
label,
|
||||
disabled,
|
||||
defaultValue,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
if (!allowUndefined) {
|
||||
return (
|
||||
<Form.Check
|
||||
id={id}
|
||||
disabled={disabled}
|
||||
checked={value}
|
||||
label={label}
|
||||
onChange={() => setValue(!value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getBooleanText(v: boolean) {
|
||||
if (v) {
|
||||
return intl.formatMessage({ id: "true" });
|
||||
}
|
||||
return intl.formatMessage({ id: "false" });
|
||||
}
|
||||
|
||||
function getButtonText(v: boolean | undefined) {
|
||||
if (v === undefined) {
|
||||
const defaultVal =
|
||||
defaultValue !== undefined ? (
|
||||
<span className="default-value">
|
||||
{" "}
|
||||
({getBooleanText(defaultValue)})
|
||||
</span>
|
||||
) : (
|
||||
""
|
||||
);
|
||||
return (
|
||||
<span>
|
||||
{intl.formatMessage({ id: "use_default" })}
|
||||
{defaultVal}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return getBooleanText(v);
|
||||
}
|
||||
|
||||
function renderModeButton(v: boolean | undefined) {
|
||||
return (
|
||||
<Form.Check
|
||||
type="radio"
|
||||
id={`${id}-value-${v ?? "undefined"}`}
|
||||
checked={value === v}
|
||||
onChange={() => setValue(v)}
|
||||
disabled={disabled}
|
||||
label={getButtonText(v)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Group>
|
||||
<h6>{label}</h6>
|
||||
<Form.Group>
|
||||
{renderModeButton(undefined)}
|
||||
{renderModeButton(false)}
|
||||
{renderModeButton(true)}
|
||||
</Form.Group>
|
||||
</Form.Group>
|
||||
);
|
||||
};
|
||||
27
ui/v2.5/src/components/Dialogs/IdentifyDialog/constants.ts
Normal file
27
ui/v2.5/src/components/Dialogs/IdentifyDialog/constants.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import * as GQL from "src/core/generated-graphql";
|
||||
|
||||
export interface IScraperSource {
|
||||
id: string;
|
||||
displayName: string;
|
||||
stash_box_endpoint?: string;
|
||||
scraper_id?: string;
|
||||
options?: GQL.IdentifyMetadataOptionsInput;
|
||||
}
|
||||
|
||||
export const sceneFields = [
|
||||
"title",
|
||||
"date",
|
||||
"details",
|
||||
"url",
|
||||
"studio",
|
||||
"performers",
|
||||
"tags",
|
||||
"stash_ids",
|
||||
] as const;
|
||||
export type SceneField = typeof sceneFields[number];
|
||||
|
||||
export const multiValueSceneFields: SceneField[] = [
|
||||
"studio",
|
||||
"performers",
|
||||
"tags",
|
||||
];
|
||||
45
ui/v2.5/src/components/Dialogs/IdentifyDialog/styles.scss
Normal file
45
ui/v2.5/src/components/Dialogs/IdentifyDialog/styles.scss
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
.identify-source-editor {
|
||||
.default-value {
|
||||
color: #bfccd6;
|
||||
}
|
||||
}
|
||||
|
||||
.scraper-source-list {
|
||||
.list-group-item {
|
||||
background-color: $textfield-bg;
|
||||
padding: 0.25em;
|
||||
|
||||
.drag-handle {
|
||||
cursor: move;
|
||||
display: inline-block;
|
||||
margin: -0.25em 0.25em -0.25em -0.25em;
|
||||
padding: 0.25em 0.5em 0.25em;
|
||||
}
|
||||
|
||||
.drag-handle:hover,
|
||||
.drag-handle:active,
|
||||
.drag-handle:focus,
|
||||
.drag-handle:focus:active {
|
||||
background-color: initial;
|
||||
border-color: initial;
|
||||
box-shadow: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.scraper-sources {
|
||||
.add-scraper-source-button {
|
||||
margin-right: 0.25em;
|
||||
}
|
||||
}
|
||||
|
||||
.field-options-table td:first-child {
|
||||
padding-left: 0.75rem;
|
||||
}
|
||||
|
||||
#selected-identify-folders {
|
||||
& > div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
|
@ -18,15 +18,18 @@ import KeyboardShortcuts from "src/docs/en/KeyboardShortcuts.md";
|
|||
import Help from "src/docs/en/Help.md";
|
||||
import Deduplication from "src/docs/en/Deduplication.md";
|
||||
import Interactive from "src/docs/en/Interactive.md";
|
||||
import Identify from "src/docs/en/Identify.md";
|
||||
import { MarkdownPage } from "../Shared/MarkdownPage";
|
||||
|
||||
interface IManualProps {
|
||||
animation?: boolean;
|
||||
show: boolean;
|
||||
onClose: () => void;
|
||||
defaultActiveTab?: string;
|
||||
}
|
||||
|
||||
export const Manual: React.FC<IManualProps> = ({
|
||||
animation,
|
||||
show,
|
||||
onClose,
|
||||
defaultActiveTab,
|
||||
|
|
@ -52,6 +55,12 @@ export const Manual: React.FC<IManualProps> = ({
|
|||
title: "Tasks",
|
||||
content: Tasks,
|
||||
},
|
||||
{
|
||||
key: "Identify.md",
|
||||
title: "Identify",
|
||||
content: Identify,
|
||||
className: "indent-1",
|
||||
},
|
||||
{
|
||||
key: "AutoTagging.md",
|
||||
title: "Auto Tagging",
|
||||
|
|
@ -152,6 +161,7 @@ export const Manual: React.FC<IManualProps> = ({
|
|||
|
||||
return (
|
||||
<Modal
|
||||
animation={animation}
|
||||
show={show}
|
||||
onHide={onClose}
|
||||
dialogClassName="modal-dialog-scrollable manual modal-xl"
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import { SceneGenerateDialog } from "./SceneGenerateDialog";
|
|||
import { ExportDialog } from "../Shared/ExportDialog";
|
||||
import { SceneCardsGrid } from "./SceneCardsGrid";
|
||||
import { TaggerContext } from "../Tagger/context";
|
||||
import { IdentifyDialog } from "../Dialogs/IdentifyDialog/IdentifyDialog";
|
||||
|
||||
interface ISceneList {
|
||||
filterHook?: (filter: ListFilterModel) => ListFilterModel;
|
||||
|
|
@ -38,6 +39,7 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||
const intl = useIntl();
|
||||
const history = useHistory();
|
||||
const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);
|
||||
const [isIdentifyDialogOpen, setIsIdentifyDialogOpen] = useState(false);
|
||||
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
|
||||
const [isExportAll, setIsExportAll] = useState(false);
|
||||
|
||||
|
|
@ -53,10 +55,15 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||
onClick: playRandom,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.generate" }),
|
||||
text: `${intl.formatMessage({ id: "actions.generate" })}…`,
|
||||
onClick: generate,
|
||||
isDisplayed: showWhenSelected,
|
||||
},
|
||||
{
|
||||
text: `${intl.formatMessage({ id: "actions.identify" })}…`,
|
||||
onClick: identify,
|
||||
isDisplayed: showWhenSelected,
|
||||
},
|
||||
{
|
||||
text: intl.formatMessage({ id: "actions.export" }),
|
||||
onClick: onExport,
|
||||
|
|
@ -138,6 +145,10 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||
setIsGenerateDialogOpen(true);
|
||||
}
|
||||
|
||||
async function identify() {
|
||||
setIsIdentifyDialogOpen(true);
|
||||
}
|
||||
|
||||
async function onExport() {
|
||||
setIsExportAll(false);
|
||||
setIsExportDialogOpen(true);
|
||||
|
|
@ -163,6 +174,21 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||
}
|
||||
}
|
||||
|
||||
function maybeRenderSceneIdentifyDialog(selectedIds: Set<string>) {
|
||||
if (isIdentifyDialogOpen) {
|
||||
return (
|
||||
<>
|
||||
<IdentifyDialog
|
||||
selectedIds={Array.from(selectedIds.values())}
|
||||
onClose={() => {
|
||||
setIsIdentifyDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function maybeRenderSceneExportDialog(selectedIds: Set<string>) {
|
||||
if (isExportDialogOpen) {
|
||||
return (
|
||||
|
|
@ -248,6 +274,7 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||
return (
|
||||
<>
|
||||
{maybeRenderSceneGenerateDialog(selectedIds)}
|
||||
{maybeRenderSceneIdentifyDialog(selectedIds)}
|
||||
{maybeRenderSceneExportDialog(selectedIds)}
|
||||
{renderScenes(result, filter, selectedIds)}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -6,18 +6,24 @@ import { FolderSelect } from "src/components/Shared/FolderSelect/FolderSelect";
|
|||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
|
||||
interface IDirectorySelectionDialogProps {
|
||||
animation?: boolean;
|
||||
initialPaths?: string[];
|
||||
allowEmpty?: boolean;
|
||||
onClose: (paths?: string[]) => void;
|
||||
}
|
||||
|
||||
export const DirectorySelectionDialog: React.FC<IDirectorySelectionDialogProps> = (
|
||||
props: IDirectorySelectionDialogProps
|
||||
) => {
|
||||
export const DirectorySelectionDialog: React.FC<IDirectorySelectionDialogProps> = ({
|
||||
animation,
|
||||
allowEmpty = false,
|
||||
initialPaths = [],
|
||||
onClose,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const { configuration } = React.useContext(ConfigurationContext);
|
||||
|
||||
const libraryPaths = configuration?.general.stashes.map((s) => s.path);
|
||||
|
||||
const [paths, setPaths] = useState<string[]>([]);
|
||||
const [paths, setPaths] = useState<string[]>(initialPaths);
|
||||
const [currentDirectory, setCurrentDirectory] = useState<string>("");
|
||||
|
||||
function removePath(p: string) {
|
||||
|
|
@ -33,17 +39,18 @@ export const DirectorySelectionDialog: React.FC<IDirectorySelectionDialogProps>
|
|||
return (
|
||||
<Modal
|
||||
show
|
||||
disabled={paths.length === 0}
|
||||
modalProps={{ animation }}
|
||||
disabled={!allowEmpty && paths.length === 0}
|
||||
icon="pencil-alt"
|
||||
header={intl.formatMessage({ id: "actions.select_folders" })}
|
||||
accept={{
|
||||
onClick: () => {
|
||||
props.onClose(paths);
|
||||
onClose(paths);
|
||||
},
|
||||
text: intl.formatMessage({ id: "actions.confirm" }),
|
||||
}}
|
||||
cancel={{
|
||||
onClick: () => props.onClose(),
|
||||
onClick: () => onClose(),
|
||||
text: intl.formatMessage({ id: "actions.cancel" }),
|
||||
variant: "secondary",
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { useToast } from "src/hooks";
|
|||
import * as GQL from "src/core/generated-graphql";
|
||||
import { LoadingIndicator, Modal } from "src/components/Shared";
|
||||
import { downloadFile } from "src/utils";
|
||||
import IdentifyDialog from "src/components/Dialogs/IdentifyDialog/IdentifyDialog";
|
||||
import { GenerateButton } from "./GenerateButton";
|
||||
import { ImportDialog } from "./ImportDialog";
|
||||
import { DirectorySelectionDialog } from "./DirectorySelectionDialog";
|
||||
|
|
@ -27,13 +28,18 @@ type PluginTask = Pick<GQL.PluginTask, "name" | "description">;
|
|||
export const SettingsTasksPanel: React.FC = () => {
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
const [isImportAlertOpen, setIsImportAlertOpen] = useState<boolean>(false);
|
||||
const [isCleanAlertOpen, setIsCleanAlertOpen] = useState<boolean>(false);
|
||||
const [isImportDialogOpen, setIsImportDialogOpen] = useState<boolean>(false);
|
||||
const [isScanDialogOpen, setIsScanDialogOpen] = useState<boolean>(false);
|
||||
const [isAutoTagDialogOpen, setIsAutoTagDialogOpen] = useState<boolean>(
|
||||
false
|
||||
);
|
||||
const [dialogOpen, setDialogOpenState] = useState({
|
||||
importAlert: false,
|
||||
cleanAlert: false,
|
||||
import: false,
|
||||
clean: false,
|
||||
scan: false,
|
||||
autoTag: false,
|
||||
identify: false,
|
||||
});
|
||||
|
||||
type DialogOpenState = typeof dialogOpen;
|
||||
|
||||
const [isBackupRunning, setIsBackupRunning] = useState<boolean>(false);
|
||||
const [useFileMetadata, setUseFileMetadata] = useState<boolean>(false);
|
||||
const [stripFileExtension, setStripFileExtension] = useState<boolean>(false);
|
||||
|
|
@ -61,8 +67,14 @@ export const SettingsTasksPanel: React.FC = () => {
|
|||
|
||||
const plugins = usePlugins();
|
||||
|
||||
function setDialogOpen(s: Partial<DialogOpenState>) {
|
||||
setDialogOpenState((v) => {
|
||||
return { ...v, ...s };
|
||||
});
|
||||
}
|
||||
|
||||
async function onImport() {
|
||||
setIsImportAlertOpen(false);
|
||||
setDialogOpen({ importAlert: false });
|
||||
try {
|
||||
await mutateMetadataImport();
|
||||
Toast.success({
|
||||
|
|
@ -79,14 +91,14 @@ export const SettingsTasksPanel: React.FC = () => {
|
|||
function renderImportAlert() {
|
||||
return (
|
||||
<Modal
|
||||
show={isImportAlertOpen}
|
||||
show={dialogOpen.importAlert}
|
||||
icon="trash-alt"
|
||||
accept={{
|
||||
text: intl.formatMessage({ id: "actions.import" }),
|
||||
variant: "danger",
|
||||
onClick: onImport,
|
||||
}}
|
||||
cancel={{ onClick: () => setIsImportAlertOpen(false) }}
|
||||
cancel={{ onClick: () => setDialogOpen({ importAlert: false }) }}
|
||||
>
|
||||
<p>{intl.formatMessage({ id: "actions.tasks.import_warning" })}</p>
|
||||
</Modal>
|
||||
|
|
@ -94,7 +106,7 @@ export const SettingsTasksPanel: React.FC = () => {
|
|||
}
|
||||
|
||||
function onClean() {
|
||||
setIsCleanAlertOpen(false);
|
||||
setDialogOpen({ cleanAlert: false });
|
||||
mutateMetadataClean({
|
||||
dryRun: cleanDryRun,
|
||||
});
|
||||
|
|
@ -116,14 +128,14 @@ export const SettingsTasksPanel: React.FC = () => {
|
|||
|
||||
return (
|
||||
<Modal
|
||||
show={isCleanAlertOpen}
|
||||
show={dialogOpen.cleanAlert}
|
||||
icon="trash-alt"
|
||||
accept={{
|
||||
text: intl.formatMessage({ id: "actions.clean" }),
|
||||
variant: "danger",
|
||||
onClick: onClean,
|
||||
}}
|
||||
cancel={{ onClick: () => setIsCleanAlertOpen(false) }}
|
||||
cancel={{ onClick: () => setDialogOpen({ cleanAlert: false }) }}
|
||||
>
|
||||
{msg}
|
||||
</Modal>
|
||||
|
|
@ -131,15 +143,15 @@ export const SettingsTasksPanel: React.FC = () => {
|
|||
}
|
||||
|
||||
function renderImportDialog() {
|
||||
if (!isImportDialogOpen) {
|
||||
if (!dialogOpen.import) {
|
||||
return;
|
||||
}
|
||||
|
||||
return <ImportDialog onClose={() => setIsImportDialogOpen(false)} />;
|
||||
return <ImportDialog onClose={() => setDialogOpen({ import: false })} />;
|
||||
}
|
||||
|
||||
function renderScanDialog() {
|
||||
if (!isScanDialogOpen) {
|
||||
if (!dialogOpen.scan) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -151,7 +163,7 @@ export const SettingsTasksPanel: React.FC = () => {
|
|||
onScan(paths);
|
||||
}
|
||||
|
||||
setIsScanDialogOpen(false);
|
||||
setDialogOpen({ scan: false });
|
||||
}
|
||||
|
||||
async function onScan(paths?: string[]) {
|
||||
|
|
@ -178,19 +190,27 @@ export const SettingsTasksPanel: React.FC = () => {
|
|||
}
|
||||
|
||||
function renderAutoTagDialog() {
|
||||
if (!isAutoTagDialogOpen) {
|
||||
if (!dialogOpen.autoTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
return <DirectorySelectionDialog onClose={onAutoTagDialogClosed} />;
|
||||
}
|
||||
|
||||
function maybeRenderIdentifyDialog() {
|
||||
if (!dialogOpen.identify) return;
|
||||
|
||||
return (
|
||||
<IdentifyDialog onClose={() => setDialogOpen({ identify: false })} />
|
||||
);
|
||||
}
|
||||
|
||||
function onAutoTagDialogClosed(paths?: string[]) {
|
||||
if (paths) {
|
||||
onAutoTag(paths);
|
||||
}
|
||||
|
||||
setIsAutoTagDialogOpen(false);
|
||||
setDialogOpen({ autoTag: false });
|
||||
}
|
||||
|
||||
function getAutoTagInput(paths?: string[]) {
|
||||
|
|
@ -343,6 +363,7 @@ export const SettingsTasksPanel: React.FC = () => {
|
|||
{renderImportDialog()}
|
||||
{renderScanDialog()}
|
||||
{renderAutoTagDialog()}
|
||||
{maybeRenderIdentifyDialog()}
|
||||
|
||||
<h4>{intl.formatMessage({ id: "config.tasks.job_queue" })}</h4>
|
||||
|
||||
|
|
@ -350,138 +371,159 @@ export const SettingsTasksPanel: React.FC = () => {
|
|||
|
||||
<hr />
|
||||
|
||||
<h5>{intl.formatMessage({ id: "library" })}</h5>
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id="use-file-metadata"
|
||||
checked={useFileMetadata}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.set_name_date_details_from_metadata_if_present",
|
||||
})}
|
||||
onChange={() => setUseFileMetadata(!useFileMetadata)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="strip-file-extension"
|
||||
checked={stripFileExtension}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.dont_include_file_extension_as_part_of_the_title",
|
||||
})}
|
||||
onChange={() => setStripFileExtension(!stripFileExtension)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="scan-generate-previews"
|
||||
checked={scanGeneratePreviews}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.generate_video_previews_during_scan",
|
||||
})}
|
||||
onChange={() => setScanGeneratePreviews(!scanGeneratePreviews)}
|
||||
/>
|
||||
<div className="d-flex flex-row">
|
||||
<div>↳</div>
|
||||
<h5>{intl.formatMessage({ id: "library" })}</h5>
|
||||
<Form.Group>
|
||||
<h6>{intl.formatMessage({ id: "actions.scan" })}</h6>
|
||||
<Form.Check
|
||||
id="scan-generate-image-previews"
|
||||
checked={scanGenerateImagePreviews}
|
||||
disabled={!scanGeneratePreviews}
|
||||
id="use-file-metadata"
|
||||
checked={useFileMetadata}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.generate_previews_during_scan",
|
||||
id: "config.tasks.set_name_date_details_from_metadata_if_present",
|
||||
})}
|
||||
onChange={() =>
|
||||
setScanGenerateImagePreviews(!scanGenerateImagePreviews)
|
||||
}
|
||||
className="ml-2 flex-grow"
|
||||
onChange={() => setUseFileMetadata(!useFileMetadata)}
|
||||
/>
|
||||
</div>
|
||||
<Form.Check
|
||||
id="scan-generate-sprites"
|
||||
checked={scanGenerateSprites}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.generate_sprites_during_scan",
|
||||
})}
|
||||
onChange={() => setScanGenerateSprites(!scanGenerateSprites)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="scan-generate-phashes"
|
||||
checked={scanGeneratePhashes}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.generate_phashes_during_scan",
|
||||
})}
|
||||
onChange={() => setScanGeneratePhashes(!scanGeneratePhashes)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="scan-generate-thumbnails"
|
||||
checked={scanGenerateThumbnails}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.generate_thumbnails_during_scan",
|
||||
})}
|
||||
onChange={() => setScanGenerateThumbnails(!scanGenerateThumbnails)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Button
|
||||
className="mr-2"
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => onScan()}
|
||||
>
|
||||
<FormattedMessage id="actions.scan" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => setIsScanDialogOpen(true)}
|
||||
>
|
||||
<FormattedMessage id="actions.selective_scan" />
|
||||
</Button>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({ id: "config.tasks.scan_for_content_desc" })}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
<Form.Check
|
||||
id="strip-file-extension"
|
||||
checked={stripFileExtension}
|
||||
label={intl.formatMessage({
|
||||
id:
|
||||
"config.tasks.dont_include_file_extension_as_part_of_the_title",
|
||||
})}
|
||||
onChange={() => setStripFileExtension(!stripFileExtension)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="scan-generate-previews"
|
||||
checked={scanGeneratePreviews}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.generate_video_previews_during_scan",
|
||||
})}
|
||||
onChange={() => setScanGeneratePreviews(!scanGeneratePreviews)}
|
||||
/>
|
||||
<div className="d-flex flex-row">
|
||||
<div>↳</div>
|
||||
<Form.Check
|
||||
id="scan-generate-image-previews"
|
||||
checked={scanGenerateImagePreviews}
|
||||
disabled={!scanGeneratePreviews}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.generate_previews_during_scan",
|
||||
})}
|
||||
onChange={() =>
|
||||
setScanGenerateImagePreviews(!scanGenerateImagePreviews)
|
||||
}
|
||||
className="ml-2 flex-grow"
|
||||
/>
|
||||
</div>
|
||||
<Form.Check
|
||||
id="scan-generate-sprites"
|
||||
checked={scanGenerateSprites}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.generate_sprites_during_scan",
|
||||
})}
|
||||
onChange={() => setScanGenerateSprites(!scanGenerateSprites)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="scan-generate-phashes"
|
||||
checked={scanGeneratePhashes}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.generate_phashes_during_scan",
|
||||
})}
|
||||
onChange={() => setScanGeneratePhashes(!scanGeneratePhashes)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="scan-generate-thumbnails"
|
||||
checked={scanGenerateThumbnails}
|
||||
label={intl.formatMessage({
|
||||
id: "config.tasks.generate_thumbnails_during_scan",
|
||||
})}
|
||||
onChange={() => setScanGenerateThumbnails(!scanGenerateThumbnails)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Button
|
||||
className="mr-2"
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => onScan()}
|
||||
>
|
||||
<FormattedMessage id="actions.scan" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => setDialogOpen({ scan: true })}
|
||||
>
|
||||
<FormattedMessage id="actions.selective_scan" />
|
||||
</Button>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({ id: "config.tasks.scan_for_content_desc" })}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<hr />
|
||||
<Form.Group>
|
||||
<h6>
|
||||
<FormattedMessage id="config.tasks.identify.heading" />
|
||||
</h6>
|
||||
<Button
|
||||
className="mr-2"
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => setDialogOpen({ identify: true })}
|
||||
>
|
||||
<FormattedMessage id="actions.identify" />…
|
||||
</Button>
|
||||
<Form.Text className="text-muted">
|
||||
<FormattedMessage id="config.tasks.identify.description" />
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
|
||||
<h5>{intl.formatMessage({ id: "config.tasks.auto_tagging" })}</h5>
|
||||
<Form.Group>
|
||||
<h6>{intl.formatMessage({ id: "config.tasks.auto_tagging" })}</h6>
|
||||
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id="autotag-performers"
|
||||
checked={autoTagPerformers}
|
||||
label={intl.formatMessage({ id: "performers" })}
|
||||
onChange={() => setAutoTagPerformers(!autoTagPerformers)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="autotag-studios"
|
||||
checked={autoTagStudios}
|
||||
label={intl.formatMessage({ id: "studios" })}
|
||||
onChange={() => setAutoTagStudios(!autoTagStudios)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="autotag-tags"
|
||||
checked={autoTagTags}
|
||||
label={intl.formatMessage({ id: "tags" })}
|
||||
onChange={() => setAutoTagTags(!autoTagTags)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
className="mr-2"
|
||||
onClick={() => onAutoTag()}
|
||||
>
|
||||
<FormattedMessage id="actions.auto_tag" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => setIsAutoTagDialogOpen(true)}
|
||||
>
|
||||
<FormattedMessage id="actions.selective_auto_tag" />
|
||||
</Button>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({
|
||||
id: "config.tasks.auto_tag_based_on_filenames",
|
||||
})}
|
||||
</Form.Text>
|
||||
<Form.Group>
|
||||
<Form.Check
|
||||
id="autotag-performers"
|
||||
checked={autoTagPerformers}
|
||||
label={intl.formatMessage({ id: "performers" })}
|
||||
onChange={() => setAutoTagPerformers(!autoTagPerformers)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="autotag-studios"
|
||||
checked={autoTagStudios}
|
||||
label={intl.formatMessage({ id: "studios" })}
|
||||
onChange={() => setAutoTagStudios(!autoTagStudios)}
|
||||
/>
|
||||
<Form.Check
|
||||
id="autotag-tags"
|
||||
checked={autoTagTags}
|
||||
label={intl.formatMessage({ id: "tags" })}
|
||||
onChange={() => setAutoTagTags(!autoTagTags)}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
className="mr-2"
|
||||
onClick={() => onAutoTag()}
|
||||
>
|
||||
<FormattedMessage id="actions.auto_tag" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
type="submit"
|
||||
onClick={() => setDialogOpen({ autoTag: true })}
|
||||
>
|
||||
<FormattedMessage id="actions.selective_auto_tag" />
|
||||
</Button>
|
||||
<Form.Text className="text-muted">
|
||||
{intl.formatMessage({
|
||||
id: "config.tasks.auto_tag_based_on_filenames",
|
||||
})}
|
||||
</Form.Text>
|
||||
</Form.Group>
|
||||
</Form.Group>
|
||||
</Form.Group>
|
||||
|
||||
<hr />
|
||||
|
|
@ -503,7 +545,7 @@ export const SettingsTasksPanel: React.FC = () => {
|
|||
<Button
|
||||
id="clean"
|
||||
variant="danger"
|
||||
onClick={() => setIsCleanAlertOpen(true)}
|
||||
onClick={() => setDialogOpen({ cleanAlert: true })}
|
||||
>
|
||||
<FormattedMessage id="actions.clean" />
|
||||
</Button>
|
||||
|
|
@ -533,7 +575,7 @@ export const SettingsTasksPanel: React.FC = () => {
|
|||
<Button
|
||||
id="import"
|
||||
variant="danger"
|
||||
onClick={() => setIsImportAlertOpen(true)}
|
||||
onClick={() => setDialogOpen({ importAlert: true })}
|
||||
>
|
||||
<FormattedMessage id="actions.full_import" />
|
||||
</Button>
|
||||
|
|
@ -546,7 +588,7 @@ export const SettingsTasksPanel: React.FC = () => {
|
|||
<Button
|
||||
id="partial-import"
|
||||
variant="danger"
|
||||
onClick={() => setIsImportDialogOpen(true)}
|
||||
onClick={() => setDialogOpen({ import: true })}
|
||||
>
|
||||
<FormattedMessage id="actions.import_from_file" />
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { useEffect } from "react";
|
||||
import React, { useEffect, useState, useMemo } from "react";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Button, InputGroup, Form } from "react-bootstrap";
|
||||
import { debounce } from "lodash";
|
||||
import { LoadingIndicator } from "src/components/Shared";
|
||||
import { useDirectory } from "src/core/StashService";
|
||||
|
||||
|
|
@ -17,22 +18,43 @@ export const FolderSelect: React.FC<IProps> = ({
|
|||
defaultDirectories,
|
||||
appendButton,
|
||||
}) => {
|
||||
const { data, error, loading } = useDirectory(currentDirectory);
|
||||
const [debouncedDirectory, setDebouncedDirectory] = useState(
|
||||
currentDirectory
|
||||
);
|
||||
const { data, error, loading } = useDirectory(debouncedDirectory);
|
||||
|
||||
const selectableDirectories: string[] = currentDirectory
|
||||
? data?.directory.directories ?? defaultDirectories ?? []
|
||||
: defaultDirectories ?? [];
|
||||
|
||||
const debouncedSetDirectory = useMemo(
|
||||
() =>
|
||||
debounce((input: string) => {
|
||||
setDebouncedDirectory(input);
|
||||
}, 250),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (currentDirectory === "" && !defaultDirectories && data?.directory.path)
|
||||
setCurrentDirectory(data.directory.path);
|
||||
}, [currentDirectory, setCurrentDirectory, data, defaultDirectories]);
|
||||
|
||||
function setInstant(value: string) {
|
||||
setCurrentDirectory(value);
|
||||
setDebouncedDirectory(value);
|
||||
}
|
||||
|
||||
function setDebounced(value: string) {
|
||||
setCurrentDirectory(value);
|
||||
debouncedSetDirectory(value);
|
||||
}
|
||||
|
||||
function goUp() {
|
||||
if (defaultDirectories?.includes(currentDirectory)) {
|
||||
setCurrentDirectory("");
|
||||
setInstant("");
|
||||
} else if (data?.directory.parent) {
|
||||
setCurrentDirectory(data.directory.parent);
|
||||
setInstant(data.directory.parent);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -51,9 +73,9 @@ export const FolderSelect: React.FC<IProps> = ({
|
|||
<InputGroup>
|
||||
<Form.Control
|
||||
placeholder="File path"
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setCurrentDirectory(e.currentTarget.value)
|
||||
}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setDebounced(e.currentTarget.value);
|
||||
}}
|
||||
value={currentDirectory}
|
||||
spellCheck={false}
|
||||
/>
|
||||
|
|
@ -71,7 +93,7 @@ export const FolderSelect: React.FC<IProps> = ({
|
|||
{selectableDirectories.map((path) => {
|
||||
return (
|
||||
<li key={path} className="folder-list-item">
|
||||
<Button variant="link" onClick={() => setCurrentDirectory(path)}>
|
||||
<Button variant="link" onClick={() => setInstant(path)}>
|
||||
{path}
|
||||
</Button>
|
||||
</li>
|
||||
|
|
|
|||
55
ui/v2.5/src/components/Shared/IndeterminateCheckbox.tsx
Normal file
55
ui/v2.5/src/components/Shared/IndeterminateCheckbox.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import React, { useEffect } from "react";
|
||||
import { Form, FormCheckProps } from "react-bootstrap";
|
||||
|
||||
const useIndeterminate = (
|
||||
ref: React.RefObject<HTMLInputElement>,
|
||||
value: boolean | undefined
|
||||
) => {
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
ref.current.indeterminate = value === undefined;
|
||||
}
|
||||
}, [ref, value]);
|
||||
};
|
||||
|
||||
interface IIndeterminateCheckbox extends FormCheckProps {
|
||||
setChecked: (v: boolean | undefined) => void;
|
||||
allowIndeterminate?: boolean;
|
||||
indeterminateClassname?: string;
|
||||
}
|
||||
|
||||
export const IndeterminateCheckbox: React.FC<IIndeterminateCheckbox> = ({
|
||||
checked,
|
||||
setChecked,
|
||||
allowIndeterminate,
|
||||
indeterminateClassname,
|
||||
...props
|
||||
}) => {
|
||||
const ref = React.createRef<HTMLInputElement>();
|
||||
|
||||
useIndeterminate(ref, checked);
|
||||
|
||||
function cycleState() {
|
||||
const undefAllowed = allowIndeterminate ?? true;
|
||||
if (undefAllowed && checked) {
|
||||
return undefined;
|
||||
}
|
||||
if ((!undefAllowed && checked) || checked === undefined) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
<Form.Check
|
||||
{...props}
|
||||
className={`${props.className ?? ""} ${
|
||||
checked === undefined ? indeterminateClassname : ""
|
||||
}`}
|
||||
ref={ref}
|
||||
checked={checked}
|
||||
onChange={() => setChecked(cycleState())}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
@ -21,6 +21,8 @@ interface IModal {
|
|||
disabled?: boolean;
|
||||
modalProps?: ModalProps;
|
||||
dialogClassName?: string;
|
||||
footerButtons?: React.ReactNode;
|
||||
leftFooterButtons?: React.ReactNode;
|
||||
}
|
||||
|
||||
const defaultOnHide = () => {};
|
||||
|
|
@ -37,8 +39,11 @@ const ModalComponent: React.FC<IModal> = ({
|
|||
disabled,
|
||||
modalProps,
|
||||
dialogClassName,
|
||||
footerButtons,
|
||||
leftFooterButtons,
|
||||
}) => (
|
||||
<Modal
|
||||
className="ModalComponent"
|
||||
keyboard={false}
|
||||
onHide={onHide ?? defaultOnHide}
|
||||
show={show}
|
||||
|
|
@ -50,14 +55,16 @@ const ModalComponent: React.FC<IModal> = ({
|
|||
<span>{header ?? ""}</span>
|
||||
</Modal.Header>
|
||||
<Modal.Body>{children}</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Modal.Footer className="ModalFooter">
|
||||
<div>{leftFooterButtons}</div>
|
||||
<div>
|
||||
{footerButtons}
|
||||
{cancel ? (
|
||||
<Button
|
||||
disabled={isRunning}
|
||||
variant={cancel.variant ?? "primary"}
|
||||
onClick={cancel.onClick}
|
||||
className="mr-2"
|
||||
className="ml-2"
|
||||
>
|
||||
{cancel.text ?? (
|
||||
<FormattedMessage
|
||||
|
|
|
|||
47
ui/v2.5/src/components/Shared/ThreeStateCheckbox.tsx
Normal file
47
ui/v2.5/src/components/Shared/ThreeStateCheckbox.tsx
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
import React from "react";
|
||||
import { Button } from "react-bootstrap";
|
||||
import { Icon } from ".";
|
||||
|
||||
interface IThreeStateCheckbox {
|
||||
value: boolean | undefined;
|
||||
setValue: (v: boolean | undefined) => void;
|
||||
allowUndefined?: boolean;
|
||||
label?: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const ThreeStateCheckbox: React.FC<IThreeStateCheckbox> = ({
|
||||
value,
|
||||
setValue,
|
||||
allowUndefined,
|
||||
label,
|
||||
disabled = false,
|
||||
}) => {
|
||||
function cycleState() {
|
||||
const undefAllowed = allowUndefined ?? true;
|
||||
if (undefAllowed && value) {
|
||||
return undefined;
|
||||
}
|
||||
if ((!undefAllowed && value) || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const icon = value === undefined ? "minus" : value ? "check" : "times";
|
||||
const labelClassName =
|
||||
value === undefined ? "unset" : value ? "checked" : "not-checked";
|
||||
|
||||
return (
|
||||
<span className={`three-state-checkbox ${labelClassName}`}>
|
||||
<Button
|
||||
onClick={() => setValue(cycleState())}
|
||||
className="minimal"
|
||||
disabled={disabled}
|
||||
>
|
||||
<Icon icon={icon} className="fa-fw" />
|
||||
</Button>
|
||||
<span className="label">{label}</span>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
@ -17,5 +17,6 @@ export { GridCard } from "./GridCard";
|
|||
export { RatingStars } from "./RatingStars";
|
||||
export { ExportDialog } from "./ExportDialog";
|
||||
export { default as DeleteEntityDialog } from "./DeleteEntityDialog";
|
||||
export { IndeterminateCheckbox } from "./IndeterminateCheckbox";
|
||||
export { OperationButton } from "./OperationButton";
|
||||
export const TITLE_SUFFIX = " | Stash";
|
||||
|
|
|
|||
|
|
@ -209,9 +209,48 @@ button.collapse-button.btn-primary:not(:disabled):not(.disabled):active {
|
|||
}
|
||||
}
|
||||
|
||||
.three-state-checkbox {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
|
||||
button.btn {
|
||||
font-size: 12.67px;
|
||||
margin-left: -0.2em;
|
||||
margin-right: 0.25rem;
|
||||
padding: 0;
|
||||
|
||||
&:not(:disabled):active,
|
||||
&:not(:disabled):active:focus,
|
||||
&:not(:disabled):hover,
|
||||
&:not(:disabled):not(:hover) {
|
||||
background-color: initial;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
&.unset {
|
||||
.label {
|
||||
color: #bfccd6;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
}
|
||||
|
||||
&.checked svg {
|
||||
color: #0f9960;
|
||||
}
|
||||
|
||||
&.not-checked svg {
|
||||
color: #db3737;
|
||||
}
|
||||
}
|
||||
|
||||
.input-group-prepend {
|
||||
.btn {
|
||||
border-bottom-right-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.ModalComponent .modal-footer {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -757,6 +757,12 @@ export const useGenerateAPIKey = () =>
|
|||
update: deleteCache([GQL.ConfigurationDocument]),
|
||||
});
|
||||
|
||||
export const useConfigureDefaults = () =>
|
||||
GQL.useConfigureDefaultsMutation({
|
||||
refetchQueries: getQueryNames([GQL.ConfigurationDocument]),
|
||||
update: deleteCache([GQL.ConfigurationDocument]),
|
||||
});
|
||||
|
||||
export const useJobsSubscribe = () => GQL.useJobsSubscribeSubscription();
|
||||
|
||||
export const useConfigureDLNA = () =>
|
||||
|
|
@ -1001,6 +1007,12 @@ export const mutateMetadataClean = (input: GQL.CleanMetadataInput) =>
|
|||
variables: { input },
|
||||
});
|
||||
|
||||
export const mutateMetadataIdentify = (input: GQL.IdentifyMetadataInput) =>
|
||||
client.mutate<GQL.MetadataIdentifyMutation>({
|
||||
mutation: GQL.MetadataIdentifyDocument,
|
||||
variables: { input },
|
||||
});
|
||||
|
||||
export const mutateMigrateHashNaming = () =>
|
||||
client.mutate<GQL.MigrateHashNamingMutation>({
|
||||
mutation: GQL.MigrateHashNamingDocument,
|
||||
|
|
|
|||
31
ui/v2.5/src/docs/en/Identify.md
Normal file
31
ui/v2.5/src/docs/en/Identify.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Identify
|
||||
|
||||
This task iterates through your Scenes and attempts to identify the scene using a selection of scraping sources.
|
||||
|
||||
This task accepts one or more scraper sources. Valid scraper sources for the Identify task are stash-box instances, and scene scrapers which support scraping via Scene Fragment. The order of the sources may be rearranged.
|
||||
|
||||
For each Scene, the Identify task iterates through the scraper sources, in the order provided, and tries to identify the scene using each source. If a result is found in a source, then the Scene is updated, and no further sources are checked for that scene.
|
||||
|
||||
## Options
|
||||
|
||||
The following options can be set:
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| 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 organised flag | If true, the organised flag is set to true when a scene is organised. |
|
||||
|
||||
Field specific options may be set as well. Each field may have a Strategy. The behaviour for each strategy value is as follows:
|
||||
|
||||
| Strategy | Description |
|
||||
|----------|-------------|
|
||||
| Ignore | Not set. |
|
||||
| Overwrite | Overwrite existing value. |
|
||||
| Merge (*default*) | For multi-value fields, adds to existing values. For single-value fields, only sets if not already set. |
|
||||
|
||||
For Studio, Performers and Tags, an option is also available to Create Missing objects. This is false by default. When true, if a Studio/Performer/Tag is included during the identification process and does not exist in the system, then it will be created.
|
||||
|
||||
Default Options are applied to all sources unless overridden in specific source options.
|
||||
|
||||
The result of the identification process for each scene is output to the log.
|
||||
|
|
@ -20,6 +20,7 @@
|
|||
@import "../node_modules/flag-icon-css/css/flag-icon.min.css";
|
||||
@import "src/components/Tagger/styles.scss";
|
||||
@import "src/hooks/Lightbox/lightbox.scss";
|
||||
@import "src/components/Dialogs/IdentifyDialog/styles.scss";
|
||||
|
||||
/* stylelint-disable */
|
||||
#root {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
"generate_thumb_from_current": "Generate thumbnail from current",
|
||||
"hash_migration": "hash migration",
|
||||
"hide": "Hide",
|
||||
"identify": "Identify",
|
||||
"import": "Import…",
|
||||
"import_from_file": "Import from file",
|
||||
"merge": "Merge",
|
||||
|
|
@ -96,6 +97,7 @@
|
|||
"actions_name": "Actions",
|
||||
"age": "Age",
|
||||
"aliases": "Aliases",
|
||||
"all": "all",
|
||||
"also_known_as": "Also known as",
|
||||
"ascending": "Ascending",
|
||||
"average_resolution": "Average Resolution",
|
||||
|
|
@ -271,6 +273,7 @@
|
|||
"entity_scrapers": "{entityType} scrapers",
|
||||
"excluded_tag_patterns_desc": "Regexps of tag names to exclude from scraping results",
|
||||
"excluded_tag_patterns_head": "Excluded Tag Patterns",
|
||||
"scraper": "Scraper",
|
||||
"scrapers": "Scrapers",
|
||||
"search_by_name": "Search by name",
|
||||
"supported_types": "Supported types",
|
||||
|
|
@ -302,6 +305,29 @@
|
|||
"generate_video_previews_during_scan": "Generate previews during scan (video previews which play when hovering over a scene)",
|
||||
"generate_thumbnails_during_scan": "Generate thumbnails for images during scan.",
|
||||
"generated_content": "Generated Content",
|
||||
"identify": {
|
||||
"and_create_missing": "and create missing",
|
||||
"create_missing": "Create missing",
|
||||
"heading": "Identify",
|
||||
"description": "Automatically set scene metadata using stash-box and scraper sources.",
|
||||
"default_options": "Default Options",
|
||||
"explicit_set_description": "The following options will be used where not overridden in the source-specific options.",
|
||||
"field_behaviour": "{strategy} {field}",
|
||||
"field_options": "Field Options",
|
||||
"field_strategies": {
|
||||
"ignore": "Ignore",
|
||||
"merge": "Merge",
|
||||
"overwrite": "Overwrite"
|
||||
},
|
||||
"identifying_scenes": "Identifying {num} {scene}",
|
||||
"identifying_from_paths": "Identifying scenes from the following paths",
|
||||
"include_male_performers": "Include male performers",
|
||||
"set_cover_images": "Set cover images",
|
||||
"set_organized": "Set organised flag",
|
||||
"source_options": "{source} Options",
|
||||
"source": "Source",
|
||||
"sources": "Sources"
|
||||
},
|
||||
"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.",
|
||||
"job_queue": "Job Queue",
|
||||
|
|
@ -572,6 +598,7 @@
|
|||
"ethnicity": "Ethnicity",
|
||||
"eye_color": "Eye Colour",
|
||||
"fake_tits": "Fake Tits",
|
||||
"false": "False",
|
||||
"favourite": "Favourite",
|
||||
"file_info": "File Info",
|
||||
"file_mod_time": "File Modification Time",
|
||||
|
|
@ -668,6 +695,7 @@
|
|||
"settings": "Settings",
|
||||
"sub_tag_of": "Sub-tag of {parent}",
|
||||
"stash_id": "Stash ID",
|
||||
"stash_ids": "Stash IDs",
|
||||
"status": "Status: {statusText}",
|
||||
"studio": "Studio",
|
||||
"studio_depth": "Levels (empty for all)",
|
||||
|
|
@ -693,10 +721,12 @@
|
|||
"updated_entity": "Updated {entity}"
|
||||
},
|
||||
"total": "Total",
|
||||
"true": "True",
|
||||
"twitter": "Twitter",
|
||||
"up-dir": "Up a directory",
|
||||
"updated_at": "Updated At",
|
||||
"url": "URL",
|
||||
"use_default": "Use default",
|
||||
"weight": "Weight",
|
||||
"years_old": "years old",
|
||||
"stats": {
|
||||
|
|
|
|||
|
|
@ -1,2 +1,11 @@
|
|||
export const filterData = <T>(data?: (T | null | undefined)[] | null) =>
|
||||
data ? (data.filter((item) => item) as T[]) : [];
|
||||
|
||||
interface ITypename {
|
||||
__typename?: string;
|
||||
}
|
||||
|
||||
export function withoutTypename<T extends ITypename>(o: T) {
|
||||
const { __typename, ...ret } = o;
|
||||
return ret;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue