mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 16:34:02 +01:00
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:
parent
f26766033e
commit
b3d35dfae4
51 changed files with 844 additions and 13 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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!]!
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
9
pkg/sqlite/migrations/63_studio_tags.up.sql
Normal file
9
pkg/sqlite/migrations/63_studio_tags.up.sql
Normal 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`);
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
×tampCriterionHandler{studioFilter.CreatedAt, studioTable + ".created_at", nil},
|
×tampCriterionHandler{studioFilter.CreatedAt, studioTable + ".created_at", nil},
|
||||||
×tampCriterionHandler{studioFilter.UpdatedAt, studioTable + ".updated_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 {
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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{}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,4 +12,8 @@ fragment SlimStudioData on Studio {
|
||||||
details
|
details
|
||||||
rating100
|
rating100
|
||||||
aliases
|
aliases
|
||||||
|
tags {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ fragment StudioData on Studio {
|
||||||
rating100
|
rating100
|
||||||
favorite
|
favorite
|
||||||
aliases
|
aliases
|
||||||
|
tags {
|
||||||
|
...SlimTagData
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment SelectStudioData on Studio {
|
fragment SelectStudioData on Studio {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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":
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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="tags"
|
||||||
|
value={renderTagsField()}
|
||||||
|
fullWidth={fullWidth}
|
||||||
|
/>
|
||||||
<DetailItem
|
<DetailItem
|
||||||
id="stash_ids"
|
id="stash_ids"
|
||||||
value={renderStashIDs()}
|
value={renderStashIDs()}
|
||||||
fullWidth={fullWidth}
|
fullWidth={fullWidth}
|
||||||
/>
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
17
ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx
Normal file
17
ui/v2.5/src/components/Tags/TagDetails/TagStudiosPanel.tsx
Normal 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} />;
|
||||||
|
};
|
||||||
|
|
@ -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}",
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue