diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 1df9d2fba1b..98b790d4fe3 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -362,6 +362,8 @@ input StudioFilterType { parents: MultiCriterionInput "Filter by StashID" stash_id_endpoint: StashIDCriterionInput + "Filter to only include studios with these tags" + tags: HierarchicalMultiCriterionInput "Filter to only include studios missing this property" is_missing: String # rating expressed as 1-100 @@ -374,6 +376,8 @@ input StudioFilterType { image_count: IntCriterionInput "Filter by gallery count" gallery_count: IntCriterionInput + "Filter by tag count" + tag_count: IntCriterionInput "Filter by url" url: StringCriterionInput "Filter by studio aliases" @@ -498,6 +502,9 @@ input TagFilterType { "Filter by number of performers with this tag" performer_count: IntCriterionInput + "Filter by number of studios with this tag" + studio_count: IntCriterionInput + "Filter by number of movies with this tag" movie_count: IntCriterionInput diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index ff4eb5011c6..f90183ed09b 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -5,6 +5,7 @@ type Studio { parent_studio: Studio child_studios: [Studio!]! aliases: [String!]! + tags: [Tag!]! ignore_auto_tag: Boolean! image_path: String # Resolver @@ -35,6 +36,7 @@ input StudioCreateInput { favorite: Boolean details: String aliases: [String!] + tag_ids: [ID!] ignore_auto_tag: Boolean } @@ -51,6 +53,7 @@ input StudioUpdateInput { favorite: Boolean details: String aliases: [String!] + tag_ids: [ID!] ignore_auto_tag: Boolean } diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 35229c5cb81..6263b64a8d8 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 + studio_count(depth: Int): Int! # Resolver movie_count(depth: Int): Int! # Resolver parents: [Tag!]! children: [Tag!]! diff --git a/internal/api/resolver_model_studio.go b/internal/api/resolver_model_studio.go index f7bc3a00df2..011ab343e0f 100644 --- a/internal/api/resolver_model_studio.go +++ b/internal/api/resolver_model_studio.go @@ -40,6 +40,20 @@ func (r *studioResolver) Aliases(ctx context.Context, obj *models.Studio) ([]str return obj.Aliases.List(), nil } +func (r *studioResolver) Tags(ctx context.Context, obj *models.Studio) (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.Studio) + }); err != nil { + return nil, err + } + } + + var errs []error + ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List()) + return ret, firstError(errs) +} + func (r *studioResolver) SceneCount(ctx context.Context, obj *models.Studio, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = scene.CountByStudioID(ctx, r.repository.Scene, obj.ID, depth) diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index 7c32667d24f..a9930fb23db 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -11,6 +11,7 @@ import ( "github.com/stashapp/stash/pkg/movie" "github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/scene" + "github.com/stashapp/stash/pkg/studio" ) func (r *tagResolver) Parents(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) { @@ -108,6 +109,17 @@ func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag, depth return ret, nil } +func (r *tagResolver) StudioCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + ret, err = studio.CountByTagID(ctx, r.repository.Studio, obj.ID, depth) + return err + }); err != nil { + return 0, err + } + + 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) diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index 05d84a97940..a33e5d9b676 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -48,6 +48,11 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio return nil, fmt.Errorf("converting parent id: %w", err) } + newStudio.TagIDs, err = translator.relatedIds(input.TagIds) + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + // Process the base 64 encoded image string var imageData []byte if input.Image != nil { @@ -114,6 +119,11 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio return nil, fmt.Errorf("converting parent id: %w", err) } + updatedStudio.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids") + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + // Process the base 64 encoded image string var imageData []byte imageIncluded := translator.hasField("image") diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 2daac200815..0a294e70e4a 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -982,6 +982,7 @@ func (t *ExportTask) ExportStudios(ctx context.Context, workers int) { func (t *ExportTask) exportStudio(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Studio) { defer wg.Done() + r := t.repository studioReader := t.repository.Studio for s := range jobChan { @@ -992,6 +993,18 @@ func (t *ExportTask) exportStudio(ctx context.Context, wg *sync.WaitGroup, jobCh continue } + tags, err := r.Tag.FindByStudioID(ctx, s.ID) + if err != nil { + logger.Errorf("[studios] <%s> error getting studio tags: %s", s.Name, err.Error()) + continue + } + + newStudioJSON.Tags = tag.GetNames(tags) + + if t.includeDependencies { + t.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tag.GetIDs(tags)) + } + fn := newStudioJSON.Filename() if err := t.json.saveStudio(fn, newStudioJSON); err != nil { diff --git a/internal/manager/task_import.go b/internal/manager/task_import.go index c9d5b54ba72..47fbf0cd1d5 100644 --- a/internal/manager/task_import.go +++ b/internal/manager/task_import.go @@ -292,8 +292,11 @@ func (t *ImportTask) ImportStudios(ctx context.Context) { } func (t *ImportTask) importStudio(ctx context.Context, studioJSON *jsonschema.Studio, pendingParent map[string][]*jsonschema.Studio) error { + r := t.repository + importer := &studio.Importer{ ReaderWriter: t.repository.Studio, + TagWriter: r.Tag, Input: *studioJSON, MissingRefBehaviour: t.MissingRefBehaviour, } diff --git a/pkg/models/jsonschema/studio.go b/pkg/models/jsonschema/studio.go index 84842fa14e3..80ed97d9294 100644 --- a/pkg/models/jsonschema/studio.go +++ b/pkg/models/jsonschema/studio.go @@ -22,6 +22,7 @@ type Studio struct { Details string `json:"details,omitempty"` Aliases []string `json:"aliases,omitempty"` StashIDs []models.StashID `json:"stash_ids,omitempty"` + Tags []string `json:"tags,omitempty"` IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` } diff --git a/pkg/models/mocks/StudioReaderWriter.go b/pkg/models/mocks/StudioReaderWriter.go index c46e45d4c24..d4932ca71da 100644 --- a/pkg/models/mocks/StudioReaderWriter.go +++ b/pkg/models/mocks/StudioReaderWriter.go @@ -58,6 +58,27 @@ func (_m *StudioReaderWriter) Count(ctx context.Context) (int, error) { return r0, r1 } +// CountByTagID provides a mock function with given fields: ctx, tagID +func (_m *StudioReaderWriter) CountByTagID(ctx context.Context, tagID int) (int, error) { + ret := _m.Called(ctx, tagID) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, int) int); ok { + r0 = rf(ctx, tagID) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, tagID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // Create provides a mock function with given fields: ctx, newStudio func (_m *StudioReaderWriter) Create(ctx context.Context, newStudio *models.Studio) error { ret := _m.Called(ctx, newStudio) @@ -316,6 +337,29 @@ func (_m *StudioReaderWriter) GetStashIDs(ctx context.Context, relatedID int) ([ return r0, r1 } +// GetTagIDs provides a mock function with given fields: ctx, relatedID +func (_m *StudioReaderWriter) 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 +} + // HasImage provides a mock function with given fields: ctx, studioID func (_m *StudioReaderWriter) HasImage(ctx context.Context, studioID int) (bool, error) { ret := _m.Called(ctx, studioID) @@ -367,6 +411,27 @@ func (_m *StudioReaderWriter) Query(ctx context.Context, studioFilter *models.St return r0, r1, r2 } +// QueryCount provides a mock function with given fields: ctx, studioFilter, findFilter +func (_m *StudioReaderWriter) QueryCount(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) (int, error) { + ret := _m.Called(ctx, studioFilter, findFilter) + + var r0 int + if rf, ok := ret.Get(0).(func(context.Context, *models.StudioFilterType, *models.FindFilterType) int); ok { + r0 = rf(ctx, studioFilter, findFilter) + } else { + r0 = ret.Get(0).(int) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, *models.StudioFilterType, *models.FindFilterType) error); ok { + r1 = rf(ctx, studioFilter, findFilter) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // QueryForAutoTag provides a mock function with given fields: ctx, words func (_m *StudioReaderWriter) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Studio, error) { ret := _m.Called(ctx, words) diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index d18f6a66b6c..c3dfe7bd255 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -427,6 +427,29 @@ func (_m *TagReaderWriter) FindBySceneMarkerID(ctx context.Context, sceneMarkerI return r0, r1 } +// FindByStudioID provides a mock function with given fields: ctx, studioID +func (_m *TagReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) { + ret := _m.Called(ctx, studioID) + + var r0 []*models.Tag + if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok { + r0 = rf(ctx, studioID) + } 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, studioID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindMany provides a mock function with given fields: ctx, ids func (_m *TagReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Tag, error) { ret := _m.Called(ctx, ids) diff --git a/pkg/models/model_studio.go b/pkg/models/model_studio.go index e6e8b7b205c..0f4a09bc202 100644 --- a/pkg/models/model_studio.go +++ b/pkg/models/model_studio.go @@ -19,6 +19,7 @@ type Studio struct { IgnoreAutoTag bool `json:"ignore_auto_tag"` Aliases RelatedStrings `json:"aliases"` + TagIDs RelatedIDs `json:"tag_ids"` StashIDs RelatedStashIDs `json:"stash_ids"` } @@ -45,6 +46,7 @@ type StudioPartial struct { IgnoreAutoTag OptionalBool Aliases *UpdateStrings + TagIDs *UpdateIDs StashIDs *UpdateStashIDs } @@ -61,6 +63,12 @@ func (s *Studio) LoadAliases(ctx context.Context, l AliasLoader) error { }) } +func (s *Studio) LoadTagIDs(ctx context.Context, l TagIDLoader) error { + return s.TagIDs.load(func() ([]int, error) { + return l.GetTagIDs(ctx, s.ID) + }) +} + func (s *Studio) LoadStashIDs(ctx context.Context, l StashIDLoader) error { return s.StashIDs.load(func() ([]StashID, error) { return l.GetStashIDs(ctx, s.ID) @@ -72,6 +80,10 @@ func (s *Studio) LoadRelationships(ctx context.Context, l PerformerReader) error return err } + if err := s.LoadTagIDs(ctx, l); err != nil { + return err + } + if err := s.LoadStashIDs(ctx, l); err != nil { return err } diff --git a/pkg/models/repository_studio.go b/pkg/models/repository_studio.go index 272bf8fed23..a2b9202f303 100644 --- a/pkg/models/repository_studio.go +++ b/pkg/models/repository_studio.go @@ -22,6 +22,7 @@ type StudioFinder interface { // StudioQueryer provides methods to query studios. type StudioQueryer interface { Query(ctx context.Context, studioFilter *StudioFilterType, findFilter *FindFilterType) ([]*Studio, int, error) + QueryCount(ctx context.Context, studioFilter *StudioFilterType, findFilter *FindFilterType) (int, error) } type StudioAutoTagQueryer interface { @@ -36,6 +37,7 @@ type StudioAutoTagQueryer interface { // StudioCounter provides methods to count studios. type StudioCounter interface { Count(ctx context.Context) (int, error) + CountByTagID(ctx context.Context, tagID int) (int, error) } // StudioCreator provides methods to create studios. @@ -74,6 +76,7 @@ type StudioReader interface { AliasLoader StashIDLoader + TagIDLoader All(ctx context.Context) ([]*Studio, error) GetImage(ctx context.Context, studioID int) ([]byte, error) diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index 287aeb211b8..00f35abc43f 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -22,6 +22,7 @@ type TagFinder interface { FindByPerformerID(ctx context.Context, performerID int) ([]*Tag, error) FindByMovieID(ctx context.Context, movieID int) ([]*Tag, error) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error) + FindByStudioID(ctx context.Context, studioID 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/studio.go b/pkg/models/studio.go index 0f8b5d15300..d5575b7ad3b 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -14,6 +14,10 @@ type StudioFilterType struct { IsMissing *string `json:"is_missing"` // Filter by rating expressed as 1-100 Rating100 *IntCriterionInput `json:"rating100"` + // Filter to only include studios with these tags + Tags *HierarchicalMultiCriterionInput `json:"tags"` + // Filter by tag count + TagCount *IntCriterionInput `json:"tag_count"` // Filter by favorite Favorite *bool `json:"favorite"` // Filter by scene count @@ -53,6 +57,7 @@ type StudioCreateInput struct { Favorite *bool `json:"favorite"` Details *string `json:"details"` Aliases []string `json:"aliases"` + TagIds []string `json:"tag_ids"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` } @@ -68,5 +73,6 @@ type StudioUpdateInput struct { Favorite *bool `json:"favorite"` Details *string `json:"details"` Aliases []string `json:"aliases"` + TagIds []string `json:"tag_ids"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` } diff --git a/pkg/models/tag.go b/pkg/models/tag.go index 7ee0705a432..cc32a6ce25c 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 studios with this tag + StudioCount *IntCriterionInput `json:"studio_count"` // Filter by number of movies with this tag MovieCount *IntCriterionInput `json:"movie_count"` // Filter by number of markers with this tag diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index cf502392f34..6436efee873 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 62 +var appSchemaVersion uint = 63 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/63_studio_tags.up.sql b/pkg/sqlite/migrations/63_studio_tags.up.sql new file mode 100644 index 00000000000..ea652f18c1e --- /dev/null +++ b/pkg/sqlite/migrations/63_studio_tags.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `studios_tags` ( + `studio_id` integer NOT NULL, + `tag_id` integer NOT NULL, + foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE, + foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE, + PRIMARY KEY(`studio_id`, `tag_id`) +); + +CREATE INDEX `index_studios_tags_on_tag_id` on `studios_tags` (`tag_id`); \ No newline at end of file diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index ab5a46c613a..4a6e3edb48d 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -207,6 +207,9 @@ const ( tagIdxWithPerformer tagIdx1WithPerformer tagIdx2WithPerformer + tagIdxWithStudio + tagIdx1WithStudio + tagIdx2WithStudio tagIdxWithGallery tagIdx1WithGallery tagIdx2WithGallery @@ -245,6 +248,10 @@ const ( studioIdxWithScenePerformer studioIdxWithImagePerformer studioIdxWithGalleryPerformer + studioIdxWithTag + studioIdx2WithTag + studioIdxWithTwoTags + studioIdxWithParentTag studioIdxWithGrandChild studioIdxWithParentAndChild studioIdxWithGrandParent @@ -510,6 +517,15 @@ var ( } ) +var ( + studioTags = linkMap{ + studioIdxWithTag: {tagIdxWithStudio}, + studioIdx2WithTag: {tagIdx2WithStudio}, + studioIdxWithTwoTags: {tagIdx1WithStudio, tagIdx2WithStudio}, + studioIdxWithParentTag: {tagIdxWithParentAndChild}, + } +) + var ( performerTags = linkMap{ performerIdxWithTag: {tagIdxWithPerformer}, @@ -1566,6 +1582,11 @@ func getTagPerformerCount(id int) int { return len(performerTags.reverseLookup(idx)) } +func getTagStudioCount(id int) int { + idx := indexFromID(tagIDs, id) + return len(studioTags.reverseLookup(idx)) +} + func getTagParentCount(id int) int { if id == tagIDs[tagIdxWithParentTag] || id == tagIDs[tagIdxWithGrandParent] || id == tagIDs[tagIdxWithParentAndChild] { return 1 @@ -1681,11 +1702,13 @@ func createStudios(ctx context.Context, n int, o int) error { // studios [ i ] and [ n + o - i - 1 ] should have similar names with only the Name!=NaMe part different name = getStudioStringValue(index, name) + tids := indexesToIDs(tagIDs, studioTags[i]) studio := models.Studio{ Name: name, URL: getStudioStringValue(index, urlField), Favorite: getStudioBoolValue(index), IgnoreAutoTag: getIgnoreAutoTag(i), + TagIDs: models.NewRelatedIDs(tids), } // only add aliases for some scenes if i == studioIdxWithMovie || i%5 == 0 { diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index ac6a4a4d938..95edf4173e2 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -25,6 +25,7 @@ const ( studioParentIDColumn = "parent_id" studioNameColumn = "name" studioImageBlobColumn = "image_blob" + studiosTagsTable = "studios_tags" ) type studioRow struct { @@ -94,6 +95,7 @@ type studioRepositoryType struct { repository stashIDs stashIDRepository + tags joinRepository scenes repository images repository @@ -124,11 +126,21 @@ var ( tableName: galleryTable, idColumn: studioIDColumn, }, + tags: joinRepository{ + repository: repository{ + tableName: studiosTagsTable, + idColumn: studioIDColumn, + }, + fkColumn: tagIDColumn, + foreignTable: tagTable, + orderBy: "tags.name ASC", + }, } ) type StudioStore struct { blobJoinQueryBuilder + tagRelationshipStore tableMgr *table } @@ -139,6 +151,11 @@ func NewStudioStore(blobStore *BlobStore) *StudioStore { blobStore: blobStore, joinTable: studioTable, }, + tagRelationshipStore: tagRelationshipStore{ + idRelationshipStore: idRelationshipStore{ + joinTable: studiosTagsTableMgr, + }, + }, tableMgr: studioTableMgr, } @@ -173,6 +190,10 @@ func (qb *StudioStore) Create(ctx context.Context, newObject *models.Studio) err } } + if err := qb.tagRelationshipStore.createRelationships(ctx, id, newObject.TagIDs); err != nil { + return err + } + if newObject.StashIDs.Loaded() { if err := studiosStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil { return err @@ -213,6 +234,10 @@ func (qb *StudioStore) UpdatePartial(ctx context.Context, input models.StudioPar } } + if err := qb.tagRelationshipStore.modifyRelationships(ctx, input.ID, input.TagIDs); err != nil { + return nil, err + } + if input.StashIDs != nil { if err := studiosStashIDsTableMgr.modifyJoins(ctx, input.ID, input.StashIDs.StashIDs, input.StashIDs.Mode); err != nil { return nil, err @@ -237,6 +262,10 @@ func (qb *StudioStore) Update(ctx context.Context, updatedObject *models.Studio) } } + if err := qb.tagRelationshipStore.replaceRelationships(ctx, updatedObject.ID, updatedObject.TagIDs); err != nil { + return err + } + if updatedObject.StashIDs.Loaded() { if err := studiosStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil { return err @@ -538,6 +567,15 @@ func (qb *StudioStore) Query(ctx context.Context, studioFilter *models.StudioFil return studios, countResult, nil } +func (qb *StudioStore) QueryCount(ctx context.Context, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) (int, error) { + query, err := qb.makeQuery(ctx, studioFilter, findFilter) + if err != nil { + return 0, err + } + + return query.executeCount(ctx) +} + var studioSortOptions = sortOptions{ "child_count", "created_at", @@ -569,6 +607,8 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string, sortQuery := "" switch sort { + case "tag_count": + sortQuery += getCountSort(studioTable, studiosTagsTable, studioIDColumn, direction) case "scenes_count": sortQuery += getCountSort(studioTable, sceneTable, studioIDColumn, direction) case "images_count": diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index 45745c4717d..040fc185818 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -74,11 +74,13 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler { }, qb.isMissingCriterionHandler(studioFilter.IsMissing), + qb.tagCountCriterionHandler(studioFilter.TagCount), qb.sceneCountCriterionHandler(studioFilter.SceneCount), qb.imageCountCriterionHandler(studioFilter.ImageCount), qb.galleryCountCriterionHandler(studioFilter.GalleryCount), qb.parentCriterionHandler(studioFilter.Parents), qb.aliasCriterionHandler(studioFilter.Aliases), + qb.tagsCriterionHandler(studioFilter.Tags), qb.childCountCriterionHandler(studioFilter.ChildCount), ×tampCriterionHandler{studioFilter.CreatedAt, studioTable + ".created_at", nil}, ×tampCriterionHandler{studioFilter.UpdatedAt, studioTable + ".updated_at", nil}, @@ -161,6 +163,16 @@ func (qb *studioFilterHandler) galleryCountCriterionHandler(galleryCount *models } } +func (qb *studioFilterHandler) tagCountCriterionHandler(tagCount *models.IntCriterionInput) criterionHandlerFunc { + h := countCriterionHandlerBuilder{ + primaryTable: studioTable, + joinTable: studiosTagsTable, + primaryFK: studioIDColumn, + } + + return h.handler(tagCount) +} + func (qb *studioFilterHandler) parentCriterionHandler(parents *models.MultiCriterionInput) criterionHandlerFunc { addJoinsFunc := func(f *filterBuilder) { f.addLeftJoin("studios", "parent_studio", "parent_studio.id = studios.parent_id") @@ -200,3 +212,18 @@ func (qb *studioFilterHandler) childCountCriterionHandler(childCount *models.Int } } } + +func (qb *studioFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { + h := joinedHierarchicalMultiCriterionHandlerBuilder{ + primaryTable: studioTable, + foreignTable: tagTable, + foreignFK: "tag_id", + + relationsTable: "tags_relations", + joinTable: studiosTagsTable, + joinAs: "studio_tag", + primaryFK: studioIDColumn, + } + + return h.handler(tags) +} diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index c75c2a61f43..627129f0d1e 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -704,6 +704,110 @@ func TestStudioQueryRating(t *testing.T) { verifyStudiosRating(t, ratingCriterion) } +func queryStudios(ctx context.Context, t *testing.T, studioFilter *models.StudioFilterType, findFilter *models.FindFilterType) []*models.Studio { + t.Helper() + studios, _, err := db.Studio.Query(ctx, studioFilter, findFilter) + if err != nil { + t.Errorf("Error querying studio: %s", err.Error()) + } + + return studios +} + +func TestStudioQueryTags(t *testing.T) { + withTxn(func(ctx context.Context) error { + tagCriterion := models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdxWithStudio]), + strconv.Itoa(tagIDs[tagIdx1WithStudio]), + }, + Modifier: models.CriterionModifierIncludes, + } + + studioFilter := models.StudioFilterType{ + Tags: &tagCriterion, + } + + // ensure ids are correct + studios := queryStudios(ctx, t, &studioFilter, nil) + assert.Len(t, studios, 2) + for _, studio := range studios { + assert.True(t, studio.ID == studioIDs[studioIdxWithTag] || studio.ID == studioIDs[studioIdxWithTwoTags]) + } + + tagCriterion = models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithStudio]), + strconv.Itoa(tagIDs[tagIdx2WithStudio]), + }, + Modifier: models.CriterionModifierIncludesAll, + } + + studios = queryStudios(ctx, t, &studioFilter, nil) + + assert.Len(t, studios, 1) + assert.Equal(t, sceneIDs[studioIdxWithTwoTags], studios[0].ID) + + tagCriterion = models.HierarchicalMultiCriterionInput{ + Value: []string{ + strconv.Itoa(tagIDs[tagIdx1WithStudio]), + }, + Modifier: models.CriterionModifierExcludes, + } + + q := getSceneStringValue(studioIdxWithTwoTags, titleField) + findFilter := models.FindFilterType{ + Q: &q, + } + + studios = queryStudios(ctx, t, &studioFilter, &findFilter) + assert.Len(t, studios, 0) + + return nil + }) +} + +func TestStudioQueryTagCount(t *testing.T) { + const tagCount = 1 + tagCountCriterion := models.IntCriterionInput{ + Value: tagCount, + Modifier: models.CriterionModifierEquals, + } + + verifyStudiosTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierNotEquals + verifyStudiosTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierGreaterThan + verifyStudiosTagCount(t, tagCountCriterion) + + tagCountCriterion.Modifier = models.CriterionModifierLessThan + verifyStudiosTagCount(t, tagCountCriterion) +} + +func verifyStudiosTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) { + withTxn(func(ctx context.Context) error { + sqb := db.Studio + studioFilter := models.StudioFilterType{ + TagCount: &tagCountCriterion, + } + + studios := queryStudios(ctx, t, &studioFilter, nil) + assert.Greater(t, len(studios), 0) + + for _, studio := range studios { + ids, err := sqb.GetTagIDs(ctx, studio.ID) + if err != nil { + return err + } + verifyInt(t, len(ids), tagCountCriterion) + } + + return nil + }) +} + func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn func(ctx context.Context, s *models.Studio)) { withTxn(func(ctx context.Context) error { t.Helper() diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index ba86d3b7f6f..2f500639e36 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -34,6 +34,7 @@ var ( performersStashIDsJoinTable = goqu.T("performer_stash_ids") studiosAliasesJoinTable = goqu.T(studioAliasesTable) + studiosTagsJoinTable = goqu.T(studiosTagsTable) studiosStashIDsJoinTable = goqu.T("studio_stash_ids") moviesURLsJoinTable = goqu.T(movieURLsTable) @@ -294,6 +295,14 @@ var ( stringColumn: studiosAliasesJoinTable.Col(studioAliasColumn), } + studiosTagsTableMgr = &joinTable{ + table: table{ + table: studiosTagsJoinTable, + idColumn: studiosTagsJoinTable.Col(studioIDColumn), + }, + fkColumn: studiosTagsJoinTable.Col(tagIDColumn), + } + studiosStashIDsTableMgr = &stashIDTable{ table: table{ table: studiosStashIDsJoinTable, diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index a4bf3793aa1..c6494f38b91 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -448,6 +448,18 @@ func (qb *TagStore) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) return qb.queryTags(ctx, query, args) } +func (qb *TagStore) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) { + query := ` + SELECT tags.* FROM tags + LEFT JOIN studios_tags as studios_join on studios_join.tag_id = tags.id + WHERE studios_join.studio_id = ? + GROUP BY tags.id + ` + query += qb.getDefaultTagSort() + args := []interface{}{studioID} + return qb.queryTags(ctx, query, args) +} + func (qb *TagStore) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) { // query := "SELECT * FROM tags WHERE name = ?" // if nocase { @@ -628,6 +640,7 @@ var tagSortOptions = sortOptions{ "id", "images_count", "movies_count", + "studios_count", "name", "performers_count", "random", @@ -668,6 +681,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 "studios_count": + sortQuery += getCountSort(tagTable, studiosTagsTable, tagIDColumn, direction) case "movies_count": sortQuery += getCountSort(tagTable, moviesTagsTable, tagIDColumn, direction) default: @@ -767,6 +782,7 @@ func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) er galleriesTagsTable: galleryIDColumn, imagesTagsTable: imageIDColumn, "performers_tags": "performer_id", + "studios_tags": "studio_id", } args = append(args, destination) diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index 776a49fc4f3..5bae18c0010 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.studioCountCriterionHandler(tagFilter.StudioCount), qb.movieCountCriterionHandler(tagFilter.MovieCount), qb.markerCountCriterionHandler(tagFilter.MarkerCount), qb.parentsCriterionHandler(tagFilter.Parents), @@ -175,6 +176,17 @@ func (qb *tagFilterHandler) performerCountCriterionHandler(performerCount *model } } +func (qb *tagFilterHandler) studioCountCriterionHandler(studioCount *models.IntCriterionInput) criterionHandlerFunc { + return func(ctx context.Context, f *filterBuilder) { + if studioCount != nil { + f.addLeftJoin("studios_tags", "", "studios_tags.tag_id = tags.id") + clause, args := getIntCriterionWhereClause("count(distinct studios_tags.studio_id)", *studioCount) + + f.addHaving(clause, args...) + } + } +} + func (qb *tagFilterHandler) movieCountCriterionHandler(movieCount *models.IntCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if movieCount != nil { diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index d71316413e4..099f8b91221 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -230,6 +230,10 @@ func TestTagQuerySort(t *testing.T) { tags = queryTags(ctx, t, sqb, nil, findFilter) assert.Equal(tagIDs[tagIdx2WithPerformer], tags[0].ID) + sortBy = "studios_count" + tags = queryTags(ctx, t, sqb, nil, findFilter) + assert.Equal(tagIDs[tagIdx2WithStudio], tags[0].ID) + sortBy = "movies_count" tags = queryTags(ctx, t, sqb, nil, findFilter) assert.Equal(tagIDs[tagIdx1WithMovie], tags[0].ID) @@ -569,6 +573,45 @@ func verifyTagPerformerCount(t *testing.T, imageCountCriterion models.IntCriteri }) } +func TestTagQueryStudioCount(t *testing.T) { + countCriterion := models.IntCriterionInput{ + Value: 1, + Modifier: models.CriterionModifierEquals, + } + + verifyTagStudioCount(t, countCriterion) + + countCriterion.Modifier = models.CriterionModifierNotEquals + verifyTagStudioCount(t, countCriterion) + + countCriterion.Modifier = models.CriterionModifierLessThan + verifyTagStudioCount(t, countCriterion) + + countCriterion.Value = 0 + countCriterion.Modifier = models.CriterionModifierGreaterThan + verifyTagStudioCount(t, countCriterion) +} + +func verifyTagStudioCount(t *testing.T, imageCountCriterion models.IntCriterionInput) { + withTxn(func(ctx context.Context) error { + qb := db.Tag + tagFilter := models.TagFilterType{ + StudioCount: &imageCountCriterion, + } + + tags, _, err := qb.Query(ctx, &tagFilter, nil) + if err != nil { + t.Errorf("Error querying tag: %s", err.Error()) + } + + for _, tag := range tags { + verifyInt(t, getTagStudioCount(tag.ID), imageCountCriterion) + } + + return nil + }) +} + func TestTagQueryParentCount(t *testing.T) { countCriterion := models.IntCriterionInput{ Value: 1, @@ -882,6 +925,9 @@ func TestTagMerge(t *testing.T) { tagIdxWithPerformer, tagIdx1WithPerformer, tagIdx2WithPerformer, + tagIdxWithStudio, + tagIdx1WithStudio, + tagIdx2WithStudio, tagIdxWithGallery, tagIdx1WithGallery, tagIdx2WithGallery, @@ -970,6 +1016,14 @@ func TestTagMerge(t *testing.T) { assert.Contains(performerTagIDs, destID) + // ensure studio points to new tag + studioTagIDs, err := db.Studio.GetTagIDs(ctx, studioIDs[studioIdxWithTwoTags]) + if err != nil { + return err + } + + assert.Contains(studioTagIDs, destID) + return nil }); err != nil { t.Error(err.Error()) diff --git a/pkg/studio/export_test.go b/pkg/studio/export_test.go index da6da8ad4f8..0e42141ec37 100644 --- a/pkg/studio/export_test.go +++ b/pkg/studio/export_test.go @@ -68,6 +68,7 @@ func createFullStudio(id int, parentID int) models.Studio { Rating: &rating, IgnoreAutoTag: autoTagIgnored, Aliases: models.NewRelatedStrings(aliases), + TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs(stashIDs), } @@ -84,6 +85,7 @@ func createEmptyStudio(id int) models.Studio { CreatedAt: createTime, UpdatedAt: updateTime, Aliases: models.NewRelatedStrings([]string{}), + TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), } } diff --git a/pkg/studio/import.go b/pkg/studio/import.go index bfee4133fb3..d880650787d 100644 --- a/pkg/studio/import.go +++ b/pkg/studio/import.go @@ -4,9 +4,11 @@ import ( "context" "errors" "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" ) @@ -19,6 +21,7 @@ var ErrParentStudioNotExist = errors.New("parent studio does not exist") type Importer struct { ReaderWriter ImporterReaderWriter + TagWriter models.TagFinderCreator Input jsonschema.Studio MissingRefBehaviour models.ImportMissingRefEnum @@ -34,6 +37,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.Image) > 0 { i.imageData, err = utils.ProcessBase64Image(i.Input.Image) @@ -45,6 +52,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.studio.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) populateParentStudio(ctx context.Context) error { if i.Input.ParentStudio != "" { studio, err := i.ReaderWriter.FindByName(ctx, i.Input.ParentStudio, false) @@ -149,6 +224,7 @@ func studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio { CreatedAt: studioJSON.CreatedAt.GetTime(), UpdatedAt: studioJSON.UpdatedAt.GetTime(), + TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs(studioJSON.StashIDs), } diff --git a/pkg/studio/import_test.go b/pkg/studio/import_test.go index e89256371cf..882b8ca5682 100644 --- a/pkg/studio/import_test.go +++ b/pkg/studio/import_test.go @@ -16,13 +16,19 @@ const invalidImage = "aW1hZ2VCeXRlcw&&" const ( studioNameErr = "studioNameErr" - existingStudioName = "existingTagName" + existingStudioName = "existingStudioName" existingStudioID = 100 + existingTagID = 105 + errTagsID = 106 existingParentStudioName = "existingParentStudioName" existingParentStudioErr = "existingParentStudioErr" missingParentStudioName = "existingParentStudioName" + + existingTagName = "existingTagName" + existingTagErr = "existingTagErr" + missingTagName = "missingTagName" ) var testCtx = context.Background() @@ -67,6 +73,97 @@ func TestImporterPreImport(t *testing.T) { assert.Equal(t, expectedStudio, i.studio) } +func TestImporterPreImportWithTag(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + ReaderWriter: db.Studio, + TagWriter: db.Tag, + MissingRefBehaviour: models.ImportMissingRefEnumFail, + Input: jsonschema.Studio{ + 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.studio.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.Studio, + TagWriter: db.Tag, + Input: jsonschema.Studio{ + 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.studio.TagIDs.List()[0]) + + db.AssertExpectations(t) +} + +func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) { + db := mocks.NewDatabase() + + i := Importer{ + ReaderWriter: db.Studio, + TagWriter: db.Tag, + Input: jsonschema.Studio{ + 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 TestImporterPreImportWithParent(t *testing.T) { db := mocks.NewDatabase() @@ -156,6 +253,7 @@ func TestImporterPostImport(t *testing.T) { i := Importer{ ReaderWriter: db.Studio, + TagWriter: db.Tag, Input: jsonschema.Studio{ Aliases: []string{"alias"}, }, @@ -181,6 +279,7 @@ func TestImporterFindExistingID(t *testing.T) { i := Importer{ ReaderWriter: db.Studio, + TagWriter: db.Tag, Input: jsonschema.Studio{ Name: studioName, }, @@ -223,6 +322,7 @@ func TestCreate(t *testing.T) { i := Importer{ ReaderWriter: db.Studio, + TagWriter: db.Tag, studio: studio, } @@ -258,6 +358,7 @@ func TestUpdate(t *testing.T) { i := Importer{ ReaderWriter: db.Studio, + TagWriter: db.Tag, studio: studio, } diff --git a/pkg/studio/query.go b/pkg/studio/query.go index b20cec33109..97e8e2c1bbe 100644 --- a/pkg/studio/query.go +++ b/pkg/studio/query.go @@ -2,6 +2,7 @@ package studio import ( "context" + "strconv" "github.com/stashapp/stash/pkg/models" ) @@ -53,3 +54,15 @@ func ByAlias(ctx context.Context, qb models.StudioQueryer, alias string) (*model return nil, nil } + +func CountByTagID(ctx context.Context, qb models.StudioQueryer, id int, depth *int) (int, error) { + filter := &models.StudioFilterType{ + Tags: &models.HierarchicalMultiCriterionInput{ + Value: []string{strconv.Itoa(id)}, + Modifier: models.CriterionModifierIncludes, + Depth: depth, + }, + } + + return qb.QueryCount(ctx, filter, nil) +} diff --git a/ui/v2.5/graphql/data/studio-slim.graphql b/ui/v2.5/graphql/data/studio-slim.graphql index c3751319427..406a2ffa70a 100644 --- a/ui/v2.5/graphql/data/studio-slim.graphql +++ b/ui/v2.5/graphql/data/studio-slim.graphql @@ -12,4 +12,8 @@ fragment SlimStudioData on Studio { details rating100 aliases + tags { + id + name + } } diff --git a/ui/v2.5/graphql/data/studio.graphql b/ui/v2.5/graphql/data/studio.graphql index 576faea230d..afd254d2294 100644 --- a/ui/v2.5/graphql/data/studio.graphql +++ b/ui/v2.5/graphql/data/studio.graphql @@ -33,6 +33,9 @@ fragment StudioData on Studio { rating100 favorite aliases + tags { + ...SlimTagData + } } fragment SelectStudioData on Studio { diff --git a/ui/v2.5/graphql/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql index d473bf8c6d4..695bb5de6de 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) + studio_count + studio_count_all: studio_count(depth: -1) movie_count movie_count_all: movie_count(depth: -1) diff --git a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx index dc30cfa1f8b..c455145fc3c 100644 --- a/ui/v2.5/src/components/Shared/PopoverCountButton.tsx +++ b/ui/v2.5/src/components/Shared/PopoverCountButton.tsx @@ -4,6 +4,7 @@ import { faImages, faPlayCircle, faUser, + faVideo, faMapMarkerAlt, } from "@fortawesome/free-solid-svg-icons"; import React, { useMemo } from "react"; @@ -20,7 +21,8 @@ type PopoverLinkType = | "gallery" | "marker" | "movie" - | "performer"; + | "performer" + | "studio"; interface IProps { className?: string; @@ -54,6 +56,8 @@ export const PopoverCountButton: React.FC = ({ return faFilm; case "performer": return faUser; + case "studio": + return faVideo; } } @@ -89,6 +93,11 @@ export const PopoverCountButton: React.FC = ({ one: "performer", other: "performers", }; + case "studio": + return { + one: "studio", + other: "studios", + }; } } diff --git a/ui/v2.5/src/components/Shared/TagLink.tsx b/ui/v2.5/src/components/Shared/TagLink.tsx index 9c2ed1cb340..f2fe7c49ff6 100644 --- a/ui/v2.5/src/components/Shared/TagLink.tsx +++ b/ui/v2.5/src/components/Shared/TagLink.tsx @@ -191,7 +191,14 @@ export const GalleryLink: React.FC = ({ interface ITagLinkProps { tag: INamedObject; - linkType?: "scene" | "gallery" | "image" | "details" | "performer" | "movie"; + linkType?: + | "scene" + | "gallery" + | "image" + | "details" + | "performer" + | "movie" + | "studio"; className?: string; hoverPlacement?: Placement; showHierarchyIcon?: boolean; @@ -212,6 +219,8 @@ export const TagLink: React.FC = ({ return NavUtils.makeTagScenesUrl(tag); case "performer": return NavUtils.makeTagPerformersUrl(tag); + case "studio": + return NavUtils.makeTagStudiosUrl(tag); case "gallery": return NavUtils.makeTagGalleriesUrl(tag); case "image": diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index 007635cce10..1c1e5e6eeac 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -6,13 +6,17 @@ import { GridCard, calculateCardWidth, } from "src/components/Shared/GridCard/GridCard"; -import { ButtonGroup } from "react-bootstrap"; +import { HoverPopover } from "../Shared/HoverPopover"; +import { Icon } from "../Shared/Icon"; +import { TagLink } from "../Shared/TagLink"; +import { Button, ButtonGroup } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; import { RatingBanner } from "../Shared/RatingBanner"; import ScreenUtils from "src/utils/screen"; import { FavoriteIcon } from "../Shared/FavoriteIcon"; import { useStudioUpdate } from "src/core/StashService"; +import { faTag } from "@fortawesome/free-solid-svg-icons"; interface IProps { studio: GQL.StudioDataFragment; @@ -164,13 +168,31 @@ export const StudioCard: React.FC = ({ ); } + function maybeRenderTagPopoverButton() { + if (studio.tags.length <= 0) return; + + const popoverContent = studio.tags.map((tag) => ( + + )); + + return ( + + + + ); + } + function maybeRenderPopoverButtonGroup() { if ( studio.scene_count || studio.image_count || studio.gallery_count || studio.movie_count || - studio.performer_count + studio.performer_count || + studio.tags.length > 0 ) { return ( <> @@ -181,6 +203,7 @@ export const StudioCard: React.FC = ({ {maybeRenderImagesPopoverButton()} {maybeRenderGalleriesPopoverButton()} {maybeRenderPerformersPopoverButton()} + {maybeRenderTagPopoverButton()} ); diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx index a6c5126cb6b..5bf877b11f2 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioDetailsPanel.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { TagLink } from "src/components/Shared/TagLink"; import * as GQL from "src/core/generated-graphql"; import { DetailItem } from "src/components/Shared/DetailItem"; import { StashIDPill } from "src/components/Shared/StashID"; @@ -15,6 +16,19 @@ export const StudioDetailsPanel: React.FC = ({ collapsed, fullWidth, }) => { + function renderTagsField() { + if (!studio.tags.length) { + return; + } + return ( +
    + {(studio.tags ?? []).map((tag) => ( + + ))} +
+ ); + } + function renderStashIDs() { if (!studio.stash_ids?.length) { return; @@ -36,11 +50,18 @@ export const StudioDetailsPanel: React.FC = ({ function maybeRenderExtraDetails() { if (!collapsed) { return ( - + <> + + + ); } } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index dc0c03f36d9..1089e5ffe74 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -16,6 +16,7 @@ import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup"; import { Studio, StudioSelect } from "../StudioSelect"; +import { useTagsEdit } from "src/hooks/tagsEdit"; interface IStudioEditPanel { studio: Partial; @@ -50,6 +51,7 @@ export const StudioEditPanel: React.FC = ({ details: yup.string().ensure(), parent_id: yup.string().required().nullable(), aliases: yupUniqueAliases(intl, "name"), + tag_ids: yup.array(yup.string().required()).defined(), ignore_auto_tag: yup.boolean().defined(), stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), @@ -62,6 +64,7 @@ export const StudioEditPanel: React.FC = ({ details: studio.details ?? "", parent_id: studio.parent_studio?.id ?? null, aliases: studio.aliases ?? [], + tag_ids: (studio.tags ?? []).map((t) => t.id), ignore_auto_tag: studio.ignore_auto_tag ?? false, stash_ids: getStashIDs(studio.stash_ids), }; @@ -75,6 +78,10 @@ export const StudioEditPanel: React.FC = ({ onSubmit: (values) => onSave(schema.cast(values)), }); + const { tagsControl } = useTagsEdit(studio.tags, (ids) => + formik.setFieldValue("tag_ids", ids) + ); + function onSetParentStudio(item: Studio | null) { setParentStudio(item); formik.setFieldValue("parent_id", item ? item.id : null); @@ -157,6 +164,11 @@ export const StudioEditPanel: React.FC = ({ return renderField("parent_id", title, control); } + function renderTagsField() { + const title = intl.formatMessage({ id: "tags" }); + return renderField("tag_ids", title, tagsControl()); + } + if (isLoading) return ; return ( @@ -178,6 +190,7 @@ export const StudioEditPanel: React.FC = ({ {renderInputField("url")} {renderInputField("details", "textarea")} {renderParentStudioField()} + {renderTagsField()} {renderStashIDsField("stash_ids", "studios")}
{renderInputField("ignore_auto_tag", "checkbox")} diff --git a/ui/v2.5/src/components/Tags/TagCard.tsx b/ui/v2.5/src/components/Tags/TagCard.tsx index 51444f99949..424f8c5f518 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 maybeRenderStudiosPopoverButton() { + if (!tag.studio_count) return; + + return ( + + ); + } + function maybeRenderMoviesPopoverButton() { if (!tag.movie_count) return; @@ -248,6 +261,7 @@ export const TagCard: React.FC = ({ {maybeRenderMoviesPopoverButton()} {maybeRenderSceneMarkersPopoverButton()} {maybeRenderPerformersPopoverButton()} + {maybeRenderStudiosPopoverButton()} ); diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index aa10275b6cb..c80473db8fd 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -26,6 +26,7 @@ import { TagScenesPanel } from "./TagScenesPanel"; import { TagMarkersPanel } from "./TagMarkersPanel"; import { TagImagesPanel } from "./TagImagesPanel"; import { TagPerformersPanel } from "./TagPerformersPanel"; +import { TagStudiosPanel } from "./TagStudiosPanel"; import { TagGalleriesPanel } from "./TagGalleriesPanel"; import { CompressedTagDetailsPanel, TagDetailsPanel } from "./TagDetailsPanel"; import { TagEditPanel } from "./TagEditPanel"; @@ -61,6 +62,7 @@ const validTabs = [ "movies", "markers", "performers", + "studios", ] as const; type TabKey = (typeof validTabs)[number]; @@ -109,6 +111,8 @@ const TagPage: React.FC = ({ tag, tabKey }) => { (showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; const performerCount = (showAllCounts ? tag.performer_count_all : tag.performer_count) ?? 0; + const studioCount = + (showAllCounts ? tag.studio_count_all : tag.studio_count) ?? 0; const populatedDefaultTab = useMemo(() => { let ret: TabKey = "scenes"; @@ -123,6 +127,8 @@ const TagPage: React.FC = ({ tag, tabKey }) => { ret = "markers"; } else if (performerCount != 0) { ret = "performers"; + } else if (studioCount != 0) { + ret = "studios"; } } @@ -133,6 +139,7 @@ const TagPage: React.FC = ({ tag, tabKey }) => { galleryCount, sceneMarkerCount, performerCount, + studioCount, movieCount, ]); @@ -521,6 +528,21 @@ const TagPage: React.FC = ({ tag, tabKey }) => { > + + {intl.formatMessage({ id: "studios" })} + + + } + > + + ); diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx new file mode 100644 index 00000000000..ef63cdd5248 --- /dev/null +++ b/ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import * as GQL from "src/core/generated-graphql"; +import { useTagFilterHook } from "src/core/tags"; +import { StudioList } from "src/components/Studios/StudioList"; + +interface ITagStudiosPanel { + active: boolean; + tag: GQL.TagDataFragment; +} + +export const TagStudiosPanel: React.FC = ({ + active, + tag, +}) => { + const filterHook = useTagFilterHook(tag); + return ; +}; diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 61daff120a9..805eb9b50a9 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1376,6 +1376,7 @@ "status": "Status: {statusText}", "studio": "Studio", "studio_and_parent": "Studio & Parent", + "studio_count": "Studio Count", "studio_depth": "Levels (empty for all)", "studio_tagger": { "add_new_studios": "Add New Studios", @@ -1415,6 +1416,7 @@ "update_studios": "Update Studios", "updating_untagged_studios_description": "Updating untagged studios will try to match any studios that lack a stashid and update the metadata." }, + "studio_tags": "Studio Tags", "studios": "Studios", "sub_tag_count": "Sub-Tag Count", "sub_tag_of": "Sub-tag of {parent}", diff --git a/ui/v2.5/src/models/list-filter/criteria/tags.ts b/ui/v2.5/src/models/list-filter/criteria/tags.ts index e85392b6500..0dd5d54e319 100644 --- a/ui/v2.5/src/models/list-filter/criteria/tags.ts +++ b/ui/v2.5/src/models/list-filter/criteria/tags.ts @@ -55,6 +55,13 @@ export const PerformerTagsCriterionOption = new BaseTagsCriterionOption( withoutEqualsModifierOptions ); +// TODO - this requires using a nested studios_filter which needs to be added separately +// export const StudioTagsCriterionOption = new BaseTagsCriterionOption( +// "studio_tags", +// "studio_tags", +// withoutEqualsModifierOptions +// ); + export const ParentTagsCriterionOption = new BaseTagsCriterionOption( "parent_tags", "parents", diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index 8c1fc5a76c0..630267c728a 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -14,6 +14,7 @@ import { ScenesCriterionOption } from "./criteria/scenes"; import { StudiosCriterionOption } from "./criteria/studios"; import { PerformerTagsCriterionOption, + // StudioTagsCriterionOption, TagsCriterionOption, } from "./criteria/tags"; import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; @@ -62,6 +63,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("performer_age"), PerformerFavoriteCriterionOption, createMandatoryNumberCriterionOption("image_count"), + // StudioTagsCriterionOption, ScenesCriterionOption, StudiosCriterionOption, createStringCriterionOption("url"), diff --git a/ui/v2.5/src/models/list-filter/images.ts b/ui/v2.5/src/models/list-filter/images.ts index b8696fea40d..d8619112df6 100644 --- a/ui/v2.5/src/models/list-filter/images.ts +++ b/ui/v2.5/src/models/list-filter/images.ts @@ -16,6 +16,7 @@ import { OrientationCriterionOption } from "./criteria/orientation"; import { StudiosCriterionOption } from "./criteria/studios"; import { PerformerTagsCriterionOption, + // StudioTagsCriterionOption, TagsCriterionOption, } from "./criteria/tags"; import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; @@ -54,6 +55,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("performer_count"), createMandatoryNumberCriterionOption("performer_age"), PerformerFavoriteCriterionOption, + // StudioTagsCriterionOption, StudiosCriterionOption, createStringCriterionOption("url"), createDateCriterionOption("date"), diff --git a/ui/v2.5/src/models/list-filter/movies.ts b/ui/v2.5/src/models/list-filter/movies.ts index 35e4a24e25c..7e89d59393c 100644 --- a/ui/v2.5/src/models/list-filter/movies.ts +++ b/ui/v2.5/src/models/list-filter/movies.ts @@ -11,6 +11,7 @@ import { PerformersCriterionOption } from "./criteria/performers"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; import { RatingCriterionOption } from "./criteria/rating"; +// import { StudioTagsCriterionOption } from "./criteria/tags"; import { TagsCriterionOption } from "./criteria/tags"; const defaultSortBy = "name"; @@ -32,6 +33,7 @@ const sortByOptions = [ ]); const displayModeOptions = [DisplayMode.Grid]; const criterionOptions = [ + // StudioTagsCriterionOption, StudiosCriterionOption, MovieIsMissingCriterionOption, createStringCriterionOption("url"), diff --git a/ui/v2.5/src/models/list-filter/scenes.ts b/ui/v2.5/src/models/list-filter/scenes.ts index f6210a91841..c25ee97668d 100644 --- a/ui/v2.5/src/models/list-filter/scenes.ts +++ b/ui/v2.5/src/models/list-filter/scenes.ts @@ -17,6 +17,7 @@ import { StudiosCriterionOption } from "./criteria/studios"; import { InteractiveCriterionOption } from "./criteria/interactive"; import { PerformerTagsCriterionOption, + // StudioTagsCriterionOption, TagsCriterionOption, } from "./criteria/tags"; import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; @@ -99,6 +100,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("performer_count"), createMandatoryNumberCriterionOption("performer_age"), PerformerFavoriteCriterionOption, + // StudioTagsCriterionOption, StudiosCriterionOption, MoviesCriterionOption, GalleriesCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/studios.ts b/ui/v2.5/src/models/list-filter/studios.ts index ff3eeeebdcb..a25fd9e2267 100644 --- a/ui/v2.5/src/models/list-filter/studios.ts +++ b/ui/v2.5/src/models/list-filter/studios.ts @@ -10,11 +10,12 @@ import { StudioIsMissingCriterionOption } from "./criteria/is-missing"; import { RatingCriterionOption } from "./criteria/rating"; import { StashIDCriterionOption } from "./criteria/stash-ids"; import { ParentStudiosCriterionOption } from "./criteria/studios"; +import { TagsCriterionOption } from "./criteria/tags"; import { ListFilterOptions } from "./filter-options"; import { DisplayMode } from "./types"; const defaultSortBy = "name"; -const sortByOptions = ["name", "random", "rating"] +const sortByOptions = ["name", "tag_count", "random", "rating"] .map(ListFilterOptions.createSortBy) .concat([ { @@ -42,8 +43,10 @@ const criterionOptions = [ createStringCriterionOption("details"), ParentStudiosCriterionOption, StudioIsMissingCriterionOption, + TagsCriterionOption, RatingCriterionOption, createBooleanCriterionOption("ignore_auto_tag"), + createMandatoryNumberCriterionOption("tag_count"), createMandatoryNumberCriterionOption("scene_count"), createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("gallery_count"), diff --git a/ui/v2.5/src/models/list-filter/tags.ts b/ui/v2.5/src/models/list-filter/tags.ts index 9a9b71680a3..51df9ed896c 100644 --- a/ui/v2.5/src/models/list-filter/tags.ts +++ b/ui/v2.5/src/models/list-filter/tags.ts @@ -43,6 +43,10 @@ const sortByOptions = ["name", "random"] messageID: "marker_count", value: "scene_markers_count", }, + { + messageID: "studio_count", + value: "studios_count", + }, ]); const displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; @@ -57,6 +61,7 @@ const criterionOptions = [ createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("gallery_count"), createMandatoryNumberCriterionOption("performer_count"), + createMandatoryNumberCriterionOption("studio_count"), createMandatoryNumberCriterionOption("movie_count"), createMandatoryNumberCriterionOption("marker_count"), ParentTagsCriterionOption, diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 9638c7e9477..5a63179ad4b 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -142,6 +142,7 @@ export type CriterionType = | "tags" | "scene_tags" | "performer_tags" + | "studio_tags" | "tag_count" | "performers" | "studios" @@ -172,6 +173,7 @@ export type CriterionType = | "image_count" | "gallery_count" | "performer_count" + | "studio_count" | "movie_count" | "death_year" | "url" diff --git a/ui/v2.5/src/utils/navigation.ts b/ui/v2.5/src/utils/navigation.ts index e77f40a38aa..864618fd414 100644 --- a/ui/v2.5/src/utils/navigation.ts +++ b/ui/v2.5/src/utils/navigation.ts @@ -263,7 +263,7 @@ const makeChildTagsUrl = (tag: Partial) => { }; function makeTagFilter(mode: GQL.FilterMode, tag: INamedObject) { - const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined); + const filter = new ListFilterModel(mode, undefined); const criterion = new TagsCriterion(TagsCriterionOption); criterion.value = { items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], @@ -282,6 +282,10 @@ const makeTagPerformersUrl = (tag: INamedObject) => { return `/performers?${makeTagFilter(GQL.FilterMode.Performers, tag)}`; }; +const makeTagStudiosUrl = (tag: INamedObject) => { + return `/studios?${makeTagFilter(GQL.FilterMode.Studios, tag)}`; +}; + const makeTagSceneMarkersUrl = (tag: INamedObject) => { return `/scenes/markers?${makeTagFilter(GQL.FilterMode.SceneMarkers, tag)}`; }; @@ -410,6 +414,7 @@ const NavUtils = { makeTagSceneMarkersUrl, makeTagScenesUrl, makeTagPerformersUrl, + makeTagStudiosUrl, makeTagGalleriesUrl, makeTagImagesUrl, makeTagMoviesUrl,