diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 92127416fd1..1df9d2fba1b 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -334,6 +334,10 @@ input MovieFilterType { url: StringCriterionInput "Filter to only include movies where performer appears in a scene" performers: MultiCriterionInput + "Filter to only include movies with these tags" + tags: HierarchicalMultiCriterionInput + "Filter by tag count" + tag_count: IntCriterionInput "Filter by date" date: DateCriterionInput "Filter by creation time" @@ -494,6 +498,9 @@ input TagFilterType { "Filter by number of performers with this tag" performer_count: IntCriterionInput + "Filter by number of movies with this tag" + movie_count: IntCriterionInput + "Filter by number of markers with this tag" marker_count: IntCriterionInput diff --git a/graphql/schema/types/movie.graphql b/graphql/schema/types/movie.graphql index 8501d88334a..0723bcc4f28 100644 --- a/graphql/schema/types/movie.graphql +++ b/graphql/schema/types/movie.graphql @@ -12,6 +12,7 @@ type Movie { synopsis: String url: String @deprecated(reason: "Use urls") urls: [String!]! + tags: [Tag!]! created_at: Time! updated_at: Time! @@ -34,6 +35,7 @@ input MovieCreateInput { synopsis: String url: String @deprecated(reason: "Use urls") urls: [String!] + tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" front_image: String "This should be a URL or a base64 encoded data URL" @@ -53,6 +55,7 @@ input MovieUpdateInput { synopsis: String url: String @deprecated(reason: "Use urls") urls: [String!] + tag_ids: [ID!] "This should be a URL or a base64 encoded data URL" front_image: String "This should be a URL or a base64 encoded data URL" @@ -67,6 +70,7 @@ input BulkMovieUpdateInput { studio_id: ID director: String urls: BulkUpdateStrings + tag_ids: BulkUpdateIds } input MovieDestroyInput { diff --git a/graphql/schema/types/scraped-movie.graphql b/graphql/schema/types/scraped-movie.graphql index f45903ccef1..5b07a222c93 100644 --- a/graphql/schema/types/scraped-movie.graphql +++ b/graphql/schema/types/scraped-movie.graphql @@ -11,6 +11,7 @@ type ScrapedMovie { urls: [String!] synopsis: String studio: ScrapedStudio + tags: [ScrapedTag!] "This should be a base64 encoded data URL" front_image: String @@ -28,4 +29,5 @@ input ScrapedMovieInput { url: String @deprecated(reason: "use urls") urls: [String!] synopsis: String + # not including tags for the input } diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 6438b52e1fa..35229c5cb81 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -13,6 +13,7 @@ type Tag { image_count(depth: Int): Int! # Resolver gallery_count(depth: Int): Int! # Resolver performer_count(depth: Int): Int! # Resolver + movie_count(depth: Int): Int! # Resolver parents: [Tag!]! children: [Tag!]! diff --git a/internal/api/resolver_model_movie.go b/internal/api/resolver_model_movie.go index 630b7d2a0ea..d1509c7a18a 100644 --- a/internal/api/resolver_model_movie.go +++ b/internal/api/resolver_model_movie.go @@ -57,6 +57,20 @@ func (r *movieResolver) Studio(ctx context.Context, obj *models.Movie) (ret *mod 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) { var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index d219fcc66d7..7c32667d24f 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -8,6 +8,7 @@ import ( "github.com/stashapp/stash/pkg/gallery" "github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/movie" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/scene" ) @@ -107,6 +108,17 @@ func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag, depth 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) { var hasImage bool if err := r.withReadTxn(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_mutation_movie.go b/internal/api/resolver_mutation_movie.go index 82198c125d4..c3fce71a601 100644 --- a/internal/api/resolver_mutation_movie.go +++ b/internal/api/resolver_mutation_movie.go @@ -50,6 +50,11 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp 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 { newMovie.URLs = models.NewRelatedStrings(input.Urls) } 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) } + 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) var frontimageData []byte @@ -211,6 +221,12 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU if err != nil { 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) ret := []*models.Movie{} diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index 4a65c52f5c6..503f73b7e80 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -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) { content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeScene) if err != nil { @@ -186,7 +203,14 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models 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) { diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 555502dc5b0..2daac200815 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -1107,6 +1107,7 @@ func (t *ExportTask) exportMovie(ctx context.Context, wg *sync.WaitGroup, jobCha r := t.repository movieReader := r.Movie studioReader := r.Studio + tagReader := r.Tag for m := range jobChan { 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 } + 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 m.StudioID != nil { t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *m.StudioID) diff --git a/internal/manager/task_import.go b/internal/manager/task_import.go index 9b5de7354fb..c9d5b54ba72 100644 --- a/internal/manager/task_import.go +++ b/internal/manager/task_import.go @@ -351,6 +351,7 @@ func (t *ImportTask) ImportMovies(ctx context.Context) { movieImporter := &movie.Importer{ ReaderWriter: r.Movie, StudioWriter: r.Studio, + TagWriter: r.Tag, Input: *movieJSON, MissingRefBehaviour: t.MissingRefBehaviour, } diff --git a/pkg/models/jsonschema/movie.go b/pkg/models/jsonschema/movie.go index 33ce10c1d4a..eeefe1ed17d 100644 --- a/pkg/models/jsonschema/movie.go +++ b/pkg/models/jsonschema/movie.go @@ -23,6 +23,7 @@ type Movie struct { BackImage string `json:"back_image,omitempty"` URLs []string `json:"urls,omitempty"` Studio string `json:"studio,omitempty"` + Tags []string `json:"tags,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` diff --git a/pkg/models/mocks/MovieReaderWriter.go b/pkg/models/mocks/MovieReaderWriter.go index 3f693be94ed..0da8c8a196f 100644 --- a/pkg/models/mocks/MovieReaderWriter.go +++ b/pkg/models/mocks/MovieReaderWriter.go @@ -312,6 +312,29 @@ func (_m *MovieReaderWriter) GetFrontImage(ctx context.Context, movieID int) ([] 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 func (_m *MovieReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { ret := _m.Called(ctx, relatedID) diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index f4c494016f3..d18f6a66b6c 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -266,6 +266,29 @@ func (_m *TagReaderWriter) FindByImageID(ctx context.Context, imageID int) ([]*m 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 func (_m *TagReaderWriter) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) { ret := _m.Called(ctx, name, nocase) diff --git a/pkg/models/model_movie.go b/pkg/models/model_movie.go index d1ce0d8dcbf..cd8bb848c80 100644 --- a/pkg/models/model_movie.go +++ b/pkg/models/model_movie.go @@ -19,7 +19,8 @@ type Movie struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - URLs RelatedStrings `json:"urls"` + URLs RelatedStrings `json:"urls"` + TagIDs RelatedIDs `json:"tag_ids"` } func NewMovie() Movie { @@ -30,9 +31,15 @@ func NewMovie() Movie { } } -func (g *Movie) LoadURLs(ctx context.Context, l URLLoader) error { - return g.URLs.load(func() ([]string, error) { - return l.GetURLs(ctx, g.ID) +func (m *Movie) LoadURLs(ctx context.Context, l URLLoader) error { + return m.URLs.load(func() ([]string, error) { + 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 Synopsis OptionalString URLs *UpdateStrings + TagIDs *UpdateIDs CreatedAt OptionalTime UpdatedAt OptionalTime } diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 5a9f2acb036..5cc5c679cb3 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -371,6 +371,7 @@ type ScrapedMovie struct { URLs []string `json:"urls"` Synopsis *string `json:"synopsis"` Studio *ScrapedStudio `json:"studio"` + Tags []*ScrapedTag `json:"tags"` // This should be a base64 encoded data URL FrontImage *string `json:"front_image"` // This should be a base64 encoded data URL diff --git a/pkg/models/movie.go b/pkg/models/movie.go index 95c6efdd1a2..5fb98190dbd 100644 --- a/pkg/models/movie.go +++ b/pkg/models/movie.go @@ -17,6 +17,10 @@ type MovieFilterType struct { URL *StringCriterionInput `json:"url"` // Filter to only include movies where performer appears in a scene 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 Date *DateCriterionInput `json:"date"` // Filter by related scenes that meet this criteria diff --git a/pkg/models/repository_movie.go b/pkg/models/repository_movie.go index 2518e21b529..dec0e042127 100644 --- a/pkg/models/repository_movie.go +++ b/pkg/models/repository_movie.go @@ -65,6 +65,7 @@ type MovieReader interface { MovieQueryer MovieCounter URLLoader + TagIDLoader All(ctx context.Context) ([]*Movie, error) GetFrontImage(ctx context.Context, movieID int) ([]byte, error) diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index 6d38785e6d0..287aeb211b8 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -20,6 +20,7 @@ type TagFinder interface { FindByImageID(ctx context.Context, imageID int) ([]*Tag, error) FindByGalleryID(ctx context.Context, galleryID 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) FindByName(ctx context.Context, name string, nocase bool) (*Tag, error) FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error) diff --git a/pkg/models/tag.go b/pkg/models/tag.go index d51ec9787b0..7ee0705a432 100644 --- a/pkg/models/tag.go +++ b/pkg/models/tag.go @@ -20,6 +20,8 @@ type TagFilterType struct { GalleryCount *IntCriterionInput `json:"gallery_count"` // Filter by number of performers with this tag 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 MarkerCount *IntCriterionInput `json:"marker_count"` // Filter by parent tags diff --git a/pkg/movie/import.go b/pkg/movie/import.go index 00e56d4e137..27c25316de0 100644 --- a/pkg/movie/import.go +++ b/pkg/movie/import.go @@ -3,9 +3,11 @@ package movie import ( "context" "fmt" + "strings" "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/jsonschema" + "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/utils" ) @@ -17,6 +19,7 @@ type ImporterReaderWriter interface { type Importer struct { ReaderWriter ImporterReaderWriter StudioWriter models.StudioFinderCreator + TagWriter models.TagFinderCreator Input jsonschema.Movie MissingRefBehaviour models.ImportMissingRefEnum @@ -32,6 +35,10 @@ func (i *Importer) PreImport(ctx context.Context) error { return err } + if err := i.populateTags(ctx); err != nil { + return err + } + var err error if len(i.Input.FrontImage) > 0 { i.frontImageData, err = utils.ProcessBase64Image(i.Input.FrontImage) @@ -49,6 +56,74 @@ func (i *Importer) PreImport(ctx context.Context) error { 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 { newMovie := models.Movie{ Name: movieJSON.Name, @@ -57,6 +132,8 @@ func (i *Importer) movieJSONToMovie(movieJSON jsonschema.Movie) models.Movie { Synopsis: movieJSON.Synopsis, CreatedAt: movieJSON.CreatedAt.GetTime(), UpdatedAt: movieJSON.UpdatedAt.GetTime(), + + TagIDs: models.NewRelatedIDs([]int{}), } if len(movieJSON.URLs) > 0 { diff --git a/pkg/movie/import_test.go b/pkg/movie/import_test.go index d62f5a89004..2cf35319c1a 100644 --- a/pkg/movie/import_test.go +++ b/pkg/movie/import_test.go @@ -26,6 +26,13 @@ const ( missingStudioName = "existingStudioName" errImageID = 3 + + existingTagID = 105 + errTagsID = 106 + + existingTagName = "existingTagName" + existingTagErr = "existingTagErr" + missingTagName = "missingTagName" ) var testCtx = context.Background() @@ -157,6 +164,97 @@ func TestImporterPreImportWithMissingStudioCreateErr(t *testing.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) { db := mocks.NewDatabase() diff --git a/pkg/movie/query.go b/pkg/movie/query.go index 3fac932a03d..72764b8ddea 100644 --- a/pkg/movie/query.go +++ b/pkg/movie/query.go @@ -18,3 +18,15 @@ func CountByStudioID(ctx context.Context, r models.MovieQueryer, id int, depth * 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) +} diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go index 1b24379cab0..7b0d6dc7e79 100644 --- a/pkg/scraper/mapped.go +++ b/pkg/scraper/mapped.go @@ -284,11 +284,13 @@ type mappedMovieScraperConfig struct { mappedConfig Studio mappedConfig `yaml:"Studio"` + Tags mappedConfig `yaml:"Tags"` } type _mappedMovieScraperConfig mappedMovieScraperConfig const ( mappedScraperConfigMovieStudio = "Studio" + mappedScraperConfigMovieTags = "Tags" ) 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[mappedScraperConfigMovieStudio] = parentMap[mappedScraperConfigMovieStudio] - delete(parentMap, mappedScraperConfigMovieStudio) + thisMap[mappedScraperConfigMovieTags] = parentMap[mappedScraperConfigMovieTags] + delete(parentMap, mappedScraperConfigMovieTags) + // re-unmarshal the sub-fields yml, err := yaml.Marshal(thisMap) if err != nil { @@ -1086,6 +1090,7 @@ func (s mappedScraper) scrapeMovie(ctx context.Context, q mappedQuery) (*models. movieMap := movieScraperConfig.mappedConfig movieStudioMap := movieScraperConfig.Studio + movieTagsMap := movieScraperConfig.Tags 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 } diff --git a/pkg/scraper/postprocessing.go b/pkg/scraper/postprocessing.go index 0cf9b5a17fb..a375b50582f 100644 --- a/pkg/scraper/postprocessing.go +++ b/pkg/scraper/postprocessing.go @@ -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) { - if m.Studio != nil { - r := c.repository - if err := r.WithReadTxn(ctx, func(ctx context.Context) error { - return match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, nil) - }); err != nil { - return nil, err + r := c.repository + if err := r.WithReadTxn(ctx, func(ctx context.Context) error { + tqb := r.TagFinder + tags, err := postProcessTags(ctx, tqb, m.Tags) + if err != nil { + 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 diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 7303400a3b6..7cfcd200384 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 60 +var appSchemaVersion uint = 61 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/61_movie_tags.up.sql b/pkg/sqlite/migrations/61_movie_tags.up.sql new file mode 100644 index 00000000000..cf898e2c590 --- /dev/null +++ b/pkg/sqlite/migrations/61_movie_tags.up.sql @@ -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`); diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index 6fc4ce5f09e..e5c08c31fbc 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -23,6 +23,8 @@ const ( movieFrontImageBlobColumn = "front_image_blob" movieBackImageBlobColumn = "back_image_blob" + moviesTagsTable = "movies_tags" + movieURLsTable = "movie_urls" movieURLColumn = "url" ) @@ -98,6 +100,7 @@ func (r *movieRowRecord) fromPartial(o models.MoviePartial) { type movieRepositoryType struct { repository scenes repository + tags joinRepository } var ( @@ -110,11 +113,21 @@ var ( tableName: moviesScenesTable, idColumn: movieIDColumn, }, + tags: joinRepository{ + repository: repository{ + tableName: moviesTagsTable, + idColumn: movieIDColumn, + }, + fkColumn: tagIDColumn, + foreignTable: tagTable, + orderBy: "tags.name ASC", + }, } ) type MovieStore struct { blobJoinQueryBuilder + tagRelationshipStore tableMgr *table } @@ -125,6 +138,11 @@ func NewMovieStore(blobStore *BlobStore) *MovieStore { blobStore: blobStore, joinTable: movieTable, }, + tagRelationshipStore: tagRelationshipStore{ + idRelationshipStore: idRelationshipStore{ + joinTable: moviesTagsTableMgr, + }, + }, 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) if err != nil { 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) } @@ -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 } @@ -430,6 +460,7 @@ var movieSortOptions = sortOptions{ "random", "rating", "scenes_count", + "tag_count", "updated_at", } @@ -451,6 +482,8 @@ func (qb *MovieStore) getMovieSort(findFilter *models.FindFilterType) (string, e sortQuery := "" switch sort { + case "tag_count": + sortQuery += getCountSort(movieTable, moviesTagsTable, movieIDColumn, direction) case "scenes_count": // generic getSort won't work for this sortQuery += getCountSort(movieTable, moviesScenesTable, movieIDColumn, direction) default: diff --git a/pkg/sqlite/movies_filter.go b/pkg/sqlite/movies_filter.go index 8ef939592c7..0a2b3d67448 100644 --- a/pkg/sqlite/movies_filter.go +++ b/pkg/sqlite/movies_filter.go @@ -63,6 +63,8 @@ func (qb *movieFilterHandler) criterionHandler() criterionHandler { qb.urlsCriterionHandler(movieFilter.URL), studioCriterionHandler(movieTable, movieFilter.Studios), qb.performersCriterionHandler(movieFilter.Performers), + qb.tagsCriterionHandler(movieFilter.Tags), + qb.tagCountCriterionHandler(movieFilter.TagCount), &dateCriterionHandler{movieFilter.Date, "movies.date", nil}, ×tampCriterionHandler{movieFilter.CreatedAt, "movies.created_at", nil}, ×tampCriterionHandler{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) +} diff --git a/pkg/sqlite/movies_test.go b/pkg/sqlite/movies_test.go index 9c4e0135fa1..3cfe05fe803 100644 --- a/pkg/sqlite/movies_test.go +++ b/pkg/sqlite/movies_test.go @@ -9,6 +9,7 @@ import ( "strconv" "strings" "testing" + "time" "github.com/stretchr/testify/assert" @@ -17,7 +18,12 @@ import ( func loadMovieRelationships(ctx context.Context, expected models.Movie, actual *models.Movie) error { 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 } } @@ -25,6 +31,337 @@ func loadMovieRelationships(ctx context.Context, expected models.Movie, actual * 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) { withTxn(func(ctx context.Context) error { mqb := db.Movie @@ -280,12 +617,12 @@ func TestMovieQueryURLExcludes(t *testing.T) { 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") // query for movies that exclude the URL "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") { assert.Equal(t, movie.Name, movies[0].Name) @@ -300,7 +637,7 @@ func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func t.Helper() sqb := db.Movie - movies := queryMovie(ctx, t, sqb, &filter, nil) + movies := queryMovies(ctx, t, &filter, nil) for _, movie := range movies { 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) if err != nil { 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 } +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) { sort := "scenes_count" direction := models.SortDirectionEnumDesc @@ -337,8 +771,7 @@ func TestMovieQuerySorting(t *testing.T) { } withTxn(func(ctx context.Context) error { - sqb := db.Movie - movies := queryMovie(ctx, t, sqb, nil, &findFilter) + movies := queryMovies(ctx, t, nil, &findFilter) // scenes should be in same order as indexes firstMovie := movies[0] @@ -348,7 +781,7 @@ func TestMovieQuerySorting(t *testing.T) { // sort in descending order direction = models.SortDirectionEnumAsc - movies = queryMovie(ctx, t, sqb, nil, &findFilter) + movies = queryMovies(ctx, t, nil, &findFilter) lastMovie := movies[len(movies)-1] assert.Equal(t, movieIDs[movieIdxWithScene], lastMovie.ID) diff --git a/pkg/sqlite/relationships.go b/pkg/sqlite/relationships.go new file mode 100644 index 00000000000..32c8fda649c --- /dev/null +++ b/pkg/sqlite/relationships.go @@ -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 +} diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 1ccab4574f7..736eae6a68b 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -150,9 +150,12 @@ const ( const ( movieIdxWithScene = iota movieIdxWithStudio + movieIdxWithTag + movieIdxWithTwoTags + movieIdxWithThreeTags // movies with dup names start from the end - // create 10 more basic movies (can remove this if we add more indexes) - movieIdxWithDupName = movieIdxWithStudio + 10 + // create 7 more basic movies (can remove this if we add more indexes) + movieIdxWithDupName = movieIdxWithStudio + 7 moviesNameCase = movieIdxWithDupName moviesNameNoCase = 1 @@ -214,6 +217,10 @@ const ( tagIdxWithParentAndChild tagIdxWithGrandParent tagIdx2WithMarkers + tagIdxWithMovie + tagIdx1WithMovie + tagIdx2WithMovie + tagIdx3WithMovie // new indexes above // tags with dup names start from the end tagIdx1WithDupName @@ -487,6 +494,12 @@ var ( movieStudioLinks = [][2]int{ {movieIdxWithStudio, studioIdxWithMovie}, } + + movieTags = linkMap{ + movieIdxWithTag: {tagIdxWithMovie}, + movieIdxWithTwoTags: {tagIdx1WithMovie, tagIdx2WithMovie}, + movieIdxWithThreeTags: {tagIdx1WithMovie, tagIdx2WithMovie, tagIdx3WithMovie}, + } ) var ( @@ -622,14 +635,14 @@ func populateDB() error { // 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 { 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 { 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 name := namePlain + tids := indexesToIDs(tagIDs, movieTags[i]) + if i >= n { // 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 @@ -1333,6 +1348,7 @@ func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o in URLs: models.NewRelatedStrings([]string{ getMovieEmptyString(i, urlField), }), + TagIDs: models.NewRelatedIDs(tids), } err := mqb.Create(ctx, &movie) diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index 2aa5b77b6c3..6b6ed9417af 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -155,6 +155,10 @@ func (t *table) join(j joiner, as string, parentIDCol string) { type joinTable struct { table fkColumn exp.IdentifierExpression + + // required for ordering + foreignTable *table + orderBy exp.OrderedExpression } 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) { 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 var ret []int if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 701c503305d..d4425cfe3e9 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -36,6 +36,7 @@ var ( studiosStashIDsJoinTable = goqu.T("studio_stash_ids") moviesURLsJoinTable = goqu.T(movieURLsTable) + moviesTagsJoinTable = goqu.T(moviesTagsTable) tagsAliasesJoinTable = goqu.T(tagAliasesTable) tagRelationsJoinTable = goqu.T(tagRelationsTable) @@ -330,6 +331,16 @@ var ( }, 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 ( diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 127ad3310e1..a4bf3793aa1 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -424,6 +424,18 @@ func (qb *TagStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mode 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) { query := ` SELECT tags.* FROM tags @@ -615,6 +627,7 @@ var tagSortOptions = sortOptions{ "galleries_count", "id", "images_count", + "movies_count", "name", "performers_count", "random", @@ -655,6 +668,8 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte sortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction) case "performers_count": sortQuery += getCountSort(tagTable, performersTagsTable, tagIDColumn, direction) + case "movies_count": + sortQuery += getCountSort(tagTable, moviesTagsTable, tagIDColumn, direction) default: 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) } + +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) +} diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 55321dbbabf..776a49fc4f3 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -66,6 +66,7 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { qb.imageCountCriterionHandler(tagFilter.ImageCount), qb.galleryCountCriterionHandler(tagFilter.GalleryCount), qb.performerCountCriterionHandler(tagFilter.PerformerCount), + qb.movieCountCriterionHandler(tagFilter.MovieCount), qb.markerCountCriterionHandler(tagFilter.MarkerCount), qb.parentsCriterionHandler(tagFilter.Parents), 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 { return func(ctx context.Context, f *filterBuilder) { if markerCount != nil { diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index a44232720b7..d71316413e4 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -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) { withTxn(func(ctx context.Context) error { tqb := db.Tag @@ -203,6 +230,10 @@ func TestTagQuerySort(t *testing.T) { tags = queryTags(ctx, t, sqb, nil, findFilter) 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 }) } diff --git a/ui/v2.5/graphql/data/movie.graphql b/ui/v2.5/graphql/data/movie.graphql index a0ed1f67f32..b94450f281e 100644 --- a/ui/v2.5/graphql/data/movie.graphql +++ b/ui/v2.5/graphql/data/movie.graphql @@ -11,6 +11,10 @@ fragment MovieData on Movie { ...SlimStudioData } + tags { + ...SlimTagData + } + synopsis urls front_image_path diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index a59d74b096e..087ba2efbc0 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -98,6 +98,9 @@ fragment ScrapedMovieData on ScrapedMovie { studio { ...ScrapedMovieStudioData } + tags { + ...ScrapedSceneTagData + } } fragment ScrapedSceneMovieData on ScrapedMovie { @@ -116,6 +119,9 @@ fragment ScrapedSceneMovieData on ScrapedMovie { studio { ...ScrapedMovieStudioData } + tags { + ...ScrapedSceneTagData + } } fragment ScrapedSceneStudioData on ScrapedStudio { diff --git a/ui/v2.5/graphql/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql index b71f487abaa..d473bf8c6d4 100644 --- a/ui/v2.5/graphql/data/tag.graphql +++ b/ui/v2.5/graphql/data/tag.graphql @@ -16,6 +16,8 @@ fragment TagData on Tag { gallery_count_all: gallery_count(depth: -1) performer_count performer_count_all: performer_count(depth: -1) + movie_count + movie_count_all: movie_count(depth: -1) parents { ...SlimTagData diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index 4c12b0232d9..9f018e0d140 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -36,9 +36,9 @@ import { yupUniqueStringList, } from "src/utils/yup"; import { formikUtils } from "src/utils/form"; -import { Tag, TagSelect } from "src/components/Tags/TagSelect"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; +import { useTagsEdit } from "src/hooks/tagsEdit"; interface IProps { gallery: Partial; @@ -58,7 +58,6 @@ export const GalleryEditPanel: React.FC = ({ const [scenes, setScenes] = useState([]); const [performers, setPerformers] = useState([]); - const [tags, setTags] = useState([]); const [studio, setStudio] = useState(null); const isNew = gallery.id === undefined; @@ -110,6 +109,11 @@ export const GalleryEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( + gallery.tags, + (ids) => formik.setFieldValue("tag_ids", ids) + ); + function onSetScenes(items: Scene[]) { setScenes(items); formik.setFieldValue( @@ -126,14 +130,6 @@ export const GalleryEditPanel: React.FC = ({ ); } - function onSetTags(items: Tag[]) { - setTags(items); - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ); - } - function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); @@ -143,10 +139,6 @@ export const GalleryEditPanel: React.FC = ({ setPerformers(gallery.performers ?? []); }, [gallery.performers]); - useEffect(() => { - setTags(gallery.tags ?? []); - }, [gallery.tags]); - useEffect(() => { setStudio(gallery.studio ?? null); }, [gallery.studio]); @@ -339,23 +331,7 @@ export const GalleryEditPanel: React.FC = ({ } } - if (galleryData?.tags?.length) { - 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: [], - }; - }) - ); - } - } + updateTagsStateFromScraper(galleryData.tags ?? undefined); } async function onScrapeGalleryURL(url: string) { @@ -437,16 +413,7 @@ export const GalleryEditPanel: React.FC = ({ function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); - const control = ( - - ); - - return renderField("tag_ids", title, control, fullWidthProps); + return renderField("tag_ids", title, tagsControl(), fullWidthProps); } function renderDetailsField() { diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx index 1daa2f5e756..dd3357fec83 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryScrapeDialog.tsx @@ -15,18 +15,17 @@ import { import { ScrapedPerformersRow, ScrapedStudioRow, - ScrapedTagsRow, } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; import { sortStoredIdObjects } from "src/utils/data"; import { Performer } from "src/components/Performers/PerformerSelect"; import { useCreateScrapedPerformer, useCreateScrapedStudio, - useCreateScrapedTag, } from "src/components/Shared/ScrapeDialog/createObjects"; import { uniq } from "lodash-es"; import { Tag } from "src/components/Tags/TagSelect"; import { Studio } from "src/components/Studios/StudioSelect"; +import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; interface IGalleryScrapeDialogProps { gallery: Partial; @@ -99,19 +98,9 @@ export const GalleryScrapeDialog: React.FC = ({ scraped.performers?.filter((t) => !t.stored_id) ?? [] ); - const [tags, setTags] = useState>( - new ObjectListScrapeResult( - sortStoredIdObjects( - galleryTags.map((t) => ({ - stored_id: t.id, - name: t.name, - })) - ), - sortStoredIdObjects(scraped.tags ?? undefined) - ) - ); - const [newTags, setNewTags] = useState( - scraped.tags?.filter((t) => !t.stored_id) ?? [] + const { tags, newTags, scrapedTagsRow } = useScrapedTags( + galleryTags, + scraped.tags ); const [details, setDetails] = useState>( @@ -131,13 +120,6 @@ export const GalleryScrapeDialog: React.FC = ({ setNewObjects: setNewPerformers, }); - const createNewTag = useCreateScrapedTag({ - scrapeResult: tags, - setScrapeResult: setTags, - newObjects: newTags, - setNewObjects: setNewTags, - }); - // don't show the dialog if nothing was scraped if ( [ @@ -218,13 +200,7 @@ export const GalleryScrapeDialog: React.FC = ({ newObjects={newPerformers} onCreateNew={createNewPerformer} /> - setTags(value)} - newObjects={newTags} - onCreateNew={createNewTag} - /> + {scrapedTagsRow} = ({ const [galleries, setGalleries] = useState([]); const [performers, setPerformers] = useState([]); - const [tags, setTags] = useState([]); const [studio, setStudio] = useState(null); useEffect(() => { @@ -98,6 +97,10 @@ export const ImageEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + const { tagsControl } = useTagsEdit(image.tags, (ids) => + formik.setFieldValue("tag_ids", ids) + ); + function onSetGalleries(items: Gallery[]) { setGalleries(items); formik.setFieldValue( @@ -114,14 +117,6 @@ export const ImageEditPanel: React.FC = ({ ); } - function onSetTags(items: Tag[]) { - setTags(items); - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ); - } - function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); @@ -131,10 +126,6 @@ export const ImageEditPanel: React.FC = ({ setPerformers(image.performers ?? []); }, [image.performers]); - useEffect(() => { - setTags(image.tags ?? []); - }, [image.tags]); - useEffect(() => { setStudio(image.studio ?? null); }, [image.studio]); @@ -233,16 +224,7 @@ export const ImageEditPanel: React.FC = ({ function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); - const control = ( - - ); - - return renderField("tag_ids", title, control, fullWidthProps); + return renderField("tag_ids", title, tagsControl(), fullWidthProps); } function renderDetailsField() { diff --git a/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx b/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx index ba46166c8c2..af48cbeaf35 100644 --- a/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx +++ b/ui/v2.5/src/components/Movies/EditMoviesDialog.tsx @@ -9,11 +9,15 @@ import { useToast } from "src/hooks/Toast"; import * as FormUtils from "src/utils/form"; import { RatingSystem } from "../Shared/Rating/RatingSystem"; import { + getAggregateInputIDs, getAggregateInputValue, getAggregateRating, getAggregateStudioId, + getAggregateTagIds, } from "src/utils/bulkUpdate"; import { faPencilAlt } from "@fortawesome/free-solid-svg-icons"; +import { isEqual } from "lodash-es"; +import { MultiSet } from "../Shared/MultiSet"; interface IListOperationProps { selected: GQL.MovieDataFragment[]; @@ -29,6 +33,12 @@ export const EditMoviesDialog: React.FC = ( const [studioId, setStudioId] = useState(); const [director, setDirector] = useState(); + const [tagMode, setTagMode] = React.useState( + GQL.BulkUpdateIdMode.Add + ); + const [tagIds, setTagIds] = useState(); + const [existingTagIds, setExistingTagIds] = useState(); + const [updateMovies] = useBulkMovieUpdate(getMovieInput()); const [isUpdating, setIsUpdating] = useState(false); @@ -36,6 +46,7 @@ export const EditMoviesDialog: React.FC = ( function getMovieInput(): GQL.BulkMovieUpdateInput { const aggregateRating = getAggregateRating(props.selected); const aggregateStudioId = getAggregateStudioId(props.selected); + const aggregateTagIds = getAggregateTagIds(props.selected); const movieInput: GQL.BulkMovieUpdateInput = { ids: props.selected.map((movie) => movie.id), @@ -45,6 +56,7 @@ export const EditMoviesDialog: React.FC = ( // if rating is undefined movieInput.rating100 = getAggregateInputValue(rating100, aggregateRating); movieInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId); + movieInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds); return movieInput; } @@ -72,14 +84,18 @@ export const EditMoviesDialog: React.FC = ( const state = props.selected; let updateRating: number | undefined; let updateStudioId: string | undefined; + let updateTagIds: string[] = []; let updateDirector: string | undefined; let first = true; state.forEach((movie: GQL.MovieDataFragment) => { + const movieTagIDs = (movie.tags ?? []).map((p) => p.id).sort(); + if (first) { first = false; updateRating = movie.rating100 ?? undefined; updateStudioId = movie.studio?.id ?? undefined; + updateTagIds = movieTagIDs; updateDirector = movie.director ?? undefined; } else { if (movie.rating100 !== updateRating) { @@ -91,11 +107,15 @@ export const EditMoviesDialog: React.FC = ( if (movie.director !== updateDirector) { updateDirector = undefined; } + if (!isEqual(movieTagIDs, updateTagIds)) { + updateTagIds = []; + } } }); setRating(updateRating); setStudioId(updateStudioId); + setExistingTagIds(updateTagIds); setDirector(updateDirector); }, [props.selected]); @@ -158,6 +178,20 @@ export const EditMoviesDialog: React.FC = ( placeholder={intl.formatMessage({ id: "director" })} /> + + + + + setTagIds(itemIDs)} + onSetMode={(newMode) => setTagMode(newMode)} + existingIds={existingTagIds ?? []} + ids={tagIds ?? []} + mode={tagMode} + /> + ); diff --git a/ui/v2.5/src/components/Movies/MovieCard.tsx b/ui/v2.5/src/components/Movies/MovieCard.tsx index d517359265e..1f763649e2d 100644 --- a/ui/v2.5/src/components/Movies/MovieCard.tsx +++ b/ui/v2.5/src/components/Movies/MovieCard.tsx @@ -4,11 +4,11 @@ import * as GQL from "src/core/generated-graphql"; import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; -import { SceneLink } from "../Shared/TagLink"; +import { SceneLink, TagLink } from "../Shared/TagLink"; import { TruncatedText } from "../Shared/TruncatedText"; import { FormattedMessage } from "react-intl"; 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"; interface IProps { @@ -20,37 +20,44 @@ interface IProps { onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void; } -export const MovieCard: React.FC = (props: IProps) => { +export const MovieCard: React.FC = ({ + movie, + sceneIndex, + containerWidth, + selecting, + selected, + onSelectedChanged, +}) => { const [cardWidth, setCardWidth] = useState(); useEffect(() => { - if (!props.containerWidth || ScreenUtils.isMobile()) return; + if (!containerWidth || ScreenUtils.isMobile()) return; let preferredCardWidth = 250; let fittedCardWidth = calculateCardWidth( - props.containerWidth, + containerWidth, preferredCardWidth! ); setCardWidth(fittedCardWidth); - }, [props, props.containerWidth]); + }, [containerWidth]); function maybeRenderSceneNumber() { - if (!props.sceneIndex) return; + if (!sceneIndex) return; return ( <>
- #{props.sceneIndex} + #{sceneIndex} ); } 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) => ( )); @@ -62,20 +69,38 @@ export const MovieCard: React.FC = (props: IProps) => { > + + ); + } + + function maybeRenderTagPopoverButton() { + if (movie.tags.length <= 0) return; + + const popoverContent = movie.tags.map((tag) => ( + + )); + + return ( + + ); } function maybeRenderPopoverButtonGroup() { - if (props.sceneIndex || props.movie.scenes.length > 0) { + if (sceneIndex || movie.scenes.length > 0 || movie.tags.length > 0) { return ( <> {maybeRenderSceneNumber()}
{maybeRenderScenesPopoverButton()} + {maybeRenderTagPopoverButton()} ); @@ -85,34 +110,34 @@ export const MovieCard: React.FC = (props: IProps) => { return ( {props.movie.name - + } details={
- {props.movie.date} + {movie.date}
} - selected={props.selected} - selecting={props.selecting} - onSelectedChanged={props.onSelectedChanged} + selected={selected} + selecting={selecting} + onSelectedChanged={onSelectedChanged} popovers={maybeRenderPopoverButtonGroup()} /> ); diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index 723b1a7ac58..69aecd20df3 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -305,6 +305,7 @@ const MoviePage: React.FC = ({ movie }) => { return ( ); diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx index 97957f7f85c..7c5a9cf3a72 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieDetailsPanel.tsx @@ -5,19 +5,54 @@ import TextUtils from "src/utils/text"; import { DetailItem } from "src/components/Shared/DetailItem"; import { Link } from "react-router-dom"; import { DirectorLink } from "src/components/Shared/Link"; +import { TagLink } from "src/components/Shared/TagLink"; interface IMovieDetailsPanel { movie: GQL.MovieDataFragment; + collapsed?: boolean; fullWidth?: boolean; } export const MovieDetailsPanel: React.FC = ({ movie, + collapsed, fullWidth, }) => { // Network state const intl = useIntl(); + function renderTagsField() { + if (!movie.tags.length) { + return; + } + return ( +
    + {(movie.tags ?? []).map((tag) => ( + + ))} +
+ ); + } + + function maybeRenderExtraDetails() { + if (!collapsed) { + return ( + <> + + + + ); + } + } + return (
= ({ } fullWidth={fullWidth} /> - + {maybeRenderExtraDetails()}
); }; diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx index 5b9bac5f8d9..5cd4cda7908 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx @@ -25,6 +25,7 @@ import { yupUniqueStringList, } from "src/utils/yup"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; +import { useTagsEdit } from "src/hooks/tagsEdit"; interface IMovieEditPanel { movie: Partial; @@ -66,6 +67,7 @@ export const MovieEditPanel: React.FC = ({ duration: yup.number().integer().min(0).nullable().defined(), date: yupDateString(intl), studio_id: yup.string().required().nullable(), + tag_ids: yup.array(yup.string().required()).defined(), director: yup.string().ensure(), urls: yupUniqueStringList(intl), synopsis: yup.string().ensure(), @@ -79,6 +81,7 @@ export const MovieEditPanel: React.FC = ({ duration: movie?.duration ?? null, date: movie?.date ?? "", studio_id: movie?.studio?.id ?? null, + tag_ids: (movie?.tags ?? []).map((t) => t.id), director: movie?.director ?? "", urls: movie?.urls ?? [], synopsis: movie?.synopsis ?? "", @@ -93,6 +96,11 @@ export const MovieEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( + movie.tags, + (ids) => formik.setFieldValue("tag_ids", ids) + ); + function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); @@ -159,6 +167,7 @@ export const MovieEditPanel: React.FC = ({ if (state.urls) { formik.setFieldValue("urls", state.urls); } + updateTagsStateFromScraper(state.tags ?? undefined); if (state.front_image) { // image is a base64 string @@ -231,6 +240,7 @@ export const MovieEditPanel: React.FC = ({ { onScrapeDialogClosed(m); @@ -351,6 +361,11 @@ export const MovieEditPanel: React.FC = ({ return renderField("studio_id", title, control); } + function renderTagsField() { + const title = intl.formatMessage({ id: "tags" }); + return renderField("tag_ids", title, tagsControl()); + } + // TODO: CSS class return (
@@ -383,6 +398,7 @@ export const MovieEditPanel: React.FC = ({ {renderInputField("director")} {renderURLListField("urls", onScrapeMovieURL, urlScrapable)} {renderInputField("synopsis", "textarea")} + {renderTagsField()} ; movieStudio: Studio | null; + movieTags: Tag[]; scraped: GQL.ScrapedMovie; onClose: (scrapedMovie?: GQL.ScrapedMovie) => void; } -export const MovieScrapeDialog: React.FC = ( - props: IMovieScrapeDialogProps -) => { +export const MovieScrapeDialog: React.FC = ({ + movie, + movieStudio, + movieTags, + scraped, + onClose, +}) => { const intl = useIntl(); const [name, setName] = useState>( - new ScrapeResult(props.movie.name, props.scraped.name) + new ScrapeResult(movie.name, scraped.name) ); const [aliases, setAliases] = useState>( - new ScrapeResult(props.movie.aliases, props.scraped.aliases) + new ScrapeResult(movie.aliases, scraped.aliases) ); const [duration, setDuration] = useState>( new ScrapeResult( - TextUtils.secondsToTimestamp(props.movie.duration || 0), + TextUtils.secondsToTimestamp(movie.duration || 0), // convert seconds to string if it's a number - props.scraped.duration && !isNaN(+props.scraped.duration) - ? TextUtils.secondsToTimestamp(parseInt(props.scraped.duration, 10)) - : props.scraped.duration + scraped.duration && !isNaN(+scraped.duration) + ? TextUtils.secondsToTimestamp(parseInt(scraped.duration, 10)) + : scraped.duration ) ); const [date, setDate] = useState>( - new ScrapeResult(props.movie.date, props.scraped.date) + new ScrapeResult(movie.date, scraped.date) ); const [director, setDirector] = useState>( - new ScrapeResult(props.movie.director, props.scraped.director) + new ScrapeResult(movie.director, scraped.director) ); const [synopsis, setSynopsis] = useState>( - new ScrapeResult(props.movie.synopsis, props.scraped.synopsis) + new ScrapeResult(movie.synopsis, scraped.synopsis) ); const [studio, setStudio] = useState>( new ObjectScrapeResult( - props.movieStudio + movieStudio ? { - stored_id: props.movieStudio.id, - name: props.movieStudio.name, + stored_id: movieStudio.id, + name: movieStudio.name, } : undefined, - props.scraped.studio?.stored_id ? props.scraped.studio : undefined + scraped.studio?.stored_id ? scraped.studio : undefined ) ); const [urls, setURLs] = useState>( new ScrapeResult( - props.movie.urls, - props.scraped.urls - ? uniq((props.movie.urls ?? []).concat(props.scraped.urls ?? [])) + movie.urls, + scraped.urls + ? uniq((movie.urls ?? []).concat(scraped.urls ?? [])) : undefined ) ); const [frontImage, setFrontImage] = useState>( - new ScrapeResult(props.movie.front_image, props.scraped.front_image) + new ScrapeResult(movie.front_image, scraped.front_image) ); const [backImage, setBackImage] = useState>( - new ScrapeResult(props.movie.back_image, props.scraped.back_image) + new ScrapeResult(movie.back_image, scraped.back_image) ); const [newStudio, setNewStudio] = useState( - props.scraped.studio && !props.scraped.studio.stored_id - ? props.scraped.studio - : undefined + scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined ); const createNewStudio = useCreateScrapedStudio({ @@ -93,6 +98,11 @@ export const MovieScrapeDialog: React.FC = ( setNewObject: setNewStudio, }); + const { tags, newTags, scrapedTagsRow } = useScrapedTags( + movieTags, + scraped.tags + ); + const allFields = [ name, aliases, @@ -101,17 +111,21 @@ export const MovieScrapeDialog: React.FC = ( director, synopsis, studio, + tags, urls, frontImage, backImage, ]; // don't show the dialog if nothing was scraped - if (allFields.every((r) => !r.scraped) && !newStudio) { - props.onClose(); + if ( + allFields.every((r) => !r.scraped) && + !newStudio && + newTags.length === 0 + ) { + onClose(); return <>; } - // todo: reenable function makeNewScrapedItem(): GQL.ScrapedMovie { const newStudioValue = studio.getNewValue(); const durationString = duration.getNewValue(); @@ -124,6 +138,7 @@ export const MovieScrapeDialog: React.FC = ( director: director.getNewValue(), synopsis: synopsis.getNewValue(), studio: newStudioValue, + tags: tags.getNewValue(), urls: urls.getNewValue(), front_image: frontImage.getNewValue(), back_image: backImage.getNewValue(), @@ -176,6 +191,7 @@ export const MovieScrapeDialog: React.FC = ( result={urls} onChange={(value) => setURLs(value)} /> + {scrapedTagsRow} = ( )} renderScrapeRows={renderScrapeRows} onClose={(apply) => { - props.onClose(apply ? makeNewScrapedItem() : undefined); + onClose(apply ? makeNewScrapedItem() : undefined); }} /> ); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index cef86ecd5d0..dc38e53ea02 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -1,5 +1,5 @@ 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 Mousetrap from "mousetrap"; import * as GQL from "src/core/generated-graphql"; @@ -8,13 +8,11 @@ import { useListPerformerScrapers, queryScrapePerformer, mutateReloadScrapers, - useTagCreate, queryScrapePerformerURL, } from "src/core/StashService"; import { Icon } from "src/components/Shared/Icon"; import { ImageInput } from "src/components/Shared/ImageInput"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; -import { CollapseButton } from "src/components/Shared/CollapseButton"; import { CountrySelect } from "src/components/Shared/CountrySelect"; import { URLField } from "src/components/Shared/URLField"; import ImageUtils from "src/utils/image"; @@ -38,7 +36,7 @@ import { PerformerScrapeDialog } from "./PerformerScrapeDialog"; import PerformerScrapeModal from "./PerformerScrapeModal"; import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal"; 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 { formikUtils } from "src/utils/form"; import { @@ -48,7 +46,7 @@ import { yupDateString, yupUniqueAliases, } from "src/utils/yup"; -import { Tag, TagSelect } from "src/components/Tags/TagSelect"; +import { useTagsEdit } from "src/hooks/tagsEdit"; const isScraper = ( scraper: GQL.Scraper | GQL.StashBox @@ -77,14 +75,11 @@ export const PerformerEditPanel: React.FC = ({ // Editing state const [scraper, setScraper] = useState(); - const [newTags, setNewTags] = useState(); const [isScraperModalOpen, setIsScraperModalOpen] = useState(false); // Network state const [isLoading, setIsLoading] = useState(false); - const [tags, setTags] = useState([]); - const Scrapers = useListPerformerScrapers(); const [queryableScrapers, setQueryableScrapers] = useState([]); @@ -92,7 +87,6 @@ export const PerformerEditPanel: React.FC = ({ useState(); const { configuration: stashConfig } = React.useContext(ConfigurationContext); - const [createTag] = useTagCreate(); const intl = useIntl(); const schema = yup.object({ @@ -163,17 +157,10 @@ export const PerformerEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); - function onSetTags(items: Tag[]) { - setTags(items); - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ); - } - - useEffect(() => { - setTags(performer.tags ?? []); - }, [performer.tags]); + const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( + performer.tags, + (ids) => formik.setFieldValue("tag_ids", ids) + ); function translateScrapedGender(scrapedGender?: string) { if (!scrapedGender) { @@ -207,43 +194,6 @@ export const PerformerEditPanel: React.FC = ({ } } - 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( - - Created tag: {toCreate.name} - - ); - } catch (e) { - Toast.error(e); - } - } - function updatePerformerEditStateFromScraper( state: Partial ) { @@ -312,20 +262,7 @@ export const PerformerEditPanel: React.FC = ({ formik.setFieldValue("circumcised", newCircumcised); } } - if (state.tags) { - // 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)); - } + updateTagsStateFromScraper(state.tags ?? undefined); // image is a base64 string // #404: don't overwrite image if it has been modified by the user @@ -702,59 +639,10 @@ export const PerformerEditPanel: React.FC = ({ return renderField("url", title, control); } - - function renderNewTags() { - if (!newTags || newTags.length === 0) { - return; - } - - const ret = ( - <> - {newTags.map((t) => ( - createNewTag(t)} - > - {t.name} - - - ))} - - ); - - const minCollapseLength = 10; - - if (newTags.length >= minCollapseLength) { - return ( - - {ret} - - ); - } - - return ret; - } - function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); - const control = ( - <> - - {renderNewTags()} - - ); - - return renderField("tag_ids", title, control); + return renderField("tag_ids", title, tagsControl()); } return ( diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx index 00d1d2a0e28..dbc4c5108e9 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx @@ -21,14 +21,9 @@ import { stringToCircumcised, } from "src/utils/circumcised"; import { IStashBox } from "./PerformerStashBoxModal"; -import { - ObjectListScrapeResult, - ScrapeResult, -} from "src/components/Shared/ScrapeDialog/scrapeResult"; -import { ScrapedTagsRow } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; -import { sortStoredIdObjects } from "src/utils/data"; +import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult"; 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( result: ScrapeResult, @@ -304,29 +299,11 @@ export const PerformerScrapeDialog: React.FC = ( ) ); - const [tags, setTags] = useState>( - new ObjectListScrapeResult( - sortStoredIdObjects( - props.performerTags.map((t) => ({ - stored_id: t.id, - name: t.name, - })) - ), - sortStoredIdObjects(props.scraped.tags ?? undefined) - ) - ); - - const [newTags, setNewTags] = useState( - props.scraped.tags?.filter((t) => !t.stored_id) ?? [] + const { tags, newTags, scrapedTagsRow } = useScrapedTags( + props.performerTags, + props.scraped.tags ); - const createNewTag = useCreateScrapedTag({ - scrapeResult: tags, - setScrapeResult: setTags, - newObjects: newTags, - setNewObjects: setNewTags, - }); - const [image, setImage] = useState>( new ScrapeResult( props.performer.image, @@ -525,13 +502,7 @@ export const PerformerScrapeDialog: React.FC = ( result={details} onChange={(value) => setDetails(value)} /> - setTags(value)} - newObjects={newTags} - onCreateNew={createNewTag} - /> + {scrapedTagsRow} import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -76,7 +76,6 @@ export const SceneEditPanel: React.FC = ({ const [galleries, setGalleries] = useState([]); const [performers, setPerformers] = useState([]); const [movies, setMovies] = useState([]); - const [tags, setTags] = useState([]); const [studio, setStudio] = useState(null); const Scrapers = useListSceneScrapers(); @@ -108,10 +107,6 @@ export const SceneEditPanel: React.FC = ({ setMovies(scene.movies?.map((m) => m.movie) ?? []); }, [scene.movies]); - useEffect(() => { - setTags(scene.tags ?? []); - }, [scene.tags]); - useEffect(() => { setStudio(scene.studio ?? null); }, [scene.studio]); @@ -174,6 +169,11 @@ export const SceneEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit( + scene.tags, + (ids) => formik.setFieldValue("tag_ids", ids) + ); + const coverImagePreview = useMemo(() => { const sceneImage = scene.paths?.screenshot; const formImage = formik.values.cover_image; @@ -214,14 +214,6 @@ export const SceneEditPanel: React.FC = ({ ); } - function onSetTags(items: Tag[]) { - setTags(items); - formik.setFieldValue( - "tag_ids", - items.map((item) => item.id) - ); - } - function onSetStudio(item: Studio | null) { setStudio(item); formik.setFieldValue("studio_id", item ? item.id : null); @@ -593,23 +585,7 @@ export const SceneEditPanel: React.FC = ({ } } - if (updatedScene?.tags?.length) { - 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: [], - }; - }) - ); - } - } + updateTagsStateFromScraper(updatedScene.tags ?? undefined); if (updatedScene.image) { // image is a base64 string @@ -771,16 +747,7 @@ export const SceneEditPanel: React.FC = ({ function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); - const control = ( - - ); - - return renderField("tag_ids", title, control, fullWidthProps); + return renderField("tag_ids", title, tagsControl(), fullWidthProps); } function renderDetailsField() { diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx index 91bc9457c47..80ad9850f9f 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneScrapeDialog.tsx @@ -20,17 +20,16 @@ import { ScrapedMoviesRow, ScrapedPerformersRow, ScrapedStudioRow, - ScrapedTagsRow, } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; import { useCreateScrapedMovie, useCreateScrapedPerformer, useCreateScrapedStudio, - useCreateScrapedTag, } from "src/components/Shared/ScrapeDialog/createObjects"; import { Tag } from "src/components/Tags/TagSelect"; import { Studio } from "src/components/Studios/StudioSelect"; import { Movie } from "src/components/Movies/MovieSelect"; +import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags"; interface ISceneScrapeDialogProps { scene: Partial; @@ -132,19 +131,9 @@ export const SceneScrapeDialog: React.FC = ({ scraped.movies?.filter((t) => !t.stored_id) ?? [] ); - const [tags, setTags] = useState>( - new ObjectListScrapeResult( - sortStoredIdObjects( - sceneTags.map((t) => ({ - stored_id: t.id, - name: t.name, - })) - ), - sortStoredIdObjects(scraped.tags ?? undefined) - ) - ); - const [newTags, setNewTags] = useState( - scraped.tags?.filter((t) => !t.stored_id) ?? [] + const { tags, newTags, scrapedTagsRow } = useScrapedTags( + sceneTags, + scraped.tags ); const [details, setDetails] = useState>( @@ -175,13 +164,6 @@ export const SceneScrapeDialog: React.FC = ({ setNewObjects: setNewMovies, }); - const createNewTag = useCreateScrapedTag({ - scrapeResult: tags, - setScrapeResult: setTags, - newObjects: newTags, - setNewObjects: setNewTags, - }); - const intl = useIntl(); // don't show the dialog if nothing was scraped @@ -278,13 +260,7 @@ export const SceneScrapeDialog: React.FC = ({ newObjects={newMovies} onCreateNew={createNewMovie} /> - setTags(value)} - newObjects={newTags} - onCreateNew={createNewTag} - /> + {scrapedTagsRow} +) { + const intl = useIntl(); + const [tags, setTags] = useState>( + new ObjectListScrapeResult( + sortStoredIdObjects( + existingTags.map((t) => ({ + stored_id: t.id, + name: t.name, + })) + ), + sortStoredIdObjects(scrapedTags ?? undefined) + ) + ); + + const [newTags, setNewTags] = useState( + scrapedTags?.filter((t) => !t.stored_id) ?? [] + ); + + const createNewTag = useCreateScrapedTag({ + scrapeResult: tags, + setScrapeResult: setTags, + newObjects: newTags, + setNewObjects: setNewTags, + }); + + const scrapedTagsRow = ( + setTags(value)} + newObjects={newTags} + onCreateNew={createNewTag} + /> + ); + + return { + tags, + newTags, + scrapedTagsRow, + }; +} diff --git a/ui/v2.5/src/components/Shared/TagLink.tsx b/ui/v2.5/src/components/Shared/TagLink.tsx index e97d0a957b3..9c2ed1cb340 100644 --- a/ui/v2.5/src/components/Shared/TagLink.tsx +++ b/ui/v2.5/src/components/Shared/TagLink.tsx @@ -191,7 +191,7 @@ export const GalleryLink: React.FC = ({ interface ITagLinkProps { tag: INamedObject; - linkType?: "scene" | "gallery" | "image" | "details" | "performer"; + linkType?: "scene" | "gallery" | "image" | "details" | "performer" | "movie"; className?: string; hoverPlacement?: Placement; showHierarchyIcon?: boolean; @@ -216,6 +216,8 @@ export const TagLink: React.FC = ({ return NavUtils.makeTagGalleriesUrl(tag); case "image": return NavUtils.makeTagImagesUrl(tag); + case "movie": + return NavUtils.makeTagMoviesUrl(tag); case "details": return NavUtils.makeTagUrl(tag.id ?? ""); } diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx index cff2326b68e..51444f99949 100644 --- a/ui/v2.5/src/components/Tags/TagCard.tsx +++ b/ui/v2.5/src/components/Tags/TagCard.tsx @@ -223,6 +223,19 @@ export const TagCard: React.FC = ({ ); } + function maybeRenderMoviesPopoverButton() { + if (!tag.movie_count) return; + + return ( + + ); + } + function maybeRenderPopoverButtonGroup() { if (tag) { return ( @@ -232,6 +245,7 @@ export const TagCard: React.FC = ({ {maybeRenderScenesPopoverButton()} {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} + {maybeRenderMoviesPopoverButton()} {maybeRenderSceneMarkersPopoverButton()} {maybeRenderPerformersPopoverButton()} diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index 81a60c0f2d3..aa10275b6cb 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -41,6 +41,7 @@ import { import { DetailImage } from "src/components/Shared/DetailImage"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; +import { TagMoviesPanel } from "./TagMoviesPanel"; interface IProps { tag: GQL.TagDataFragment; @@ -57,6 +58,7 @@ const validTabs = [ "scenes", "images", "galleries", + "movies", "markers", "performers", ] as const; @@ -101,6 +103,8 @@ const TagPage: React.FC = ({ tag, tabKey }) => { (showAllCounts ? tag.image_count_all : tag.image_count) ?? 0; const galleryCount = (showAllCounts ? tag.gallery_count_all : tag.gallery_count) ?? 0; + const movieCount = + (showAllCounts ? tag.movie_count_all : tag.movie_count) ?? 0; const sceneMarkerCount = (showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; const performerCount = @@ -113,6 +117,8 @@ const TagPage: React.FC = ({ tag, tabKey }) => { ret = "images"; } else if (galleryCount != 0) { ret = "galleries"; + } else if (movieCount != 0) { + ret = "movies"; } else if (sceneMarkerCount != 0) { ret = "markers"; } else if (performerCount != 0) { @@ -121,7 +127,14 @@ const TagPage: React.FC = ({ tag, tabKey }) => { } return ret; - }, [sceneCount, imageCount, galleryCount, sceneMarkerCount, performerCount]); + }, [ + sceneCount, + imageCount, + galleryCount, + sceneMarkerCount, + performerCount, + movieCount, + ]); const setTabKey = useCallback( (newTabKey: string | null) => { @@ -463,6 +476,21 @@ const TagPage: React.FC = ({ tag, tabKey }) => { > + + {intl.formatMessage({ id: "movies" })} + + + } + > + + = ({ active, tag }) => { + const filterHook = useTagFilterHook(tag); + return ; +}; diff --git a/ui/v2.5/src/hooks/tagsEdit.tsx b/ui/v2.5/src/hooks/tagsEdit.tsx new file mode 100644 index 00000000000..e4458291180 --- /dev/null +++ b/ui/v2.5/src/hooks/tagsEdit.tsx @@ -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([]); + const [newTags, setNewTags] = useState(); + + 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[] + ) { + 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) => ( + createNewTag(t)} + > + {t.name} + + + ))} + + ); + + const minCollapseLength = 10; + + if (newTags.length >= minCollapseLength) { + return ( + + {ret} + + ); + } + + return ret; + } + + function tagsControl() { + return ( + <> + + {renderNewTags()} + + ); + } + + return { + tags, + onSetTags, + tagsControl, + updateTagsStateFromScraper, + }; +} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index d1072183a64..61daff120a9 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1118,6 +1118,7 @@ "megabits_per_second": "{value} mbps", "metadata": "Metadata", "movie": "Movie", + "movie_count": "Movie Count", "movie_scene_number": "Scene Number", "movies": "Movies", "name": "Name", diff --git a/ui/v2.5/src/models/list-filter/movies.ts b/ui/v2.5/src/models/list-filter/movies.ts index 9a769e0249f..35e4a24e25c 100644 --- a/ui/v2.5/src/models/list-filter/movies.ts +++ b/ui/v2.5/src/models/list-filter/movies.ts @@ -3,6 +3,7 @@ import { createDateCriterionOption, createMandatoryTimestampCriterionOption, createDurationCriterionOption, + createMandatoryNumberCriterionOption, } from "./criteria/criterion"; import { MovieIsMissingCriterionOption } from "./criteria/is-missing"; import { StudiosCriterionOption } from "./criteria/studios"; @@ -10,10 +11,18 @@ import { PerformersCriterionOption } from "./criteria/performers"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; import { RatingCriterionOption } from "./criteria/rating"; +import { TagsCriterionOption } from "./criteria/tags"; const defaultSortBy = "name"; -const sortByOptions = ["name", "random", "date", "duration", "rating"] +const sortByOptions = [ + "name", + "random", + "date", + "duration", + "rating", + "tag_count", +] .map(ListFilterOptions.createSortBy) .concat([ { @@ -33,6 +42,8 @@ const criterionOptions = [ RatingCriterionOption, PerformersCriterionOption, createDateCriterionOption("date"), + TagsCriterionOption, + createMandatoryNumberCriterionOption("tag_count"), createMandatoryTimestampCriterionOption("created_at"), createMandatoryTimestampCriterionOption("updated_at"), ]; diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index fe1a906f027..9a9b71680a3 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -35,6 +35,10 @@ const sortByOptions = ["name", "random"] messageID: "scene_count", value: "scenes_count", }, + { + messageID: "movie_count", + value: "movies_count", + }, { messageID: "marker_count", value: "scene_markers_count", @@ -53,6 +57,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("gallery_count"), createMandatoryNumberCriterionOption("performer_count"), + createMandatoryNumberCriterionOption("movie_count"), createMandatoryNumberCriterionOption("marker_count"), ParentTagsCriterionOption, new MandatoryNumberCriterionOption("parent_tag_count", "parent_count"), diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 4a0c49e1771..9638c7e9477 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -172,6 +172,7 @@ export type CriterionType = | "image_count" | "gallery_count" | "performer_count" + | "movie_count" | "death_year" | "url" | "interactive" diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index 1aece914c8a..e77f40a38aa 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -78,7 +78,7 @@ const makePerformerImagesUrl = ( }; export interface INamedObject { - id?: string; + id: string; name?: string; } @@ -262,8 +262,7 @@ const makeChildTagsUrl = (tag: Partial) => { return `/tags?${filter.makeQueryParameters()}`; }; -const makeTagScenesUrl = (tag: Partial) => { - if (!tag.id) return "#"; +function makeTagFilter(mode: GQL.FilterMode, tag: INamedObject) { const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { @@ -272,59 +271,31 @@ const makeTagScenesUrl = (tag: Partial) => { depth: 0, }; 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) => { - if (!tag.id) return "#"; - 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 makeTagPerformersUrl = (tag: INamedObject) => { + return `/performers?${makeTagFilter(GQL.FilterMode.Performers, tag)}`; }; -const makeTagSceneMarkersUrl = (tag: Partial) => { - if (!tag.id) return "#"; - 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 makeTagSceneMarkersUrl = (tag: INamedObject) => { + return `/scenes/markers?${makeTagFilter(GQL.FilterMode.SceneMarkers, tag)}`; }; -const makeTagGalleriesUrl = (tag: Partial) => { - if (!tag.id) return "#"; - 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 makeTagGalleriesUrl = (tag: INamedObject) => { + return `/galleries?${makeTagFilter(GQL.FilterMode.Galleries, tag)}`; }; -const makeTagImagesUrl = (tag: Partial) => { - if (!tag.id) return "#"; - const filter = new ListFilterModel(GQL.FilterMode.Images, 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 `/images?${filter.makeQueryParameters()}`; +const makeTagImagesUrl = (tag: INamedObject) => { + return `/images?${makeTagFilter(GQL.FilterMode.Images, tag)}`; +}; + +const makeTagMoviesUrl = (tag: INamedObject) => { + return `/movies?${makeTagFilter(GQL.FilterMode.Movies, tag)}`; }; type SceneMarkerDataFragment = Pick & { @@ -441,6 +412,7 @@ const NavUtils = { makeTagPerformersUrl, makeTagGalleriesUrl, makeTagImagesUrl, + makeTagMoviesUrl, makeScenesPHashMatchUrl, makeSceneMarkerUrl, makeMovieScenesUrl,