Movie/Group tags (#4969)

* Combine common tag control code into hook
* Combine common scraped tag row code into hook
This commit is contained in:
WithoutPants 2024-06-18 11:24:15 +10:00 committed by GitHub
parent f9a624b803
commit fda4776d30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
63 changed files with 1586 additions and 450 deletions

View file

@ -334,6 +334,10 @@ input MovieFilterType {
url: StringCriterionInput url: StringCriterionInput
"Filter to only include movies where performer appears in a scene" "Filter to only include movies where performer appears in a scene"
performers: MultiCriterionInput performers: MultiCriterionInput
"Filter to only include movies with these tags"
tags: HierarchicalMultiCriterionInput
"Filter by tag count"
tag_count: IntCriterionInput
"Filter by date" "Filter by date"
date: DateCriterionInput date: DateCriterionInput
"Filter by creation time" "Filter by creation time"
@ -494,6 +498,9 @@ input TagFilterType {
"Filter by number of performers with this tag" "Filter by number of performers with this tag"
performer_count: IntCriterionInput performer_count: IntCriterionInput
"Filter by number of movies with this tag"
movie_count: IntCriterionInput
"Filter by number of markers with this tag" "Filter by number of markers with this tag"
marker_count: IntCriterionInput marker_count: IntCriterionInput

View file

@ -12,6 +12,7 @@ type Movie {
synopsis: String synopsis: String
url: String @deprecated(reason: "Use urls") url: String @deprecated(reason: "Use urls")
urls: [String!]! urls: [String!]!
tags: [Tag!]!
created_at: Time! created_at: Time!
updated_at: Time! updated_at: Time!
@ -34,6 +35,7 @@ input MovieCreateInput {
synopsis: String synopsis: String
url: String @deprecated(reason: "Use urls") url: String @deprecated(reason: "Use urls")
urls: [String!] urls: [String!]
tag_ids: [ID!]
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
front_image: String front_image: String
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
@ -53,6 +55,7 @@ input MovieUpdateInput {
synopsis: String synopsis: String
url: String @deprecated(reason: "Use urls") url: String @deprecated(reason: "Use urls")
urls: [String!] urls: [String!]
tag_ids: [ID!]
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
front_image: String front_image: String
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
@ -67,6 +70,7 @@ input BulkMovieUpdateInput {
studio_id: ID studio_id: ID
director: String director: String
urls: BulkUpdateStrings urls: BulkUpdateStrings
tag_ids: BulkUpdateIds
} }
input MovieDestroyInput { input MovieDestroyInput {

View file

@ -11,6 +11,7 @@ type ScrapedMovie {
urls: [String!] urls: [String!]
synopsis: String synopsis: String
studio: ScrapedStudio studio: ScrapedStudio
tags: [ScrapedTag!]
"This should be a base64 encoded data URL" "This should be a base64 encoded data URL"
front_image: String front_image: String
@ -28,4 +29,5 @@ input ScrapedMovieInput {
url: String @deprecated(reason: "use urls") url: String @deprecated(reason: "use urls")
urls: [String!] urls: [String!]
synopsis: String synopsis: String
# not including tags for the input
} }

View file

@ -13,6 +13,7 @@ type Tag {
image_count(depth: Int): Int! # Resolver image_count(depth: Int): Int! # Resolver
gallery_count(depth: Int): Int! # Resolver gallery_count(depth: Int): Int! # Resolver
performer_count(depth: Int): Int! # Resolver performer_count(depth: Int): Int! # Resolver
movie_count(depth: Int): Int! # Resolver
parents: [Tag!]! parents: [Tag!]!
children: [Tag!]! children: [Tag!]!

View file

@ -57,6 +57,20 @@ func (r *movieResolver) Studio(ctx context.Context, obj *models.Movie) (ret *mod
return loaders.From(ctx).StudioByID.Load(*obj.StudioID) return loaders.From(ctx).StudioByID.Load(*obj.StudioID)
} }
func (r movieResolver) Tags(ctx context.Context, obj *models.Movie) (ret []*models.Tag, err error) {
if !obj.TagIDs.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadTagIDs(ctx, r.repository.Movie)
}); err != nil {
return nil, err
}
}
var errs []error
ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List())
return ret, firstError(errs)
}
func (r *movieResolver) FrontImagePath(ctx context.Context, obj *models.Movie) (*string, error) { func (r *movieResolver) FrontImagePath(ctx context.Context, obj *models.Movie) (*string, error) {
var hasImage bool var hasImage bool
if err := r.withReadTxn(ctx, func(ctx context.Context) error { if err := r.withReadTxn(ctx, func(ctx context.Context) error {

View file

@ -8,6 +8,7 @@ import (
"github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/movie"
"github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/scene"
) )
@ -107,6 +108,17 @@ func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag, depth
return ret, nil return ret, nil
} }
func (r *tagResolver) MovieCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = movie.CountByTagID(ctx, r.repository.Movie, obj.ID, depth)
return err
}); err != nil {
return 0, err
}
return ret, nil
}
func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) { func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) {
var hasImage bool var hasImage bool
if err := r.withReadTxn(ctx, func(ctx context.Context) error { if err := r.withReadTxn(ctx, func(ctx context.Context) error {

View file

@ -50,6 +50,11 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp
return nil, fmt.Errorf("converting studio id: %w", err) return nil, fmt.Errorf("converting studio id: %w", err)
} }
newMovie.TagIDs, err = translator.relatedIds(input.TagIds)
if err != nil {
return nil, fmt.Errorf("converting tag ids: %w", err)
}
if input.Urls != nil { if input.Urls != nil {
newMovie.URLs = models.NewRelatedStrings(input.Urls) newMovie.URLs = models.NewRelatedStrings(input.Urls)
} else if input.URL != nil { } else if input.URL != nil {
@ -140,6 +145,11 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp
return nil, fmt.Errorf("converting studio id: %w", err) return nil, fmt.Errorf("converting studio id: %w", err)
} }
updatedMovie.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids")
if err != nil {
return nil, fmt.Errorf("converting tag ids: %w", err)
}
updatedMovie.URLs = translator.optionalURLs(input.Urls, input.URL) updatedMovie.URLs = translator.optionalURLs(input.Urls, input.URL)
var frontimageData []byte var frontimageData []byte
@ -211,6 +221,12 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU
if err != nil { if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err) return nil, fmt.Errorf("converting studio id: %w", err)
} }
updatedMovie.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids")
if err != nil {
return nil, fmt.Errorf("converting tag ids: %w", err)
}
updatedMovie.URLs = translator.optionalURLsBulk(input.Urls, nil) updatedMovie.URLs = translator.optionalURLsBulk(input.Urls, nil)
ret := []*models.Movie{} ret := []*models.Movie{}

View file

@ -144,6 +144,23 @@ func filterPerformerTags(p []*models.ScrapedPerformer) {
} }
} }
// filterMovieTags removes tags matching excluded tag patterns from the provided scraped movies
func filterMovieTags(p []*models.ScrapedMovie) {
excludeRegexps := compileRegexps(manager.GetInstance().Config.GetScraperExcludeTagPatterns())
var ignoredTags []string
for _, s := range p {
var ignored []string
s.Tags, ignored = filterTags(excludeRegexps, s.Tags)
ignoredTags = sliceutil.AppendUniques(ignoredTags, ignored)
}
if len(ignoredTags) > 0 {
logger.Debugf("Scraping ignored tags: %s", strings.Join(ignoredTags, ", "))
}
}
func (r *queryResolver) ScrapeSceneURL(ctx context.Context, url string) (*scraper.ScrapedScene, error) { func (r *queryResolver) ScrapeSceneURL(ctx context.Context, url string) (*scraper.ScrapedScene, error) {
content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeScene) content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeScene)
if err != nil { if err != nil {
@ -186,7 +203,14 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models
return nil, err return nil, err
} }
return marshalScrapedMovie(content) ret, err := marshalScrapedMovie(content)
if err != nil {
return nil, err
}
filterMovieTags([]*models.ScrapedMovie{ret})
return ret, nil
} }
func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.Source, input ScrapeSingleSceneInput) ([]*scraper.ScrapedScene, error) { func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.Source, input ScrapeSingleSceneInput) ([]*scraper.ScrapedScene, error) {

View file

@ -1107,6 +1107,7 @@ func (t *ExportTask) exportMovie(ctx context.Context, wg *sync.WaitGroup, jobCha
r := t.repository r := t.repository
movieReader := r.Movie movieReader := r.Movie
studioReader := r.Studio studioReader := r.Studio
tagReader := r.Tag
for m := range jobChan { for m := range jobChan {
if err := m.LoadURLs(ctx, r.Movie); err != nil { if err := m.LoadURLs(ctx, r.Movie); err != nil {
@ -1121,6 +1122,14 @@ func (t *ExportTask) exportMovie(ctx context.Context, wg *sync.WaitGroup, jobCha
continue continue
} }
tags, err := tagReader.FindByMovieID(ctx, m.ID)
if err != nil {
logger.Errorf("[movies] <%s> error getting image tag names: %v", m.Name, err)
continue
}
newMovieJSON.Tags = tag.GetNames(tags)
if t.includeDependencies { if t.includeDependencies {
if m.StudioID != nil { if m.StudioID != nil {
t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *m.StudioID) t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *m.StudioID)

View file

@ -351,6 +351,7 @@ func (t *ImportTask) ImportMovies(ctx context.Context) {
movieImporter := &movie.Importer{ movieImporter := &movie.Importer{
ReaderWriter: r.Movie, ReaderWriter: r.Movie,
StudioWriter: r.Studio, StudioWriter: r.Studio,
TagWriter: r.Tag,
Input: *movieJSON, Input: *movieJSON,
MissingRefBehaviour: t.MissingRefBehaviour, MissingRefBehaviour: t.MissingRefBehaviour,
} }

View file

@ -23,6 +23,7 @@ type Movie struct {
BackImage string `json:"back_image,omitempty"` BackImage string `json:"back_image,omitempty"`
URLs []string `json:"urls,omitempty"` URLs []string `json:"urls,omitempty"`
Studio string `json:"studio,omitempty"` Studio string `json:"studio,omitempty"`
Tags []string `json:"tags,omitempty"`
CreatedAt json.JSONTime `json:"created_at,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"`
UpdatedAt json.JSONTime `json:"updated_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"`

View file

@ -312,6 +312,29 @@ func (_m *MovieReaderWriter) GetFrontImage(ctx context.Context, movieID int) ([]
return r0, r1 return r0, r1
} }
// GetTagIDs provides a mock function with given fields: ctx, relatedID
func (_m *MovieReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) {
ret := _m.Called(ctx, relatedID)
var r0 []int
if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {
r0 = rf(ctx, relatedID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]int)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, relatedID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetURLs provides a mock function with given fields: ctx, relatedID // GetURLs provides a mock function with given fields: ctx, relatedID
func (_m *MovieReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { func (_m *MovieReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) {
ret := _m.Called(ctx, relatedID) ret := _m.Called(ctx, relatedID)

View file

@ -266,6 +266,29 @@ func (_m *TagReaderWriter) FindByImageID(ctx context.Context, imageID int) ([]*m
return r0, r1 return r0, r1
} }
// FindByMovieID provides a mock function with given fields: ctx, movieID
func (_m *TagReaderWriter) FindByMovieID(ctx context.Context, movieID int) ([]*models.Tag, error) {
ret := _m.Called(ctx, movieID)
var r0 []*models.Tag
if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok {
r0 = rf(ctx, movieID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Tag)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, movieID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByName provides a mock function with given fields: ctx, name, nocase // FindByName provides a mock function with given fields: ctx, name, nocase
func (_m *TagReaderWriter) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) { func (_m *TagReaderWriter) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) {
ret := _m.Called(ctx, name, nocase) ret := _m.Called(ctx, name, nocase)

View file

@ -19,7 +19,8 @@ type Movie struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
URLs RelatedStrings `json:"urls"` URLs RelatedStrings `json:"urls"`
TagIDs RelatedIDs `json:"tag_ids"`
} }
func NewMovie() Movie { func NewMovie() Movie {
@ -30,9 +31,15 @@ func NewMovie() Movie {
} }
} }
func (g *Movie) LoadURLs(ctx context.Context, l URLLoader) error { func (m *Movie) LoadURLs(ctx context.Context, l URLLoader) error {
return g.URLs.load(func() ([]string, error) { return m.URLs.load(func() ([]string, error) {
return l.GetURLs(ctx, g.ID) return l.GetURLs(ctx, m.ID)
})
}
func (m *Movie) LoadTagIDs(ctx context.Context, l TagIDLoader) error {
return m.TagIDs.load(func() ([]int, error) {
return l.GetTagIDs(ctx, m.ID)
}) })
} }
@ -47,6 +54,7 @@ type MoviePartial struct {
Director OptionalString Director OptionalString
Synopsis OptionalString Synopsis OptionalString
URLs *UpdateStrings URLs *UpdateStrings
TagIDs *UpdateIDs
CreatedAt OptionalTime CreatedAt OptionalTime
UpdatedAt OptionalTime UpdatedAt OptionalTime
} }

View file

@ -371,6 +371,7 @@ type ScrapedMovie struct {
URLs []string `json:"urls"` URLs []string `json:"urls"`
Synopsis *string `json:"synopsis"` Synopsis *string `json:"synopsis"`
Studio *ScrapedStudio `json:"studio"` Studio *ScrapedStudio `json:"studio"`
Tags []*ScrapedTag `json:"tags"`
// This should be a base64 encoded data URL // This should be a base64 encoded data URL
FrontImage *string `json:"front_image"` FrontImage *string `json:"front_image"`
// This should be a base64 encoded data URL // This should be a base64 encoded data URL

View file

@ -17,6 +17,10 @@ type MovieFilterType struct {
URL *StringCriterionInput `json:"url"` URL *StringCriterionInput `json:"url"`
// Filter to only include movies where performer appears in a scene // Filter to only include movies where performer appears in a scene
Performers *MultiCriterionInput `json:"performers"` Performers *MultiCriterionInput `json:"performers"`
// Filter to only include performers with these tags
Tags *HierarchicalMultiCriterionInput `json:"tags"`
// Filter by tag count
TagCount *IntCriterionInput `json:"tag_count"`
// Filter by date // Filter by date
Date *DateCriterionInput `json:"date"` Date *DateCriterionInput `json:"date"`
// Filter by related scenes that meet this criteria // Filter by related scenes that meet this criteria

View file

@ -65,6 +65,7 @@ type MovieReader interface {
MovieQueryer MovieQueryer
MovieCounter MovieCounter
URLLoader URLLoader
TagIDLoader
All(ctx context.Context) ([]*Movie, error) All(ctx context.Context) ([]*Movie, error)
GetFrontImage(ctx context.Context, movieID int) ([]byte, error) GetFrontImage(ctx context.Context, movieID int) ([]byte, error)

View file

@ -20,6 +20,7 @@ type TagFinder interface {
FindByImageID(ctx context.Context, imageID int) ([]*Tag, error) FindByImageID(ctx context.Context, imageID int) ([]*Tag, error)
FindByGalleryID(ctx context.Context, galleryID int) ([]*Tag, error) FindByGalleryID(ctx context.Context, galleryID int) ([]*Tag, error)
FindByPerformerID(ctx context.Context, performerID int) ([]*Tag, error) FindByPerformerID(ctx context.Context, performerID int) ([]*Tag, error)
FindByMovieID(ctx context.Context, movieID int) ([]*Tag, error)
FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error)
FindByName(ctx context.Context, name string, nocase bool) (*Tag, error) FindByName(ctx context.Context, name string, nocase bool) (*Tag, error)
FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error) FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error)

View file

@ -20,6 +20,8 @@ type TagFilterType struct {
GalleryCount *IntCriterionInput `json:"gallery_count"` GalleryCount *IntCriterionInput `json:"gallery_count"`
// Filter by number of performers with this tag // Filter by number of performers with this tag
PerformerCount *IntCriterionInput `json:"performer_count"` PerformerCount *IntCriterionInput `json:"performer_count"`
// Filter by number of movies with this tag
MovieCount *IntCriterionInput `json:"movie_count"`
// Filter by number of markers with this tag // Filter by number of markers with this tag
MarkerCount *IntCriterionInput `json:"marker_count"` MarkerCount *IntCriterionInput `json:"marker_count"`
// Filter by parent tags // Filter by parent tags

View file

@ -3,9 +3,11 @@ package movie
import ( import (
"context" "context"
"fmt" "fmt"
"strings"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/pkg/utils"
) )
@ -17,6 +19,7 @@ type ImporterReaderWriter interface {
type Importer struct { type Importer struct {
ReaderWriter ImporterReaderWriter ReaderWriter ImporterReaderWriter
StudioWriter models.StudioFinderCreator StudioWriter models.StudioFinderCreator
TagWriter models.TagFinderCreator
Input jsonschema.Movie Input jsonschema.Movie
MissingRefBehaviour models.ImportMissingRefEnum MissingRefBehaviour models.ImportMissingRefEnum
@ -32,6 +35,10 @@ func (i *Importer) PreImport(ctx context.Context) error {
return err return err
} }
if err := i.populateTags(ctx); err != nil {
return err
}
var err error var err error
if len(i.Input.FrontImage) > 0 { if len(i.Input.FrontImage) > 0 {
i.frontImageData, err = utils.ProcessBase64Image(i.Input.FrontImage) i.frontImageData, err = utils.ProcessBase64Image(i.Input.FrontImage)
@ -49,6 +56,74 @@ func (i *Importer) PreImport(ctx context.Context) error {
return nil return nil
} }
func (i *Importer) populateTags(ctx context.Context) error {
if len(i.Input.Tags) > 0 {
tags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour)
if err != nil {
return err
}
for _, p := range tags {
i.movie.TagIDs.Add(p.ID)
}
}
return nil
}
func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) {
tags, err := tagWriter.FindByNames(ctx, names, false)
if err != nil {
return nil, err
}
var pluckedNames []string
for _, tag := range tags {
pluckedNames = append(pluckedNames, tag.Name)
}
missingTags := sliceutil.Filter(names, func(name string) bool {
return !sliceutil.Contains(pluckedNames, name)
})
if len(missingTags) > 0 {
if missingRefBehaviour == models.ImportMissingRefEnumFail {
return nil, fmt.Errorf("tags [%s] not found", strings.Join(missingTags, ", "))
}
if missingRefBehaviour == models.ImportMissingRefEnumCreate {
createdTags, err := createTags(ctx, tagWriter, missingTags)
if err != nil {
return nil, fmt.Errorf("error creating tags: %v", err)
}
tags = append(tags, createdTags...)
}
// ignore if MissingRefBehaviour set to Ignore
}
return tags, nil
}
func createTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string) ([]*models.Tag, error) {
var ret []*models.Tag
for _, name := range names {
newTag := models.NewTag()
newTag.Name = name
err := tagWriter.Create(ctx, &newTag)
if err != nil {
return nil, err
}
ret = append(ret, &newTag)
}
return ret, nil
}
func (i *Importer) movieJSONToMovie(movieJSON jsonschema.Movie) models.Movie { func (i *Importer) movieJSONToMovie(movieJSON jsonschema.Movie) models.Movie {
newMovie := models.Movie{ newMovie := models.Movie{
Name: movieJSON.Name, Name: movieJSON.Name,
@ -57,6 +132,8 @@ func (i *Importer) movieJSONToMovie(movieJSON jsonschema.Movie) models.Movie {
Synopsis: movieJSON.Synopsis, Synopsis: movieJSON.Synopsis,
CreatedAt: movieJSON.CreatedAt.GetTime(), CreatedAt: movieJSON.CreatedAt.GetTime(),
UpdatedAt: movieJSON.UpdatedAt.GetTime(), UpdatedAt: movieJSON.UpdatedAt.GetTime(),
TagIDs: models.NewRelatedIDs([]int{}),
} }
if len(movieJSON.URLs) > 0 { if len(movieJSON.URLs) > 0 {

View file

@ -26,6 +26,13 @@ const (
missingStudioName = "existingStudioName" missingStudioName = "existingStudioName"
errImageID = 3 errImageID = 3
existingTagID = 105
errTagsID = 106
existingTagName = "existingTagName"
existingTagErr = "existingTagErr"
missingTagName = "missingTagName"
) )
var testCtx = context.Background() var testCtx = context.Background()
@ -157,6 +164,97 @@ func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) {
db.AssertExpectations(t) db.AssertExpectations(t)
} }
func TestImporterPreImportWithTag(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
ReaderWriter: db.Movie,
TagWriter: db.Tag,
MissingRefBehaviour: models.ImportMissingRefEnumFail,
Input: jsonschema.Movie{
Tags: []string{
existingTagName,
},
},
}
db.Tag.On("FindByNames", testCtx, []string{existingTagName}, false).Return([]*models.Tag{
{
ID: existingTagID,
Name: existingTagName,
},
}, nil).Once()
db.Tag.On("FindByNames", testCtx, []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Once()
err := i.PreImport(testCtx)
assert.Nil(t, err)
assert.Equal(t, existingTagID, i.movie.TagIDs.List()[0])
i.Input.Tags = []string{existingTagErr}
err = i.PreImport(testCtx)
assert.NotNil(t, err)
db.AssertExpectations(t)
}
func TestImporterPreImportWithMissingTag(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
ReaderWriter: db.Movie,
TagWriter: db.Tag,
Input: jsonschema.Movie{
Tags: []string{
missingTagName,
},
},
MissingRefBehaviour: models.ImportMissingRefEnumFail,
}
db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3)
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Run(func(args mock.Arguments) {
t := args.Get(1).(*models.Tag)
t.ID = existingTagID
}).Return(nil)
err := i.PreImport(testCtx)
assert.NotNil(t, err)
i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore
err = i.PreImport(testCtx)
assert.Nil(t, err)
i.MissingRefBehaviour = models.ImportMissingRefEnumCreate
err = i.PreImport(testCtx)
assert.Nil(t, err)
assert.Equal(t, existingTagID, i.movie.TagIDs.List()[0])
db.AssertExpectations(t)
}
func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) {
db := mocks.NewDatabase()
i := Importer{
ReaderWriter: db.Movie,
TagWriter: db.Tag,
Input: jsonschema.Movie{
Tags: []string{
missingTagName,
},
},
MissingRefBehaviour: models.ImportMissingRefEnumCreate,
}
db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once()
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Return(errors.New("Create error"))
err := i.PreImport(testCtx)
assert.NotNil(t, err)
db.AssertExpectations(t)
}
func TestImporterPostImport(t *testing.T) { func TestImporterPostImport(t *testing.T) {
db := mocks.NewDatabase() db := mocks.NewDatabase()

View file

@ -18,3 +18,15 @@ func CountByStudioID(ctx context.Context, r models.MovieQueryer, id int, depth *
return r.QueryCount(ctx, filter, nil) return r.QueryCount(ctx, filter, nil)
} }
func CountByTagID(ctx context.Context, r models.MovieQueryer, id int, depth *int) (int, error) {
filter := &models.MovieFilterType{
Tags: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(id)},
Modifier: models.CriterionModifierIncludes,
Depth: depth,
},
}
return r.QueryCount(ctx, filter, nil)
}

View file

@ -284,11 +284,13 @@ type mappedMovieScraperConfig struct {
mappedConfig mappedConfig
Studio mappedConfig `yaml:"Studio"` Studio mappedConfig `yaml:"Studio"`
Tags mappedConfig `yaml:"Tags"`
} }
type _mappedMovieScraperConfig mappedMovieScraperConfig type _mappedMovieScraperConfig mappedMovieScraperConfig
const ( const (
mappedScraperConfigMovieStudio = "Studio" mappedScraperConfigMovieStudio = "Studio"
mappedScraperConfigMovieTags = "Tags"
) )
func (s *mappedMovieScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { func (s *mappedMovieScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
@ -303,9 +305,11 @@ func (s *mappedMovieScraperConfig) UnmarshalYAML(unmarshal func(interface{}) err
thisMap := make(map[string]interface{}) thisMap := make(map[string]interface{})
thisMap[mappedScraperConfigMovieStudio] = parentMap[mappedScraperConfigMovieStudio] thisMap[mappedScraperConfigMovieStudio] = parentMap[mappedScraperConfigMovieStudio]
delete(parentMap, mappedScraperConfigMovieStudio) delete(parentMap, mappedScraperConfigMovieStudio)
thisMap[mappedScraperConfigMovieTags] = parentMap[mappedScraperConfigMovieTags]
delete(parentMap, mappedScraperConfigMovieTags)
// re-unmarshal the sub-fields // re-unmarshal the sub-fields
yml, err := yaml.Marshal(thisMap) yml, err := yaml.Marshal(thisMap)
if err != nil { if err != nil {
@ -1086,6 +1090,7 @@ func (s mappedScraper) scrapeMovie(ctx context.Context, q mappedQuery) (*models.
movieMap := movieScraperConfig.mappedConfig movieMap := movieScraperConfig.mappedConfig
movieStudioMap := movieScraperConfig.Studio movieStudioMap := movieScraperConfig.Studio
movieTagsMap := movieScraperConfig.Tags
results := movieMap.process(ctx, q, s.Common) results := movieMap.process(ctx, q, s.Common)
@ -1100,7 +1105,19 @@ func (s mappedScraper) scrapeMovie(ctx context.Context, q mappedQuery) (*models.
} }
} }
if len(results) == 0 && ret.Studio == nil { // now apply the tags
if movieTagsMap != nil {
logger.Debug(`Processing movie tags:`)
tagResults := movieTagsMap.process(ctx, q, s.Common)
for _, p := range tagResults {
tag := &models.ScrapedTag{}
p.apply(tag)
ret.Tags = append(ret.Tags, tag)
}
}
if len(results) == 0 && ret.Studio == nil && len(ret.Tags) == 0 {
return nil, nil return nil, nil
} }

View file

@ -71,13 +71,24 @@ func (c Cache) postScrapePerformer(ctx context.Context, p models.ScrapedPerforme
} }
func (c Cache) postScrapeMovie(ctx context.Context, m models.ScrapedMovie) (ScrapedContent, error) { func (c Cache) postScrapeMovie(ctx context.Context, m models.ScrapedMovie) (ScrapedContent, error) {
if m.Studio != nil { r := c.repository
r := c.repository if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
if err := r.WithReadTxn(ctx, func(ctx context.Context) error { tqb := r.TagFinder
return match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, nil) tags, err := postProcessTags(ctx, tqb, m.Tags)
}); err != nil { if err != nil {
return nil, err return err
} }
m.Tags = tags
if m.Studio != nil {
if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, nil); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
} }
// post-process - set the image if applicable // post-process - set the image if applicable

View file

@ -30,7 +30,7 @@ const (
dbConnTimeout = 30 dbConnTimeout = 30
) )
var appSchemaVersion uint = 60 var appSchemaVersion uint = 61
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsBox embed.FS var migrationsBox embed.FS

View file

@ -0,0 +1,10 @@
CREATE TABLE `movies_tags` (
`movie_id` integer NOT NULL,
`tag_id` integer NOT NULL,
foreign key(`movie_id`) references `movies`(`id`) on delete CASCADE,
foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE,
PRIMARY KEY(`movie_id`, `tag_id`)
);
CREATE INDEX `index_movies_tags_on_tag_id` on `movies_tags` (`tag_id`);
CREATE INDEX `index_movies_tags_on_movie_id` on `movies_tags` (`movie_id`);

View file

@ -23,6 +23,8 @@ const (
movieFrontImageBlobColumn = "front_image_blob" movieFrontImageBlobColumn = "front_image_blob"
movieBackImageBlobColumn = "back_image_blob" movieBackImageBlobColumn = "back_image_blob"
moviesTagsTable = "movies_tags"
movieURLsTable = "movie_urls" movieURLsTable = "movie_urls"
movieURLColumn = "url" movieURLColumn = "url"
) )
@ -98,6 +100,7 @@ func (r *movieRowRecord) fromPartial(o models.MoviePartial) {
type movieRepositoryType struct { type movieRepositoryType struct {
repository repository
scenes repository scenes repository
tags joinRepository
} }
var ( var (
@ -110,11 +113,21 @@ var (
tableName: moviesScenesTable, tableName: moviesScenesTable,
idColumn: movieIDColumn, idColumn: movieIDColumn,
}, },
tags: joinRepository{
repository: repository{
tableName: moviesTagsTable,
idColumn: movieIDColumn,
},
fkColumn: tagIDColumn,
foreignTable: tagTable,
orderBy: "tags.name ASC",
},
} }
) )
type MovieStore struct { type MovieStore struct {
blobJoinQueryBuilder blobJoinQueryBuilder
tagRelationshipStore
tableMgr *table tableMgr *table
} }
@ -125,6 +138,11 @@ func NewMovieStore(blobStore *BlobStore) *MovieStore {
blobStore: blobStore, blobStore: blobStore,
joinTable: movieTable, joinTable: movieTable,
}, },
tagRelationshipStore: tagRelationshipStore{
idRelationshipStore: idRelationshipStore{
joinTable: moviesTagsTableMgr,
},
},
tableMgr: movieTableMgr, tableMgr: movieTableMgr,
} }
@ -154,6 +172,10 @@ func (qb *MovieStore) Create(ctx context.Context, newObject *models.Movie) error
} }
} }
if err := qb.tagRelationshipStore.createRelationships(ctx, id, newObject.TagIDs); err != nil {
return err
}
updated, err := qb.find(ctx, id) updated, err := qb.find(ctx, id)
if err != nil { if err != nil {
return fmt.Errorf("finding after create: %w", err) return fmt.Errorf("finding after create: %w", err)
@ -185,6 +207,10 @@ func (qb *MovieStore) UpdatePartial(ctx context.Context, id int, partial models.
} }
} }
if err := qb.tagRelationshipStore.modifyRelationships(ctx, id, partial.TagIDs); err != nil {
return nil, err
}
return qb.find(ctx, id) return qb.find(ctx, id)
} }
@ -202,6 +228,10 @@ func (qb *MovieStore) Update(ctx context.Context, updatedObject *models.Movie) e
} }
} }
if err := qb.tagRelationshipStore.replaceRelationships(ctx, updatedObject.ID, updatedObject.TagIDs); err != nil {
return err
}
return nil return nil
} }
@ -430,6 +460,7 @@ var movieSortOptions = sortOptions{
"random", "random",
"rating", "rating",
"scenes_count", "scenes_count",
"tag_count",
"updated_at", "updated_at",
} }
@ -451,6 +482,8 @@ func (qb *MovieStore) getMovieSort(findFilter *models.FindFilterType) (string, e
sortQuery := "" sortQuery := ""
switch sort { switch sort {
case "tag_count":
sortQuery += getCountSort(movieTable, moviesTagsTable, movieIDColumn, direction)
case "scenes_count": // generic getSort won't work for this case "scenes_count": // generic getSort won't work for this
sortQuery += getCountSort(movieTable, moviesScenesTable, movieIDColumn, direction) sortQuery += getCountSort(movieTable, moviesScenesTable, movieIDColumn, direction)
default: default:

View file

@ -63,6 +63,8 @@ func (qb *movieFilterHandler) criterionHandler() criterionHandler {
qb.urlsCriterionHandler(movieFilter.URL), qb.urlsCriterionHandler(movieFilter.URL),
studioCriterionHandler(movieTable, movieFilter.Studios), studioCriterionHandler(movieTable, movieFilter.Studios),
qb.performersCriterionHandler(movieFilter.Performers), qb.performersCriterionHandler(movieFilter.Performers),
qb.tagsCriterionHandler(movieFilter.Tags),
qb.tagCountCriterionHandler(movieFilter.TagCount),
&dateCriterionHandler{movieFilter.Date, "movies.date", nil}, &dateCriterionHandler{movieFilter.Date, "movies.date", nil},
&timestampCriterionHandler{movieFilter.CreatedAt, "movies.created_at", nil}, &timestampCriterionHandler{movieFilter.CreatedAt, "movies.created_at", nil},
&timestampCriterionHandler{movieFilter.UpdatedAt, "movies.updated_at", nil}, &timestampCriterionHandler{movieFilter.UpdatedAt, "movies.updated_at", nil},
@ -162,3 +164,28 @@ func (qb *movieFilterHandler) performersCriterionHandler(performers *models.Mult
} }
} }
} }
func (qb *movieFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
h := joinedHierarchicalMultiCriterionHandlerBuilder{
primaryTable: movieTable,
foreignTable: tagTable,
foreignFK: "tag_id",
relationsTable: "tags_relations",
joinAs: "movie_tag",
joinTable: moviesTagsTable,
primaryFK: movieIDColumn,
}
return h.handler(tags)
}
func (qb *movieFilterHandler) tagCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {
h := countCriterionHandlerBuilder{
primaryTable: movieTable,
joinTable: moviesTagsTable,
primaryFK: movieIDColumn,
}
return h.handler(count)
}

View file

@ -9,6 +9,7 @@ import (
"strconv" "strconv"
"strings" "strings"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@ -17,7 +18,12 @@ import (
func loadMovieRelationships(ctx context.Context, expected models.Movie, actual *models.Movie) error { func loadMovieRelationships(ctx context.Context, expected models.Movie, actual *models.Movie) error {
if expected.URLs.Loaded() { if expected.URLs.Loaded() {
if err := actual.LoadURLs(ctx, db.Gallery); err != nil { if err := actual.LoadURLs(ctx, db.Movie); err != nil {
return err
}
}
if expected.TagIDs.Loaded() {
if err := actual.LoadTagIDs(ctx, db.Movie); err != nil {
return err return err
} }
} }
@ -25,6 +31,337 @@ func loadMovieRelationships(ctx context.Context, expected models.Movie, actual *
return nil return nil
} }
func Test_MovieStore_Create(t *testing.T) {
var (
name = "name"
url = "url"
aliases = "alias1, alias2"
director = "director"
rating = 60
duration = 34
synopsis = "synopsis"
date, _ = models.ParseDate("2003-02-01")
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
)
tests := []struct {
name string
newObject models.Movie
wantErr bool
}{
{
"full",
models.Movie{
Name: name,
Duration: &duration,
Date: &date,
Rating: &rating,
StudioID: &studioIDs[studioIdxWithMovie],
Director: director,
Synopsis: synopsis,
URLs: models.NewRelatedStrings([]string{url}),
TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithMovie]}),
Aliases: aliases,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
false,
},
{
"invalid tag id",
models.Movie{
Name: name,
TagIDs: models.NewRelatedIDs([]int{invalidID}),
},
true,
},
}
qb := db.Movie
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
p := tt.newObject
if err := qb.Create(ctx, &p); (err != nil) != tt.wantErr {
t.Errorf("MovieStore.Create() error = %v, wantErr = %v", err, tt.wantErr)
}
if tt.wantErr {
assert.Zero(p.ID)
return
}
assert.NotZero(p.ID)
copy := tt.newObject
copy.ID = p.ID
// load relationships
if err := loadMovieRelationships(ctx, copy, &p); err != nil {
t.Errorf("loadMovieRelationships() error = %v", err)
return
}
assert.Equal(copy, p)
// ensure can find the movie
found, err := qb.Find(ctx, p.ID)
if err != nil {
t.Errorf("MovieStore.Find() error = %v", err)
}
if !assert.NotNil(found) {
return
}
// load relationships
if err := loadMovieRelationships(ctx, copy, found); err != nil {
t.Errorf("loadMovieRelationships() error = %v", err)
return
}
assert.Equal(copy, *found)
return
})
}
}
func Test_movieQueryBuilder_Update(t *testing.T) {
var (
name = "name"
url = "url"
aliases = "alias1, alias2"
director = "director"
rating = 60
duration = 34
synopsis = "synopsis"
date, _ = models.ParseDate("2003-02-01")
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
)
tests := []struct {
name string
updatedObject *models.Movie
wantErr bool
}{
{
"full",
&models.Movie{
ID: movieIDs[movieIdxWithTag],
Name: name,
Duration: &duration,
Date: &date,
Rating: &rating,
StudioID: &studioIDs[studioIdxWithMovie],
Director: director,
Synopsis: synopsis,
URLs: models.NewRelatedStrings([]string{url}),
TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithMovie]}),
Aliases: aliases,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
},
false,
},
{
"clear tag ids",
&models.Movie{
ID: movieIDs[movieIdxWithTag],
Name: name,
TagIDs: models.NewRelatedIDs([]int{}),
},
false,
},
{
"invalid studio id",
&models.Movie{
ID: movieIDs[movieIdxWithScene],
Name: name,
StudioID: &invalidID,
},
true,
},
{
"invalid tag id",
&models.Movie{
ID: movieIDs[movieIdxWithScene],
Name: name,
TagIDs: models.NewRelatedIDs([]int{invalidID}),
},
true,
},
}
qb := db.Movie
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
copy := *tt.updatedObject
if err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr {
t.Errorf("movieQueryBuilder.Update() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantErr {
return
}
s, err := qb.Find(ctx, tt.updatedObject.ID)
if err != nil {
t.Errorf("movieQueryBuilder.Find() error = %v", err)
}
// load relationships
if err := loadMovieRelationships(ctx, copy, s); err != nil {
t.Errorf("loadMovieRelationships() error = %v", err)
return
}
assert.Equal(copy, *s)
})
}
}
func clearMoviePartial() models.MoviePartial {
// leave mandatory fields
return models.MoviePartial{
Aliases: models.OptionalString{Set: true, Null: true},
Synopsis: models.OptionalString{Set: true, Null: true},
Director: models.OptionalString{Set: true, Null: true},
Duration: models.OptionalInt{Set: true, Null: true},
URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet},
Date: models.OptionalDate{Set: true, Null: true},
Rating: models.OptionalInt{Set: true, Null: true},
StudioID: models.OptionalInt{Set: true, Null: true},
TagIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet},
}
}
func Test_movieQueryBuilder_UpdatePartial(t *testing.T) {
var (
name = "name"
url = "url"
aliases = "alias1, alias2"
director = "director"
rating = 60
duration = 34
synopsis = "synopsis"
date, _ = models.ParseDate("2003-02-01")
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
)
tests := []struct {
name string
id int
partial models.MoviePartial
want models.Movie
wantErr bool
}{
{
"full",
movieIDs[movieIdxWithScene],
models.MoviePartial{
Name: models.NewOptionalString(name),
Director: models.NewOptionalString(director),
Synopsis: models.NewOptionalString(synopsis),
Aliases: models.NewOptionalString(aliases),
URLs: &models.UpdateStrings{
Values: []string{url},
Mode: models.RelationshipUpdateModeSet,
},
Date: models.NewOptionalDate(date),
Duration: models.NewOptionalInt(duration),
Rating: models.NewOptionalInt(rating),
StudioID: models.NewOptionalInt(studioIDs[studioIdxWithMovie]),
CreatedAt: models.NewOptionalTime(createdAt),
UpdatedAt: models.NewOptionalTime(updatedAt),
TagIDs: &models.UpdateIDs{
IDs: []int{tagIDs[tagIdx1WithMovie], tagIDs[tagIdx1WithDupName]},
Mode: models.RelationshipUpdateModeSet,
},
},
models.Movie{
ID: movieIDs[movieIdxWithScene],
Name: name,
Director: director,
Synopsis: synopsis,
Aliases: aliases,
URLs: models.NewRelatedStrings([]string{url}),
Date: &date,
Duration: &duration,
Rating: &rating,
StudioID: &studioIDs[studioIdxWithMovie],
CreatedAt: createdAt,
UpdatedAt: updatedAt,
TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithMovie]}),
},
false,
},
{
"clear all",
movieIDs[movieIdxWithScene],
clearMoviePartial(),
models.Movie{
ID: movieIDs[movieIdxWithScene],
Name: movieNames[movieIdxWithScene],
TagIDs: models.NewRelatedIDs([]int{}),
},
false,
},
{
"invalid id",
invalidID,
models.MoviePartial{},
models.Movie{},
true,
},
}
for _, tt := range tests {
qb := db.Movie
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
got, err := qb.UpdatePartial(ctx, tt.id, tt.partial)
if (err != nil) != tt.wantErr {
t.Errorf("movieQueryBuilder.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
return
}
// load relationships
if err := loadMovieRelationships(ctx, tt.want, got); err != nil {
t.Errorf("loadMovieRelationships() error = %v", err)
return
}
assert.Equal(tt.want, *got)
s, err := qb.Find(ctx, tt.id)
if err != nil {
t.Errorf("movieQueryBuilder.Find() error = %v", err)
}
// load relationships
if err := loadMovieRelationships(ctx, tt.want, s); err != nil {
t.Errorf("loadMovieRelationships() error = %v", err)
return
}
assert.Equal(tt.want, *s)
})
}
}
func TestMovieFindByName(t *testing.T) { func TestMovieFindByName(t *testing.T) {
withTxn(func(ctx context.Context) error { withTxn(func(ctx context.Context) error {
mqb := db.Movie mqb := db.Movie
@ -280,12 +617,12 @@ func TestMovieQueryURLExcludes(t *testing.T) {
Name: &nameCriterion, Name: &nameCriterion,
} }
movies := queryMovie(ctx, t, mqb, &filter, nil) movies := queryMovies(ctx, t, &filter, nil)
assert.Len(t, movies, 0, "Expected no movies to be found") assert.Len(t, movies, 0, "Expected no movies to be found")
// query for movies that exclude the URL "ccc" // query for movies that exclude the URL "ccc"
urlCriterion.Value = "ccc" urlCriterion.Value = "ccc"
movies = queryMovie(ctx, t, mqb, &filter, nil) movies = queryMovies(ctx, t, &filter, nil)
if assert.Len(t, movies, 1, "Expected one movie to be found") { if assert.Len(t, movies, 1, "Expected one movie to be found") {
assert.Equal(t, movie.Name, movies[0].Name) assert.Equal(t, movie.Name, movies[0].Name)
@ -300,7 +637,7 @@ func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func
t.Helper() t.Helper()
sqb := db.Movie sqb := db.Movie
movies := queryMovie(ctx, t, sqb, &filter, nil) movies := queryMovies(ctx, t, &filter, nil)
for _, movie := range movies { for _, movie := range movies {
if err := movie.LoadURLs(ctx, sqb); err != nil { if err := movie.LoadURLs(ctx, sqb); err != nil {
@ -319,7 +656,8 @@ func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func
}) })
} }
func queryMovie(ctx context.Context, t *testing.T, sqb models.MovieReader, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) []*models.Movie { func queryMovies(ctx context.Context, t *testing.T, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) []*models.Movie {
sqb := db.Movie
movies, _, err := sqb.Query(ctx, movieFilter, findFilter) movies, _, err := sqb.Query(ctx, movieFilter, findFilter)
if err != nil { if err != nil {
t.Errorf("Error querying movie: %s", err.Error()) t.Errorf("Error querying movie: %s", err.Error())
@ -328,6 +666,102 @@ func queryMovie(ctx context.Context, t *testing.T, sqb models.MovieReader, movie
return movies return movies
} }
func TestMovieQueryTags(t *testing.T) {
withTxn(func(ctx context.Context) error {
tagCriterion := models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdxWithMovie]),
strconv.Itoa(tagIDs[tagIdx1WithMovie]),
},
Modifier: models.CriterionModifierIncludes,
}
movieFilter := models.MovieFilterType{
Tags: &tagCriterion,
}
// ensure ids are correct
movies := queryMovies(ctx, t, &movieFilter, nil)
assert.Len(t, movies, 3)
for _, movie := range movies {
assert.True(t, movie.ID == movieIDs[movieIdxWithTag] || movie.ID == movieIDs[movieIdxWithTwoTags] || movie.ID == movieIDs[movieIdxWithThreeTags])
}
tagCriterion = models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdx1WithMovie]),
strconv.Itoa(tagIDs[tagIdx2WithMovie]),
},
Modifier: models.CriterionModifierIncludesAll,
}
movies = queryMovies(ctx, t, &movieFilter, nil)
if assert.Len(t, movies, 2) {
assert.Equal(t, sceneIDs[movieIdxWithTwoTags], movies[0].ID)
assert.Equal(t, sceneIDs[movieIdxWithThreeTags], movies[1].ID)
}
tagCriterion = models.HierarchicalMultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdx1WithMovie]),
},
Modifier: models.CriterionModifierExcludes,
}
q := getSceneStringValue(movieIdxWithTwoTags, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
movies = queryMovies(ctx, t, &movieFilter, &findFilter)
assert.Len(t, movies, 0)
return nil
})
}
func TestMovieQueryTagCount(t *testing.T) {
const tagCount = 1
tagCountCriterion := models.IntCriterionInput{
Value: tagCount,
Modifier: models.CriterionModifierEquals,
}
verifyMoviesTagCount(t, tagCountCriterion)
tagCountCriterion.Modifier = models.CriterionModifierNotEquals
verifyMoviesTagCount(t, tagCountCriterion)
tagCountCriterion.Modifier = models.CriterionModifierGreaterThan
verifyMoviesTagCount(t, tagCountCriterion)
tagCountCriterion.Modifier = models.CriterionModifierLessThan
verifyMoviesTagCount(t, tagCountCriterion)
}
func verifyMoviesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) {
withTxn(func(ctx context.Context) error {
sqb := db.Movie
movieFilter := models.MovieFilterType{
TagCount: &tagCountCriterion,
}
movies := queryMovies(ctx, t, &movieFilter, nil)
assert.Greater(t, len(movies), 0)
for _, movie := range movies {
ids, err := sqb.GetTagIDs(ctx, movie.ID)
if err != nil {
return err
}
verifyInt(t, len(ids), tagCountCriterion)
}
return nil
})
}
func TestMovieQuerySorting(t *testing.T) { func TestMovieQuerySorting(t *testing.T) {
sort := "scenes_count" sort := "scenes_count"
direction := models.SortDirectionEnumDesc direction := models.SortDirectionEnumDesc
@ -337,8 +771,7 @@ func TestMovieQuerySorting(t *testing.T) {
} }
withTxn(func(ctx context.Context) error { withTxn(func(ctx context.Context) error {
sqb := db.Movie movies := queryMovies(ctx, t, nil, &findFilter)
movies := queryMovie(ctx, t, sqb, nil, &findFilter)
// scenes should be in same order as indexes // scenes should be in same order as indexes
firstMovie := movies[0] firstMovie := movies[0]
@ -348,7 +781,7 @@ func TestMovieQuerySorting(t *testing.T) {
// sort in descending order // sort in descending order
direction = models.SortDirectionEnumAsc direction = models.SortDirectionEnumAsc
movies = queryMovie(ctx, t, sqb, nil, &findFilter) movies = queryMovies(ctx, t, nil, &findFilter)
lastMovie := movies[len(movies)-1] lastMovie := movies[len(movies)-1]
assert.Equal(t, movieIDs[movieIdxWithScene], lastMovie.ID) assert.Equal(t, movieIDs[movieIdxWithScene], lastMovie.ID)

View file

@ -0,0 +1,41 @@
package sqlite
import (
"context"
"github.com/stashapp/stash/pkg/models"
)
type idRelationshipStore struct {
joinTable *joinTable
}
func (s *idRelationshipStore) createRelationships(ctx context.Context, id int, fkIDs models.RelatedIDs) error {
if fkIDs.Loaded() {
if err := s.joinTable.insertJoins(ctx, id, fkIDs.List()); err != nil {
return err
}
}
return nil
}
func (s *idRelationshipStore) modifyRelationships(ctx context.Context, id int, fkIDs *models.UpdateIDs) error {
if fkIDs != nil {
if err := s.joinTable.modifyJoins(ctx, id, fkIDs.IDs, fkIDs.Mode); err != nil {
return err
}
}
return nil
}
func (s *idRelationshipStore) replaceRelationships(ctx context.Context, id int, fkIDs models.RelatedIDs) error {
if fkIDs.Loaded() {
if err := s.joinTable.replaceJoins(ctx, id, fkIDs.List()); err != nil {
return err
}
}
return nil
}

View file

@ -150,9 +150,12 @@ const (
const ( const (
movieIdxWithScene = iota movieIdxWithScene = iota
movieIdxWithStudio movieIdxWithStudio
movieIdxWithTag
movieIdxWithTwoTags
movieIdxWithThreeTags
// movies with dup names start from the end // movies with dup names start from the end
// create 10 more basic movies (can remove this if we add more indexes) // create 7 more basic movies (can remove this if we add more indexes)
movieIdxWithDupName = movieIdxWithStudio + 10 movieIdxWithDupName = movieIdxWithStudio + 7
moviesNameCase = movieIdxWithDupName moviesNameCase = movieIdxWithDupName
moviesNameNoCase = 1 moviesNameNoCase = 1
@ -214,6 +217,10 @@ const (
tagIdxWithParentAndChild tagIdxWithParentAndChild
tagIdxWithGrandParent tagIdxWithGrandParent
tagIdx2WithMarkers tagIdx2WithMarkers
tagIdxWithMovie
tagIdx1WithMovie
tagIdx2WithMovie
tagIdx3WithMovie
// new indexes above // new indexes above
// tags with dup names start from the end // tags with dup names start from the end
tagIdx1WithDupName tagIdx1WithDupName
@ -487,6 +494,12 @@ var (
movieStudioLinks = [][2]int{ movieStudioLinks = [][2]int{
{movieIdxWithStudio, studioIdxWithMovie}, {movieIdxWithStudio, studioIdxWithMovie},
} }
movieTags = linkMap{
movieIdxWithTag: {tagIdxWithMovie},
movieIdxWithTwoTags: {tagIdx1WithMovie, tagIdx2WithMovie},
movieIdxWithThreeTags: {tagIdx1WithMovie, tagIdx2WithMovie, tagIdx3WithMovie},
}
) )
var ( var (
@ -622,14 +635,14 @@ func populateDB() error {
// TODO - link folders to zip files // TODO - link folders to zip files
if err := createMovies(ctx, db.Movie, moviesNameCase, moviesNameNoCase); err != nil {
return fmt.Errorf("error creating movies: %s", err.Error())
}
if err := createTags(ctx, db.Tag, tagsNameCase, tagsNameNoCase); err != nil { if err := createTags(ctx, db.Tag, tagsNameCase, tagsNameNoCase); err != nil {
return fmt.Errorf("error creating tags: %s", err.Error()) return fmt.Errorf("error creating tags: %s", err.Error())
} }
if err := createMovies(ctx, db.Movie, moviesNameCase, moviesNameNoCase); err != nil {
return fmt.Errorf("error creating movies: %s", err.Error())
}
if err := createPerformers(ctx, performersNameCase, performersNameNoCase); err != nil { if err := createPerformers(ctx, performersNameCase, performersNameNoCase); err != nil {
return fmt.Errorf("error creating performers: %s", err.Error()) return fmt.Errorf("error creating performers: %s", err.Error())
} }
@ -1321,6 +1334,8 @@ func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o in
index := i index := i
name := namePlain name := namePlain
tids := indexesToIDs(tagIDs, movieTags[i])
if i >= n { // i<n tags get normal names if i >= n { // i<n tags get normal names
name = nameNoCase // i>=n movies get dup names if case is not checked name = nameNoCase // i>=n movies get dup names if case is not checked
index = n + o - (i + 1) // for the name to be the same the number (index) must be the same also index = n + o - (i + 1) // for the name to be the same the number (index) must be the same also
@ -1333,6 +1348,7 @@ func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o in
URLs: models.NewRelatedStrings([]string{ URLs: models.NewRelatedStrings([]string{
getMovieEmptyString(i, urlField), getMovieEmptyString(i, urlField),
}), }),
TagIDs: models.NewRelatedIDs(tids),
} }
err := mqb.Create(ctx, &movie) err := mqb.Create(ctx, &movie)

View file

@ -155,6 +155,10 @@ func (t *table) join(j joiner, as string, parentIDCol string) {
type joinTable struct { type joinTable struct {
table table
fkColumn exp.IdentifierExpression fkColumn exp.IdentifierExpression
// required for ordering
foreignTable *table
orderBy exp.OrderedExpression
} }
func (t *joinTable) invert() *joinTable { func (t *joinTable) invert() *joinTable {
@ -170,6 +174,13 @@ func (t *joinTable) invert() *joinTable {
func (t *joinTable) get(ctx context.Context, id int) ([]int, error) { func (t *joinTable) get(ctx context.Context, id int) ([]int, error) {
q := dialect.Select(t.fkColumn).From(t.table.table).Where(t.idColumn.Eq(id)) q := dialect.Select(t.fkColumn).From(t.table.table).Where(t.idColumn.Eq(id))
if t.orderBy != nil {
if t.foreignTable != nil {
q = q.InnerJoin(t.foreignTable.table, goqu.On(t.foreignTable.idColumn.Eq(t.fkColumn)))
}
q = q.Order(t.orderBy)
}
const single = false const single = false
var ret []int var ret []int
if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {

View file

@ -36,6 +36,7 @@ var (
studiosStashIDsJoinTable = goqu.T("studio_stash_ids") studiosStashIDsJoinTable = goqu.T("studio_stash_ids")
moviesURLsJoinTable = goqu.T(movieURLsTable) moviesURLsJoinTable = goqu.T(movieURLsTable)
moviesTagsJoinTable = goqu.T(moviesTagsTable)
tagsAliasesJoinTable = goqu.T(tagAliasesTable) tagsAliasesJoinTable = goqu.T(tagAliasesTable)
tagRelationsJoinTable = goqu.T(tagRelationsTable) tagRelationsJoinTable = goqu.T(tagRelationsTable)
@ -330,6 +331,16 @@ var (
}, },
valueColumn: moviesURLsJoinTable.Col(movieURLColumn), valueColumn: moviesURLsJoinTable.Col(movieURLColumn),
} }
moviesTagsTableMgr = &joinTable{
table: table{
table: moviesTagsJoinTable,
idColumn: moviesTagsJoinTable.Col(movieIDColumn),
},
fkColumn: moviesTagsJoinTable.Col(tagIDColumn),
foreignTable: tagTableMgr,
orderBy: tagTableMgr.table.Col("name").Asc(),
}
) )
var ( var (

View file

@ -424,6 +424,18 @@ func (qb *TagStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mode
return qb.queryTags(ctx, query, args) return qb.queryTags(ctx, query, args)
} }
func (qb *TagStore) FindByMovieID(ctx context.Context, movieID int) ([]*models.Tag, error) {
query := `
SELECT tags.* FROM tags
LEFT JOIN movies_tags as movies_join on movies_join.tag_id = tags.id
WHERE movies_join.movie_id = ?
GROUP BY tags.id
`
query += qb.getDefaultTagSort()
args := []interface{}{movieID}
return qb.queryTags(ctx, query, args)
}
func (qb *TagStore) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*models.Tag, error) { func (qb *TagStore) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*models.Tag, error) {
query := ` query := `
SELECT tags.* FROM tags SELECT tags.* FROM tags
@ -615,6 +627,7 @@ var tagSortOptions = sortOptions{
"galleries_count", "galleries_count",
"id", "id",
"images_count", "images_count",
"movies_count",
"name", "name",
"performers_count", "performers_count",
"random", "random",
@ -655,6 +668,8 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte
sortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction) sortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction)
case "performers_count": case "performers_count":
sortQuery += getCountSort(tagTable, performersTagsTable, tagIDColumn, direction) sortQuery += getCountSort(tagTable, performersTagsTable, tagIDColumn, direction)
case "movies_count":
sortQuery += getCountSort(tagTable, moviesTagsTable, tagIDColumn, direction)
default: default:
sortQuery += getSort(sort, direction, "tags") sortQuery += getSort(sort, direction, "tags")
} }
@ -888,3 +903,17 @@ SELECT t.*, c.path FROM tags t INNER JOIN children c ON t.id = c.child_id
return qb.queryTagPaths(ctx, query, args) return qb.queryTagPaths(ctx, query, args)
} }
type tagRelationshipStore struct {
idRelationshipStore
}
func (s *tagRelationshipStore) CountByTagID(ctx context.Context, tagID int) (int, error) {
joinTable := s.joinTable.table.table
q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(tagIDColumn).Eq(tagID))
return count(ctx, q)
}
func (s *tagRelationshipStore) GetTagIDs(ctx context.Context, id int) ([]int, error) {
return s.joinTable.get(ctx, id)
}

View file

@ -66,6 +66,7 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler {
qb.imageCountCriterionHandler(tagFilter.ImageCount), qb.imageCountCriterionHandler(tagFilter.ImageCount),
qb.galleryCountCriterionHandler(tagFilter.GalleryCount), qb.galleryCountCriterionHandler(tagFilter.GalleryCount),
qb.performerCountCriterionHandler(tagFilter.PerformerCount), qb.performerCountCriterionHandler(tagFilter.PerformerCount),
qb.movieCountCriterionHandler(tagFilter.MovieCount),
qb.markerCountCriterionHandler(tagFilter.MarkerCount), qb.markerCountCriterionHandler(tagFilter.MarkerCount),
qb.parentsCriterionHandler(tagFilter.Parents), qb.parentsCriterionHandler(tagFilter.Parents),
qb.childrenCriterionHandler(tagFilter.Children), qb.childrenCriterionHandler(tagFilter.Children),
@ -174,6 +175,17 @@ func (qb *tagFilterHandler) performerCountCriterionHandler(performerCount *model
} }
} }
func (qb *tagFilterHandler) movieCountCriterionHandler(movieCount *models.IntCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if movieCount != nil {
f.addLeftJoin("movies_tags", "", "movies_tags.tag_id = tags.id")
clause, args := getIntCriterionWhereClause("count(distinct movies_tags.movie_id)", *movieCount)
f.addHaving(clause, args...)
}
}
}
func (qb *tagFilterHandler) markerCountCriterionHandler(markerCount *models.IntCriterionInput) criterionHandlerFunc { func (qb *tagFilterHandler) markerCountCriterionHandler(markerCount *models.IntCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if markerCount != nil { if markerCount != nil {

View file

@ -42,6 +42,33 @@ func TestMarkerFindBySceneMarkerID(t *testing.T) {
}) })
} }
func TestTagFindByMovieID(t *testing.T) {
withTxn(func(ctx context.Context) error {
tqb := db.Tag
movieID := movieIDs[movieIdxWithTag]
tags, err := tqb.FindByMovieID(ctx, movieID)
if err != nil {
t.Errorf("Error finding tags: %s", err.Error())
}
assert.Len(t, tags, 1)
assert.Equal(t, tagIDs[tagIdxWithMovie], tags[0].ID)
tags, err = tqb.FindByMovieID(ctx, 0)
if err != nil {
t.Errorf("Error finding tags: %s", err.Error())
}
assert.Len(t, tags, 0)
return nil
})
}
func TestTagFindByName(t *testing.T) { func TestTagFindByName(t *testing.T) {
withTxn(func(ctx context.Context) error { withTxn(func(ctx context.Context) error {
tqb := db.Tag tqb := db.Tag
@ -203,6 +230,10 @@ func TestTagQuerySort(t *testing.T) {
tags = queryTags(ctx, t, sqb, nil, findFilter) tags = queryTags(ctx, t, sqb, nil, findFilter)
assert.Equal(tagIDs[tagIdx2WithPerformer], tags[0].ID) assert.Equal(tagIDs[tagIdx2WithPerformer], tags[0].ID)
sortBy = "movies_count"
tags = queryTags(ctx, t, sqb, nil, findFilter)
assert.Equal(tagIDs[tagIdx1WithMovie], tags[0].ID)
return nil return nil
}) })
} }

View file

@ -11,6 +11,10 @@ fragment MovieData on Movie {
...SlimStudioData ...SlimStudioData
} }
tags {
...SlimTagData
}
synopsis synopsis
urls urls
front_image_path front_image_path

View file

@ -98,6 +98,9 @@ fragment ScrapedMovieData on ScrapedMovie {
studio { studio {
...ScrapedMovieStudioData ...ScrapedMovieStudioData
} }
tags {
...ScrapedSceneTagData
}
} }
fragment ScrapedSceneMovieData on ScrapedMovie { fragment ScrapedSceneMovieData on ScrapedMovie {
@ -116,6 +119,9 @@ fragment ScrapedSceneMovieData on ScrapedMovie {
studio { studio {
...ScrapedMovieStudioData ...ScrapedMovieStudioData
} }
tags {
...ScrapedSceneTagData
}
} }
fragment ScrapedSceneStudioData on ScrapedStudio { fragment ScrapedSceneStudioData on ScrapedStudio {

View file

@ -16,6 +16,8 @@ fragment TagData on Tag {
gallery_count_all: gallery_count(depth: -1) gallery_count_all: gallery_count(depth: -1)
performer_count performer_count
performer_count_all: performer_count(depth: -1) performer_count_all: performer_count(depth: -1)
movie_count
movie_count_all: movie_count(depth: -1)
parents { parents {
...SlimTagData ...SlimTagData

View file

@ -36,9 +36,9 @@ import {
yupUniqueStringList, yupUniqueStringList,
} from "src/utils/yup"; } from "src/utils/yup";
import { formikUtils } from "src/utils/form"; import { formikUtils } from "src/utils/form";
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect";
import { useTagsEdit } from "src/hooks/tagsEdit";
interface IProps { interface IProps {
gallery: Partial<GQL.GalleryDataFragment>; gallery: Partial<GQL.GalleryDataFragment>;
@ -58,7 +58,6 @@ export const GalleryEditPanel: React.FC<IProps> = ({
const [scenes, setScenes] = useState<Scene[]>([]); const [scenes, setScenes] = useState<Scene[]>([]);
const [performers, setPerformers] = useState<Performer[]>([]); const [performers, setPerformers] = useState<Performer[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [studio, setStudio] = useState<Studio | null>(null); const [studio, setStudio] = useState<Studio | null>(null);
const isNew = gallery.id === undefined; const isNew = gallery.id === undefined;
@ -110,6 +109,11 @@ export const GalleryEditPanel: React.FC<IProps> = ({
onSubmit: (values) => onSave(schema.cast(values)), onSubmit: (values) => onSave(schema.cast(values)),
}); });
const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(
gallery.tags,
(ids) => formik.setFieldValue("tag_ids", ids)
);
function onSetScenes(items: Scene[]) { function onSetScenes(items: Scene[]) {
setScenes(items); setScenes(items);
formik.setFieldValue( formik.setFieldValue(
@ -126,14 +130,6 @@ export const GalleryEditPanel: React.FC<IProps> = ({
); );
} }
function onSetTags(items: Tag[]) {
setTags(items);
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
);
}
function onSetStudio(item: Studio | null) { function onSetStudio(item: Studio | null) {
setStudio(item); setStudio(item);
formik.setFieldValue("studio_id", item ? item.id : null); formik.setFieldValue("studio_id", item ? item.id : null);
@ -143,10 +139,6 @@ export const GalleryEditPanel: React.FC<IProps> = ({
setPerformers(gallery.performers ?? []); setPerformers(gallery.performers ?? []);
}, [gallery.performers]); }, [gallery.performers]);
useEffect(() => {
setTags(gallery.tags ?? []);
}, [gallery.tags]);
useEffect(() => { useEffect(() => {
setStudio(gallery.studio ?? null); setStudio(gallery.studio ?? null);
}, [gallery.studio]); }, [gallery.studio]);
@ -339,23 +331,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
} }
} }
if (galleryData?.tags?.length) { updateTagsStateFromScraper(galleryData.tags ?? undefined);
const idTags = galleryData.tags.filter((t) => {
return t.stored_id !== undefined && t.stored_id !== null;
});
if (idTags.length > 0) {
onSetTags(
idTags.map((p) => {
return {
id: p.stored_id!,
name: p.name ?? "",
aliases: [],
};
})
);
}
}
} }
async function onScrapeGalleryURL(url: string) { async function onScrapeGalleryURL(url: string) {
@ -437,16 +413,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
function renderTagsField() { function renderTagsField() {
const title = intl.formatMessage({ id: "tags" }); const title = intl.formatMessage({ id: "tags" });
const control = ( return renderField("tag_ids", title, tagsControl(), fullWidthProps);
<TagSelect
isMulti
onSelect={onSetTags}
values={tags}
hoverPlacement="right"
/>
);
return renderField("tag_ids", title, control, fullWidthProps);
} }
function renderDetailsField() { function renderDetailsField() {

View file

@ -15,18 +15,17 @@ import {
import { import {
ScrapedPerformersRow, ScrapedPerformersRow,
ScrapedStudioRow, ScrapedStudioRow,
ScrapedTagsRow,
} from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow";
import { sortStoredIdObjects } from "src/utils/data"; import { sortStoredIdObjects } from "src/utils/data";
import { Performer } from "src/components/Performers/PerformerSelect"; import { Performer } from "src/components/Performers/PerformerSelect";
import { import {
useCreateScrapedPerformer, useCreateScrapedPerformer,
useCreateScrapedStudio, useCreateScrapedStudio,
useCreateScrapedTag,
} from "src/components/Shared/ScrapeDialog/createObjects"; } from "src/components/Shared/ScrapeDialog/createObjects";
import { uniq } from "lodash-es"; import { uniq } from "lodash-es";
import { Tag } from "src/components/Tags/TagSelect"; import { Tag } from "src/components/Tags/TagSelect";
import { Studio } from "src/components/Studios/StudioSelect"; import { Studio } from "src/components/Studios/StudioSelect";
import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags";
interface IGalleryScrapeDialogProps { interface IGalleryScrapeDialogProps {
gallery: Partial<GQL.GalleryUpdateInput>; gallery: Partial<GQL.GalleryUpdateInput>;
@ -99,19 +98,9 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = ({
scraped.performers?.filter((t) => !t.stored_id) ?? [] scraped.performers?.filter((t) => !t.stored_id) ?? []
); );
const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>( const { tags, newTags, scrapedTagsRow } = useScrapedTags(
new ObjectListScrapeResult<GQL.ScrapedTag>( galleryTags,
sortStoredIdObjects( scraped.tags
galleryTags.map((t) => ({
stored_id: t.id,
name: t.name,
}))
),
sortStoredIdObjects(scraped.tags ?? undefined)
)
);
const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>(
scraped.tags?.filter((t) => !t.stored_id) ?? []
); );
const [details, setDetails] = useState<ScrapeResult<string>>( const [details, setDetails] = useState<ScrapeResult<string>>(
@ -131,13 +120,6 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = ({
setNewObjects: setNewPerformers, setNewObjects: setNewPerformers,
}); });
const createNewTag = useCreateScrapedTag({
scrapeResult: tags,
setScrapeResult: setTags,
newObjects: newTags,
setNewObjects: setNewTags,
});
// don't show the dialog if nothing was scraped // don't show the dialog if nothing was scraped
if ( if (
[ [
@ -218,13 +200,7 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = ({
newObjects={newPerformers} newObjects={newPerformers}
onCreateNew={createNewPerformer} onCreateNew={createNewPerformer}
/> />
<ScrapedTagsRow {scrapedTagsRow}
title={intl.formatMessage({ id: "tags" })}
result={tags}
onChange={(value) => setTags(value)}
newObjects={newTags}
onCreateNew={createNewTag}
/>
<ScrapedTextAreaRow <ScrapedTextAreaRow
title={intl.formatMessage({ id: "details" })} title={intl.formatMessage({ id: "details" })}
result={details} result={details}

View file

@ -19,7 +19,6 @@ import {
PerformerSelect, PerformerSelect,
} from "src/components/Performers/PerformerSelect"; } from "src/components/Performers/PerformerSelect";
import { formikUtils } from "src/utils/form"; import { formikUtils } from "src/utils/form";
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
import { galleryTitle } from "src/core/galleries"; import { galleryTitle } from "src/core/galleries";
import { import {
@ -27,6 +26,7 @@ import {
GallerySelect, GallerySelect,
excludeFileBasedGalleries, excludeFileBasedGalleries,
} from "src/components/Galleries/GallerySelect"; } from "src/components/Galleries/GallerySelect";
import { useTagsEdit } from "src/hooks/tagsEdit";
interface IProps { interface IProps {
image: GQL.ImageDataFragment; image: GQL.ImageDataFragment;
@ -49,7 +49,6 @@ export const ImageEditPanel: React.FC<IProps> = ({
const [galleries, setGalleries] = useState<Gallery[]>([]); const [galleries, setGalleries] = useState<Gallery[]>([]);
const [performers, setPerformers] = useState<Performer[]>([]); const [performers, setPerformers] = useState<Performer[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [studio, setStudio] = useState<Studio | null>(null); const [studio, setStudio] = useState<Studio | null>(null);
useEffect(() => { useEffect(() => {
@ -98,6 +97,10 @@ export const ImageEditPanel: React.FC<IProps> = ({
onSubmit: (values) => onSave(schema.cast(values)), onSubmit: (values) => onSave(schema.cast(values)),
}); });
const { tagsControl } = useTagsEdit(image.tags, (ids) =>
formik.setFieldValue("tag_ids", ids)
);
function onSetGalleries(items: Gallery[]) { function onSetGalleries(items: Gallery[]) {
setGalleries(items); setGalleries(items);
formik.setFieldValue( formik.setFieldValue(
@ -114,14 +117,6 @@ export const ImageEditPanel: React.FC<IProps> = ({
); );
} }
function onSetTags(items: Tag[]) {
setTags(items);
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
);
}
function onSetStudio(item: Studio | null) { function onSetStudio(item: Studio | null) {
setStudio(item); setStudio(item);
formik.setFieldValue("studio_id", item ? item.id : null); formik.setFieldValue("studio_id", item ? item.id : null);
@ -131,10 +126,6 @@ export const ImageEditPanel: React.FC<IProps> = ({
setPerformers(image.performers ?? []); setPerformers(image.performers ?? []);
}, [image.performers]); }, [image.performers]);
useEffect(() => {
setTags(image.tags ?? []);
}, [image.tags]);
useEffect(() => { useEffect(() => {
setStudio(image.studio ?? null); setStudio(image.studio ?? null);
}, [image.studio]); }, [image.studio]);
@ -233,16 +224,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
function renderTagsField() { function renderTagsField() {
const title = intl.formatMessage({ id: "tags" }); const title = intl.formatMessage({ id: "tags" });
const control = ( return renderField("tag_ids", title, tagsControl(), fullWidthProps);
<TagSelect
isMulti
onSelect={onSetTags}
values={tags}
hoverPlacement="right"
/>
);
return renderField("tag_ids", title, control, fullWidthProps);
} }
function renderDetailsField() { function renderDetailsField() {

View file

@ -9,11 +9,15 @@ import { useToast } from "src/hooks/Toast";
import * as FormUtils from "src/utils/form"; import * as FormUtils from "src/utils/form";
import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { RatingSystem } from "../Shared/Rating/RatingSystem";
import { import {
getAggregateInputIDs,
getAggregateInputValue, getAggregateInputValue,
getAggregateRating, getAggregateRating,
getAggregateStudioId, getAggregateStudioId,
getAggregateTagIds,
} from "src/utils/bulkUpdate"; } from "src/utils/bulkUpdate";
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
import { isEqual } from "lodash-es";
import { MultiSet } from "../Shared/MultiSet";
interface IListOperationProps { interface IListOperationProps {
selected: GQL.MovieDataFragment[]; selected: GQL.MovieDataFragment[];
@ -29,6 +33,12 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
const [studioId, setStudioId] = useState<string | undefined>(); const [studioId, setStudioId] = useState<string | undefined>();
const [director, setDirector] = useState<string | undefined>(); const [director, setDirector] = useState<string | undefined>();
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
GQL.BulkUpdateIdMode.Add
);
const [tagIds, setTagIds] = useState<string[]>();
const [existingTagIds, setExistingTagIds] = useState<string[]>();
const [updateMovies] = useBulkMovieUpdate(getMovieInput()); const [updateMovies] = useBulkMovieUpdate(getMovieInput());
const [isUpdating, setIsUpdating] = useState(false); const [isUpdating, setIsUpdating] = useState(false);
@ -36,6 +46,7 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
function getMovieInput(): GQL.BulkMovieUpdateInput { function getMovieInput(): GQL.BulkMovieUpdateInput {
const aggregateRating = getAggregateRating(props.selected); const aggregateRating = getAggregateRating(props.selected);
const aggregateStudioId = getAggregateStudioId(props.selected); const aggregateStudioId = getAggregateStudioId(props.selected);
const aggregateTagIds = getAggregateTagIds(props.selected);
const movieInput: GQL.BulkMovieUpdateInput = { const movieInput: GQL.BulkMovieUpdateInput = {
ids: props.selected.map((movie) => movie.id), ids: props.selected.map((movie) => movie.id),
@ -45,6 +56,7 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
// if rating is undefined // if rating is undefined
movieInput.rating100 = getAggregateInputValue(rating100, aggregateRating); movieInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
movieInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); movieInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
movieInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
return movieInput; return movieInput;
} }
@ -72,14 +84,18 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
const state = props.selected; const state = props.selected;
let updateRating: number | undefined; let updateRating: number | undefined;
let updateStudioId: string | undefined; let updateStudioId: string | undefined;
let updateTagIds: string[] = [];
let updateDirector: string | undefined; let updateDirector: string | undefined;
let first = true; let first = true;
state.forEach((movie: GQL.MovieDataFragment) => { state.forEach((movie: GQL.MovieDataFragment) => {
const movieTagIDs = (movie.tags ?? []).map((p) => p.id).sort();
if (first) { if (first) {
first = false; first = false;
updateRating = movie.rating100 ?? undefined; updateRating = movie.rating100 ?? undefined;
updateStudioId = movie.studio?.id ?? undefined; updateStudioId = movie.studio?.id ?? undefined;
updateTagIds = movieTagIDs;
updateDirector = movie.director ?? undefined; updateDirector = movie.director ?? undefined;
} else { } else {
if (movie.rating100 !== updateRating) { if (movie.rating100 !== updateRating) {
@ -91,11 +107,15 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
if (movie.director !== updateDirector) { if (movie.director !== updateDirector) {
updateDirector = undefined; updateDirector = undefined;
} }
if (!isEqual(movieTagIDs, updateTagIds)) {
updateTagIds = [];
}
} }
}); });
setRating(updateRating); setRating(updateRating);
setStudioId(updateStudioId); setStudioId(updateStudioId);
setExistingTagIds(updateTagIds);
setDirector(updateDirector); setDirector(updateDirector);
}, [props.selected]); }, [props.selected]);
@ -158,6 +178,20 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
placeholder={intl.formatMessage({ id: "director" })} placeholder={intl.formatMessage({ id: "director" })}
/> />
</Form.Group> </Form.Group>
<Form.Group controlId="tags">
<Form.Label>
<FormattedMessage id="tags" />
</Form.Label>
<MultiSet
type="tags"
disabled={isUpdating}
onUpdate={(itemIDs) => setTagIds(itemIDs)}
onSetMode={(newMode) => setTagMode(newMode)}
existingIds={existingTagIds ?? []}
ids={tagIds ?? []}
mode={tagMode}
/>
</Form.Group>
</Form> </Form>
</ModalComponent> </ModalComponent>
); );

View file

@ -4,11 +4,11 @@ import * as GQL from "src/core/generated-graphql";
import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard"; import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard";
import { HoverPopover } from "../Shared/HoverPopover"; import { HoverPopover } from "../Shared/HoverPopover";
import { Icon } from "../Shared/Icon"; import { Icon } from "../Shared/Icon";
import { SceneLink } from "../Shared/TagLink"; import { SceneLink, TagLink } from "../Shared/TagLink";
import { TruncatedText } from "../Shared/TruncatedText"; import { TruncatedText } from "../Shared/TruncatedText";
import { FormattedMessage } from "react-intl"; import { FormattedMessage } from "react-intl";
import { RatingBanner } from "../Shared/RatingBanner"; import { RatingBanner } from "../Shared/RatingBanner";
import { faPlayCircle } from "@fortawesome/free-solid-svg-icons"; import { faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons";
import ScreenUtils from "src/utils/screen"; import ScreenUtils from "src/utils/screen";
interface IProps { interface IProps {
@ -20,37 +20,44 @@ interface IProps {
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
} }
export const MovieCard: React.FC<IProps> = (props: IProps) => { export const MovieCard: React.FC<IProps> = ({
movie,
sceneIndex,
containerWidth,
selecting,
selected,
onSelectedChanged,
}) => {
const [cardWidth, setCardWidth] = useState<number>(); const [cardWidth, setCardWidth] = useState<number>();
useEffect(() => { useEffect(() => {
if (!props.containerWidth || ScreenUtils.isMobile()) return; if (!containerWidth || ScreenUtils.isMobile()) return;
let preferredCardWidth = 250; let preferredCardWidth = 250;
let fittedCardWidth = calculateCardWidth( let fittedCardWidth = calculateCardWidth(
props.containerWidth, containerWidth,
preferredCardWidth! preferredCardWidth!
); );
setCardWidth(fittedCardWidth); setCardWidth(fittedCardWidth);
}, [props, props.containerWidth]); }, [containerWidth]);
function maybeRenderSceneNumber() { function maybeRenderSceneNumber() {
if (!props.sceneIndex) return; if (!sceneIndex) return;
return ( return (
<> <>
<hr /> <hr />
<span className="movie-scene-number"> <span className="movie-scene-number">
<FormattedMessage id="scene" /> #{props.sceneIndex} <FormattedMessage id="scene" /> #{sceneIndex}
</span> </span>
</> </>
); );
} }
function maybeRenderScenesPopoverButton() { function maybeRenderScenesPopoverButton() {
if (props.movie.scenes.length === 0) return; if (movie.scenes.length === 0) return;
const popoverContent = props.movie.scenes.map((scene) => ( const popoverContent = movie.scenes.map((scene) => (
<SceneLink key={scene.id} scene={scene} /> <SceneLink key={scene.id} scene={scene} />
)); ));
@ -62,20 +69,38 @@ export const MovieCard: React.FC<IProps> = (props: IProps) => {
> >
<Button className="minimal"> <Button className="minimal">
<Icon icon={faPlayCircle} /> <Icon icon={faPlayCircle} />
<span>{props.movie.scenes.length}</span> <span>{movie.scenes.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderTagPopoverButton() {
if (movie.tags.length <= 0) return;
const popoverContent = movie.tags.map((tag) => (
<TagLink key={tag.id} linkType="movie" tag={tag} />
));
return (
<HoverPopover placement="bottom" content={popoverContent}>
<Button className="minimal tag-count">
<Icon icon={faTag} />
<span>{movie.tags.length}</span>
</Button> </Button>
</HoverPopover> </HoverPopover>
); );
} }
function maybeRenderPopoverButtonGroup() { function maybeRenderPopoverButtonGroup() {
if (props.sceneIndex || props.movie.scenes.length > 0) { if (sceneIndex || movie.scenes.length > 0 || movie.tags.length > 0) {
return ( return (
<> <>
{maybeRenderSceneNumber()} {maybeRenderSceneNumber()}
<hr /> <hr />
<ButtonGroup className="card-popovers"> <ButtonGroup className="card-popovers">
{maybeRenderScenesPopoverButton()} {maybeRenderScenesPopoverButton()}
{maybeRenderTagPopoverButton()}
</ButtonGroup> </ButtonGroup>
</> </>
); );
@ -85,34 +110,34 @@ export const MovieCard: React.FC<IProps> = (props: IProps) => {
return ( return (
<GridCard <GridCard
className="movie-card" className="movie-card"
url={`/movies/${props.movie.id}`} url={`/movies/${movie.id}`}
width={cardWidth} width={cardWidth}
title={props.movie.name} title={movie.name}
linkClassName="movie-card-header" linkClassName="movie-card-header"
image={ image={
<> <>
<img <img
loading="lazy" loading="lazy"
className="movie-card-image" className="movie-card-image"
alt={props.movie.name ?? ""} alt={movie.name ?? ""}
src={props.movie.front_image_path ?? ""} src={movie.front_image_path ?? ""}
/> />
<RatingBanner rating={props.movie.rating100} /> <RatingBanner rating={movie.rating100} />
</> </>
} }
details={ details={
<div className="movie-card__details"> <div className="movie-card__details">
<span className="movie-card__date">{props.movie.date}</span> <span className="movie-card__date">{movie.date}</span>
<TruncatedText <TruncatedText
className="movie-card__description" className="movie-card__description"
text={props.movie.synopsis} text={movie.synopsis}
lineCount={3} lineCount={3}
/> />
</div> </div>
} }
selected={props.selected} selected={selected}
selecting={props.selecting} selecting={selecting}
onSelectedChanged={props.onSelectedChanged} onSelectedChanged={onSelectedChanged}
popovers={maybeRenderPopoverButtonGroup()} popovers={maybeRenderPopoverButtonGroup()}
/> />
); );

View file

@ -305,6 +305,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
return ( return (
<MovieDetailsPanel <MovieDetailsPanel
movie={movie} movie={movie}
collapsed={collapsed}
fullWidth={!collapsed && !compactExpandedDetails} fullWidth={!collapsed && !compactExpandedDetails}
/> />
); );

View file

@ -5,19 +5,54 @@ import TextUtils from "src/utils/text";
import { DetailItem } from "src/components/Shared/DetailItem"; import { DetailItem } from "src/components/Shared/DetailItem";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { DirectorLink } from "src/components/Shared/Link"; import { DirectorLink } from "src/components/Shared/Link";
import { TagLink } from "src/components/Shared/TagLink";
interface IMovieDetailsPanel { interface IMovieDetailsPanel {
movie: GQL.MovieDataFragment; movie: GQL.MovieDataFragment;
collapsed?: boolean;
fullWidth?: boolean; fullWidth?: boolean;
} }
export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({
movie, movie,
collapsed,
fullWidth, fullWidth,
}) => { }) => {
// Network state // Network state
const intl = useIntl(); const intl = useIntl();
function renderTagsField() {
if (!movie.tags.length) {
return;
}
return (
<ul className="pl-0">
{(movie.tags ?? []).map((tag) => (
<TagLink key={tag.id} linkType="movie" tag={tag} />
))}
</ul>
);
}
function maybeRenderExtraDetails() {
if (!collapsed) {
return (
<>
<DetailItem
id="synopsis"
value={movie.synopsis}
fullWidth={fullWidth}
/>
<DetailItem
id="tags"
value={renderTagsField()}
fullWidth={fullWidth}
/>
</>
);
}
}
return ( return (
<div className="detail-group"> <div className="detail-group">
<DetailItem <DetailItem
@ -57,7 +92,7 @@ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({
} }
fullWidth={fullWidth} fullWidth={fullWidth}
/> />
<DetailItem id="synopsis" value={movie.synopsis} fullWidth={fullWidth} /> {maybeRenderExtraDetails()}
</div> </div>
); );
}; };

View file

@ -25,6 +25,7 @@ import {
yupUniqueStringList, yupUniqueStringList,
} from "src/utils/yup"; } from "src/utils/yup";
import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
import { useTagsEdit } from "src/hooks/tagsEdit";
interface IMovieEditPanel { interface IMovieEditPanel {
movie: Partial<GQL.MovieDataFragment>; movie: Partial<GQL.MovieDataFragment>;
@ -66,6 +67,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
duration: yup.number().integer().min(0).nullable().defined(), duration: yup.number().integer().min(0).nullable().defined(),
date: yupDateString(intl), date: yupDateString(intl),
studio_id: yup.string().required().nullable(), studio_id: yup.string().required().nullable(),
tag_ids: yup.array(yup.string().required()).defined(),
director: yup.string().ensure(), director: yup.string().ensure(),
urls: yupUniqueStringList(intl), urls: yupUniqueStringList(intl),
synopsis: yup.string().ensure(), synopsis: yup.string().ensure(),
@ -79,6 +81,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
duration: movie?.duration ?? null, duration: movie?.duration ?? null,
date: movie?.date ?? "", date: movie?.date ?? "",
studio_id: movie?.studio?.id ?? null, studio_id: movie?.studio?.id ?? null,
tag_ids: (movie?.tags ?? []).map((t) => t.id),
director: movie?.director ?? "", director: movie?.director ?? "",
urls: movie?.urls ?? [], urls: movie?.urls ?? [],
synopsis: movie?.synopsis ?? "", synopsis: movie?.synopsis ?? "",
@ -93,6 +96,11 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
onSubmit: (values) => onSave(schema.cast(values)), onSubmit: (values) => onSave(schema.cast(values)),
}); });
const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(
movie.tags,
(ids) => formik.setFieldValue("tag_ids", ids)
);
function onSetStudio(item: Studio | null) { function onSetStudio(item: Studio | null) {
setStudio(item); setStudio(item);
formik.setFieldValue("studio_id", item ? item.id : null); formik.setFieldValue("studio_id", item ? item.id : null);
@ -159,6 +167,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
if (state.urls) { if (state.urls) {
formik.setFieldValue("urls", state.urls); formik.setFieldValue("urls", state.urls);
} }
updateTagsStateFromScraper(state.tags ?? undefined);
if (state.front_image) { if (state.front_image) {
// image is a base64 string // image is a base64 string
@ -231,6 +240,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
<MovieScrapeDialog <MovieScrapeDialog
movie={currentMovie} movie={currentMovie}
movieStudio={studio} movieStudio={studio}
movieTags={tags}
scraped={scrapedMovie} scraped={scrapedMovie}
onClose={(m) => { onClose={(m) => {
onScrapeDialogClosed(m); onScrapeDialogClosed(m);
@ -351,6 +361,11 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
return renderField("studio_id", title, control); return renderField("studio_id", title, control);
} }
function renderTagsField() {
const title = intl.formatMessage({ id: "tags" });
return renderField("tag_ids", title, tagsControl());
}
// TODO: CSS class // TODO: CSS class
return ( return (
<div> <div>
@ -383,6 +398,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
{renderInputField("director")} {renderInputField("director")}
{renderURLListField("urls", onScrapeMovieURL, urlScrapable)} {renderURLListField("urls", onScrapeMovieURL, urlScrapable)}
{renderInputField("synopsis", "textarea")} {renderInputField("synopsis", "textarea")}
{renderTagsField()}
</Form> </Form>
<DetailsEditNavbar <DetailsEditNavbar

View file

@ -17,74 +17,79 @@ import { Studio } from "src/components/Studios/StudioSelect";
import { useCreateScrapedStudio } from "src/components/Shared/ScrapeDialog/createObjects"; import { useCreateScrapedStudio } from "src/components/Shared/ScrapeDialog/createObjects";
import { ScrapedStudioRow } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; import { ScrapedStudioRow } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow";
import { uniq } from "lodash-es"; import { uniq } from "lodash-es";
import { Tag } from "src/components/Tags/TagSelect";
import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags";
interface IMovieScrapeDialogProps { interface IMovieScrapeDialogProps {
movie: Partial<GQL.MovieUpdateInput>; movie: Partial<GQL.MovieUpdateInput>;
movieStudio: Studio | null; movieStudio: Studio | null;
movieTags: Tag[];
scraped: GQL.ScrapedMovie; scraped: GQL.ScrapedMovie;
onClose: (scrapedMovie?: GQL.ScrapedMovie) => void; onClose: (scrapedMovie?: GQL.ScrapedMovie) => void;
} }
export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = ( export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = ({
props: IMovieScrapeDialogProps movie,
) => { movieStudio,
movieTags,
scraped,
onClose,
}) => {
const intl = useIntl(); const intl = useIntl();
const [name, setName] = useState<ScrapeResult<string>>( const [name, setName] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.movie.name, props.scraped.name) new ScrapeResult<string>(movie.name, scraped.name)
); );
const [aliases, setAliases] = useState<ScrapeResult<string>>( const [aliases, setAliases] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.movie.aliases, props.scraped.aliases) new ScrapeResult<string>(movie.aliases, scraped.aliases)
); );
const [duration, setDuration] = useState<ScrapeResult<string>>( const [duration, setDuration] = useState<ScrapeResult<string>>(
new ScrapeResult<string>( new ScrapeResult<string>(
TextUtils.secondsToTimestamp(props.movie.duration || 0), TextUtils.secondsToTimestamp(movie.duration || 0),
// convert seconds to string if it's a number // convert seconds to string if it's a number
props.scraped.duration && !isNaN(+props.scraped.duration) scraped.duration && !isNaN(+scraped.duration)
? TextUtils.secondsToTimestamp(parseInt(props.scraped.duration, 10)) ? TextUtils.secondsToTimestamp(parseInt(scraped.duration, 10))
: props.scraped.duration : scraped.duration
) )
); );
const [date, setDate] = useState<ScrapeResult<string>>( const [date, setDate] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.movie.date, props.scraped.date) new ScrapeResult<string>(movie.date, scraped.date)
); );
const [director, setDirector] = useState<ScrapeResult<string>>( const [director, setDirector] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.movie.director, props.scraped.director) new ScrapeResult<string>(movie.director, scraped.director)
); );
const [synopsis, setSynopsis] = useState<ScrapeResult<string>>( const [synopsis, setSynopsis] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.movie.synopsis, props.scraped.synopsis) new ScrapeResult<string>(movie.synopsis, scraped.synopsis)
); );
const [studio, setStudio] = useState<ObjectScrapeResult<GQL.ScrapedStudio>>( const [studio, setStudio] = useState<ObjectScrapeResult<GQL.ScrapedStudio>>(
new ObjectScrapeResult<GQL.ScrapedStudio>( new ObjectScrapeResult<GQL.ScrapedStudio>(
props.movieStudio movieStudio
? { ? {
stored_id: props.movieStudio.id, stored_id: movieStudio.id,
name: props.movieStudio.name, name: movieStudio.name,
} }
: undefined, : undefined,
props.scraped.studio?.stored_id ? props.scraped.studio : undefined scraped.studio?.stored_id ? scraped.studio : undefined
) )
); );
const [urls, setURLs] = useState<ScrapeResult<string[]>>( const [urls, setURLs] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>( new ScrapeResult<string[]>(
props.movie.urls, movie.urls,
props.scraped.urls scraped.urls
? uniq((props.movie.urls ?? []).concat(props.scraped.urls ?? [])) ? uniq((movie.urls ?? []).concat(scraped.urls ?? []))
: undefined : undefined
) )
); );
const [frontImage, setFrontImage] = useState<ScrapeResult<string>>( const [frontImage, setFrontImage] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.movie.front_image, props.scraped.front_image) new ScrapeResult<string>(movie.front_image, scraped.front_image)
); );
const [backImage, setBackImage] = useState<ScrapeResult<string>>( const [backImage, setBackImage] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.movie.back_image, props.scraped.back_image) new ScrapeResult<string>(movie.back_image, scraped.back_image)
); );
const [newStudio, setNewStudio] = useState<GQL.ScrapedStudio | undefined>( const [newStudio, setNewStudio] = useState<GQL.ScrapedStudio | undefined>(
props.scraped.studio && !props.scraped.studio.stored_id scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined
? props.scraped.studio
: undefined
); );
const createNewStudio = useCreateScrapedStudio({ const createNewStudio = useCreateScrapedStudio({
@ -93,6 +98,11 @@ export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = (
setNewObject: setNewStudio, setNewObject: setNewStudio,
}); });
const { tags, newTags, scrapedTagsRow } = useScrapedTags(
movieTags,
scraped.tags
);
const allFields = [ const allFields = [
name, name,
aliases, aliases,
@ -101,17 +111,21 @@ export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = (
director, director,
synopsis, synopsis,
studio, studio,
tags,
urls, urls,
frontImage, frontImage,
backImage, backImage,
]; ];
// don't show the dialog if nothing was scraped // don't show the dialog if nothing was scraped
if (allFields.every((r) => !r.scraped) && !newStudio) { if (
props.onClose(); allFields.every((r) => !r.scraped) &&
!newStudio &&
newTags.length === 0
) {
onClose();
return <></>; return <></>;
} }
// todo: reenable
function makeNewScrapedItem(): GQL.ScrapedMovie { function makeNewScrapedItem(): GQL.ScrapedMovie {
const newStudioValue = studio.getNewValue(); const newStudioValue = studio.getNewValue();
const durationString = duration.getNewValue(); const durationString = duration.getNewValue();
@ -124,6 +138,7 @@ export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = (
director: director.getNewValue(), director: director.getNewValue(),
synopsis: synopsis.getNewValue(), synopsis: synopsis.getNewValue(),
studio: newStudioValue, studio: newStudioValue,
tags: tags.getNewValue(),
urls: urls.getNewValue(), urls: urls.getNewValue(),
front_image: frontImage.getNewValue(), front_image: frontImage.getNewValue(),
back_image: backImage.getNewValue(), back_image: backImage.getNewValue(),
@ -176,6 +191,7 @@ export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = (
result={urls} result={urls}
onChange={(value) => setURLs(value)} onChange={(value) => setURLs(value)}
/> />
{scrapedTagsRow}
<ScrapedImageRow <ScrapedImageRow
title="Front Image" title="Front Image"
className="movie-image" className="movie-image"
@ -200,7 +216,7 @@ export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = (
)} )}
renderScrapeRows={renderScrapeRows} renderScrapeRows={renderScrapeRows}
onClose={(apply) => { onClose={(apply) => {
props.onClose(apply ? makeNewScrapedItem() : undefined); onClose(apply ? makeNewScrapedItem() : undefined);
}} }}
/> />
); );

View file

@ -1,5 +1,5 @@
import React, { useEffect, useState } from "react"; import React, { useEffect, useState } from "react";
import { Button, Form, Badge, Dropdown } from "react-bootstrap"; import { Button, Form, Dropdown } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl"; import { FormattedMessage, useIntl } from "react-intl";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@ -8,13 +8,11 @@ import {
useListPerformerScrapers, useListPerformerScrapers,
queryScrapePerformer, queryScrapePerformer,
mutateReloadScrapers, mutateReloadScrapers,
useTagCreate,
queryScrapePerformerURL, queryScrapePerformerURL,
} from "src/core/StashService"; } from "src/core/StashService";
import { Icon } from "src/components/Shared/Icon"; import { Icon } from "src/components/Shared/Icon";
import { ImageInput } from "src/components/Shared/ImageInput"; import { ImageInput } from "src/components/Shared/ImageInput";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { CollapseButton } from "src/components/Shared/CollapseButton";
import { CountrySelect } from "src/components/Shared/CountrySelect"; import { CountrySelect } from "src/components/Shared/CountrySelect";
import { URLField } from "src/components/Shared/URLField"; import { URLField } from "src/components/Shared/URLField";
import ImageUtils from "src/utils/image"; import ImageUtils from "src/utils/image";
@ -38,7 +36,7 @@ import { PerformerScrapeDialog } from "./PerformerScrapeDialog";
import PerformerScrapeModal from "./PerformerScrapeModal"; import PerformerScrapeModal from "./PerformerScrapeModal";
import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal"; import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal";
import cx from "classnames"; import cx from "classnames";
import { faPlus, faSyncAlt } from "@fortawesome/free-solid-svg-icons"; import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
import isEqual from "lodash-es/isEqual"; import isEqual from "lodash-es/isEqual";
import { formikUtils } from "src/utils/form"; import { formikUtils } from "src/utils/form";
import { import {
@ -48,7 +46,7 @@ import {
yupDateString, yupDateString,
yupUniqueAliases, yupUniqueAliases,
} from "src/utils/yup"; } from "src/utils/yup";
import { Tag, TagSelect } from "src/components/Tags/TagSelect"; import { useTagsEdit } from "src/hooks/tagsEdit";
const isScraper = ( const isScraper = (
scraper: GQL.Scraper | GQL.StashBox scraper: GQL.Scraper | GQL.StashBox
@ -77,14 +75,11 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
// Editing state // Editing state
const [scraper, setScraper] = useState<GQL.Scraper | IStashBox>(); const [scraper, setScraper] = useState<GQL.Scraper | IStashBox>();
const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>();
const [isScraperModalOpen, setIsScraperModalOpen] = useState<boolean>(false); const [isScraperModalOpen, setIsScraperModalOpen] = useState<boolean>(false);
// Network state // Network state
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [tags, setTags] = useState<Tag[]>([]);
const Scrapers = useListPerformerScrapers(); const Scrapers = useListPerformerScrapers();
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]); const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
@ -92,7 +87,6 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
useState<GQL.ScrapedPerformer>(); useState<GQL.ScrapedPerformer>();
const { configuration: stashConfig } = React.useContext(ConfigurationContext); const { configuration: stashConfig } = React.useContext(ConfigurationContext);
const [createTag] = useTagCreate();
const intl = useIntl(); const intl = useIntl();
const schema = yup.object({ const schema = yup.object({
@ -163,17 +157,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
onSubmit: (values) => onSave(schema.cast(values)), onSubmit: (values) => onSave(schema.cast(values)),
}); });
function onSetTags(items: Tag[]) { const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(
setTags(items); performer.tags,
formik.setFieldValue( (ids) => formik.setFieldValue("tag_ids", ids)
"tag_ids", );
items.map((item) => item.id)
);
}
useEffect(() => {
setTags(performer.tags ?? []);
}, [performer.tags]);
function translateScrapedGender(scrapedGender?: string) { function translateScrapedGender(scrapedGender?: string) {
if (!scrapedGender) { if (!scrapedGender) {
@ -207,43 +194,6 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
} }
} }
async function createNewTag(toCreate: GQL.ScrapedTag) {
const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" };
try {
const result = await createTag({
variables: {
input: tagInput,
},
});
if (!result.data?.tagCreate) {
Toast.error(new Error("Failed to create tag"));
return;
}
// add the new tag to the new tags value
const newTagIds = formik.values.tag_ids.concat([
result.data.tagCreate.id,
]);
formik.setFieldValue("tag_ids", newTagIds);
// remove the tag from the list
const newTagsClone = newTags!.concat();
const pIndex = newTagsClone.indexOf(toCreate);
newTagsClone.splice(pIndex, 1);
setNewTags(newTagsClone);
Toast.success(
<span>
Created tag: <b>{toCreate.name}</b>
</span>
);
} catch (e) {
Toast.error(e);
}
}
function updatePerformerEditStateFromScraper( function updatePerformerEditStateFromScraper(
state: Partial<GQL.ScrapedPerformerDataFragment> state: Partial<GQL.ScrapedPerformerDataFragment>
) { ) {
@ -312,20 +262,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
formik.setFieldValue("circumcised", newCircumcised); formik.setFieldValue("circumcised", newCircumcised);
} }
} }
if (state.tags) { updateTagsStateFromScraper(state.tags ?? undefined);
// map tags to their ids and filter out those not found
onSetTags(
state.tags.map((p) => {
return {
id: p.stored_id!,
name: p.name ?? "",
aliases: [],
};
})
);
setNewTags(state.tags.filter((t) => !t.stored_id));
}
// image is a base64 string // image is a base64 string
// #404: don't overwrite image if it has been modified by the user // #404: don't overwrite image if it has been modified by the user
@ -702,59 +639,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
return renderField("url", title, control); return renderField("url", title, control);
} }
function renderNewTags() {
if (!newTags || newTags.length === 0) {
return;
}
const ret = (
<>
{newTags.map((t) => (
<Badge
className="tag-item"
variant="secondary"
key={t.name}
onClick={() => createNewTag(t)}
>
{t.name}
<Button className="minimal ml-2">
<Icon className="fa-fw" icon={faPlus} />
</Button>
</Badge>
))}
</>
);
const minCollapseLength = 10;
if (newTags.length >= minCollapseLength) {
return (
<CollapseButton text={`Missing (${newTags.length})`}>
{ret}
</CollapseButton>
);
}
return ret;
}
function renderTagsField() { function renderTagsField() {
const title = intl.formatMessage({ id: "tags" }); const title = intl.formatMessage({ id: "tags" });
const control = ( return renderField("tag_ids", title, tagsControl());
<>
<TagSelect
menuPortalTarget={document.body}
isMulti
onSelect={onSetTags}
values={tags}
/>
{renderNewTags()}
</>
);
return renderField("tag_ids", title, control);
} }
return ( return (

View file

@ -21,14 +21,9 @@ import {
stringToCircumcised, stringToCircumcised,
} from "src/utils/circumcised"; } from "src/utils/circumcised";
import { IStashBox } from "./PerformerStashBoxModal"; import { IStashBox } from "./PerformerStashBoxModal";
import { import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult";
ObjectListScrapeResult,
ScrapeResult,
} from "src/components/Shared/ScrapeDialog/scrapeResult";
import { ScrapedTagsRow } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow";
import { sortStoredIdObjects } from "src/utils/data";
import { Tag } from "src/components/Tags/TagSelect"; import { Tag } from "src/components/Tags/TagSelect";
import { useCreateScrapedTag } from "src/components/Shared/ScrapeDialog/createObjects"; import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags";
function renderScrapedGender( function renderScrapedGender(
result: ScrapeResult<string>, result: ScrapeResult<string>,
@ -304,29 +299,11 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
) )
); );
const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>( const { tags, newTags, scrapedTagsRow } = useScrapedTags(
new ObjectListScrapeResult<GQL.ScrapedTag>( props.performerTags,
sortStoredIdObjects( props.scraped.tags
props.performerTags.map((t) => ({
stored_id: t.id,
name: t.name,
}))
),
sortStoredIdObjects(props.scraped.tags ?? undefined)
)
); );
const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>(
props.scraped.tags?.filter((t) => !t.stored_id) ?? []
);
const createNewTag = useCreateScrapedTag({
scrapeResult: tags,
setScrapeResult: setTags,
newObjects: newTags,
setNewObjects: setNewTags,
});
const [image, setImage] = useState<ScrapeResult<string>>( const [image, setImage] = useState<ScrapeResult<string>>(
new ScrapeResult<string>( new ScrapeResult<string>(
props.performer.image, props.performer.image,
@ -525,13 +502,7 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
result={details} result={details}
onChange={(value) => setDetails(value)} onChange={(value) => setDetails(value)}
/> />
<ScrapedTagsRow {scrapedTagsRow}
title={intl.formatMessage({ id: "tags" })}
result={tags}
onChange={(value) => setTags(value)}
newObjects={newTags}
onCreateNew={createNewTag}
/>
<ScrapedImagesRow <ScrapedImagesRow
title={intl.formatMessage({ id: "performer_image" })} title={intl.formatMessage({ id: "performer_image" })}
className="performer-image" className="performer-image"

View file

@ -45,10 +45,10 @@ import {
PerformerSelect, PerformerSelect,
} from "src/components/Performers/PerformerSelect"; } from "src/components/Performers/PerformerSelect";
import { formikUtils } from "src/utils/form"; import { formikUtils } from "src/utils/form";
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
import { Gallery, GallerySelect } from "src/components/Galleries/GallerySelect"; import { Gallery, GallerySelect } from "src/components/Galleries/GallerySelect";
import { Movie } from "src/components/Movies/MovieSelect"; import { Movie } from "src/components/Movies/MovieSelect";
import { useTagsEdit } from "src/hooks/tagsEdit";
const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog"));
const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal"));
@ -76,7 +76,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
const [galleries, setGalleries] = useState<Gallery[]>([]); const [galleries, setGalleries] = useState<Gallery[]>([]);
const [performers, setPerformers] = useState<Performer[]>([]); const [performers, setPerformers] = useState<Performer[]>([]);
const [movies, setMovies] = useState<Movie[]>([]); const [movies, setMovies] = useState<Movie[]>([]);
const [tags, setTags] = useState<Tag[]>([]);
const [studio, setStudio] = useState<Studio | null>(null); const [studio, setStudio] = useState<Studio | null>(null);
const Scrapers = useListSceneScrapers(); const Scrapers = useListSceneScrapers();
@ -108,10 +107,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
setMovies(scene.movies?.map((m) => m.movie) ?? []); setMovies(scene.movies?.map((m) => m.movie) ?? []);
}, [scene.movies]); }, [scene.movies]);
useEffect(() => {
setTags(scene.tags ?? []);
}, [scene.tags]);
useEffect(() => { useEffect(() => {
setStudio(scene.studio ?? null); setStudio(scene.studio ?? null);
}, [scene.studio]); }, [scene.studio]);
@ -174,6 +169,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
onSubmit: (values) => onSave(schema.cast(values)), onSubmit: (values) => onSave(schema.cast(values)),
}); });
const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(
scene.tags,
(ids) => formik.setFieldValue("tag_ids", ids)
);
const coverImagePreview = useMemo(() => { const coverImagePreview = useMemo(() => {
const sceneImage = scene.paths?.screenshot; const sceneImage = scene.paths?.screenshot;
const formImage = formik.values.cover_image; const formImage = formik.values.cover_image;
@ -214,14 +214,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
); );
} }
function onSetTags(items: Tag[]) {
setTags(items);
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
);
}
function onSetStudio(item: Studio | null) { function onSetStudio(item: Studio | null) {
setStudio(item); setStudio(item);
formik.setFieldValue("studio_id", item ? item.id : null); formik.setFieldValue("studio_id", item ? item.id : null);
@ -593,23 +585,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
} }
} }
if (updatedScene?.tags?.length) { updateTagsStateFromScraper(updatedScene.tags ?? undefined);
const idTags = updatedScene.tags.filter((p) => {
return p.stored_id !== undefined && p.stored_id !== null;
});
if (idTags.length > 0) {
onSetTags(
idTags.map((p) => {
return {
id: p.stored_id!,
name: p.name ?? "",
aliases: [],
};
})
);
}
}
if (updatedScene.image) { if (updatedScene.image) {
// image is a base64 string // image is a base64 string
@ -771,16 +747,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
function renderTagsField() { function renderTagsField() {
const title = intl.formatMessage({ id: "tags" }); const title = intl.formatMessage({ id: "tags" });
const control = ( return renderField("tag_ids", title, tagsControl(), fullWidthProps);
<TagSelect
isMulti
onSelect={onSetTags}
values={tags}
hoverPlacement="right"
/>
);
return renderField("tag_ids", title, control, fullWidthProps);
} }
function renderDetailsField() { function renderDetailsField() {

View file

@ -20,17 +20,16 @@ import {
ScrapedMoviesRow, ScrapedMoviesRow,
ScrapedPerformersRow, ScrapedPerformersRow,
ScrapedStudioRow, ScrapedStudioRow,
ScrapedTagsRow,
} from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow";
import { import {
useCreateScrapedMovie, useCreateScrapedMovie,
useCreateScrapedPerformer, useCreateScrapedPerformer,
useCreateScrapedStudio, useCreateScrapedStudio,
useCreateScrapedTag,
} from "src/components/Shared/ScrapeDialog/createObjects"; } from "src/components/Shared/ScrapeDialog/createObjects";
import { Tag } from "src/components/Tags/TagSelect"; import { Tag } from "src/components/Tags/TagSelect";
import { Studio } from "src/components/Studios/StudioSelect"; import { Studio } from "src/components/Studios/StudioSelect";
import { Movie } from "src/components/Movies/MovieSelect"; import { Movie } from "src/components/Movies/MovieSelect";
import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags";
interface ISceneScrapeDialogProps { interface ISceneScrapeDialogProps {
scene: Partial<GQL.SceneUpdateInput>; scene: Partial<GQL.SceneUpdateInput>;
@ -132,19 +131,9 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
scraped.movies?.filter((t) => !t.stored_id) ?? [] scraped.movies?.filter((t) => !t.stored_id) ?? []
); );
const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>( const { tags, newTags, scrapedTagsRow } = useScrapedTags(
new ObjectListScrapeResult<GQL.ScrapedTag>( sceneTags,
sortStoredIdObjects( scraped.tags
sceneTags.map((t) => ({
stored_id: t.id,
name: t.name,
}))
),
sortStoredIdObjects(scraped.tags ?? undefined)
)
);
const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>(
scraped.tags?.filter((t) => !t.stored_id) ?? []
); );
const [details, setDetails] = useState<ScrapeResult<string>>( const [details, setDetails] = useState<ScrapeResult<string>>(
@ -175,13 +164,6 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
setNewObjects: setNewMovies, setNewObjects: setNewMovies,
}); });
const createNewTag = useCreateScrapedTag({
scrapeResult: tags,
setScrapeResult: setTags,
newObjects: newTags,
setNewObjects: setNewTags,
});
const intl = useIntl(); const intl = useIntl();
// don't show the dialog if nothing was scraped // don't show the dialog if nothing was scraped
@ -278,13 +260,7 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
newObjects={newMovies} newObjects={newMovies}
onCreateNew={createNewMovie} onCreateNew={createNewMovie}
/> />
<ScrapedTagsRow {scrapedTagsRow}
title={intl.formatMessage({ id: "tags" })}
result={tags}
onChange={(value) => setTags(value)}
newObjects={newTags}
onCreateNew={createNewTag}
/>
<ScrapedTextAreaRow <ScrapedTextAreaRow
title={intl.formatMessage({ id: "details" })} title={intl.formatMessage({ id: "details" })}
result={details} result={details}

View file

@ -0,0 +1,53 @@
import { useState } from "react";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { ObjectListScrapeResult } from "./scrapeResult";
import { sortStoredIdObjects } from "src/utils/data";
import { Tag } from "src/components/Tags/TagSelect";
import { useCreateScrapedTag } from "./createObjects";
import { ScrapedTagsRow } from "./ScrapedObjectsRow";
export function useScrapedTags(
existingTags: Tag[],
scrapedTags?: GQL.Maybe<GQL.ScrapedTag[]>
) {
const intl = useIntl();
const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>(
new ObjectListScrapeResult<GQL.ScrapedTag>(
sortStoredIdObjects(
existingTags.map((t) => ({
stored_id: t.id,
name: t.name,
}))
),
sortStoredIdObjects(scrapedTags ?? undefined)
)
);
const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>(
scrapedTags?.filter((t) => !t.stored_id) ?? []
);
const createNewTag = useCreateScrapedTag({
scrapeResult: tags,
setScrapeResult: setTags,
newObjects: newTags,
setNewObjects: setNewTags,
});
const scrapedTagsRow = (
<ScrapedTagsRow
title={intl.formatMessage({ id: "tags" })}
result={tags}
onChange={(value) => setTags(value)}
newObjects={newTags}
onCreateNew={createNewTag}
/>
);
return {
tags,
newTags,
scrapedTagsRow,
};
}

View file

@ -191,7 +191,7 @@ export const GalleryLink: React.FC<IGalleryLinkProps> = ({
interface ITagLinkProps { interface ITagLinkProps {
tag: INamedObject; tag: INamedObject;
linkType?: "scene" | "gallery" | "image" | "details" | "performer"; linkType?: "scene" | "gallery" | "image" | "details" | "performer" | "movie";
className?: string; className?: string;
hoverPlacement?: Placement; hoverPlacement?: Placement;
showHierarchyIcon?: boolean; showHierarchyIcon?: boolean;
@ -216,6 +216,8 @@ export const TagLink: React.FC<ITagLinkProps> = ({
return NavUtils.makeTagGalleriesUrl(tag); return NavUtils.makeTagGalleriesUrl(tag);
case "image": case "image":
return NavUtils.makeTagImagesUrl(tag); return NavUtils.makeTagImagesUrl(tag);
case "movie":
return NavUtils.makeTagMoviesUrl(tag);
case "details": case "details":
return NavUtils.makeTagUrl(tag.id ?? ""); return NavUtils.makeTagUrl(tag.id ?? "");
} }

View file

@ -223,6 +223,19 @@ export const TagCard: React.FC<IProps> = ({
); );
} }
function maybeRenderMoviesPopoverButton() {
if (!tag.movie_count) return;
return (
<PopoverCountButton
className="movie-count"
type="movie"
count={tag.movie_count}
url={NavUtils.makeTagMoviesUrl(tag)}
/>
);
}
function maybeRenderPopoverButtonGroup() { function maybeRenderPopoverButtonGroup() {
if (tag) { if (tag) {
return ( return (
@ -232,6 +245,7 @@ export const TagCard: React.FC<IProps> = ({
{maybeRenderScenesPopoverButton()} {maybeRenderScenesPopoverButton()}
{maybeRenderImagesPopoverButton()} {maybeRenderImagesPopoverButton()}
{maybeRenderGalleriesPopoverButton()} {maybeRenderGalleriesPopoverButton()}
{maybeRenderMoviesPopoverButton()}
{maybeRenderSceneMarkersPopoverButton()} {maybeRenderSceneMarkersPopoverButton()}
{maybeRenderPerformersPopoverButton()} {maybeRenderPerformersPopoverButton()}
</ButtonGroup> </ButtonGroup>

View file

@ -41,6 +41,7 @@ import {
import { DetailImage } from "src/components/Shared/DetailImage"; import { DetailImage } from "src/components/Shared/DetailImage";
import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useLoadStickyHeader } from "src/hooks/detailsPanel";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
import { TagMoviesPanel } from "./TagMoviesPanel";
interface IProps { interface IProps {
tag: GQL.TagDataFragment; tag: GQL.TagDataFragment;
@ -57,6 +58,7 @@ const validTabs = [
"scenes", "scenes",
"images", "images",
"galleries", "galleries",
"movies",
"markers", "markers",
"performers", "performers",
] as const; ] as const;
@ -101,6 +103,8 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
(showAllCounts ? tag.image_count_all : tag.image_count) ?? 0; (showAllCounts ? tag.image_count_all : tag.image_count) ?? 0;
const galleryCount = const galleryCount =
(showAllCounts ? tag.gallery_count_all : tag.gallery_count) ?? 0; (showAllCounts ? tag.gallery_count_all : tag.gallery_count) ?? 0;
const movieCount =
(showAllCounts ? tag.movie_count_all : tag.movie_count) ?? 0;
const sceneMarkerCount = const sceneMarkerCount =
(showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; (showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0;
const performerCount = const performerCount =
@ -113,6 +117,8 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
ret = "images"; ret = "images";
} else if (galleryCount != 0) { } else if (galleryCount != 0) {
ret = "galleries"; ret = "galleries";
} else if (movieCount != 0) {
ret = "movies";
} else if (sceneMarkerCount != 0) { } else if (sceneMarkerCount != 0) {
ret = "markers"; ret = "markers";
} else if (performerCount != 0) { } else if (performerCount != 0) {
@ -121,7 +127,14 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
} }
return ret; return ret;
}, [sceneCount, imageCount, galleryCount, sceneMarkerCount, performerCount]); }, [
sceneCount,
imageCount,
galleryCount,
sceneMarkerCount,
performerCount,
movieCount,
]);
const setTabKey = useCallback( const setTabKey = useCallback(
(newTabKey: string | null) => { (newTabKey: string | null) => {
@ -463,6 +476,21 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
> >
<TagGalleriesPanel active={tabKey === "galleries"} tag={tag} /> <TagGalleriesPanel active={tabKey === "galleries"} tag={tag} />
</Tab> </Tab>
<Tab
eventKey="movies"
title={
<>
{intl.formatMessage({ id: "movies" })}
<Counter
abbreviateCounter={abbreviateCounter}
count={movieCount}
hideZero
/>
</>
}
>
<TagMoviesPanel active={tabKey === "movies"} tag={tag} />
</Tab>
<Tab <Tab
eventKey="markers" eventKey="markers"
title={ title={

View file

@ -0,0 +1,12 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { useTagFilterHook } from "src/core/tags";
import { MovieList } from "src/components/Movies/MovieList";
export const TagMoviesPanel: React.FC<{
active: boolean;
tag: GQL.TagDataFragment;
}> = ({ active, tag }) => {
const filterHook = useTagFilterHook(tag);
return <MovieList filterHook={filterHook} alterQuery={active} />;
};

View file

@ -0,0 +1,148 @@
import * as GQL from "src/core/generated-graphql";
import { useTagCreate } from "src/core/StashService";
import { useEffect, useState } from "react";
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
import { useToast } from "src/hooks/Toast";
import { useIntl } from "react-intl";
import { Badge, Button } from "react-bootstrap";
import { Icon } from "src/components/Shared/Icon";
import { faPlus } from "@fortawesome/free-solid-svg-icons";
import { CollapseButton } from "src/components/Shared/CollapseButton";
export function useTagsEdit(
srcTags: Tag[] | undefined,
setFieldValue: (ids: string[]) => void
) {
const intl = useIntl();
const Toast = useToast();
const [createTag] = useTagCreate();
const [tags, setTags] = useState<Tag[]>([]);
const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>();
function onSetTags(items: Tag[]) {
setTags(items);
setFieldValue(items.map((item) => item.id));
}
useEffect(() => {
setTags(srcTags ?? []);
}, [srcTags]);
async function createNewTag(toCreate: GQL.ScrapedTag) {
const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" };
try {
const result = await createTag({
variables: {
input: tagInput,
},
});
if (!result.data?.tagCreate) {
Toast.error(new Error("Failed to create tag"));
return;
}
// add the new tag to the new tags value
const newTagIds = tags
.map((t) => t.id)
.concat([result.data.tagCreate.id]);
setFieldValue(newTagIds);
// remove the tag from the list
const newTagsClone = newTags!.concat();
const pIndex = newTagsClone.indexOf(toCreate);
newTagsClone.splice(pIndex, 1);
setNewTags(newTagsClone);
Toast.success(
intl.formatMessage(
{ id: "toast.created_entity" },
{
entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase(),
entity_name: toCreate.name,
}
)
);
} catch (e) {
Toast.error(e);
}
}
function updateTagsStateFromScraper(
scrapedTags?: Pick<GQL.ScrapedTag, "name" | "stored_id">[]
) {
if (scrapedTags) {
// map tags to their ids and filter out those not found
onSetTags(
scrapedTags.map((p) => {
return {
id: p.stored_id!,
name: p.name ?? "",
aliases: [],
};
})
);
setNewTags(scrapedTags.filter((t) => !t.stored_id));
}
}
function renderNewTags() {
if (!newTags || newTags.length === 0) {
return;
}
const ret = (
<>
{newTags.map((t) => (
<Badge
className="tag-item"
variant="secondary"
key={t.name}
onClick={() => createNewTag(t)}
>
{t.name}
<Button className="minimal ml-2">
<Icon className="fa-fw" icon={faPlus} />
</Button>
</Badge>
))}
</>
);
const minCollapseLength = 10;
if (newTags.length >= minCollapseLength) {
return (
<CollapseButton text={`Missing (${newTags.length})`}>
{ret}
</CollapseButton>
);
}
return ret;
}
function tagsControl() {
return (
<>
<TagSelect
menuPortalTarget={document.body}
isMulti
onSelect={onSetTags}
values={tags}
/>
{renderNewTags()}
</>
);
}
return {
tags,
onSetTags,
tagsControl,
updateTagsStateFromScraper,
};
}

View file

@ -1118,6 +1118,7 @@
"megabits_per_second": "{value} mbps", "megabits_per_second": "{value} mbps",
"metadata": "Metadata", "metadata": "Metadata",
"movie": "Movie", "movie": "Movie",
"movie_count": "Movie Count",
"movie_scene_number": "Scene Number", "movie_scene_number": "Scene Number",
"movies": "Movies", "movies": "Movies",
"name": "Name", "name": "Name",

View file

@ -3,6 +3,7 @@ import {
createDateCriterionOption, createDateCriterionOption,
createMandatoryTimestampCriterionOption, createMandatoryTimestampCriterionOption,
createDurationCriterionOption, createDurationCriterionOption,
createMandatoryNumberCriterionOption,
} from "./criteria/criterion"; } from "./criteria/criterion";
import { MovieIsMissingCriterionOption } from "./criteria/is-missing"; import { MovieIsMissingCriterionOption } from "./criteria/is-missing";
import { StudiosCriterionOption } from "./criteria/studios"; import { StudiosCriterionOption } from "./criteria/studios";
@ -10,10 +11,18 @@ import { PerformersCriterionOption } from "./criteria/performers";
import { ListFilterOptions } from "./filter-options"; import { ListFilterOptions } from "./filter-options";
import { DisplayMode } from "./types"; import { DisplayMode } from "./types";
import { RatingCriterionOption } from "./criteria/rating"; import { RatingCriterionOption } from "./criteria/rating";
import { TagsCriterionOption } from "./criteria/tags";
const defaultSortBy = "name"; const defaultSortBy = "name";
const sortByOptions = ["name", "random", "date", "duration", "rating"] const sortByOptions = [
"name",
"random",
"date",
"duration",
"rating",
"tag_count",
]
.map(ListFilterOptions.createSortBy) .map(ListFilterOptions.createSortBy)
.concat([ .concat([
{ {
@ -33,6 +42,8 @@ const criterionOptions = [
RatingCriterionOption, RatingCriterionOption,
PerformersCriterionOption, PerformersCriterionOption,
createDateCriterionOption("date"), createDateCriterionOption("date"),
TagsCriterionOption,
createMandatoryNumberCriterionOption("tag_count"),
createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("created_at"),
createMandatoryTimestampCriterionOption("updated_at"), createMandatoryTimestampCriterionOption("updated_at"),
]; ];

View file

@ -35,6 +35,10 @@ const sortByOptions = ["name", "random"]
messageID: "scene_count", messageID: "scene_count",
value: "scenes_count", value: "scenes_count",
}, },
{
messageID: "movie_count",
value: "movies_count",
},
{ {
messageID: "marker_count", messageID: "marker_count",
value: "scene_markers_count", value: "scene_markers_count",
@ -53,6 +57,7 @@ const criterionOptions = [
createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("image_count"),
createMandatoryNumberCriterionOption("gallery_count"), createMandatoryNumberCriterionOption("gallery_count"),
createMandatoryNumberCriterionOption("performer_count"), createMandatoryNumberCriterionOption("performer_count"),
createMandatoryNumberCriterionOption("movie_count"),
createMandatoryNumberCriterionOption("marker_count"), createMandatoryNumberCriterionOption("marker_count"),
ParentTagsCriterionOption, ParentTagsCriterionOption,
new MandatoryNumberCriterionOption("parent_tag_count", "parent_count"), new MandatoryNumberCriterionOption("parent_tag_count", "parent_count"),

View file

@ -172,6 +172,7 @@ export type CriterionType =
| "image_count" | "image_count"
| "gallery_count" | "gallery_count"
| "performer_count" | "performer_count"
| "movie_count"
| "death_year" | "death_year"
| "url" | "url"
| "interactive" | "interactive"

View file

@ -78,7 +78,7 @@ const makePerformerImagesUrl = (
}; };
export interface INamedObject { export interface INamedObject {
id?: string; id: string;
name?: string; name?: string;
} }
@ -262,8 +262,7 @@ const makeChildTagsUrl = (tag: Partial<GQL.TagDataFragment>) => {
return `/tags?${filter.makeQueryParameters()}`; return `/tags?${filter.makeQueryParameters()}`;
}; };
const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => { function makeTagFilter(mode: GQL.FilterMode, tag: INamedObject) {
if (!tag.id) return "#";
const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined);
const criterion = new TagsCriterion(TagsCriterionOption); const criterion = new TagsCriterion(TagsCriterionOption);
criterion.value = { criterion.value = {
@ -272,59 +271,31 @@ const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => {
depth: 0, depth: 0,
}; };
filter.criteria.push(criterion); filter.criteria.push(criterion);
return `/scenes?${filter.makeQueryParameters()}`; return filter.makeQueryParameters();
}
const makeTagScenesUrl = (tag: INamedObject) => {
return `/scenes?${makeTagFilter(GQL.FilterMode.Scenes, tag)}`;
}; };
const makeTagPerformersUrl = (tag: Partial<GQL.TagDataFragment>) => { const makeTagPerformersUrl = (tag: INamedObject) => {
if (!tag.id) return "#"; return `/performers?${makeTagFilter(GQL.FilterMode.Performers, tag)}`;
const filter = new ListFilterModel(GQL.FilterMode.Performers, undefined);
const criterion = new TagsCriterion(TagsCriterionOption);
criterion.value = {
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
excluded: [],
depth: 0,
};
filter.criteria.push(criterion);
return `/performers?${filter.makeQueryParameters()}`;
}; };
const makeTagSceneMarkersUrl = (tag: Partial<GQL.TagDataFragment>) => { const makeTagSceneMarkersUrl = (tag: INamedObject) => {
if (!tag.id) return "#"; return `/scenes/markers?${makeTagFilter(GQL.FilterMode.SceneMarkers, tag)}`;
const filter = new ListFilterModel(GQL.FilterMode.SceneMarkers, undefined);
const criterion = new TagsCriterion(TagsCriterionOption);
criterion.value = {
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
excluded: [],
depth: 0,
};
filter.criteria.push(criterion);
return `/scenes/markers?${filter.makeQueryParameters()}`;
}; };
const makeTagGalleriesUrl = (tag: Partial<GQL.TagDataFragment>) => { const makeTagGalleriesUrl = (tag: INamedObject) => {
if (!tag.id) return "#"; return `/galleries?${makeTagFilter(GQL.FilterMode.Galleries, tag)}`;
const filter = new ListFilterModel(GQL.FilterMode.Galleries, undefined);
const criterion = new TagsCriterion(TagsCriterionOption);
criterion.value = {
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
excluded: [],
depth: 0,
};
filter.criteria.push(criterion);
return `/galleries?${filter.makeQueryParameters()}`;
}; };
const makeTagImagesUrl = (tag: Partial<GQL.TagDataFragment>) => { const makeTagImagesUrl = (tag: INamedObject) => {
if (!tag.id) return "#"; return `/images?${makeTagFilter(GQL.FilterMode.Images, tag)}`;
const filter = new ListFilterModel(GQL.FilterMode.Images, undefined); };
const criterion = new TagsCriterion(TagsCriterionOption);
criterion.value = { const makeTagMoviesUrl = (tag: INamedObject) => {
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], return `/movies?${makeTagFilter(GQL.FilterMode.Movies, tag)}`;
excluded: [],
depth: 0,
};
filter.criteria.push(criterion);
return `/images?${filter.makeQueryParameters()}`;
}; };
type SceneMarkerDataFragment = Pick<GQL.SceneMarker, "id" | "seconds"> & { type SceneMarkerDataFragment = Pick<GQL.SceneMarker, "id" | "seconds"> & {
@ -441,6 +412,7 @@ const NavUtils = {
makeTagPerformersUrl, makeTagPerformersUrl,
makeTagGalleriesUrl, makeTagGalleriesUrl,
makeTagImagesUrl, makeTagImagesUrl,
makeTagMoviesUrl,
makeScenesPHashMatchUrl, makeScenesPHashMatchUrl,
makeSceneMarkerUrl, makeSceneMarkerUrl,
makeMovieScenesUrl, makeMovieScenesUrl,