Add tags to studios (#4858)

* Fix makeTagFilter mode

* Remove studio_tags filter criterion

This is handled by studios_filter. The support for this still needs to be added in the UI, so I have removed the criterion options in the short-term.
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
bob123491234 2024-06-18 00:55:20 -05:00 committed by GitHub
parent f26766033e
commit b3d35dfae4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 844 additions and 13 deletions

View file

@ -362,6 +362,8 @@ input StudioFilterType {
parents: MultiCriterionInput parents: MultiCriterionInput
"Filter by StashID" "Filter by StashID"
stash_id_endpoint: StashIDCriterionInput stash_id_endpoint: StashIDCriterionInput
"Filter to only include studios with these tags"
tags: HierarchicalMultiCriterionInput
"Filter to only include studios missing this property" "Filter to only include studios missing this property"
is_missing: String is_missing: String
# rating expressed as 1-100 # rating expressed as 1-100
@ -374,6 +376,8 @@ input StudioFilterType {
image_count: IntCriterionInput image_count: IntCriterionInput
"Filter by gallery count" "Filter by gallery count"
gallery_count: IntCriterionInput gallery_count: IntCriterionInput
"Filter by tag count"
tag_count: IntCriterionInput
"Filter by url" "Filter by url"
url: StringCriterionInput url: StringCriterionInput
"Filter by studio aliases" "Filter by studio aliases"
@ -498,6 +502,9 @@ input TagFilterType {
"Filter by number of performers with this tag" "Filter by number of performers with this tag"
performer_count: IntCriterionInput performer_count: IntCriterionInput
"Filter by number of studios with this tag"
studio_count: IntCriterionInput
"Filter by number of movies with this tag" "Filter by number of movies with this tag"
movie_count: IntCriterionInput movie_count: IntCriterionInput

View file

@ -5,6 +5,7 @@ type Studio {
parent_studio: Studio parent_studio: Studio
child_studios: [Studio!]! child_studios: [Studio!]!
aliases: [String!]! aliases: [String!]!
tags: [Tag!]!
ignore_auto_tag: Boolean! ignore_auto_tag: Boolean!
image_path: String # Resolver image_path: String # Resolver
@ -35,6 +36,7 @@ input StudioCreateInput {
favorite: Boolean favorite: Boolean
details: String details: String
aliases: [String!] aliases: [String!]
tag_ids: [ID!]
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
} }
@ -51,6 +53,7 @@ input StudioUpdateInput {
favorite: Boolean favorite: Boolean
details: String details: String
aliases: [String!] aliases: [String!]
tag_ids: [ID!]
ignore_auto_tag: Boolean ignore_auto_tag: Boolean
} }

View file

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

View file

@ -40,6 +40,20 @@ func (r *studioResolver) Aliases(ctx context.Context, obj *models.Studio) ([]str
return obj.Aliases.List(), nil 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) { 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 { if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = scene.CountByStudioID(ctx, r.repository.Scene, obj.ID, depth) ret, err = scene.CountByStudioID(ctx, r.repository.Scene, obj.ID, depth)

View file

@ -11,6 +11,7 @@ import (
"github.com/stashapp/stash/pkg/movie" "github.com/stashapp/stash/pkg/movie"
"github.com/stashapp/stash/pkg/performer" "github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/studio"
) )
func (r *tagResolver) Parents(ctx context.Context, obj *models.Tag) (ret []*models.Tag, err error) { 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 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) { 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 { if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = movie.CountByTagID(ctx, r.repository.Movie, obj.ID, depth) ret, err = movie.CountByTagID(ctx, r.repository.Movie, obj.ID, depth)

View file

@ -48,6 +48,11 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
return nil, fmt.Errorf("converting parent id: %w", err) 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 // Process the base 64 encoded image string
var imageData []byte var imageData []byte
if input.Image != nil { 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) 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 // Process the base 64 encoded image string
var imageData []byte var imageData []byte
imageIncluded := translator.hasField("image") imageIncluded := translator.hasField("image")

View file

@ -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) { func (t *ExportTask) exportStudio(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *models.Studio) {
defer wg.Done() defer wg.Done()
r := t.repository
studioReader := t.repository.Studio studioReader := t.repository.Studio
for s := range jobChan { for s := range jobChan {
@ -992,6 +993,18 @@ func (t *ExportTask) exportStudio(ctx context.Context, wg *sync.WaitGroup, jobCh
continue 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() fn := newStudioJSON.Filename()
if err := t.json.saveStudio(fn, newStudioJSON); err != nil { if err := t.json.saveStudio(fn, newStudioJSON); err != nil {

View file

@ -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 { func (t *ImportTask) importStudio(ctx context.Context, studioJSON *jsonschema.Studio, pendingParent map[string][]*jsonschema.Studio) error {
r := t.repository
importer := &studio.Importer{ importer := &studio.Importer{
ReaderWriter: t.repository.Studio, ReaderWriter: t.repository.Studio,
TagWriter: r.Tag,
Input: *studioJSON, Input: *studioJSON,
MissingRefBehaviour: t.MissingRefBehaviour, MissingRefBehaviour: t.MissingRefBehaviour,
} }

View file

@ -22,6 +22,7 @@ type Studio struct {
Details string `json:"details,omitempty"` Details string `json:"details,omitempty"`
Aliases []string `json:"aliases,omitempty"` Aliases []string `json:"aliases,omitempty"`
StashIDs []models.StashID `json:"stash_ids,omitempty"` StashIDs []models.StashID `json:"stash_ids,omitempty"`
Tags []string `json:"tags,omitempty"`
IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"`
} }

View file

@ -58,6 +58,27 @@ func (_m *StudioReaderWriter) Count(ctx context.Context) (int, error) {
return r0, r1 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 // Create provides a mock function with given fields: ctx, newStudio
func (_m *StudioReaderWriter) Create(ctx context.Context, newStudio *models.Studio) error { func (_m *StudioReaderWriter) Create(ctx context.Context, newStudio *models.Studio) error {
ret := _m.Called(ctx, newStudio) ret := _m.Called(ctx, newStudio)
@ -316,6 +337,29 @@ func (_m *StudioReaderWriter) GetStashIDs(ctx context.Context, relatedID int) ([
return r0, r1 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 // HasImage provides a mock function with given fields: ctx, studioID
func (_m *StudioReaderWriter) HasImage(ctx context.Context, studioID int) (bool, error) { func (_m *StudioReaderWriter) HasImage(ctx context.Context, studioID int) (bool, error) {
ret := _m.Called(ctx, studioID) ret := _m.Called(ctx, studioID)
@ -367,6 +411,27 @@ func (_m *StudioReaderWriter) Query(ctx context.Context, studioFilter *models.St
return r0, r1, r2 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 // QueryForAutoTag provides a mock function with given fields: ctx, words
func (_m *StudioReaderWriter) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Studio, error) { func (_m *StudioReaderWriter) QueryForAutoTag(ctx context.Context, words []string) ([]*models.Studio, error) {
ret := _m.Called(ctx, words) ret := _m.Called(ctx, words)

View file

@ -427,6 +427,29 @@ func (_m *TagReaderWriter) FindBySceneMarkerID(ctx context.Context, sceneMarkerI
return r0, r1 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 // FindMany provides a mock function with given fields: ctx, ids
func (_m *TagReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Tag, error) { func (_m *TagReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.Tag, error) {
ret := _m.Called(ctx, ids) ret := _m.Called(ctx, ids)

View file

@ -19,6 +19,7 @@ type Studio struct {
IgnoreAutoTag bool `json:"ignore_auto_tag"` IgnoreAutoTag bool `json:"ignore_auto_tag"`
Aliases RelatedStrings `json:"aliases"` Aliases RelatedStrings `json:"aliases"`
TagIDs RelatedIDs `json:"tag_ids"`
StashIDs RelatedStashIDs `json:"stash_ids"` StashIDs RelatedStashIDs `json:"stash_ids"`
} }
@ -45,6 +46,7 @@ type StudioPartial struct {
IgnoreAutoTag OptionalBool IgnoreAutoTag OptionalBool
Aliases *UpdateStrings Aliases *UpdateStrings
TagIDs *UpdateIDs
StashIDs *UpdateStashIDs 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 { func (s *Studio) LoadStashIDs(ctx context.Context, l StashIDLoader) error {
return s.StashIDs.load(func() ([]StashID, error) { return s.StashIDs.load(func() ([]StashID, error) {
return l.GetStashIDs(ctx, s.ID) return l.GetStashIDs(ctx, s.ID)
@ -72,6 +80,10 @@ func (s *Studio) LoadRelationships(ctx context.Context, l PerformerReader) error
return err return err
} }
if err := s.LoadTagIDs(ctx, l); err != nil {
return err
}
if err := s.LoadStashIDs(ctx, l); err != nil { if err := s.LoadStashIDs(ctx, l); err != nil {
return err return err
} }

View file

@ -22,6 +22,7 @@ type StudioFinder interface {
// StudioQueryer provides methods to query studios. // StudioQueryer provides methods to query studios.
type StudioQueryer interface { type StudioQueryer interface {
Query(ctx context.Context, studioFilter *StudioFilterType, findFilter *FindFilterType) ([]*Studio, int, error) Query(ctx context.Context, studioFilter *StudioFilterType, findFilter *FindFilterType) ([]*Studio, int, error)
QueryCount(ctx context.Context, studioFilter *StudioFilterType, findFilter *FindFilterType) (int, error)
} }
type StudioAutoTagQueryer interface { type StudioAutoTagQueryer interface {
@ -36,6 +37,7 @@ type StudioAutoTagQueryer interface {
// StudioCounter provides methods to count studios. // StudioCounter provides methods to count studios.
type StudioCounter interface { type StudioCounter interface {
Count(ctx context.Context) (int, error) Count(ctx context.Context) (int, error)
CountByTagID(ctx context.Context, tagID int) (int, error)
} }
// StudioCreator provides methods to create studios. // StudioCreator provides methods to create studios.
@ -74,6 +76,7 @@ type StudioReader interface {
AliasLoader AliasLoader
StashIDLoader StashIDLoader
TagIDLoader
All(ctx context.Context) ([]*Studio, error) All(ctx context.Context) ([]*Studio, error)
GetImage(ctx context.Context, studioID int) ([]byte, error) GetImage(ctx context.Context, studioID int) ([]byte, error)

View file

@ -22,6 +22,7 @@ type TagFinder interface {
FindByPerformerID(ctx context.Context, performerID int) ([]*Tag, error) FindByPerformerID(ctx context.Context, performerID int) ([]*Tag, error)
FindByMovieID(ctx context.Context, movieID int) ([]*Tag, error) FindByMovieID(ctx context.Context, movieID int) ([]*Tag, error)
FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error)
FindByStudioID(ctx context.Context, studioID int) ([]*Tag, error)
FindByName(ctx context.Context, name string, nocase bool) (*Tag, error) FindByName(ctx context.Context, name string, nocase bool) (*Tag, error)
FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error) FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error)
} }

View file

@ -14,6 +14,10 @@ type StudioFilterType struct {
IsMissing *string `json:"is_missing"` IsMissing *string `json:"is_missing"`
// Filter by rating expressed as 1-100 // Filter by rating expressed as 1-100
Rating100 *IntCriterionInput `json:"rating100"` 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 // Filter by favorite
Favorite *bool `json:"favorite"` Favorite *bool `json:"favorite"`
// Filter by scene count // Filter by scene count
@ -53,6 +57,7 @@ type StudioCreateInput struct {
Favorite *bool `json:"favorite"` Favorite *bool `json:"favorite"`
Details *string `json:"details"` Details *string `json:"details"`
Aliases []string `json:"aliases"` Aliases []string `json:"aliases"`
TagIds []string `json:"tag_ids"`
IgnoreAutoTag *bool `json:"ignore_auto_tag"` IgnoreAutoTag *bool `json:"ignore_auto_tag"`
} }
@ -68,5 +73,6 @@ type StudioUpdateInput struct {
Favorite *bool `json:"favorite"` Favorite *bool `json:"favorite"`
Details *string `json:"details"` Details *string `json:"details"`
Aliases []string `json:"aliases"` Aliases []string `json:"aliases"`
TagIds []string `json:"tag_ids"`
IgnoreAutoTag *bool `json:"ignore_auto_tag"` IgnoreAutoTag *bool `json:"ignore_auto_tag"`
} }

View file

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

View file

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

View file

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

View file

@ -207,6 +207,9 @@ const (
tagIdxWithPerformer tagIdxWithPerformer
tagIdx1WithPerformer tagIdx1WithPerformer
tagIdx2WithPerformer tagIdx2WithPerformer
tagIdxWithStudio
tagIdx1WithStudio
tagIdx2WithStudio
tagIdxWithGallery tagIdxWithGallery
tagIdx1WithGallery tagIdx1WithGallery
tagIdx2WithGallery tagIdx2WithGallery
@ -245,6 +248,10 @@ const (
studioIdxWithScenePerformer studioIdxWithScenePerformer
studioIdxWithImagePerformer studioIdxWithImagePerformer
studioIdxWithGalleryPerformer studioIdxWithGalleryPerformer
studioIdxWithTag
studioIdx2WithTag
studioIdxWithTwoTags
studioIdxWithParentTag
studioIdxWithGrandChild studioIdxWithGrandChild
studioIdxWithParentAndChild studioIdxWithParentAndChild
studioIdxWithGrandParent studioIdxWithGrandParent
@ -510,6 +517,15 @@ var (
} }
) )
var (
studioTags = linkMap{
studioIdxWithTag: {tagIdxWithStudio},
studioIdx2WithTag: {tagIdx2WithStudio},
studioIdxWithTwoTags: {tagIdx1WithStudio, tagIdx2WithStudio},
studioIdxWithParentTag: {tagIdxWithParentAndChild},
}
)
var ( var (
performerTags = linkMap{ performerTags = linkMap{
performerIdxWithTag: {tagIdxWithPerformer}, performerIdxWithTag: {tagIdxWithPerformer},
@ -1566,6 +1582,11 @@ func getTagPerformerCount(id int) int {
return len(performerTags.reverseLookup(idx)) return len(performerTags.reverseLookup(idx))
} }
func getTagStudioCount(id int) int {
idx := indexFromID(tagIDs, id)
return len(studioTags.reverseLookup(idx))
}
func getTagParentCount(id int) int { func getTagParentCount(id int) int {
if id == tagIDs[tagIdxWithParentTag] || id == tagIDs[tagIdxWithGrandParent] || id == tagIDs[tagIdxWithParentAndChild] { if id == tagIDs[tagIdxWithParentTag] || id == tagIDs[tagIdxWithGrandParent] || id == tagIDs[tagIdxWithParentAndChild] {
return 1 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 // studios [ i ] and [ n + o - i - 1 ] should have similar names with only the Name!=NaMe part different
name = getStudioStringValue(index, name) name = getStudioStringValue(index, name)
tids := indexesToIDs(tagIDs, studioTags[i])
studio := models.Studio{ studio := models.Studio{
Name: name, Name: name,
URL: getStudioStringValue(index, urlField), URL: getStudioStringValue(index, urlField),
Favorite: getStudioBoolValue(index), Favorite: getStudioBoolValue(index),
IgnoreAutoTag: getIgnoreAutoTag(i), IgnoreAutoTag: getIgnoreAutoTag(i),
TagIDs: models.NewRelatedIDs(tids),
} }
// only add aliases for some scenes // only add aliases for some scenes
if i == studioIdxWithMovie || i%5 == 0 { if i == studioIdxWithMovie || i%5 == 0 {

View file

@ -25,6 +25,7 @@ const (
studioParentIDColumn = "parent_id" studioParentIDColumn = "parent_id"
studioNameColumn = "name" studioNameColumn = "name"
studioImageBlobColumn = "image_blob" studioImageBlobColumn = "image_blob"
studiosTagsTable = "studios_tags"
) )
type studioRow struct { type studioRow struct {
@ -94,6 +95,7 @@ type studioRepositoryType struct {
repository repository
stashIDs stashIDRepository stashIDs stashIDRepository
tags joinRepository
scenes repository scenes repository
images repository images repository
@ -124,11 +126,21 @@ var (
tableName: galleryTable, tableName: galleryTable,
idColumn: studioIDColumn, idColumn: studioIDColumn,
}, },
tags: joinRepository{
repository: repository{
tableName: studiosTagsTable,
idColumn: studioIDColumn,
},
fkColumn: tagIDColumn,
foreignTable: tagTable,
orderBy: "tags.name ASC",
},
} }
) )
type StudioStore struct { type StudioStore struct {
blobJoinQueryBuilder blobJoinQueryBuilder
tagRelationshipStore
tableMgr *table tableMgr *table
} }
@ -139,6 +151,11 @@ func NewStudioStore(blobStore *BlobStore) *StudioStore {
blobStore: blobStore, blobStore: blobStore,
joinTable: studioTable, joinTable: studioTable,
}, },
tagRelationshipStore: tagRelationshipStore{
idRelationshipStore: idRelationshipStore{
joinTable: studiosTagsTableMgr,
},
},
tableMgr: studioTableMgr, 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 newObject.StashIDs.Loaded() {
if err := studiosStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil { if err := studiosStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil {
return err 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 input.StashIDs != nil {
if err := studiosStashIDsTableMgr.modifyJoins(ctx, input.ID, input.StashIDs.StashIDs, input.StashIDs.Mode); err != nil { if err := studiosStashIDsTableMgr.modifyJoins(ctx, input.ID, input.StashIDs.StashIDs, input.StashIDs.Mode); err != nil {
return nil, err 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 updatedObject.StashIDs.Loaded() {
if err := studiosStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil { if err := studiosStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil {
return err return err
@ -538,6 +567,15 @@ func (qb *StudioStore) Query(ctx context.Context, studioFilter *models.StudioFil
return studios, countResult, nil 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{ var studioSortOptions = sortOptions{
"child_count", "child_count",
"created_at", "created_at",
@ -569,6 +607,8 @@ func (qb *StudioStore) getStudioSort(findFilter *models.FindFilterType) (string,
sortQuery := "" sortQuery := ""
switch sort { switch sort {
case "tag_count":
sortQuery += getCountSort(studioTable, studiosTagsTable, studioIDColumn, direction)
case "scenes_count": case "scenes_count":
sortQuery += getCountSort(studioTable, sceneTable, studioIDColumn, direction) sortQuery += getCountSort(studioTable, sceneTable, studioIDColumn, direction)
case "images_count": case "images_count":

View file

@ -74,11 +74,13 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler {
}, },
qb.isMissingCriterionHandler(studioFilter.IsMissing), qb.isMissingCriterionHandler(studioFilter.IsMissing),
qb.tagCountCriterionHandler(studioFilter.TagCount),
qb.sceneCountCriterionHandler(studioFilter.SceneCount), qb.sceneCountCriterionHandler(studioFilter.SceneCount),
qb.imageCountCriterionHandler(studioFilter.ImageCount), qb.imageCountCriterionHandler(studioFilter.ImageCount),
qb.galleryCountCriterionHandler(studioFilter.GalleryCount), qb.galleryCountCriterionHandler(studioFilter.GalleryCount),
qb.parentCriterionHandler(studioFilter.Parents), qb.parentCriterionHandler(studioFilter.Parents),
qb.aliasCriterionHandler(studioFilter.Aliases), qb.aliasCriterionHandler(studioFilter.Aliases),
qb.tagsCriterionHandler(studioFilter.Tags),
qb.childCountCriterionHandler(studioFilter.ChildCount), qb.childCountCriterionHandler(studioFilter.ChildCount),
&timestampCriterionHandler{studioFilter.CreatedAt, studioTable + ".created_at", nil}, &timestampCriterionHandler{studioFilter.CreatedAt, studioTable + ".created_at", nil},
&timestampCriterionHandler{studioFilter.UpdatedAt, studioTable + ".updated_at", nil}, &timestampCriterionHandler{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 { func (qb *studioFilterHandler) parentCriterionHandler(parents *models.MultiCriterionInput) criterionHandlerFunc {
addJoinsFunc := func(f *filterBuilder) { addJoinsFunc := func(f *filterBuilder) {
f.addLeftJoin("studios", "parent_studio", "parent_studio.id = studios.parent_id") 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)
}

View file

@ -704,6 +704,110 @@ func TestStudioQueryRating(t *testing.T) {
verifyStudiosRating(t, ratingCriterion) 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)) { func verifyStudioQuery(t *testing.T, filter models.StudioFilterType, verifyFn func(ctx context.Context, s *models.Studio)) {
withTxn(func(ctx context.Context) error { withTxn(func(ctx context.Context) error {
t.Helper() t.Helper()

View file

@ -34,6 +34,7 @@ var (
performersStashIDsJoinTable = goqu.T("performer_stash_ids") performersStashIDsJoinTable = goqu.T("performer_stash_ids")
studiosAliasesJoinTable = goqu.T(studioAliasesTable) studiosAliasesJoinTable = goqu.T(studioAliasesTable)
studiosTagsJoinTable = goqu.T(studiosTagsTable)
studiosStashIDsJoinTable = goqu.T("studio_stash_ids") studiosStashIDsJoinTable = goqu.T("studio_stash_ids")
moviesURLsJoinTable = goqu.T(movieURLsTable) moviesURLsJoinTable = goqu.T(movieURLsTable)
@ -294,6 +295,14 @@ var (
stringColumn: studiosAliasesJoinTable.Col(studioAliasColumn), stringColumn: studiosAliasesJoinTable.Col(studioAliasColumn),
} }
studiosTagsTableMgr = &joinTable{
table: table{
table: studiosTagsJoinTable,
idColumn: studiosTagsJoinTable.Col(studioIDColumn),
},
fkColumn: studiosTagsJoinTable.Col(tagIDColumn),
}
studiosStashIDsTableMgr = &stashIDTable{ studiosStashIDsTableMgr = &stashIDTable{
table: table{ table: table{
table: studiosStashIDsJoinTable, table: studiosStashIDsJoinTable,

View file

@ -448,6 +448,18 @@ func (qb *TagStore) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int)
return qb.queryTags(ctx, query, args) 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) { func (qb *TagStore) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) {
// query := "SELECT * FROM tags WHERE name = ?" // query := "SELECT * FROM tags WHERE name = ?"
// if nocase { // if nocase {
@ -628,6 +640,7 @@ var tagSortOptions = sortOptions{
"id", "id",
"images_count", "images_count",
"movies_count", "movies_count",
"studios_count",
"name", "name",
"performers_count", "performers_count",
"random", "random",
@ -668,6 +681,8 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte
sortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction) sortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction)
case "performers_count": case "performers_count":
sortQuery += getCountSort(tagTable, performersTagsTable, tagIDColumn, direction) sortQuery += getCountSort(tagTable, performersTagsTable, tagIDColumn, direction)
case "studios_count":
sortQuery += getCountSort(tagTable, studiosTagsTable, tagIDColumn, direction)
case "movies_count": case "movies_count":
sortQuery += getCountSort(tagTable, moviesTagsTable, tagIDColumn, direction) sortQuery += getCountSort(tagTable, moviesTagsTable, tagIDColumn, direction)
default: default:
@ -767,6 +782,7 @@ func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) er
galleriesTagsTable: galleryIDColumn, galleriesTagsTable: galleryIDColumn,
imagesTagsTable: imageIDColumn, imagesTagsTable: imageIDColumn,
"performers_tags": "performer_id", "performers_tags": "performer_id",
"studios_tags": "studio_id",
} }
args = append(args, destination) args = append(args, destination)

View file

@ -66,6 +66,7 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler {
qb.imageCountCriterionHandler(tagFilter.ImageCount), qb.imageCountCriterionHandler(tagFilter.ImageCount),
qb.galleryCountCriterionHandler(tagFilter.GalleryCount), qb.galleryCountCriterionHandler(tagFilter.GalleryCount),
qb.performerCountCriterionHandler(tagFilter.PerformerCount), qb.performerCountCriterionHandler(tagFilter.PerformerCount),
qb.studioCountCriterionHandler(tagFilter.StudioCount),
qb.movieCountCriterionHandler(tagFilter.MovieCount), qb.movieCountCriterionHandler(tagFilter.MovieCount),
qb.markerCountCriterionHandler(tagFilter.MarkerCount), qb.markerCountCriterionHandler(tagFilter.MarkerCount),
qb.parentsCriterionHandler(tagFilter.Parents), 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 { func (qb *tagFilterHandler) movieCountCriterionHandler(movieCount *models.IntCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if movieCount != nil { if movieCount != nil {

View file

@ -230,6 +230,10 @@ func TestTagQuerySort(t *testing.T) {
tags = queryTags(ctx, t, sqb, nil, findFilter) tags = queryTags(ctx, t, sqb, nil, findFilter)
assert.Equal(tagIDs[tagIdx2WithPerformer], tags[0].ID) assert.Equal(tagIDs[tagIdx2WithPerformer], tags[0].ID)
sortBy = "studios_count"
tags = queryTags(ctx, t, sqb, nil, findFilter)
assert.Equal(tagIDs[tagIdx2WithStudio], tags[0].ID)
sortBy = "movies_count" sortBy = "movies_count"
tags = queryTags(ctx, t, sqb, nil, findFilter) tags = queryTags(ctx, t, sqb, nil, findFilter)
assert.Equal(tagIDs[tagIdx1WithMovie], tags[0].ID) 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) { func TestTagQueryParentCount(t *testing.T) {
countCriterion := models.IntCriterionInput{ countCriterion := models.IntCriterionInput{
Value: 1, Value: 1,
@ -882,6 +925,9 @@ func TestTagMerge(t *testing.T) {
tagIdxWithPerformer, tagIdxWithPerformer,
tagIdx1WithPerformer, tagIdx1WithPerformer,
tagIdx2WithPerformer, tagIdx2WithPerformer,
tagIdxWithStudio,
tagIdx1WithStudio,
tagIdx2WithStudio,
tagIdxWithGallery, tagIdxWithGallery,
tagIdx1WithGallery, tagIdx1WithGallery,
tagIdx2WithGallery, tagIdx2WithGallery,
@ -970,6 +1016,14 @@ func TestTagMerge(t *testing.T) {
assert.Contains(performerTagIDs, destID) 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 return nil
}); err != nil { }); err != nil {
t.Error(err.Error()) t.Error(err.Error())

View file

@ -68,6 +68,7 @@ func createFullStudio(id int, parentID int) models.Studio {
Rating: &rating, Rating: &rating,
IgnoreAutoTag: autoTagIgnored, IgnoreAutoTag: autoTagIgnored,
Aliases: models.NewRelatedStrings(aliases), Aliases: models.NewRelatedStrings(aliases),
TagIDs: models.NewRelatedIDs([]int{}),
StashIDs: models.NewRelatedStashIDs(stashIDs), StashIDs: models.NewRelatedStashIDs(stashIDs),
} }
@ -84,6 +85,7 @@ func createEmptyStudio(id int) models.Studio {
CreatedAt: createTime, CreatedAt: createTime,
UpdatedAt: updateTime, UpdatedAt: updateTime,
Aliases: models.NewRelatedStrings([]string{}), Aliases: models.NewRelatedStrings([]string{}),
TagIDs: models.NewRelatedIDs([]int{}),
StashIDs: models.NewRelatedStashIDs([]models.StashID{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}),
} }
} }

View file

@ -4,9 +4,11 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"strings"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/jsonschema" "github.com/stashapp/stash/pkg/models/jsonschema"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/pkg/utils"
) )
@ -19,6 +21,7 @@ var ErrParentStudioNotExist = errors.New("parent studio does not exist")
type Importer struct { type Importer struct {
ReaderWriter ImporterReaderWriter ReaderWriter ImporterReaderWriter
TagWriter models.TagFinderCreator
Input jsonschema.Studio Input jsonschema.Studio
MissingRefBehaviour models.ImportMissingRefEnum MissingRefBehaviour models.ImportMissingRefEnum
@ -34,6 +37,10 @@ func (i *Importer) PreImport(ctx context.Context) error {
return err return err
} }
if err := i.populateTags(ctx); err != nil {
return err
}
var err error var err error
if len(i.Input.Image) > 0 { if len(i.Input.Image) > 0 {
i.imageData, err = utils.ProcessBase64Image(i.Input.Image) i.imageData, err = utils.ProcessBase64Image(i.Input.Image)
@ -45,6 +52,74 @@ func (i *Importer) PreImport(ctx context.Context) error {
return nil return nil
} }
func (i *Importer) populateTags(ctx context.Context) error {
if len(i.Input.Tags) > 0 {
tags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour)
if err != nil {
return err
}
for _, p := range tags {
i.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 { func (i *Importer) populateParentStudio(ctx context.Context) error {
if i.Input.ParentStudio != "" { if i.Input.ParentStudio != "" {
studio, err := i.ReaderWriter.FindByName(ctx, i.Input.ParentStudio, false) 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(), CreatedAt: studioJSON.CreatedAt.GetTime(),
UpdatedAt: studioJSON.UpdatedAt.GetTime(), UpdatedAt: studioJSON.UpdatedAt.GetTime(),
TagIDs: models.NewRelatedIDs([]int{}),
StashIDs: models.NewRelatedStashIDs(studioJSON.StashIDs), StashIDs: models.NewRelatedStashIDs(studioJSON.StashIDs),
} }

View file

@ -16,13 +16,19 @@ const invalidImage = "aW1hZ2VCeXRlcw&&"
const ( const (
studioNameErr = "studioNameErr" studioNameErr = "studioNameErr"
existingStudioName = "existingTagName" existingStudioName = "existingStudioName"
existingStudioID = 100 existingStudioID = 100
existingTagID = 105
errTagsID = 106
existingParentStudioName = "existingParentStudioName" existingParentStudioName = "existingParentStudioName"
existingParentStudioErr = "existingParentStudioErr" existingParentStudioErr = "existingParentStudioErr"
missingParentStudioName = "existingParentStudioName" missingParentStudioName = "existingParentStudioName"
existingTagName = "existingTagName"
existingTagErr = "existingTagErr"
missingTagName = "missingTagName"
) )
var testCtx = context.Background() var testCtx = context.Background()
@ -67,6 +73,97 @@ func TestImporterPreImport(t *testing.T) {
assert.Equal(t, expectedStudio, i.studio) 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) { func TestImporterPreImportWithParent(t *testing.T) {
db := mocks.NewDatabase() db := mocks.NewDatabase()
@ -156,6 +253,7 @@ func TestImporterPostImport(t *testing.T) {
i := Importer{ i := Importer{
ReaderWriter: db.Studio, ReaderWriter: db.Studio,
TagWriter: db.Tag,
Input: jsonschema.Studio{ Input: jsonschema.Studio{
Aliases: []string{"alias"}, Aliases: []string{"alias"},
}, },
@ -181,6 +279,7 @@ func TestImporterFindExistingID(t *testing.T) {
i := Importer{ i := Importer{
ReaderWriter: db.Studio, ReaderWriter: db.Studio,
TagWriter: db.Tag,
Input: jsonschema.Studio{ Input: jsonschema.Studio{
Name: studioName, Name: studioName,
}, },
@ -223,6 +322,7 @@ func TestCreate(t *testing.T) {
i := Importer{ i := Importer{
ReaderWriter: db.Studio, ReaderWriter: db.Studio,
TagWriter: db.Tag,
studio: studio, studio: studio,
} }
@ -258,6 +358,7 @@ func TestUpdate(t *testing.T) {
i := Importer{ i := Importer{
ReaderWriter: db.Studio, ReaderWriter: db.Studio,
TagWriter: db.Tag,
studio: studio, studio: studio,
} }

View file

@ -2,6 +2,7 @@ package studio
import ( import (
"context" "context"
"strconv"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
@ -53,3 +54,15 @@ func ByAlias(ctx context.Context, qb models.StudioQueryer, alias string) (*model
return nil, nil 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)
}

View file

@ -12,4 +12,8 @@ fragment SlimStudioData on Studio {
details details
rating100 rating100
aliases aliases
tags {
id
name
}
} }

View file

@ -33,6 +33,9 @@ fragment StudioData on Studio {
rating100 rating100
favorite favorite
aliases aliases
tags {
...SlimTagData
}
} }
fragment SelectStudioData on Studio { fragment SelectStudioData on Studio {

View file

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

View file

@ -4,6 +4,7 @@ import {
faImages, faImages,
faPlayCircle, faPlayCircle,
faUser, faUser,
faVideo,
faMapMarkerAlt, faMapMarkerAlt,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import React, { useMemo } from "react"; import React, { useMemo } from "react";
@ -20,7 +21,8 @@ type PopoverLinkType =
| "gallery" | "gallery"
| "marker" | "marker"
| "movie" | "movie"
| "performer"; | "performer"
| "studio";
interface IProps { interface IProps {
className?: string; className?: string;
@ -54,6 +56,8 @@ export const PopoverCountButton: React.FC<IProps> = ({
return faFilm; return faFilm;
case "performer": case "performer":
return faUser; return faUser;
case "studio":
return faVideo;
} }
} }
@ -89,6 +93,11 @@ export const PopoverCountButton: React.FC<IProps> = ({
one: "performer", one: "performer",
other: "performers", other: "performers",
}; };
case "studio":
return {
one: "studio",
other: "studios",
};
} }
} }

View file

@ -191,7 +191,14 @@ export const GalleryLink: React.FC<IGalleryLinkProps> = ({
interface ITagLinkProps { interface ITagLinkProps {
tag: INamedObject; tag: INamedObject;
linkType?: "scene" | "gallery" | "image" | "details" | "performer" | "movie"; linkType?:
| "scene"
| "gallery"
| "image"
| "details"
| "performer"
| "movie"
| "studio";
className?: string; className?: string;
hoverPlacement?: Placement; hoverPlacement?: Placement;
showHierarchyIcon?: boolean; showHierarchyIcon?: boolean;
@ -212,6 +219,8 @@ export const TagLink: React.FC<ITagLinkProps> = ({
return NavUtils.makeTagScenesUrl(tag); return NavUtils.makeTagScenesUrl(tag);
case "performer": case "performer":
return NavUtils.makeTagPerformersUrl(tag); return NavUtils.makeTagPerformersUrl(tag);
case "studio":
return NavUtils.makeTagStudiosUrl(tag);
case "gallery": case "gallery":
return NavUtils.makeTagGalleriesUrl(tag); return NavUtils.makeTagGalleriesUrl(tag);
case "image": case "image":

View file

@ -6,13 +6,17 @@ import {
GridCard, GridCard,
calculateCardWidth, calculateCardWidth,
} from "src/components/Shared/GridCard/GridCard"; } 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 { FormattedMessage } from "react-intl";
import { PopoverCountButton } from "../Shared/PopoverCountButton"; import { PopoverCountButton } from "../Shared/PopoverCountButton";
import { RatingBanner } from "../Shared/RatingBanner"; import { RatingBanner } from "../Shared/RatingBanner";
import ScreenUtils from "src/utils/screen"; import ScreenUtils from "src/utils/screen";
import { FavoriteIcon } from "../Shared/FavoriteIcon"; import { FavoriteIcon } from "../Shared/FavoriteIcon";
import { useStudioUpdate } from "src/core/StashService"; import { useStudioUpdate } from "src/core/StashService";
import { faTag } from "@fortawesome/free-solid-svg-icons";
interface IProps { interface IProps {
studio: GQL.StudioDataFragment; studio: GQL.StudioDataFragment;
@ -164,13 +168,31 @@ export const StudioCard: React.FC<IProps> = ({
); );
} }
function maybeRenderTagPopoverButton() {
if (studio.tags.length <= 0) return;
const popoverContent = studio.tags.map((tag) => (
<TagLink key={tag.id} linkType="studio" tag={tag} />
));
return (
<HoverPopover placement="bottom" content={popoverContent}>
<Button className="minimal tag-count">
<Icon icon={faTag} />
<span>{studio.tags.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderPopoverButtonGroup() { function maybeRenderPopoverButtonGroup() {
if ( if (
studio.scene_count || studio.scene_count ||
studio.image_count || studio.image_count ||
studio.gallery_count || studio.gallery_count ||
studio.movie_count || studio.movie_count ||
studio.performer_count studio.performer_count ||
studio.tags.length > 0
) { ) {
return ( return (
<> <>
@ -181,6 +203,7 @@ export const StudioCard: React.FC<IProps> = ({
{maybeRenderImagesPopoverButton()} {maybeRenderImagesPopoverButton()}
{maybeRenderGalleriesPopoverButton()} {maybeRenderGalleriesPopoverButton()}
{maybeRenderPerformersPopoverButton()} {maybeRenderPerformersPopoverButton()}
{maybeRenderTagPopoverButton()}
</ButtonGroup> </ButtonGroup>
</> </>
); );

View file

@ -1,4 +1,5 @@
import React from "react"; import React from "react";
import { TagLink } from "src/components/Shared/TagLink";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { DetailItem } from "src/components/Shared/DetailItem"; import { DetailItem } from "src/components/Shared/DetailItem";
import { StashIDPill } from "src/components/Shared/StashID"; import { StashIDPill } from "src/components/Shared/StashID";
@ -15,6 +16,19 @@ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
collapsed, collapsed,
fullWidth, fullWidth,
}) => { }) => {
function renderTagsField() {
if (!studio.tags.length) {
return;
}
return (
<ul className="pl-0">
{(studio.tags ?? []).map((tag) => (
<TagLink key={tag.id} linkType="studio" tag={tag} />
))}
</ul>
);
}
function renderStashIDs() { function renderStashIDs() {
if (!studio.stash_ids?.length) { if (!studio.stash_ids?.length) {
return; return;
@ -36,11 +50,18 @@ export const StudioDetailsPanel: React.FC<IStudioDetailsPanel> = ({
function maybeRenderExtraDetails() { function maybeRenderExtraDetails() {
if (!collapsed) { if (!collapsed) {
return ( return (
<DetailItem <>
id="stash_ids" <DetailItem
value={renderStashIDs()} id="tags"
fullWidth={fullWidth} value={renderTagsField()}
/> fullWidth={fullWidth}
/>
<DetailItem
id="stash_ids"
value={renderStashIDs()}
fullWidth={fullWidth}
/>
</>
); );
} }
} }

View file

@ -16,6 +16,7 @@ import { handleUnsavedChanges } from "src/utils/navigation";
import { formikUtils } from "src/utils/form"; import { formikUtils } from "src/utils/form";
import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup"; import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup";
import { Studio, StudioSelect } from "../StudioSelect"; import { Studio, StudioSelect } from "../StudioSelect";
import { useTagsEdit } from "src/hooks/tagsEdit";
interface IStudioEditPanel { interface IStudioEditPanel {
studio: Partial<GQL.StudioDataFragment>; studio: Partial<GQL.StudioDataFragment>;
@ -50,6 +51,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
details: yup.string().ensure(), details: yup.string().ensure(),
parent_id: yup.string().required().nullable(), parent_id: yup.string().required().nullable(),
aliases: yupUniqueAliases(intl, "name"), aliases: yupUniqueAliases(intl, "name"),
tag_ids: yup.array(yup.string().required()).defined(),
ignore_auto_tag: yup.boolean().defined(), ignore_auto_tag: yup.boolean().defined(),
stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(), stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),
image: yup.string().nullable().optional(), image: yup.string().nullable().optional(),
@ -62,6 +64,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
details: studio.details ?? "", details: studio.details ?? "",
parent_id: studio.parent_studio?.id ?? null, parent_id: studio.parent_studio?.id ?? null,
aliases: studio.aliases ?? [], aliases: studio.aliases ?? [],
tag_ids: (studio.tags ?? []).map((t) => t.id),
ignore_auto_tag: studio.ignore_auto_tag ?? false, ignore_auto_tag: studio.ignore_auto_tag ?? false,
stash_ids: getStashIDs(studio.stash_ids), stash_ids: getStashIDs(studio.stash_ids),
}; };
@ -75,6 +78,10 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
onSubmit: (values) => onSave(schema.cast(values)), onSubmit: (values) => onSave(schema.cast(values)),
}); });
const { tagsControl } = useTagsEdit(studio.tags, (ids) =>
formik.setFieldValue("tag_ids", ids)
);
function onSetParentStudio(item: Studio | null) { function onSetParentStudio(item: Studio | null) {
setParentStudio(item); setParentStudio(item);
formik.setFieldValue("parent_id", item ? item.id : null); formik.setFieldValue("parent_id", item ? item.id : null);
@ -157,6 +164,11 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
return renderField("parent_id", title, control); return renderField("parent_id", title, control);
} }
function renderTagsField() {
const title = intl.formatMessage({ id: "tags" });
return renderField("tag_ids", title, tagsControl());
}
if (isLoading) return <LoadingIndicator />; if (isLoading) return <LoadingIndicator />;
return ( return (
@ -178,6 +190,7 @@ export const StudioEditPanel: React.FC<IStudioEditPanel> = ({
{renderInputField("url")} {renderInputField("url")}
{renderInputField("details", "textarea")} {renderInputField("details", "textarea")}
{renderParentStudioField()} {renderParentStudioField()}
{renderTagsField()}
{renderStashIDsField("stash_ids", "studios")} {renderStashIDsField("stash_ids", "studios")}
<hr /> <hr />
{renderInputField("ignore_auto_tag", "checkbox")} {renderInputField("ignore_auto_tag", "checkbox")}

View file

@ -223,6 +223,19 @@ export const TagCard: React.FC<IProps> = ({
); );
} }
function maybeRenderStudiosPopoverButton() {
if (!tag.studio_count) return;
return (
<PopoverCountButton
className="studio-count"
type="studio"
count={tag.studio_count}
url={NavUtils.makeTagStudiosUrl(tag)}
/>
);
}
function maybeRenderMoviesPopoverButton() { function maybeRenderMoviesPopoverButton() {
if (!tag.movie_count) return; if (!tag.movie_count) return;
@ -248,6 +261,7 @@ export const TagCard: React.FC<IProps> = ({
{maybeRenderMoviesPopoverButton()} {maybeRenderMoviesPopoverButton()}
{maybeRenderSceneMarkersPopoverButton()} {maybeRenderSceneMarkersPopoverButton()}
{maybeRenderPerformersPopoverButton()} {maybeRenderPerformersPopoverButton()}
{maybeRenderStudiosPopoverButton()}
</ButtonGroup> </ButtonGroup>
</> </>
); );

View file

@ -26,6 +26,7 @@ import { TagScenesPanel } from "./TagScenesPanel";
import { TagMarkersPanel } from "./TagMarkersPanel"; import { TagMarkersPanel } from "./TagMarkersPanel";
import { TagImagesPanel } from "./TagImagesPanel"; import { TagImagesPanel } from "./TagImagesPanel";
import { TagPerformersPanel } from "./TagPerformersPanel"; import { TagPerformersPanel } from "./TagPerformersPanel";
import { TagStudiosPanel } from "./TagStudiosPanel";
import { TagGalleriesPanel } from "./TagGalleriesPanel"; import { TagGalleriesPanel } from "./TagGalleriesPanel";
import { CompressedTagDetailsPanel, TagDetailsPanel } from "./TagDetailsPanel"; import { CompressedTagDetailsPanel, TagDetailsPanel } from "./TagDetailsPanel";
import { TagEditPanel } from "./TagEditPanel"; import { TagEditPanel } from "./TagEditPanel";
@ -61,6 +62,7 @@ const validTabs = [
"movies", "movies",
"markers", "markers",
"performers", "performers",
"studios",
] as const; ] as const;
type TabKey = (typeof validTabs)[number]; type TabKey = (typeof validTabs)[number];
@ -109,6 +111,8 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
(showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0; (showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0;
const performerCount = const performerCount =
(showAllCounts ? tag.performer_count_all : tag.performer_count) ?? 0; (showAllCounts ? tag.performer_count_all : tag.performer_count) ?? 0;
const studioCount =
(showAllCounts ? tag.studio_count_all : tag.studio_count) ?? 0;
const populatedDefaultTab = useMemo(() => { const populatedDefaultTab = useMemo(() => {
let ret: TabKey = "scenes"; let ret: TabKey = "scenes";
@ -123,6 +127,8 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
ret = "markers"; ret = "markers";
} else if (performerCount != 0) { } else if (performerCount != 0) {
ret = "performers"; ret = "performers";
} else if (studioCount != 0) {
ret = "studios";
} }
} }
@ -133,6 +139,7 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
galleryCount, galleryCount,
sceneMarkerCount, sceneMarkerCount,
performerCount, performerCount,
studioCount,
movieCount, movieCount,
]); ]);
@ -521,6 +528,21 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
> >
<TagPerformersPanel active={tabKey === "performers"} tag={tag} /> <TagPerformersPanel active={tabKey === "performers"} tag={tag} />
</Tab> </Tab>
<Tab
eventKey="studios"
title={
<>
{intl.formatMessage({ id: "studios" })}
<Counter
abbreviateCounter={abbreviateCounter}
count={studioCount}
hideZero
/>
</>
}
>
<TagStudiosPanel active={tabKey === "studios"} tag={tag} />
</Tab>
</Tabs> </Tabs>
); );

View file

@ -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<ITagStudiosPanel> = ({
active,
tag,
}) => {
const filterHook = useTagFilterHook(tag);
return <StudioList filterHook={filterHook} alterQuery={active} />;
};

View file

@ -1376,6 +1376,7 @@
"status": "Status: {statusText}", "status": "Status: {statusText}",
"studio": "Studio", "studio": "Studio",
"studio_and_parent": "Studio & Parent", "studio_and_parent": "Studio & Parent",
"studio_count": "Studio Count",
"studio_depth": "Levels (empty for all)", "studio_depth": "Levels (empty for all)",
"studio_tagger": { "studio_tagger": {
"add_new_studios": "Add New Studios", "add_new_studios": "Add New Studios",
@ -1415,6 +1416,7 @@
"update_studios": "Update Studios", "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." "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", "studios": "Studios",
"sub_tag_count": "Sub-Tag Count", "sub_tag_count": "Sub-Tag Count",
"sub_tag_of": "Sub-tag of {parent}", "sub_tag_of": "Sub-tag of {parent}",

View file

@ -55,6 +55,13 @@ export const PerformerTagsCriterionOption = new BaseTagsCriterionOption(
withoutEqualsModifierOptions 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( export const ParentTagsCriterionOption = new BaseTagsCriterionOption(
"parent_tags", "parent_tags",
"parents", "parents",

View file

@ -14,6 +14,7 @@ import { ScenesCriterionOption } from "./criteria/scenes";
import { StudiosCriterionOption } from "./criteria/studios"; import { StudiosCriterionOption } from "./criteria/studios";
import { import {
PerformerTagsCriterionOption, PerformerTagsCriterionOption,
// StudioTagsCriterionOption,
TagsCriterionOption, TagsCriterionOption,
} from "./criteria/tags"; } from "./criteria/tags";
import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; import { ListFilterOptions, MediaSortByOptions } from "./filter-options";
@ -62,6 +63,7 @@ const criterionOptions = [
createMandatoryNumberCriterionOption("performer_age"), createMandatoryNumberCriterionOption("performer_age"),
PerformerFavoriteCriterionOption, PerformerFavoriteCriterionOption,
createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("image_count"),
// StudioTagsCriterionOption,
ScenesCriterionOption, ScenesCriterionOption,
StudiosCriterionOption, StudiosCriterionOption,
createStringCriterionOption("url"), createStringCriterionOption("url"),

View file

@ -16,6 +16,7 @@ import { OrientationCriterionOption } from "./criteria/orientation";
import { StudiosCriterionOption } from "./criteria/studios"; import { StudiosCriterionOption } from "./criteria/studios";
import { import {
PerformerTagsCriterionOption, PerformerTagsCriterionOption,
// StudioTagsCriterionOption,
TagsCriterionOption, TagsCriterionOption,
} from "./criteria/tags"; } from "./criteria/tags";
import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; import { ListFilterOptions, MediaSortByOptions } from "./filter-options";
@ -54,6 +55,7 @@ const criterionOptions = [
createMandatoryNumberCriterionOption("performer_count"), createMandatoryNumberCriterionOption("performer_count"),
createMandatoryNumberCriterionOption("performer_age"), createMandatoryNumberCriterionOption("performer_age"),
PerformerFavoriteCriterionOption, PerformerFavoriteCriterionOption,
// StudioTagsCriterionOption,
StudiosCriterionOption, StudiosCriterionOption,
createStringCriterionOption("url"), createStringCriterionOption("url"),
createDateCriterionOption("date"), createDateCriterionOption("date"),

View file

@ -11,6 +11,7 @@ import { PerformersCriterionOption } from "./criteria/performers";
import { ListFilterOptions } from "./filter-options"; import { ListFilterOptions } from "./filter-options";
import { DisplayMode } from "./types"; import { DisplayMode } from "./types";
import { RatingCriterionOption } from "./criteria/rating"; import { RatingCriterionOption } from "./criteria/rating";
// import { StudioTagsCriterionOption } from "./criteria/tags";
import { TagsCriterionOption } from "./criteria/tags"; import { TagsCriterionOption } from "./criteria/tags";
const defaultSortBy = "name"; const defaultSortBy = "name";
@ -32,6 +33,7 @@ const sortByOptions = [
]); ]);
const displayModeOptions = [DisplayMode.Grid]; const displayModeOptions = [DisplayMode.Grid];
const criterionOptions = [ const criterionOptions = [
// StudioTagsCriterionOption,
StudiosCriterionOption, StudiosCriterionOption,
MovieIsMissingCriterionOption, MovieIsMissingCriterionOption,
createStringCriterionOption("url"), createStringCriterionOption("url"),

View file

@ -17,6 +17,7 @@ import { StudiosCriterionOption } from "./criteria/studios";
import { InteractiveCriterionOption } from "./criteria/interactive"; import { InteractiveCriterionOption } from "./criteria/interactive";
import { import {
PerformerTagsCriterionOption, PerformerTagsCriterionOption,
// StudioTagsCriterionOption,
TagsCriterionOption, TagsCriterionOption,
} from "./criteria/tags"; } from "./criteria/tags";
import { ListFilterOptions, MediaSortByOptions } from "./filter-options"; import { ListFilterOptions, MediaSortByOptions } from "./filter-options";
@ -99,6 +100,7 @@ const criterionOptions = [
createMandatoryNumberCriterionOption("performer_count"), createMandatoryNumberCriterionOption("performer_count"),
createMandatoryNumberCriterionOption("performer_age"), createMandatoryNumberCriterionOption("performer_age"),
PerformerFavoriteCriterionOption, PerformerFavoriteCriterionOption,
// StudioTagsCriterionOption,
StudiosCriterionOption, StudiosCriterionOption,
MoviesCriterionOption, MoviesCriterionOption,
GalleriesCriterionOption, GalleriesCriterionOption,

View file

@ -10,11 +10,12 @@ import { StudioIsMissingCriterionOption } from "./criteria/is-missing";
import { RatingCriterionOption } from "./criteria/rating"; import { RatingCriterionOption } from "./criteria/rating";
import { StashIDCriterionOption } from "./criteria/stash-ids"; import { StashIDCriterionOption } from "./criteria/stash-ids";
import { ParentStudiosCriterionOption } from "./criteria/studios"; import { ParentStudiosCriterionOption } from "./criteria/studios";
import { TagsCriterionOption } from "./criteria/tags";
import { ListFilterOptions } from "./filter-options"; import { ListFilterOptions } from "./filter-options";
import { DisplayMode } from "./types"; import { DisplayMode } from "./types";
const defaultSortBy = "name"; const defaultSortBy = "name";
const sortByOptions = ["name", "random", "rating"] const sortByOptions = ["name", "tag_count", "random", "rating"]
.map(ListFilterOptions.createSortBy) .map(ListFilterOptions.createSortBy)
.concat([ .concat([
{ {
@ -42,8 +43,10 @@ const criterionOptions = [
createStringCriterionOption("details"), createStringCriterionOption("details"),
ParentStudiosCriterionOption, ParentStudiosCriterionOption,
StudioIsMissingCriterionOption, StudioIsMissingCriterionOption,
TagsCriterionOption,
RatingCriterionOption, RatingCriterionOption,
createBooleanCriterionOption("ignore_auto_tag"), createBooleanCriterionOption("ignore_auto_tag"),
createMandatoryNumberCriterionOption("tag_count"),
createMandatoryNumberCriterionOption("scene_count"), createMandatoryNumberCriterionOption("scene_count"),
createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("image_count"),
createMandatoryNumberCriterionOption("gallery_count"), createMandatoryNumberCriterionOption("gallery_count"),

View file

@ -43,6 +43,10 @@ const sortByOptions = ["name", "random"]
messageID: "marker_count", messageID: "marker_count",
value: "scene_markers_count", value: "scene_markers_count",
}, },
{
messageID: "studio_count",
value: "studios_count",
},
]); ]);
const displayModeOptions = [DisplayMode.Grid, DisplayMode.List]; const displayModeOptions = [DisplayMode.Grid, DisplayMode.List];
@ -57,6 +61,7 @@ const criterionOptions = [
createMandatoryNumberCriterionOption("image_count"), createMandatoryNumberCriterionOption("image_count"),
createMandatoryNumberCriterionOption("gallery_count"), createMandatoryNumberCriterionOption("gallery_count"),
createMandatoryNumberCriterionOption("performer_count"), createMandatoryNumberCriterionOption("performer_count"),
createMandatoryNumberCriterionOption("studio_count"),
createMandatoryNumberCriterionOption("movie_count"), createMandatoryNumberCriterionOption("movie_count"),
createMandatoryNumberCriterionOption("marker_count"), createMandatoryNumberCriterionOption("marker_count"),
ParentTagsCriterionOption, ParentTagsCriterionOption,

View file

@ -142,6 +142,7 @@ export type CriterionType =
| "tags" | "tags"
| "scene_tags" | "scene_tags"
| "performer_tags" | "performer_tags"
| "studio_tags"
| "tag_count" | "tag_count"
| "performers" | "performers"
| "studios" | "studios"
@ -172,6 +173,7 @@ export type CriterionType =
| "image_count" | "image_count"
| "gallery_count" | "gallery_count"
| "performer_count" | "performer_count"
| "studio_count"
| "movie_count" | "movie_count"
| "death_year" | "death_year"
| "url" | "url"

View file

@ -263,7 +263,7 @@ const makeChildTagsUrl = (tag: Partial<GQL.TagDataFragment>) => {
}; };
function makeTagFilter(mode: GQL.FilterMode, tag: INamedObject) { 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); const criterion = new TagsCriterion(TagsCriterionOption);
criterion.value = { criterion.value = {
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }], 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)}`; return `/performers?${makeTagFilter(GQL.FilterMode.Performers, tag)}`;
}; };
const makeTagStudiosUrl = (tag: INamedObject) => {
return `/studios?${makeTagFilter(GQL.FilterMode.Studios, tag)}`;
};
const makeTagSceneMarkersUrl = (tag: INamedObject) => { const makeTagSceneMarkersUrl = (tag: INamedObject) => {
return `/scenes/markers?${makeTagFilter(GQL.FilterMode.SceneMarkers, tag)}`; return `/scenes/markers?${makeTagFilter(GQL.FilterMode.SceneMarkers, tag)}`;
}; };
@ -410,6 +414,7 @@ const NavUtils = {
makeTagSceneMarkersUrl, makeTagSceneMarkersUrl,
makeTagScenesUrl, makeTagScenesUrl,
makeTagPerformersUrl, makeTagPerformersUrl,
makeTagStudiosUrl,
makeTagGalleriesUrl, makeTagGalleriesUrl,
makeTagImagesUrl, makeTagImagesUrl,
makeTagMoviesUrl, makeTagMoviesUrl,