Performer tags (#1132)

* Add scraping support for performer tags
* Add performer count to tag cards
* Refactor sqlite test setup
* Add performer tag filtering in gallery and image
* Add bulk update performer
* Add Performers tab to tag page
* Add count filters and sort bys for tags
* Move scene count to icon in performer card #1148
This commit is contained in:
WithoutPants
2021-03-10 12:25:51 +11:00
committed by GitHub
parent 698e21a04f
commit a0676d5c30
65 changed files with 2548 additions and 475 deletions

View File

@@ -3,6 +3,11 @@ fragment SlimPerformerData on Performer {
name
gender
image_path
favorite
tags {
id
name
}
stash_ids {
endpoint
stash_id

View File

@@ -20,6 +20,11 @@ fragment PerformerData on Performer {
favorite
image_path
scene_count
tags {
...TagData
}
stash_ids {
stash_id
endpoint

View File

@@ -15,6 +15,9 @@ fragment ScrapedPerformerData on ScrapedPerformer {
tattoos
piercings
aliases
tags {
...ScrapedSceneTagData
}
image
}
@@ -36,6 +39,9 @@ fragment ScrapedScenePerformerData on ScrapedScenePerformer {
tattoos
piercings
aliases
tags {
...ScrapedSceneTagData
}
remote_site_id
images
}

View File

@@ -4,4 +4,5 @@ fragment TagData on Tag {
image_path
scene_count
scene_marker_count
performer_count
}

View File

@@ -16,6 +16,7 @@ mutation PerformerCreate(
$twitter: String,
$instagram: String,
$favorite: Boolean,
$tag_ids: [ID!],
$stash_ids: [StashIDInput!],
$image: String) {
@@ -37,6 +38,7 @@ mutation PerformerCreate(
twitter: $twitter,
instagram: $instagram,
favorite: $favorite,
tag_ids: $tag_ids,
stash_ids: $stash_ids,
image: $image
}) {
@@ -52,6 +54,14 @@ mutation PerformerUpdate(
}
}
mutation BulkPerformerUpdate(
$input: BulkPerformerUpdateInput!) {
bulkPerformerUpdate(input: $input) {
...PerformerData
}
}
mutation PerformerDestroy($id: ID!) {
performerDestroy(input: { id: $id })
}

View File

@@ -174,6 +174,7 @@ type Mutation {
performerUpdate(input: PerformerUpdateInput!): Performer
performerDestroy(input: PerformerDestroyInput!): Boolean!
performersDestroy(ids: [ID!]!): Boolean!
bulkPerformerUpdate(input: BulkPerformerUpdateInput!): [Performer!]
studioCreate(input: StudioCreateInput!): Studio
studioUpdate(input: StudioUpdateInput!): Studio

View File

@@ -59,6 +59,8 @@ input PerformerFilterType {
gender: GenderCriterionInput
"""Filter to only include performers missing this property"""
is_missing: String
"""Filter to only include performers with these tags"""
tags: MultiCriterionInput
"""Filter by StashID"""
stash_id: String
}
@@ -101,6 +103,8 @@ input SceneFilterType {
movies: MultiCriterionInput
"""Filter to only include scenes with these tags"""
tags: MultiCriterionInput
"""Filter to only include scenes with performers with these tags"""
performer_tags: MultiCriterionInput
"""Filter to only include scenes with these performers"""
performers: MultiCriterionInput
"""Filter by StashID"""
@@ -136,11 +140,13 @@ input GalleryFilterType {
organized: Boolean
"""Filter by average image resolution"""
average_resolution: ResolutionEnum
"""Filter to only include scenes with this studio"""
"""Filter to only include galleries with this studio"""
studios: MultiCriterionInput
"""Filter to only include scenes with these tags"""
"""Filter to only include galleries with these tags"""
tags: MultiCriterionInput
"""Filter to only include scenes with these performers"""
"""Filter to only include galleries with performers with these tags"""
performer_tags: MultiCriterionInput
"""Filter to only include galleries with these performers"""
performers: MultiCriterionInput
"""Filter by number of images in this gallery"""
image_count: IntCriterionInput
@@ -153,6 +159,15 @@ input TagFilterType {
"""Filter by number of scenes with this tag"""
scene_count: IntCriterionInput
"""Filter by number of images with this tag"""
image_count: IntCriterionInput
"""Filter by number of galleries with this tag"""
gallery_count: IntCriterionInput
"""Filter by number of performers with this tag"""
performer_count: IntCriterionInput
"""Filter by number of markers with this tag"""
marker_count: IntCriterionInput
}
@@ -174,6 +189,8 @@ input ImageFilterType {
studios: MultiCriterionInput
"""Filter to only include images with these tags"""
tags: MultiCriterionInput
"""Filter to only include images with performers with these tags"""
performer_tags: MultiCriterionInput
"""Filter to only include images with these performers"""
performers: MultiCriterionInput
"""Filter to only include images with these galleries"""

View File

@@ -27,6 +27,7 @@ type Performer {
piercings: String
aliases: String
favorite: Boolean!
tags: [Tag!]!
image_path: String # Resolver
scene_count: Int # Resolver
@@ -52,6 +53,7 @@ input PerformerCreateInput {
twitter: String
instagram: String
favorite: Boolean
tag_ids: [ID!]
"""This should be base64 encoded"""
image: String
stash_ids: [StashIDInput!]
@@ -76,11 +78,34 @@ input PerformerUpdateInput {
twitter: String
instagram: String
favorite: Boolean
tag_ids: [ID!]
"""This should be base64 encoded"""
image: String
stash_ids: [StashIDInput!]
}
input BulkPerformerUpdateInput {
clientMutationId: String
ids: [ID!]
url: String
gender: GenderEnum
birthdate: String
ethnicity: String
country: String
eye_color: String
height: String
measurements: String
fake_tits: String
career_length: String
tattoos: String
piercings: String
aliases: String
twitter: String
instagram: String
favorite: Boolean
tag_ids: BulkUpdateIds
}
input PerformerDestroyInput {
id: ID!
}

View File

@@ -16,6 +16,8 @@ type ScrapedPerformer {
tattoos: String
piercings: String
aliases: String
# Should be ScrapedPerformerTag - but would be identical types
tags: [ScrapedSceneTag!]
"""This should be base64 encoded"""
image: String
@@ -39,5 +41,6 @@ input ScrapedPerformerInput {
piercings: String
aliases: String
# not including tags for the input
# not including image for the input
}

View File

@@ -45,6 +45,7 @@ type ScrapedScenePerformer {
tattoos: String
piercings: String
aliases: String
tags: [ScrapedSceneTag!]
remote_site_id: String
images: [String!]

View File

@@ -5,6 +5,7 @@ type Tag {
image_path: String # Resolver
scene_count: Int # Resolver
scene_marker_count: Int # Resolver
performer_count: Int
}
input TagCreateInput {

View File

@@ -138,6 +138,17 @@ func (r *performerResolver) ImagePath(ctx context.Context, obj *models.Performer
return &imagePath, nil
}
func (r *performerResolver) Tags(ctx context.Context, obj *models.Performer) (ret []*models.Tag, err error) {
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
ret, err = repo.Tag().FindByPerformerID(obj.ID)
return err
}); err != nil {
return nil, err
}
return ret, nil
}
func (r *performerResolver) SceneCount(ctx context.Context, obj *models.Performer) (ret *int, err error) {
var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {

View File

@@ -31,6 +31,18 @@ func (r *tagResolver) SceneMarkerCount(ctx context.Context, obj *models.Tag) (re
return &count, err
}
func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag) (ret *int, err error) {
var count int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error {
count, err = repo.Performer().CountByTagID(obj.ID)
return err
}); err != nil {
return nil, err
}
return &count, err
}
func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
imagePath := urlbuilders.NewTagURLBuilder(baseURL, obj.ID).GetTagImageURL()

View File

@@ -94,6 +94,12 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
return err
}
if len(input.TagIds) > 0 {
if err := r.updatePerformerTags(qb, performer.ID, input.TagIds); err != nil {
return err
}
}
// update image table
if len(imageData) > 0 {
if err := qb.UpdateImage(performer.ID, imageData); err != nil {
@@ -183,6 +189,13 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
return err
}
// Save the tags
if translator.hasField("tag_ids") {
if err := r.updatePerformerTags(qb, performer.ID, input.TagIds); err != nil {
return err
}
}
// update image table
if len(imageData) > 0 {
if err := qb.UpdateImage(performer.ID, imageData); err != nil {
@@ -211,6 +224,92 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
return performer, nil
}
func (r *mutationResolver) updatePerformerTags(qb models.PerformerReaderWriter, performerID int, tagsIDs []string) error {
ids, err := utils.StringSliceToIntSlice(tagsIDs)
if err != nil {
return err
}
return qb.UpdateTags(performerID, ids)
}
func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input models.BulkPerformerUpdateInput) ([]*models.Performer, error) {
performerIDs, err := utils.StringSliceToIntSlice(input.Ids)
if err != nil {
return nil, err
}
// Populate performer from the input
updatedTime := time.Now()
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
updatedPerformer := models.PerformerPartial{
UpdatedAt: &models.SQLiteTimestamp{Timestamp: updatedTime},
}
updatedPerformer.URL = translator.nullString(input.URL, "url")
updatedPerformer.Birthdate = translator.sqliteDate(input.Birthdate, "birthdate")
updatedPerformer.Ethnicity = translator.nullString(input.Ethnicity, "ethnicity")
updatedPerformer.Country = translator.nullString(input.Country, "country")
updatedPerformer.EyeColor = translator.nullString(input.EyeColor, "eye_color")
updatedPerformer.Height = translator.nullString(input.Height, "height")
updatedPerformer.Measurements = translator.nullString(input.Measurements, "measurements")
updatedPerformer.FakeTits = translator.nullString(input.FakeTits, "fake_tits")
updatedPerformer.CareerLength = translator.nullString(input.CareerLength, "career_length")
updatedPerformer.Tattoos = translator.nullString(input.Tattoos, "tattoos")
updatedPerformer.Piercings = translator.nullString(input.Piercings, "piercings")
updatedPerformer.Aliases = translator.nullString(input.Aliases, "aliases")
updatedPerformer.Twitter = translator.nullString(input.Twitter, "twitter")
updatedPerformer.Instagram = translator.nullString(input.Instagram, "instagram")
updatedPerformer.Favorite = translator.nullBool(input.Favorite, "favorite")
if translator.hasField("gender") {
if input.Gender != nil {
updatedPerformer.Gender = &sql.NullString{String: input.Gender.String(), Valid: true}
} else {
updatedPerformer.Gender = &sql.NullString{String: "", Valid: false}
}
}
ret := []*models.Performer{}
// Start the transaction and save the scene marker
if err := r.withTxn(ctx, func(repo models.Repository) error {
qb := repo.Performer()
for _, performerID := range performerIDs {
updatedPerformer.ID = performerID
performer, err := qb.Update(updatedPerformer)
if err != nil {
return err
}
ret = append(ret, performer)
// Save the tags
if translator.hasField("tag_ids") {
tagIDs, err := adjustTagIDs(qb, performerID, *input.TagIds)
if err != nil {
return err
}
if err := qb.UpdateTags(performerID, tagIDs); err != nil {
return err
}
}
}
return nil
}); err != nil {
return nil, err
}
return ret, nil
}
func (r *mutationResolver) PerformerDestroy(ctx context.Context, input models.PerformerDestroyInput) (bool, error) {
id, err := strconv.Atoi(input.ID)
if err != nil {

View File

@@ -253,7 +253,7 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input models.Bul
// Save the tags
if translator.hasField("tag_ids") {
tagIDs, err := adjustSceneTagIDs(qb, sceneID, *input.TagIds)
tagIDs, err := adjustTagIDs(qb, sceneID, *input.TagIds)
if err != nil {
return err
}
@@ -330,7 +330,11 @@ func adjustScenePerformerIDs(qb models.SceneReader, sceneID int, ids models.Bulk
return adjustIDs(ret, ids), nil
}
func adjustSceneTagIDs(qb models.SceneReader, sceneID int, ids models.BulkUpdateIds) (ret []int, err error) {
type tagIDsGetter interface {
GetTagIDs(id int) ([]int, error)
}
func adjustTagIDs(qb tagIDsGetter, sceneID int, ids models.BulkUpdateIds) (ret []int, err error) {
ret, err = qb.GetTagIDs(sceneID)
if err != nil {
return nil, err

View File

@@ -21,7 +21,7 @@ import (
var DB *sqlx.DB
var dbPath string
var appSchemaVersion uint = 18
var appSchemaVersion uint = 19
var databaseSchemaVersion uint
const sqlite3Driver = "sqlite3ex"

View File

@@ -0,0 +1,9 @@
CREATE TABLE `performers_tags` (
`performer_id` integer NOT NULL,
`tag_id` integer NOT NULL,
foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE,
foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE
);
CREATE INDEX `index_performers_tags_on_tag_id` on `performers_tags` (`tag_id`);
CREATE INDEX `index_performers_tags_on_performer_id` on `performers_tags` (`performer_id`);

View File

@@ -2,9 +2,9 @@ package jsonschema
import (
"fmt"
"github.com/json-iterator/go"
"os"
jsoniter "github.com/json-iterator/go"
"github.com/stashapp/stash/pkg/models"
)
@@ -26,6 +26,7 @@ type Performer struct {
Piercings string `json:"piercings,omitempty"`
Aliases string `json:"aliases,omitempty"`
Favorite bool `json:"favorite,omitempty"`
Tags []string `json:"tags,omitempty"`
Image string `json:"image,omitempty"`
CreatedAt models.JSONTime `json:"created_at,omitempty"`
UpdatedAt models.JSONTime `json:"updated_at,omitempty"`

View File

@@ -725,6 +725,18 @@ func (t *ExportTask) exportPerformer(wg *sync.WaitGroup, jobChan <-chan *models.
continue
}
tags, err := repo.Tag().FindByPerformerID(p.ID)
if err != nil {
logger.Errorf("[performers] <%s> error getting performer tags: %s", p.Checksum, err.Error())
continue
}
newPerformerJSON.Tags = tag.GetNames(tags)
if t.includeDependencies {
t.tags.IDs = utils.IntAppendUniques(t.tags.IDs, tag.GetIDs(tags))
}
performerJSON, err := t.json.getPerformer(p.Checksum)
if err != nil {
logger.Debugf("[performers] error reading performer json: %s", err.Error())

View File

@@ -300,8 +300,8 @@ func (_m *GalleryReaderWriter) GetPerformerIDs(galleryID int) ([]int, error) {
return r0, r1
}
// GetTagIDs provides a mock function with given fields: galleryID
func (_m *GalleryReaderWriter) GetTagIDs(galleryID int) ([]int, error) {
// GetSceneIDs provides a mock function with given fields: galleryID
func (_m *GalleryReaderWriter) GetSceneIDs(galleryID int) ([]int, error) {
ret := _m.Called(galleryID)
var r0 []int
@@ -323,8 +323,8 @@ func (_m *GalleryReaderWriter) GetTagIDs(galleryID int) ([]int, error) {
return r0, r1
}
// GetSceneIDs provides a mock function with given fields: galleryID
func (_m *GalleryReaderWriter) GetSceneIDs(galleryID int) ([]int, error) {
// GetTagIDs provides a mock function with given fields: galleryID
func (_m *GalleryReaderWriter) GetTagIDs(galleryID int) ([]int, error) {
ret := _m.Called(galleryID)
var r0 []int
@@ -464,20 +464,6 @@ func (_m *GalleryReaderWriter) UpdatePerformers(galleryID int, performerIDs []in
return r0
}
// UpdateTags provides a mock function with given fields: galleryID, tagIDs
func (_m *GalleryReaderWriter) UpdateTags(galleryID int, tagIDs []int) error {
ret := _m.Called(galleryID, tagIDs)
var r0 error
if rf, ok := ret.Get(0).(func(int, []int) error); ok {
r0 = rf(galleryID, tagIDs)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateScenes provides a mock function with given fields: galleryID, sceneIDs
func (_m *GalleryReaderWriter) UpdateScenes(galleryID int, sceneIDs []int) error {
ret := _m.Called(galleryID, sceneIDs)
@@ -491,3 +477,17 @@ func (_m *GalleryReaderWriter) UpdateScenes(galleryID int, sceneIDs []int) error
return r0
}
// UpdateTags provides a mock function with given fields: galleryID, tagIDs
func (_m *GalleryReaderWriter) UpdateTags(galleryID int, tagIDs []int) error {
ret := _m.Called(galleryID, tagIDs)
var r0 error
if rf, ok := ret.Get(0).(func(int, []int) error); ok {
r0 = rf(galleryID, tagIDs)
} else {
r0 = ret.Error(0)
}
return r0
}

View File

@@ -79,6 +79,27 @@ func (_m *PerformerReaderWriter) Count() (int, error) {
return r0, r1
}
// CountByTagID provides a mock function with given fields: tagID
func (_m *PerformerReaderWriter) CountByTagID(tagID int) (int, error) {
ret := _m.Called(tagID)
var r0 int
if rf, ok := ret.Get(0).(func(int) int); ok {
r0 = rf(tagID)
} else {
r0 = ret.Get(0).(int)
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(tagID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Create provides a mock function with given fields: newPerformer
func (_m *PerformerReaderWriter) Create(newPerformer models.Performer) (*models.Performer, error) {
ret := _m.Called(newPerformer)
@@ -337,6 +358,29 @@ func (_m *PerformerReaderWriter) GetStashIDs(performerID int) ([]*models.StashID
return r0, r1
}
// GetTagIDs provides a mock function with given fields: sceneID
func (_m *PerformerReaderWriter) GetTagIDs(sceneID int) ([]int, error) {
ret := _m.Called(sceneID)
var r0 []int
if rf, ok := ret.Get(0).(func(int) []int); ok {
r0 = rf(sceneID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]int)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(sceneID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Query provides a mock function with given fields: performerFilter, findFilter
func (_m *PerformerReaderWriter) Query(performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) {
ret := _m.Called(performerFilter, findFilter)
@@ -440,3 +484,17 @@ func (_m *PerformerReaderWriter) UpdateStashIDs(performerID int, stashIDs []mode
return r0
}
// UpdateTags provides a mock function with given fields: sceneID, tagIDs
func (_m *PerformerReaderWriter) UpdateTags(sceneID int, tagIDs []int) error {
ret := _m.Called(sceneID, tagIDs)
var r0 error
if rf, ok := ret.Get(0).(func(int, []int) error); ok {
r0 = rf(sceneID, tagIDs)
} else {
r0 = ret.Error(0)
}
return r0
}

View File

@@ -300,6 +300,29 @@ func (_m *SceneReaderWriter) FindByChecksum(checksum string) (*models.Scene, err
return r0, r1
}
// FindByGalleryID provides a mock function with given fields: performerID
func (_m *SceneReaderWriter) FindByGalleryID(performerID int) ([]*models.Scene, error) {
ret := _m.Called(performerID)
var r0 []*models.Scene
if rf, ok := ret.Get(0).(func(int) []*models.Scene); ok {
r0 = rf(performerID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Scene)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(performerID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByMovieID provides a mock function with given fields: movieID
func (_m *SceneReaderWriter) FindByMovieID(movieID int) ([]*models.Scene, error) {
ret := _m.Called(movieID)
@@ -392,29 +415,6 @@ func (_m *SceneReaderWriter) FindByPerformerID(performerID int) ([]*models.Scene
return r0, r1
}
// FindByGalleryID provides a mock function with given fields: galleryID
func (_m *SceneReaderWriter) FindByGalleryID(galleryID int) ([]*models.Scene, error) {
ret := _m.Called(galleryID)
var r0 []*models.Scene
if rf, ok := ret.Get(0).(func(int) []*models.Scene); ok {
r0 = rf(galleryID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Scene)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(galleryID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindMany provides a mock function with given fields: ids
func (_m *SceneReaderWriter) FindMany(ids []int) ([]*models.Scene, error) {
ret := _m.Called(ids)
@@ -461,6 +461,29 @@ func (_m *SceneReaderWriter) GetCover(sceneID int) ([]byte, error) {
return r0, r1
}
// GetGalleryIDs provides a mock function with given fields: sceneID
func (_m *SceneReaderWriter) GetGalleryIDs(sceneID int) ([]int, error) {
ret := _m.Called(sceneID)
var r0 []int
if rf, ok := ret.Get(0).(func(int) []int); ok {
r0 = rf(sceneID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]int)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(sceneID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetMovies provides a mock function with given fields: sceneID
func (_m *SceneReaderWriter) GetMovies(sceneID int) ([]models.MoviesScenes, error) {
ret := _m.Called(sceneID)
@@ -507,8 +530,31 @@ func (_m *SceneReaderWriter) GetPerformerIDs(sceneID int) ([]int, error) {
return r0, r1
}
// GetGalleryIDs provides a mock function with given fields: sceneID
func (_m *SceneReaderWriter) GetGalleryIDs(sceneID int) ([]int, error) {
// GetStashIDs provides a mock function with given fields: sceneID
func (_m *SceneReaderWriter) GetStashIDs(sceneID int) ([]*models.StashID, error) {
ret := _m.Called(sceneID)
var r0 []*models.StashID
if rf, ok := ret.Get(0).(func(int) []*models.StashID); ok {
r0 = rf(sceneID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.StashID)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(sceneID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetTagIDs provides a mock function with given fields: sceneID
func (_m *SceneReaderWriter) GetTagIDs(sceneID int) ([]int, error) {
ret := _m.Called(sceneID)
var r0 []int
@@ -530,52 +576,6 @@ func (_m *SceneReaderWriter) GetGalleryIDs(sceneID int) ([]int, error) {
return r0, r1
}
// GetStashIDs provides a mock function with given fields: performerID
func (_m *SceneReaderWriter) GetStashIDs(performerID int) ([]*models.StashID, error) {
ret := _m.Called(performerID)
var r0 []*models.StashID
if rf, ok := ret.Get(0).(func(int) []*models.StashID); ok {
r0 = rf(performerID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.StashID)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(performerID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// GetTagIDs provides a mock function with given fields: imageID
func (_m *SceneReaderWriter) GetTagIDs(imageID int) ([]int, error) {
ret := _m.Called(imageID)
var r0 []int
if rf, ok := ret.Get(0).(func(int) []int); ok {
r0 = rf(imageID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]int)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(imageID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// IncrementOCounter provides a mock function with given fields: id
func (_m *SceneReaderWriter) IncrementOCounter(id int) (int, error) {
ret := _m.Called(id)
@@ -766,6 +766,20 @@ func (_m *SceneReaderWriter) UpdateFull(updatedScene models.Scene) (*models.Scen
return r0, r1
}
// UpdateGalleries provides a mock function with given fields: sceneID, galleryIDs
func (_m *SceneReaderWriter) UpdateGalleries(sceneID int, galleryIDs []int) error {
ret := _m.Called(sceneID, galleryIDs)
var r0 error
if rf, ok := ret.Get(0).(func(int, []int) error); ok {
r0 = rf(sceneID, galleryIDs)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateMovies provides a mock function with given fields: sceneID, movies
func (_m *SceneReaderWriter) UpdateMovies(sceneID int, movies []models.MoviesScenes) error {
ret := _m.Called(sceneID, movies)
@@ -794,20 +808,6 @@ func (_m *SceneReaderWriter) UpdatePerformers(sceneID int, performerIDs []int) e
return r0
}
// UpdateGalleries provides a mock function with given fields: sceneID, galleryIDs
func (_m *SceneReaderWriter) UpdateGalleries(sceneID int, galleryIDs []int) error {
ret := _m.Called(sceneID, galleryIDs)
var r0 error
if rf, ok := ret.Get(0).(func(int, []int) error); ok {
r0 = rf(sceneID, galleryIDs)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateStashIDs provides a mock function with given fields: sceneID, stashIDs
func (_m *SceneReaderWriter) UpdateStashIDs(sceneID int, stashIDs []models.StashID) error {
ret := _m.Called(sceneID, stashIDs)

View File

@@ -245,6 +245,29 @@ func (_m *TagReaderWriter) FindByNames(names []string, nocase bool) ([]*models.T
return r0, r1
}
// FindByPerformerID provides a mock function with given fields: performerID
func (_m *TagReaderWriter) FindByPerformerID(performerID int) ([]*models.Tag, error) {
ret := _m.Called(performerID)
var r0 []*models.Tag
if rf, ok := ret.Get(0).(func(int) []*models.Tag); ok {
r0 = rf(performerID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Tag)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(performerID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindBySceneID provides a mock function with given fields: sceneID
func (_m *TagReaderWriter) FindBySceneID(sceneID int) ([]*models.Tag, error) {
ret := _m.Called(sceneID)

View File

@@ -40,6 +40,7 @@ type ScrapedPerformer struct {
Tattoos *string `graphql:"tattoos" json:"tattoos"`
Piercings *string `graphql:"piercings" json:"piercings"`
Aliases *string `graphql:"aliases" json:"aliases"`
Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"`
Image *string `graphql:"image" json:"image"`
}
@@ -61,6 +62,7 @@ type ScrapedPerformerStash struct {
Tattoos *string `graphql:"tattoos" json:"tattoos"`
Piercings *string `graphql:"piercings" json:"piercings"`
Aliases *string `graphql:"aliases" json:"aliases"`
Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"`
}
type ScrapedScene struct {
@@ -123,6 +125,7 @@ type ScrapedScenePerformer struct {
Tattoos *string `graphql:"tattoos" json:"tattoos"`
Piercings *string `graphql:"piercings" json:"piercings"`
Aliases *string `graphql:"aliases" json:"aliases"`
Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"`
RemoteSiteID *string `graphql:"remote_site_id" json:"remote_site_id"`
Images []string `graphql:"images" json:"images"`
}

View File

@@ -8,12 +8,14 @@ type PerformerReader interface {
FindByImageID(imageID int) ([]*Performer, error)
FindByGalleryID(galleryID int) ([]*Performer, error)
FindByNames(names []string, nocase bool) ([]*Performer, error)
CountByTagID(tagID int) (int, error)
Count() (int, error)
All() ([]*Performer, error)
AllSlim() ([]*Performer, error)
Query(performerFilter *PerformerFilterType, findFilter *FindFilterType) ([]*Performer, int, error)
GetImage(performerID int) ([]byte, error)
GetStashIDs(performerID int) ([]*StashID, error)
GetTagIDs(sceneID int) ([]int, error)
}
type PerformerWriter interface {
@@ -24,6 +26,7 @@ type PerformerWriter interface {
UpdateImage(performerID int, image []byte) error
DestroyImage(performerID int) error
UpdateStashIDs(performerID int, stashIDs []StashID) error
UpdateTags(sceneID int, tagIDs []int) error
}
type PerformerReaderWriter interface {

View File

@@ -4,6 +4,7 @@ type TagReader interface {
Find(id int) (*Tag, error)
FindMany(ids []int) ([]*Tag, error)
FindBySceneID(sceneID int) ([]*Tag, error)
FindByPerformerID(performerID int) ([]*Tag, error)
FindBySceneMarkerID(sceneMarkerID int) ([]*Tag, error)
FindByImageID(imageID int) ([]*Tag, error)
FindByGalleryID(galleryID int) ([]*Tag, error)

View File

@@ -3,6 +3,7 @@ package performer
import (
"database/sql"
"fmt"
"strings"
"github.com/stashapp/stash/pkg/manager/jsonschema"
"github.com/stashapp/stash/pkg/models"
@@ -11,15 +12,24 @@ import (
type Importer struct {
ReaderWriter models.PerformerReaderWriter
TagWriter models.TagReaderWriter
Input jsonschema.Performer
MissingRefBehaviour models.ImportMissingRefEnum
ID int
performer models.Performer
imageData []byte
tags []*models.Tag
}
func (i *Importer) PreImport() error {
i.performer = performerJSONToPerformer(i.Input)
if err := i.populateTags(); err != nil {
return err
}
var err error
if len(i.Input.Image) > 0 {
_, i.imageData, err = utils.ProcessBase64Image(i.Input.Image)
@@ -31,7 +41,82 @@ func (i *Importer) PreImport() error {
return nil
}
func (i *Importer) populateTags() error {
if len(i.Input.Tags) > 0 {
tags, err := importTags(i.TagWriter, i.Input.Tags, i.MissingRefBehaviour)
if err != nil {
return err
}
i.tags = tags
}
return nil
}
func importTags(tagWriter models.TagReaderWriter, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) {
tags, err := tagWriter.FindByNames(names, false)
if err != nil {
return nil, err
}
var pluckedNames []string
for _, tag := range tags {
pluckedNames = append(pluckedNames, tag.Name)
}
missingTags := utils.StrFilter(names, func(name string) bool {
return !utils.StrInclude(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(tagWriter, missingTags)
if err != nil {
return nil, fmt.Errorf("error creating tags: %s", err.Error())
}
tags = append(tags, createdTags...)
}
// ignore if MissingRefBehaviour set to Ignore
}
return tags, nil
}
func createTags(tagWriter models.TagWriter, names []string) ([]*models.Tag, error) {
var ret []*models.Tag
for _, name := range names {
newTag := *models.NewTag(name)
created, err := tagWriter.Create(newTag)
if err != nil {
return nil, err
}
ret = append(ret, created)
}
return ret, nil
}
func (i *Importer) PostImport(id int) error {
if len(i.tags) > 0 {
var tagIDs []int
for _, t := range i.tags {
tagIDs = append(tagIDs, t.ID)
}
if err := i.ReaderWriter.UpdateTags(id, tagIDs); err != nil {
return fmt.Errorf("failed to associate tags: %s", err.Error())
}
}
if len(i.imageData) > 0 {
if err := i.ReaderWriter.UpdateImage(id, i.imageData); err != nil {
return fmt.Errorf("error setting performer image: %s", err.Error())

View File

@@ -3,6 +3,8 @@ package performer
import (
"errors"
"github.com/stretchr/testify/mock"
"github.com/stashapp/stash/pkg/manager/jsonschema"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
@@ -16,9 +18,15 @@ const invalidImage = "aW1hZ2VCeXRlcw&&"
const (
existingPerformerID = 100
existingTagID = 105
errTagsID = 106
existingPerformerName = "existingPerformerName"
performerNameErr = "performerNameErr"
existingTagName = "existingTagName"
existingTagErr = "existingTagErr"
missingTagName = "missingTagName"
)
func TestImporterName(t *testing.T) {
@@ -53,6 +61,91 @@ func TestImporterPreImport(t *testing.T) {
assert.Equal(t, expectedPerformer, i.performer)
}
func TestImporterPreImportWithTag(t *testing.T) {
tagReaderWriter := &mocks.TagReaderWriter{}
i := Importer{
TagWriter: tagReaderWriter,
MissingRefBehaviour: models.ImportMissingRefEnumFail,
Input: jsonschema.Performer{
Tags: []string{
existingTagName,
},
},
}
tagReaderWriter.On("FindByNames", []string{existingTagName}, false).Return([]*models.Tag{
{
ID: existingTagID,
Name: existingTagName,
},
}, nil).Once()
tagReaderWriter.On("FindByNames", []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Once()
err := i.PreImport()
assert.Nil(t, err)
assert.Equal(t, existingTagID, i.tags[0].ID)
i.Input.Tags = []string{existingTagErr}
err = i.PreImport()
assert.NotNil(t, err)
tagReaderWriter.AssertExpectations(t)
}
func TestImporterPreImportWithMissingTag(t *testing.T) {
tagReaderWriter := &mocks.TagReaderWriter{}
i := Importer{
TagWriter: tagReaderWriter,
Input: jsonschema.Performer{
Tags: []string{
missingTagName,
},
},
MissingRefBehaviour: models.ImportMissingRefEnumFail,
}
tagReaderWriter.On("FindByNames", []string{missingTagName}, false).Return(nil, nil).Times(3)
tagReaderWriter.On("Create", mock.AnythingOfType("models.Tag")).Return(&models.Tag{
ID: existingTagID,
}, nil)
err := i.PreImport()
assert.NotNil(t, err)
i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore
err = i.PreImport()
assert.Nil(t, err)
i.MissingRefBehaviour = models.ImportMissingRefEnumCreate
err = i.PreImport()
assert.Nil(t, err)
assert.Equal(t, existingTagID, i.tags[0].ID)
tagReaderWriter.AssertExpectations(t)
}
func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) {
tagReaderWriter := &mocks.TagReaderWriter{}
i := Importer{
TagWriter: tagReaderWriter,
Input: jsonschema.Performer{
Tags: []string{
missingTagName,
},
},
MissingRefBehaviour: models.ImportMissingRefEnumCreate,
}
tagReaderWriter.On("FindByNames", []string{missingTagName}, false).Return(nil, nil).Once()
tagReaderWriter.On("Create", mock.AnythingOfType("models.Tag")).Return(nil, errors.New("Create error"))
err := i.PreImport()
assert.NotNil(t, err)
}
func TestImporterPostImport(t *testing.T) {
readerWriter := &mocks.PerformerReaderWriter{}
@@ -111,6 +204,32 @@ func TestImporterFindExistingID(t *testing.T) {
readerWriter.AssertExpectations(t)
}
func TestImporterPostImportUpdateTags(t *testing.T) {
readerWriter := &mocks.PerformerReaderWriter{}
i := Importer{
ReaderWriter: readerWriter,
tags: []*models.Tag{
{
ID: existingTagID,
},
},
}
updateErr := errors.New("UpdateTags error")
readerWriter.On("UpdateTags", performerID, []int{existingTagID}).Return(nil).Once()
readerWriter.On("UpdateTags", errTagsID, mock.AnythingOfType("[]int")).Return(updateErr).Once()
err := i.PostImport(performerID)
assert.Nil(t, err)
err = i.PostImport(errTagsID)
assert.NotNil(t, err)
readerWriter.AssertExpectations(t)
}
func TestCreate(t *testing.T) {
readerWriter := &mocks.PerformerReaderWriter{}

View File

@@ -95,7 +95,7 @@ type mappedSceneScraperConfig struct {
mappedConfig
Tags mappedConfig `yaml:"Tags"`
Performers mappedConfig `yaml:"Performers"`
Performers mappedPerformerScraperConfig `yaml:"Performers"`
Studio mappedConfig `yaml:"Studio"`
Movies mappedConfig `yaml:"Movies"`
}
@@ -211,10 +211,54 @@ func (s *mappedGalleryScraperConfig) UnmarshalYAML(unmarshal func(interface{}) e
type mappedPerformerScraperConfig struct {
mappedConfig
Tags mappedConfig `yaml:"Tags"`
}
type _mappedPerformerScraperConfig mappedPerformerScraperConfig
const (
mappedScraperConfigPerformerTags = "Tags"
)
func (s *mappedPerformerScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
return unmarshal(&s.mappedConfig)
// HACK - unmarshal to map first, then remove known scene sub-fields, then
// remarshal to yaml and pass that down to the base map
parentMap := make(map[string]interface{})
if err := unmarshal(parentMap); err != nil {
return err
}
// move the known sub-fields to a separate map
thisMap := make(map[string]interface{})
thisMap[mappedScraperConfigPerformerTags] = parentMap[mappedScraperConfigPerformerTags]
delete(parentMap, mappedScraperConfigPerformerTags)
// re-unmarshal the sub-fields
yml, err := yaml.Marshal(thisMap)
if err != nil {
return err
}
// needs to be a different type to prevent infinite recursion
c := _mappedPerformerScraperConfig{}
if err := yaml.Unmarshal(yml, &c); err != nil {
return err
}
*s = mappedPerformerScraperConfig(c)
yml, err = yaml.Marshal(parentMap)
if err != nil {
return err
}
if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil {
return err
}
return nil
}
type mappedMovieScraperConfig struct {
@@ -647,9 +691,23 @@ func (s mappedScraper) scrapePerformer(q mappedQuery) (*models.ScrapedPerformer,
return nil, nil
}
performerTagsMap := performerMap.Tags
results := performerMap.process(q, s.Common)
if len(results) > 0 {
results[0].apply(&ret)
// now apply the tags
if performerTagsMap != nil {
logger.Debug(`Processing performer tags:`)
tagResults := performerTagsMap.process(q, s.Common)
for _, p := range tagResults {
tag := &models.ScrapedSceneTag{}
p.apply(tag)
ret.Tags = append(ret.Tags, tag)
}
}
}
return &ret, nil
@@ -687,19 +745,34 @@ func (s mappedScraper) scrapeScene(q mappedQuery) (*models.ScrapedScene, error)
sceneStudioMap := sceneScraperConfig.Studio
sceneMoviesMap := sceneScraperConfig.Movies
scenePerformerTagsMap := scenePerformersMap.Tags
logger.Debug(`Processing scene:`)
results := sceneMap.process(q, s.Common)
if len(results) > 0 {
results[0].apply(&ret)
// process performer tags once
var performerTagResults mappedResults
if scenePerformerTagsMap != nil {
performerTagResults = scenePerformerTagsMap.process(q, s.Common)
}
// now apply the performers and tags
if scenePerformersMap != nil {
if scenePerformersMap.mappedConfig != nil {
logger.Debug(`Processing scene performers:`)
performerResults := scenePerformersMap.process(q, s.Common)
for _, p := range performerResults {
performer := &models.ScrapedScenePerformer{}
p.apply(performer)
for _, p := range performerTagResults {
tag := &models.ScrapedSceneTag{}
p.apply(tag)
ret.Tags = append(ret.Tags, tag)
}
ret.Performers = append(ret.Performers, performer)
}
}

View File

@@ -220,9 +220,11 @@ func (c Cache) ScrapePerformerURL(url string) (*models.ScrapedPerformer, error)
return nil, err
}
// post-process - set the image if applicable
if err := setPerformerImage(ret, c.globalConfig); err != nil {
logger.Warnf("Could not set image using URL %s: %s", *ret.Image, err.Error())
if ret != nil {
err = c.postScrapePerformer(ret)
if err != nil {
return nil, err
}
}
return ret, nil
@@ -232,6 +234,49 @@ func (c Cache) ScrapePerformerURL(url string) (*models.ScrapedPerformer, error)
return nil, nil
}
func (c Cache) postScrapePerformer(ret *models.ScrapedPerformer) error {
if err := c.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
tqb := r.Tag()
for _, t := range ret.Tags {
err := MatchScrapedSceneTag(tqb, t)
if err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
// post-process - set the image if applicable
if err := setPerformerImage(ret, c.globalConfig); err != nil {
logger.Warnf("Could not set image using URL %s: %s", *ret.Image, err.Error())
}
return nil
}
func (c Cache) postScrapeScenePerformer(ret *models.ScrapedScenePerformer) error {
if err := c.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
tqb := r.Tag()
for _, t := range ret.Tags {
err := MatchScrapedSceneTag(tqb, t)
if err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
return nil
}
func (c Cache) postScrapeScene(ret *models.ScrapedScene) error {
if err := c.txnManager.WithReadTxn(context.TODO(), func(r models.ReaderRepository) error {
pqb := r.Performer()
@@ -240,8 +285,11 @@ func (c Cache) postScrapeScene(ret *models.ScrapedScene) error {
sqb := r.Studio()
for _, p := range ret.Performers {
err := MatchScrapedScenePerformer(pqb, p)
if err != nil {
if err := c.postScrapeScenePerformer(p); err != nil {
return err
}
if err := MatchScrapedScenePerformer(pqb, p); err != nil {
return err
}
}

View File

@@ -100,6 +100,13 @@ func (s *stashScraper) scrapePerformerByFragment(scrapedPerformer models.Scraped
return nil, err
}
if q.FindPerformer != nil {
// the ids of the tags must be nilled
for _, t := range q.FindPerformer.Tags {
t.ID = nil
}
}
// need to copy back to a scraped performer
ret := models.ScrapedPerformer{}
err = copier.Copy(&ret, q.FindPerformer)

View File

@@ -322,6 +322,7 @@ func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *mode
Twitter: findURL(p.Urls, "TWITTER"),
RemoteSiteID: &id,
Images: images,
// TODO - tags not currently supported
// TODO - Image - should be returned as a set of URLs. Will need a
// graphql schema change to accommodate this. Leave off for now.
}

View File

@@ -520,7 +520,7 @@ func makeSceneXPathConfig() mappedScraper {
performerConfig := make(mappedConfig)
performerConfig["Name"] = makeSimpleAttrConfig(`$performerElem/@data-mxptext`)
performerConfig["URL"] = makeSimpleAttrConfig(`$performerElem/@href`)
config.Performers = performerConfig
config.Performers.mappedConfig = performerConfig
studioConfig := make(mappedConfig)
studioConfig["Name"] = makeSimpleAttrConfig(`$studioElem`)
@@ -730,7 +730,7 @@ xPathScrapers:
assert.Equal(t, "//title", sceneConfig.mappedConfig["Title"].Selector)
assert.Equal(t, "//tags", sceneConfig.Tags["Name"].Selector)
assert.Equal(t, "//movies", sceneConfig.Movies["Name"].Selector)
assert.Equal(t, "//performers", sceneConfig.Performers["Name"].Selector)
assert.Equal(t, "//performers", sceneConfig.Performers.mappedConfig["Name"].Selector)
assert.Equal(t, "//studio", sceneConfig.Studio["Name"].Selector)
postProcess := sceneConfig.mappedConfig["Title"].postProcessActions

View File

@@ -259,6 +259,8 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi
query.addHaving(havingClause)
}
handleGalleryPerformerTagsCriterion(&query, galleryFilter.PerformerTags)
query.sortAndPagination = qb.getGallerySort(findFilter) + getPagination(findFilter)
idsResult, countResult, err := query.executeFind()
if err != nil {
@@ -344,6 +346,31 @@ func (qb *galleryQueryBuilder) handleAverageResolutionFilter(query *queryBuilder
}
}
func handleGalleryPerformerTagsCriterion(query *queryBuilder, performerTagsFilter *models.MultiCriterionInput) {
if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 {
for _, tagID := range performerTagsFilter.Value {
query.addArg(tagID)
}
query.body += " LEFT JOIN performers_tags AS performer_tags_join on performers_join.performer_id = performer_tags_join.performer_id"
if performerTagsFilter.Modifier == models.CriterionModifierIncludes {
// includes any of the provided ids
query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value)))
} else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll {
// includes all of the provided ids
query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value)))
query.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value)))
} else if performerTagsFilter.Modifier == models.CriterionModifierExcludes {
query.addWhere(fmt.Sprintf(`not exists
(select performers_galleries.performer_id from performers_galleries
left join performers_tags on performers_tags.performer_id = performers_galleries.performer_id where
performers_galleries.gallery_id = galleries.id AND
performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value))))
}
}
}
func (qb *galleryQueryBuilder) getGallerySort(findFilter *models.FindFilterType) string {
var sort string
var direction string

View File

@@ -519,6 +519,61 @@ func TestGalleryQueryStudio(t *testing.T) {
})
}
func TestGalleryQueryPerformerTags(t *testing.T) {
withTxn(func(r models.Repository) error {
sqb := r.Gallery()
tagCriterion := models.MultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdxWithPerformer]),
strconv.Itoa(tagIDs[tagIdx1WithPerformer]),
},
Modifier: models.CriterionModifierIncludes,
}
galleryFilter := models.GalleryFilterType{
PerformerTags: &tagCriterion,
}
galleries := queryGallery(t, sqb, &galleryFilter, nil)
assert.Len(t, galleries, 2)
// ensure ids are correct
for _, gallery := range galleries {
assert.True(t, gallery.ID == galleryIDs[galleryIdxWithPerformerTag] || gallery.ID == galleryIDs[galleryIdxWithPerformerTwoTags])
}
tagCriterion = models.MultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdx1WithPerformer]),
strconv.Itoa(tagIDs[tagIdx2WithPerformer]),
},
Modifier: models.CriterionModifierIncludesAll,
}
galleries = queryGallery(t, sqb, &galleryFilter, nil)
assert.Len(t, galleries, 1)
assert.Equal(t, galleryIDs[galleryIdxWithPerformerTwoTags], galleries[0].ID)
tagCriterion = models.MultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdx1WithPerformer]),
},
Modifier: models.CriterionModifierExcludes,
}
q := getGalleryStringValue(galleryIdxWithPerformerTwoTags, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
galleries = queryGallery(t, sqb, &galleryFilter, &findFilter)
assert.Len(t, galleries, 0)
return nil
})
}
// TODO Count
// TODO All
// TODO Query

View File

@@ -360,6 +360,8 @@ func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilt
query.addHaving(havingClause)
}
handleImagePerformerTagsCriterion(&query, imageFilter.PerformerTags)
query.sortAndPagination = qb.getImageSort(findFilter) + getPagination(findFilter)
idsResult, countResult, err := query.executeFind()
if err != nil {
@@ -379,6 +381,31 @@ func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilt
return images, countResult, nil
}
func handleImagePerformerTagsCriterion(query *queryBuilder, performerTagsFilter *models.MultiCriterionInput) {
if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 {
for _, tagID := range performerTagsFilter.Value {
query.addArg(tagID)
}
query.body += " LEFT JOIN performers_tags AS performer_tags_join on performers_join.performer_id = performer_tags_join.performer_id"
if performerTagsFilter.Modifier == models.CriterionModifierIncludes {
// includes any of the provided ids
query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value)))
} else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll {
// includes all of the provided ids
query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value)))
query.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value)))
} else if performerTagsFilter.Modifier == models.CriterionModifierExcludes {
query.addWhere(fmt.Sprintf(`not exists
(select performers_images.performer_id from performers_images
left join performers_tags on performers_tags.performer_id = performers_images.performer_id where
performers_images.image_id = images.id AND
performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value))))
}
}
}
func (qb *imageQueryBuilder) getImageSort(findFilter *models.FindFilterType) string {
if findFilter == nil {
return " ORDER BY images.path ASC "

View File

@@ -619,6 +619,70 @@ func TestImageQueryStudio(t *testing.T) {
})
}
func queryImages(t *testing.T, sqb models.ImageReader, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) []*models.Image {
images, _, err := sqb.Query(imageFilter, findFilter)
if err != nil {
t.Errorf("Error querying images: %s", err.Error())
}
return images
}
func TestImageQueryPerformerTags(t *testing.T) {
withTxn(func(r models.Repository) error {
sqb := r.Image()
tagCriterion := models.MultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdxWithPerformer]),
strconv.Itoa(tagIDs[tagIdx1WithPerformer]),
},
Modifier: models.CriterionModifierIncludes,
}
imageFilter := models.ImageFilterType{
PerformerTags: &tagCriterion,
}
images := queryImages(t, sqb, &imageFilter, nil)
assert.Len(t, images, 2)
// ensure ids are correct
for _, image := range images {
assert.True(t, image.ID == imageIDs[imageIdxWithPerformerTag] || image.ID == imageIDs[imageIdxWithPerformerTwoTags])
}
tagCriterion = models.MultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdx1WithPerformer]),
strconv.Itoa(tagIDs[tagIdx2WithPerformer]),
},
Modifier: models.CriterionModifierIncludesAll,
}
images = queryImages(t, sqb, &imageFilter, nil)
assert.Len(t, images, 1)
assert.Equal(t, imageIDs[imageIdxWithPerformerTwoTags], images[0].ID)
tagCriterion = models.MultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdx1WithPerformer]),
},
Modifier: models.CriterionModifierExcludes,
}
q := getImageStringValue(imageIdxWithPerformerTwoTags, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
images = queryImages(t, sqb, &imageFilter, &findFilter)
assert.Len(t, images, 0)
return nil
})
}
func TestImageQuerySorting(t *testing.T) {
withTxn(func(r models.Repository) error {
sort := titleField

View File

@@ -11,6 +11,13 @@ import (
const performerTable = "performers"
const performerIDColumn = "performer_id"
const performersTagsTable = "performers_tags"
var countPerformersForTagQuery = `
SELECT tag_id AS id FROM performers_tags
WHERE performers_tags.tag_id = ?
GROUP BY performers_tags.performer_id
`
type performerQueryBuilder struct {
repository
@@ -153,6 +160,11 @@ func (qb *performerQueryBuilder) FindByNames(names []string, nocase bool) ([]*mo
return qb.queryPerformers(query, args)
}
func (qb *performerQueryBuilder) CountByTagID(tagID int) (int, error) {
args := []interface{}{tagID}
return qb.runCountQuery(qb.buildCountQuery(countPerformersForTagQuery), args)
}
func (qb *performerQueryBuilder) Count() (int, error) {
return qb.runCountQuery(qb.buildCountQuery("SELECT performers.id FROM performers"), nil)
}
@@ -250,6 +262,18 @@ func (qb *performerQueryBuilder) Query(performerFilter *models.PerformerFilterTy
// TODO - need better handling of aliases
query.handleStringCriterionInput(performerFilter.Aliases, tableName+".aliases")
if tagsFilter := performerFilter.Tags; tagsFilter != nil && len(tagsFilter.Value) > 0 {
for _, tagID := range tagsFilter.Value {
query.addArg(tagID)
}
query.body += ` left join performers_tags as tags_join on tags_join.performer_id = performers.id
LEFT JOIN tags on tags_join.tag_id = tags.id`
whereClause, havingClause := getMultiCriterionClause("performers", "tags", "performers_tags", "performer_id", "tag_id", tagsFilter)
query.addWhere(whereClause)
query.addHaving(havingClause)
}
query.sortAndPagination = qb.getPerformerSort(findFilter) + getPagination(findFilter)
idsResult, countResult, err := query.executeFind()
if err != nil {
@@ -361,6 +385,26 @@ func (qb *performerQueryBuilder) queryPerformers(query string, args []interface{
return []*models.Performer(ret), nil
}
func (qb *performerQueryBuilder) tagsRepository() *joinRepository {
return &joinRepository{
repository: repository{
tx: qb.tx,
tableName: performersTagsTable,
idColumn: performerIDColumn,
},
fkColumn: tagIDColumn,
}
}
func (qb *performerQueryBuilder) GetTagIDs(id int) ([]int, error) {
return qb.tagsRepository().getIDs(id)
}
func (qb *performerQueryBuilder) UpdateTags(id int, tagIDs []int) error {
// Delete the existing joins and then create new ones
return qb.tagsRepository().replace(id, tagIDs)
}
func (qb *performerQueryBuilder) imageRepository() *imageRepository {
return &imageRepository{
repository: repository{

View File

@@ -5,6 +5,7 @@ package sqlite_test
import (
"database/sql"
"fmt"
"strconv"
"strings"
"testing"
"time"
@@ -227,6 +228,69 @@ func verifyPerformerAge(t *testing.T, ageCriterion models.IntCriterionInput) {
})
}
func queryPerformers(t *testing.T, qb models.PerformerReader, performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) []*models.Performer {
performers, _, err := qb.Query(performerFilter, findFilter)
if err != nil {
t.Errorf("Error querying performers: %s", err.Error())
}
return performers
}
func TestPerformerQueryTags(t *testing.T) {
withTxn(func(r models.Repository) error {
sqb := r.Performer()
tagCriterion := models.MultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdxWithPerformer]),
strconv.Itoa(tagIDs[tagIdx1WithPerformer]),
},
Modifier: models.CriterionModifierIncludes,
}
performerFilter := models.PerformerFilterType{
Tags: &tagCriterion,
}
// ensure ids are correct
performers := queryPerformers(t, sqb, &performerFilter, nil)
assert.Len(t, performers, 2)
for _, performer := range performers {
assert.True(t, performer.ID == performerIDs[performerIdxWithTag] || performer.ID == performerIDs[performerIdxWithTwoTags])
}
tagCriterion = models.MultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdx1WithPerformer]),
strconv.Itoa(tagIDs[tagIdx2WithPerformer]),
},
Modifier: models.CriterionModifierIncludesAll,
}
performers = queryPerformers(t, sqb, &performerFilter, nil)
assert.Len(t, performers, 1)
assert.Equal(t, sceneIDs[performerIdxWithTwoTags], performers[0].ID)
tagCriterion = models.MultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdx1WithPerformer]),
},
Modifier: models.CriterionModifierExcludes,
}
q := getSceneStringValue(performerIdxWithTwoTags, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
performers = queryPerformers(t, sqb, &performerFilter, &findFilter)
assert.Len(t, performers, 0)
return nil
})
}
func TestPerformerStashIDs(t *testing.T) {
if err := withTxn(func(r models.Repository) error {
qb := r.Performer()

View File

@@ -351,6 +351,7 @@ func (qb *sceneQueryBuilder) makeFilter(sceneFilter *models.SceneFilterType) *fi
query.handleCriterionFunc(sceneStudioCriterionHandler(qb, sceneFilter.Studios))
query.handleCriterionFunc(sceneMoviesCriterionHandler(qb, sceneFilter.Movies))
query.handleCriterionFunc(sceneStashIDsHandler(qb, sceneFilter.StashID))
query.handleCriterionFunc(scenePerformerTagsCriterionHandler(qb, sceneFilter.PerformerTags))
return query
}
@@ -565,6 +566,60 @@ func sceneStashIDsHandler(qb *sceneQueryBuilder, stashID *string) criterionHandl
}
}
func scenePerformerTagsCriterionHandler(qb *sceneQueryBuilder, performerTagsFilter *models.MultiCriterionInput) criterionHandlerFunc {
return func(f *filterBuilder) {
if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 {
qb.performersRepository().join(f, "performers_join", "scenes.id")
f.addJoin("performers_tags", "performer_tags_join", "performers_join.performer_id = performer_tags_join.performer_id")
var args []interface{}
for _, tagID := range performerTagsFilter.Value {
args = append(args, tagID)
}
if performerTagsFilter.Modifier == models.CriterionModifierIncludes {
// includes any of the provided ids
f.addWhere("performer_tags_join.tag_id IN "+getInBinding(len(performerTagsFilter.Value)), args...)
} else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll {
// includes all of the provided ids
f.addWhere("performer_tags_join.tag_id IN "+getInBinding(len(performerTagsFilter.Value)), args...)
f.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value)))
} else if performerTagsFilter.Modifier == models.CriterionModifierExcludes {
f.addWhere(fmt.Sprintf(`not exists
(select performers_scenes.performer_id from performers_scenes
left join performers_tags on performers_tags.performer_id = performers_scenes.performer_id where
performers_scenes.scene_id = scenes.id AND
performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value))), args...)
}
}
}
}
func handleScenePerformerTagsCriterion(query *queryBuilder, performerTagsFilter *models.MultiCriterionInput) {
if performerTagsFilter != nil && len(performerTagsFilter.Value) > 0 {
for _, tagID := range performerTagsFilter.Value {
query.addArg(tagID)
}
query.body += " LEFT JOIN performers_tags AS performer_tags_join on performers_join.performer_id = performer_tags_join.performer_id"
if performerTagsFilter.Modifier == models.CriterionModifierIncludes {
// includes any of the provided ids
query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value)))
} else if performerTagsFilter.Modifier == models.CriterionModifierIncludesAll {
// includes all of the provided ids
query.addWhere("performer_tags_join.tag_id IN " + getInBinding(len(performerTagsFilter.Value)))
query.addHaving(fmt.Sprintf("count(distinct performer_tags_join.tag_id) IS %d", len(performerTagsFilter.Value)))
} else if performerTagsFilter.Modifier == models.CriterionModifierExcludes {
query.addWhere(fmt.Sprintf(`not exists
(select performers_scenes.performer_id from performers_scenes
left join performers_tags on performers_tags.performer_id = performers_scenes.performer_id where
performers_scenes.scene_id = scenes.id AND
performers_tags.tag_id in %s)`, getInBinding(len(performerTagsFilter.Value))))
}
}
}
func (qb *sceneQueryBuilder) getSceneSort(findFilter *models.FindFilterType) string {
if findFilter == nil {
return " ORDER BY scenes.path, scenes.date ASC "

View File

@@ -940,6 +940,61 @@ func TestSceneQueryTags(t *testing.T) {
})
}
func TestSceneQueryPerformerTags(t *testing.T) {
withTxn(func(r models.Repository) error {
sqb := r.Scene()
tagCriterion := models.MultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdxWithPerformer]),
strconv.Itoa(tagIDs[tagIdx1WithPerformer]),
},
Modifier: models.CriterionModifierIncludes,
}
sceneFilter := models.SceneFilterType{
PerformerTags: &tagCriterion,
}
scenes := queryScene(t, sqb, &sceneFilter, nil)
assert.Len(t, scenes, 2)
// ensure ids are correct
for _, scene := range scenes {
assert.True(t, scene.ID == sceneIDs[sceneIdxWithPerformerTag] || scene.ID == sceneIDs[sceneIdxWithPerformerTwoTags])
}
tagCriterion = models.MultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdx1WithPerformer]),
strconv.Itoa(tagIDs[tagIdx2WithPerformer]),
},
Modifier: models.CriterionModifierIncludesAll,
}
scenes = queryScene(t, sqb, &sceneFilter, nil)
assert.Len(t, scenes, 1)
assert.Equal(t, sceneIDs[sceneIdxWithPerformerTwoTags], scenes[0].ID)
tagCriterion = models.MultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdx1WithPerformer]),
},
Modifier: models.CriterionModifierExcludes,
}
q := getSceneStringValue(sceneIdxWithPerformerTwoTags, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
scenes = queryScene(t, sqb, &sceneFilter, &findFilter)
assert.Len(t, scenes, 0)
return nil
})
}
func TestSceneQueryStudio(t *testing.T) {
withTxn(func(r models.Repository) error {
sqb := r.Scene()

View File

@@ -20,110 +20,240 @@ import (
"github.com/stashapp/stash/pkg/utils"
)
const totalScenes = 12
const totalImages = 6 // TODO - add one for zip file
const performersNameCase = 9
const performersNameNoCase = 2
const moviesNameCase = 2
const moviesNameNoCase = 1
const totalGalleries = 8
const tagsNameNoCase = 2
const tagsNameCase = 12
const studiosNameCase = 6
const studiosNameNoCase = 1
const (
sceneIdxWithMovie = iota
sceneIdxWithGallery
sceneIdxWithPerformer
sceneIdxWithTwoPerformers
sceneIdxWithTag
sceneIdxWithTwoTags
sceneIdxWithStudio
sceneIdxWithMarker
sceneIdxWithPerformerTag
sceneIdxWithPerformerTwoTags
// new indexes above
lastSceneIdx
var sceneIDs []int
var imageIDs []int
var performerIDs []int
var movieIDs []int
var galleryIDs []int
var tagIDs []int
var studioIDs []int
var markerIDs []int
totalScenes = lastSceneIdx + 3
)
var tagNames []string
var studioNames []string
var movieNames []string
var performerNames []string
const (
imageIdxWithGallery = iota
imageIdxWithPerformer
imageIdxWithTwoPerformers
imageIdxWithTag
imageIdxWithTwoTags
imageIdxWithStudio
imageIdxInZip // TODO - not implemented
imageIdxWithPerformerTag
imageIdxWithPerformerTwoTags
// new indexes above
totalImages
)
const sceneIdxWithMovie = 0
const sceneIdxWithGallery = 1
const sceneIdxWithPerformer = 2
const sceneIdxWithTwoPerformers = 3
const sceneIdxWithTag = 4
const sceneIdxWithTwoTags = 5
const sceneIdxWithStudio = 6
const sceneIdxWithMarker = 7
const (
performerIdxWithScene = iota
performerIdx1WithScene
performerIdx2WithScene
performerIdxWithImage
performerIdx1WithImage
performerIdx2WithImage
performerIdxWithTag
performerIdxWithTwoTags
performerIdxWithGallery
performerIdx1WithGallery
performerIdx2WithGallery
// new indexes above
// performers with dup names start from the end
performerIdx1WithDupName
performerIdxWithDupName
const imageIdxWithGallery = 0
const imageIdxWithPerformer = 1
const imageIdxWithTwoPerformers = 2
const imageIdxWithTag = 3
const imageIdxWithTwoTags = 4
const imageIdxWithStudio = 5
const imageIdxInZip = 6
performersNameCase = performerIdx1WithDupName
performersNameNoCase = 2
)
const performerIdxWithScene = 0
const performerIdx1WithScene = 1
const performerIdx2WithScene = 2
const performerIdxWithImage = 3
const performerIdx1WithImage = 4
const performerIdx2WithImage = 5
const performerIdxWithGallery = 6
const performerIdx1WithGallery = 7
const performerIdx2WithGallery = 8
const (
movieIdxWithScene = iota
movieIdxWithStudio
// movies with dup names start from the end
movieIdxWithDupName
// performers with dup names start from the end
const performerIdx1WithDupName = 9
const performerIdxWithDupName = 10
moviesNameCase = movieIdxWithDupName
moviesNameNoCase = 1
)
const movieIdxWithScene = 0
const movieIdxWithStudio = 1
const (
galleryIdxWithScene = iota
galleryIdxWithImage
galleryIdxWithPerformer
galleryIdxWithTwoPerformers
galleryIdxWithTag
galleryIdxWithTwoTags
galleryIdxWithStudio
galleryIdxWithPerformerTag
galleryIdxWithPerformerTwoTags
// new indexes above
lastGalleryIdx
// movies with dup names start from the end
const movieIdxWithDupName = 2
totalGalleries = lastGalleryIdx + 1
)
const galleryIdxWithScene = 0
const galleryIdxWithImage = 1
const galleryIdxWithPerformer = 2
const galleryIdxWithTwoPerformers = 3
const galleryIdxWithTag = 4
const galleryIdxWithTwoTags = 5
const galleryIdxWithStudio = 6
const (
tagIdxWithScene = iota
tagIdx1WithScene
tagIdx2WithScene
tagIdxWithPrimaryMarker
tagIdxWithMarker
tagIdxWithCoverImage
tagIdxWithImage
tagIdx1WithImage
tagIdx2WithImage
tagIdxWithPerformer
tagIdx1WithPerformer
tagIdx2WithPerformer
tagIdxWithGallery
tagIdx1WithGallery
tagIdx2WithGallery
// new indexes above
// tags with dup names start from the end
tagIdx1WithDupName
tagIdxWithDupName
const tagIdxWithScene = 0
const tagIdx1WithScene = 1
const tagIdx2WithScene = 2
const tagIdxWithPrimaryMarker = 3
const tagIdxWithMarker = 4
const tagIdxWithCoverImage = 5
const tagIdxWithImage = 6
const tagIdx1WithImage = 7
const tagIdx2WithImage = 8
const tagIdxWithGallery = 9
const tagIdx1WithGallery = 10
const tagIdx2WithGallery = 11
tagsNameNoCase = 2
tagsNameCase = tagIdx1WithDupName
)
// tags with dup names start from the end
const tagIdx1WithDupName = 12
const tagIdxWithDupName = 13
const (
studioIdxWithScene = iota
studioIdxWithMovie
studioIdxWithChildStudio
studioIdxWithParentStudio
studioIdxWithImage
studioIdxWithGallery
// new indexes above
// studios with dup names start from the end
studioIdxWithDupName
const studioIdxWithScene = 0
const studioIdxWithMovie = 1
const studioIdxWithChildStudio = 2
const studioIdxWithParentStudio = 3
const studioIdxWithImage = 4
const studioIdxWithGallery = 5
studiosNameCase = studioIdxWithDupName
studiosNameNoCase = 1
)
// studios with dup names start from the end
const studioIdxWithDupName = 6
const (
markerIdxWithScene = iota
)
const markerIdxWithScene = 0
const (
pathField = "Path"
checksumField = "Checksum"
titleField = "Title"
zipPath = "zipPath.zip"
)
const pathField = "Path"
const checksumField = "Checksum"
const titleField = "Title"
const zipPath = "zipPath.zip"
var (
sceneIDs []int
imageIDs []int
performerIDs []int
movieIDs []int
galleryIDs []int
tagIDs []int
studioIDs []int
markerIDs []int
tagNames []string
studioNames []string
movieNames []string
performerNames []string
)
type idAssociation struct {
first int
second int
}
var (
sceneTagLinks = [][2]int{
{sceneIdxWithTag, tagIdxWithScene},
{sceneIdxWithTwoTags, tagIdx1WithScene},
{sceneIdxWithTwoTags, tagIdx2WithScene},
}
scenePerformerLinks = [][2]int{
{sceneIdxWithPerformer, performerIdxWithScene},
{sceneIdxWithTwoPerformers, performerIdx1WithScene},
{sceneIdxWithTwoPerformers, performerIdx2WithScene},
{sceneIdxWithPerformerTag, performerIdxWithTag},
{sceneIdxWithPerformerTwoTags, performerIdxWithTwoTags},
}
sceneGalleryLinks = [][2]int{
{sceneIdxWithGallery, galleryIdxWithScene},
}
sceneMovieLinks = [][2]int{
{sceneIdxWithMovie, movieIdxWithScene},
}
sceneStudioLinks = [][2]int{
{sceneIdxWithStudio, studioIdxWithScene},
}
)
var (
imageGalleryLinks = [][2]int{
{imageIdxWithGallery, galleryIdxWithImage},
}
imageStudioLinks = [][2]int{
{imageIdxWithStudio, studioIdxWithImage},
}
imageTagLinks = [][2]int{
{imageIdxWithTag, tagIdxWithImage},
{imageIdxWithTwoTags, tagIdx1WithImage},
{imageIdxWithTwoTags, tagIdx2WithImage},
}
imagePerformerLinks = [][2]int{
{imageIdxWithPerformer, performerIdxWithImage},
{imageIdxWithTwoPerformers, performerIdx1WithImage},
{imageIdxWithTwoPerformers, performerIdx2WithImage},
{imageIdxWithPerformerTag, performerIdxWithTag},
{imageIdxWithPerformerTwoTags, performerIdxWithTwoTags},
}
)
var (
galleryPerformerLinks = [][2]int{
{galleryIdxWithPerformer, performerIdxWithGallery},
{galleryIdxWithTwoPerformers, performerIdx1WithGallery},
{galleryIdxWithTwoPerformers, performerIdx2WithGallery},
{galleryIdxWithPerformerTag, performerIdxWithTag},
{galleryIdxWithPerformerTwoTags, performerIdxWithTwoTags},
}
galleryTagLinks = [][2]int{
{galleryIdxWithTag, tagIdxWithGallery},
{galleryIdxWithTwoTags, tagIdx1WithGallery},
{galleryIdxWithTwoTags, tagIdx2WithGallery},
}
)
var (
movieStudioLinks = [][2]int{
{movieIdxWithStudio, studioIdxWithMovie},
}
)
var (
studioParentLinks = [][2]int{
{studioIdxWithChildStudio, studioIdxWithParentStudio},
}
)
var (
performerTagLinks = [][2]int{
{performerIdxWithTag, tagIdxWithPerformer},
{performerIdxWithTwoTags, tagIdx1WithPerformer},
{performerIdxWithTwoTags, tagIdx2WithPerformer},
}
)
func TestMain(m *testing.M) {
ret := runTests(m)
@@ -205,12 +335,16 @@ func populateDB() error {
return fmt.Errorf("error creating studios: %s", err.Error())
}
if err := linkSceneGallery(r.Scene(), sceneIdxWithGallery, galleryIdxWithScene); err != nil {
return fmt.Errorf("error linking scene to gallery: %s", err.Error())
if err := linkPerformerTags(r.Performer()); err != nil {
return fmt.Errorf("error linking performer tags: %s", err.Error())
}
if err := linkSceneMovie(r.Scene(), sceneIdxWithMovie, movieIdxWithScene); err != nil {
return fmt.Errorf("error scene to movie: %s", err.Error())
if err := linkSceneGalleries(r.Scene()); err != nil {
return fmt.Errorf("error linking scenes to galleries: %s", err.Error())
}
if err := linkSceneMovies(r.Scene()); err != nil {
return fmt.Errorf("error linking scenes to movies: %s", err.Error())
}
if err := linkScenePerformers(r.Scene()); err != nil {
@@ -221,11 +355,11 @@ func populateDB() error {
return fmt.Errorf("error linking scene tags: %s", err.Error())
}
if err := linkSceneStudio(r.Scene(), sceneIdxWithStudio, studioIdxWithScene); err != nil {
return fmt.Errorf("error linking scene studio: %s", err.Error())
if err := linkSceneStudios(r.Scene()); err != nil {
return fmt.Errorf("error linking scene studios: %s", err.Error())
}
if err := linkImageGallery(r.Gallery(), imageIdxWithGallery, galleryIdxWithImage); err != nil {
if err := linkImageGalleries(r.Gallery()); err != nil {
return fmt.Errorf("error linking gallery images: %s", err.Error())
}
@@ -237,16 +371,16 @@ func populateDB() error {
return fmt.Errorf("error linking image tags: %s", err.Error())
}
if err := linkImageStudio(r.Image(), imageIdxWithStudio, studioIdxWithImage); err != nil {
if err := linkImageStudios(r.Image()); err != nil {
return fmt.Errorf("error linking image studio: %s", err.Error())
}
if err := linkMovieStudio(r.Movie(), movieIdxWithStudio, studioIdxWithMovie); err != nil {
return fmt.Errorf("error linking movie studio: %s", err.Error())
if err := linkMovieStudios(r.Movie()); err != nil {
return fmt.Errorf("error linking movie studios: %s", err.Error())
}
if err := linkStudioParent(r.Studio(), studioIdxWithChildStudio, studioIdxWithParentStudio); err != nil {
return fmt.Errorf("error linking studio parent: %s", err.Error())
if err := linkStudiosParent(r.Studio()); err != nil {
return fmt.Errorf("error linking studios parent: %s", err.Error())
}
if err := linkGalleryPerformers(r.Gallery()); err != nil {
@@ -512,6 +646,30 @@ func getTagMarkerCount(id int) int {
return 0
}
func getTagImageCount(id int) int {
if id == tagIDs[tagIdx1WithImage] || id == tagIDs[tagIdx2WithImage] || id == tagIDs[tagIdxWithImage] {
return 1
}
return 0
}
func getTagGalleryCount(id int) int {
if id == tagIDs[tagIdx1WithGallery] || id == tagIDs[tagIdx2WithGallery] || id == tagIDs[tagIdxWithGallery] {
return 1
}
return 0
}
func getTagPerformerCount(id int) int {
if id == tagIDs[tagIdx1WithPerformer] || id == tagIDs[tagIdx2WithPerformer] || id == tagIDs[tagIdxWithPerformer] {
return 1
}
return 0
}
//createTags creates n tags with plain Name and o tags with camel cased NaMe included
func createTags(tqb models.TagReaderWriter, n int, o int) error {
const namePlain = "Name"
@@ -624,7 +782,33 @@ func createMarker(mqb models.SceneMarkerReaderWriter, sceneIdx, primaryTagIdx in
return nil
}
func linkSceneMovie(qb models.SceneReaderWriter, sceneIndex, movieIndex int) error {
func doLinks(links [][2]int, fn func(idx1, idx2 int) error) error {
for _, l := range links {
if err := fn(l[0], l[1]); err != nil {
return err
}
}
return nil
}
func linkPerformerTags(qb models.PerformerReaderWriter) error {
return doLinks(performerTagLinks, func(performerIndex, tagIndex int) error {
performerID := performerIDs[performerIndex]
tagID := tagIDs[tagIndex]
tagIDs, err := qb.GetTagIDs(performerID)
if err != nil {
return err
}
tagIDs = utils.IntAppendUnique(tagIDs, tagID)
return qb.UpdateTags(performerID, tagIDs)
})
}
func linkSceneMovies(qb models.SceneReaderWriter) error {
return doLinks(sceneMovieLinks, func(sceneIndex, movieIndex int) error {
sceneID := sceneIDs[sceneIndex]
movies, err := qb.GetMovies(sceneID)
if err != nil {
@@ -636,52 +820,32 @@ func linkSceneMovie(qb models.SceneReaderWriter, sceneIndex, movieIndex int) err
SceneID: sceneID,
})
return qb.UpdateMovies(sceneID, movies)
})
}
func linkScenePerformers(qb models.SceneReaderWriter) error {
if err := linkScenePerformer(qb, sceneIdxWithPerformer, performerIdxWithScene); err != nil {
return err
}
if err := linkScenePerformer(qb, sceneIdxWithTwoPerformers, performerIdx1WithScene); err != nil {
return err
}
if err := linkScenePerformer(qb, sceneIdxWithTwoPerformers, performerIdx2WithScene); err != nil {
return err
}
return nil
}
func linkScenePerformer(qb models.SceneReaderWriter, sceneIndex, performerIndex int) error {
return doLinks(scenePerformerLinks, func(sceneIndex, performerIndex int) error {
_, err := scene.AddPerformer(qb, sceneIDs[sceneIndex], performerIDs[performerIndex])
return err
})
}
func linkSceneGallery(qb models.SceneReaderWriter, sceneIndex, galleryIndex int) error {
func linkSceneGalleries(qb models.SceneReaderWriter) error {
return doLinks(sceneGalleryLinks, func(sceneIndex, galleryIndex int) error {
_, err := scene.AddGallery(qb, sceneIDs[sceneIndex], galleryIDs[galleryIndex])
return err
})
}
func linkSceneTags(qb models.SceneReaderWriter) error {
if err := linkSceneTag(qb, sceneIdxWithTag, tagIdxWithScene); err != nil {
return err
}
if err := linkSceneTag(qb, sceneIdxWithTwoTags, tagIdx1WithScene); err != nil {
return err
}
if err := linkSceneTag(qb, sceneIdxWithTwoTags, tagIdx2WithScene); err != nil {
return err
}
return nil
}
func linkSceneTag(qb models.SceneReaderWriter, sceneIndex, tagIndex int) error {
return doLinks(sceneTagLinks, func(sceneIndex, tagIndex int) error {
_, err := scene.AddTag(qb, sceneIDs[sceneIndex], tagIDs[tagIndex])
return err
})
}
func linkSceneStudio(sqb models.SceneWriter, sceneIndex, studioIndex int) error {
func linkSceneStudios(sqb models.SceneWriter) error {
return doLinks(sceneStudioLinks, func(sceneIndex, studioIndex int) error {
scene := models.ScenePartial{
ID: sceneIDs[sceneIndex],
StudioID: &sql.NullInt64{Int64: int64(studioIDs[studioIndex]), Valid: true},
@@ -689,27 +853,17 @@ func linkSceneStudio(sqb models.SceneWriter, sceneIndex, studioIndex int) error
_, err := sqb.Update(scene)
return err
})
}
func linkImageGallery(gqb models.GalleryReaderWriter, imageIndex, galleryIndex int) error {
func linkImageGalleries(gqb models.GalleryReaderWriter) error {
return doLinks(imageGalleryLinks, func(imageIndex, galleryIndex int) error {
return gallery.AddImage(gqb, galleryIDs[galleryIndex], imageIDs[imageIndex])
})
}
func linkImageTags(iqb models.ImageReaderWriter) error {
if err := linkImageTag(iqb, imageIdxWithTag, tagIdxWithImage); err != nil {
return err
}
if err := linkImageTag(iqb, imageIdxWithTwoTags, tagIdx1WithImage); err != nil {
return err
}
if err := linkImageTag(iqb, imageIdxWithTwoTags, tagIdx2WithImage); err != nil {
return err
}
return nil
}
func linkImageTag(iqb models.ImageReaderWriter, imageIndex, tagIndex int) error {
return doLinks(imageTagLinks, func(imageIndex, tagIndex int) error {
imageID := imageIDs[imageIndex]
tags, err := iqb.GetTagIDs(imageID)
if err != nil {
@@ -719,9 +873,11 @@ func linkImageTag(iqb models.ImageReaderWriter, imageIndex, tagIndex int) error
tags = append(tags, tagIDs[tagIndex])
return iqb.UpdateTags(imageID, tags)
})
}
func linkImageStudio(qb models.ImageWriter, imageIndex, studioIndex int) error {
func linkImageStudios(qb models.ImageWriter) error {
return doLinks(imageStudioLinks, func(imageIndex, studioIndex int) error {
image := models.ImagePartial{
ID: imageIDs[imageIndex],
StudioID: &sql.NullInt64{Int64: int64(studioIDs[studioIndex]), Valid: true},
@@ -729,74 +885,40 @@ func linkImageStudio(qb models.ImageWriter, imageIndex, studioIndex int) error {
_, err := qb.Update(image)
return err
})
}
func linkImagePerformers(qb models.ImageReaderWriter) error {
if err := linkImagePerformer(qb, imageIdxWithPerformer, performerIdxWithImage); err != nil {
return err
}
if err := linkImagePerformer(qb, imageIdxWithTwoPerformers, performerIdx1WithImage); err != nil {
return err
}
if err := linkImagePerformer(qb, imageIdxWithTwoPerformers, performerIdx2WithImage); err != nil {
return err
}
return nil
}
func linkImagePerformer(iqb models.ImageReaderWriter, imageIndex, performerIndex int) error {
return doLinks(imagePerformerLinks, func(imageIndex, performerIndex int) error {
imageID := imageIDs[imageIndex]
performers, err := iqb.GetPerformerIDs(imageID)
performers, err := qb.GetPerformerIDs(imageID)
if err != nil {
return err
}
performers = append(performers, performerIDs[performerIndex])
return iqb.UpdatePerformers(imageID, performers)
return qb.UpdatePerformers(imageID, performers)
})
}
func linkMovieStudio(mqb models.MovieWriter, movieIndex, studioIndex int) error {
movie := models.MoviePartial{
ID: movieIDs[movieIndex],
StudioID: &sql.NullInt64{Int64: int64(studioIDs[studioIndex]), Valid: true},
}
_, err := mqb.Update(movie)
func linkGalleryPerformers(qb models.GalleryReaderWriter) error {
return doLinks(galleryPerformerLinks, func(galleryIndex, performerIndex int) error {
galleryID := imageIDs[galleryIndex]
performers, err := qb.GetPerformerIDs(galleryID)
if err != nil {
return err
}
func linkStudioParent(qb models.StudioWriter, parentIndex, childIndex int) error {
studio := models.StudioPartial{
ID: studioIDs[childIndex],
ParentID: &sql.NullInt64{Int64: int64(studioIDs[parentIndex]), Valid: true},
}
_, err := qb.Update(studio)
return err
}
performers = append(performers, performerIDs[performerIndex])
func addTagImage(qb models.TagWriter, tagIndex int) error {
return qb.UpdateImage(tagIDs[tagIndex], models.DefaultTagImage)
return qb.UpdatePerformers(galleryID, performers)
})
}
func linkGalleryTags(iqb models.GalleryReaderWriter) error {
if err := linkGalleryTag(iqb, galleryIdxWithTag, tagIdxWithGallery); err != nil {
return err
}
if err := linkGalleryTag(iqb, galleryIdxWithTwoTags, tagIdx1WithGallery); err != nil {
return err
}
if err := linkGalleryTag(iqb, galleryIdxWithTwoTags, tagIdx2WithGallery); err != nil {
return err
}
return nil
}
func linkGalleryTag(iqb models.GalleryReaderWriter, galleryIndex, tagIndex int) error {
galleryID := galleryIDs[galleryIndex]
return doLinks(galleryTagLinks, func(galleryIndex, tagIndex int) error {
galleryID := imageIDs[galleryIndex]
tags, err := iqb.GetTagIDs(galleryID)
if err != nil {
return err
@@ -805,6 +927,35 @@ func linkGalleryTag(iqb models.GalleryReaderWriter, galleryIndex, tagIndex int)
tags = append(tags, tagIDs[tagIndex])
return iqb.UpdateTags(galleryID, tags)
})
}
func linkMovieStudios(mqb models.MovieWriter) error {
return doLinks(movieStudioLinks, func(movieIndex, studioIndex int) error {
movie := models.MoviePartial{
ID: movieIDs[movieIndex],
StudioID: &sql.NullInt64{Int64: int64(studioIDs[studioIndex]), Valid: true},
}
_, err := mqb.Update(movie)
return err
})
}
func linkStudiosParent(qb models.StudioWriter) error {
return doLinks(studioParentLinks, func(parentIndex, childIndex int) error {
studio := models.StudioPartial{
ID: studioIDs[childIndex],
ParentID: &sql.NullInt64{Int64: int64(studioIDs[parentIndex]), Valid: true},
}
_, err := qb.Update(studio)
return err
})
}
func addTagImage(qb models.TagWriter, tagIndex int) error {
return qb.UpdateImage(tagIDs[tagIndex], models.DefaultTagImage)
}
func linkGalleryStudio(qb models.GalleryWriter, galleryIndex, studioIndex int) error {
@@ -816,29 +967,3 @@ func linkGalleryStudio(qb models.GalleryWriter, galleryIndex, studioIndex int) e
return err
}
func linkGalleryPerformers(qb models.GalleryReaderWriter) error {
if err := linkGalleryPerformer(qb, galleryIdxWithPerformer, performerIdxWithGallery); err != nil {
return err
}
if err := linkGalleryPerformer(qb, galleryIdxWithTwoPerformers, performerIdx1WithGallery); err != nil {
return err
}
if err := linkGalleryPerformer(qb, galleryIdxWithTwoPerformers, performerIdx2WithGallery); err != nil {
return err
}
return nil
}
func linkGalleryPerformer(iqb models.GalleryReaderWriter, galleryIndex, performerIndex int) error {
galleryID := galleryIDs[galleryIndex]
performers, err := iqb.GetPerformerIDs(galleryID)
if err != nil {
return err
}
performers = append(performers, performerIDs[performerIndex])
return iqb.UpdatePerformers(galleryID, performers)
}

View File

@@ -113,6 +113,18 @@ func (qb *tagQueryBuilder) FindBySceneID(sceneID int) ([]*models.Tag, error) {
return qb.queryTags(query, args)
}
func (qb *tagQueryBuilder) FindByPerformerID(performerID int) ([]*models.Tag, error) {
query := `
SELECT tags.* FROM tags
LEFT JOIN performers_tags as performers_join on performers_join.tag_id = tags.id
WHERE performers_join.performer_id = ?
GROUP BY tags.id
`
query += qb.getTagSort(nil)
args := []interface{}{performerID}
return qb.queryTags(query, args)
}
func (qb *tagQueryBuilder) FindByImageID(imageID int) ([]*models.Tag, error) {
query := `
SELECT tags.* FROM tags
@@ -211,6 +223,12 @@ func (qb *tagQueryBuilder) Query(tagFilter *models.TagFilterType, findFilter *mo
query.body += `
left join tags_image on tags_image.tag_id = tags.id
left join images_tags on images_tags.tag_id = tags.id
left join images on images_tags.image_id = images.id
left join galleries_tags on galleries_tags.tag_id = tags.id
left join galleries on galleries_tags.gallery_id = galleries.id
left join performers_tags on performers_tags.tag_id = tags.id
left join performers on performers_tags.performer_id = performers.id
left join scenes_tags on scenes_tags.tag_id = tags.id
left join scenes on scenes_tags.scene_id = scenes.id`
@@ -238,6 +256,30 @@ func (qb *tagQueryBuilder) Query(tagFilter *models.TagFilterType, findFilter *mo
}
}
if imageCount := tagFilter.ImageCount; imageCount != nil {
clause, count := getIntCriterionWhereClause("count(distinct images_tags.image_id)", *imageCount)
query.addHaving(clause)
if count == 1 {
query.addArg(imageCount.Value)
}
}
if galleryCount := tagFilter.GalleryCount; galleryCount != nil {
clause, count := getIntCriterionWhereClause("count(distinct galleries_tags.gallery_id)", *galleryCount)
query.addHaving(clause)
if count == 1 {
query.addArg(galleryCount.Value)
}
}
if performersCount := tagFilter.PerformerCount; performersCount != nil {
clause, count := getIntCriterionWhereClause("count(distinct performers_tags.performer_id)", *performersCount)
query.addHaving(clause)
if count == 1 {
query.addArg(performersCount.Value)
}
}
// if markerCount := tagFilter.MarkerCount; markerCount != nil {
// clause, count := getIntCriterionWhereClause("count(distinct scene_markers.id)", *markerCount)
// query.addHaving(clause)

View File

@@ -238,6 +238,132 @@ func verifyTagMarkerCount(t *testing.T, markerCountCriterion models.IntCriterion
})
}
func TestTagQueryImageCount(t *testing.T) {
countCriterion := models.IntCriterionInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
}
verifyTagImageCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierNotEquals
verifyTagImageCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierLessThan
verifyTagImageCount(t, countCriterion)
countCriterion.Value = 0
countCriterion.Modifier = models.CriterionModifierGreaterThan
verifyTagImageCount(t, countCriterion)
}
func verifyTagImageCount(t *testing.T, imageCountCriterion models.IntCriterionInput) {
withTxn(func(r models.Repository) error {
qb := r.Tag()
tagFilter := models.TagFilterType{
ImageCount: &imageCountCriterion,
}
tags, _, err := qb.Query(&tagFilter, nil)
if err != nil {
t.Errorf("Error querying tag: %s", err.Error())
}
for _, tag := range tags {
verifyInt64(t, sql.NullInt64{
Int64: int64(getTagImageCount(tag.ID)),
Valid: true,
}, imageCountCriterion)
}
return nil
})
}
func TestTagQueryGalleryCount(t *testing.T) {
countCriterion := models.IntCriterionInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
}
verifyTagGalleryCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierNotEquals
verifyTagGalleryCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierLessThan
verifyTagGalleryCount(t, countCriterion)
countCriterion.Value = 0
countCriterion.Modifier = models.CriterionModifierGreaterThan
verifyTagGalleryCount(t, countCriterion)
}
func verifyTagGalleryCount(t *testing.T, imageCountCriterion models.IntCriterionInput) {
withTxn(func(r models.Repository) error {
qb := r.Tag()
tagFilter := models.TagFilterType{
GalleryCount: &imageCountCriterion,
}
tags, _, err := qb.Query(&tagFilter, nil)
if err != nil {
t.Errorf("Error querying tag: %s", err.Error())
}
for _, tag := range tags {
verifyInt64(t, sql.NullInt64{
Int64: int64(getTagGalleryCount(tag.ID)),
Valid: true,
}, imageCountCriterion)
}
return nil
})
}
func TestTagQueryPerformerCount(t *testing.T) {
countCriterion := models.IntCriterionInput{
Value: 1,
Modifier: models.CriterionModifierEquals,
}
verifyTagPerformerCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierNotEquals
verifyTagPerformerCount(t, countCriterion)
countCriterion.Modifier = models.CriterionModifierLessThan
verifyTagPerformerCount(t, countCriterion)
countCriterion.Value = 0
countCriterion.Modifier = models.CriterionModifierGreaterThan
verifyTagPerformerCount(t, countCriterion)
}
func verifyTagPerformerCount(t *testing.T, imageCountCriterion models.IntCriterionInput) {
withTxn(func(r models.Repository) error {
qb := r.Tag()
tagFilter := models.TagFilterType{
PerformerCount: &imageCountCriterion,
}
tags, _, err := qb.Query(&tagFilter, nil)
if err != nil {
t.Errorf("Error querying tag: %s", err.Error())
}
for _, tag := range tags {
verifyInt64(t, sql.NullInt64{
Int64: int64(getTagPerformerCount(tag.ID)),
Valid: true,
}, imageCountCriterion)
}
return nil
})
}
func TestTagUpdateTagImage(t *testing.T) {
if err := withTxn(func(r models.Repository) error {
qb := r.Tag()

View File

@@ -1,3 +1,6 @@
### ✨ New Features
* Added Performer tags.
### 🎨 Improvements
* Improved performer details and edit UI pages.
* Resolve python executable to `python3` or `python` for python script scrapers.

View File

@@ -154,6 +154,7 @@ export const AddFilter: React.FC<IAddFilterProps> = (
criterion.type !== "parent_studios" &&
criterion.type !== "tags" &&
criterion.type !== "sceneTags" &&
criterion.type !== "performerTags" &&
criterion.type !== "movies"
)
return;

View File

@@ -0,0 +1,225 @@
import React, { useEffect, useState } from "react";
import { Form } from "react-bootstrap";
import _ from "lodash";
import { useBulkPerformerUpdate } from "src/core/StashService";
import * as GQL from "src/core/generated-graphql";
import { Modal } from "src/components/Shared";
import { useToast } from "src/hooks";
import MultiSet from "../Shared/MultiSet";
interface IListOperationProps {
selected: GQL.SlimPerformerDataFragment[];
onClose: (applied: boolean) => void;
}
export const EditPerformersDialog: React.FC<IListOperationProps> = (
props: IListOperationProps
) => {
const Toast = useToast();
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
GQL.BulkUpdateIdMode.Add
);
const [tagIds, setTagIds] = useState<string[]>();
const [favorite, setFavorite] = useState<boolean | undefined>();
const [updatePerformers] = useBulkPerformerUpdate(getPerformerInput());
// Network state
const [isUpdating, setIsUpdating] = useState(false);
const checkboxRef = React.createRef<HTMLInputElement>();
function makeBulkUpdateIds(
ids: string[],
mode: GQL.BulkUpdateIdMode
): GQL.BulkUpdateIds {
return {
mode,
ids,
};
}
function getPerformerInput(): GQL.BulkPerformerUpdateInput {
// need to determine what we are actually setting on each performer
const aggregateTagIds = getTagIds(props.selected);
const performerInput: GQL.BulkPerformerUpdateInput = {
ids: props.selected.map((performer) => {
return performer.id;
}),
};
// if tagIds non-empty, then we are setting them
if (
tagMode === GQL.BulkUpdateIdMode.Set &&
(!tagIds || tagIds.length === 0)
) {
// and all performers have the same ids,
if (aggregateTagIds.length > 0) {
// then unset the tagIds, otherwise ignore
performerInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
}
} else {
// if tagIds non-empty, then we are setting them
performerInput.tag_ids = makeBulkUpdateIds(tagIds || [], tagMode);
}
if (favorite !== undefined) {
performerInput.favorite = favorite;
}
return performerInput;
}
async function onSave() {
setIsUpdating(true);
try {
await updatePerformers();
Toast.success({ content: "Updated performers" });
props.onClose(true);
} catch (e) {
Toast.error(e);
}
setIsUpdating(false);
}
function getTagIds(state: GQL.SlimPerformerDataFragment[]) {
let ret: string[] = [];
let first = true;
state.forEach((performer: GQL.SlimPerformerDataFragment) => {
if (first) {
ret = performer.tags ? performer.tags.map((t) => t.id).sort() : [];
first = false;
} else {
const tIds = performer.tags
? performer.tags.map((t) => t.id).sort()
: [];
if (!_.isEqual(ret, tIds)) {
ret = [];
}
}
});
return ret;
}
useEffect(() => {
const state = props.selected;
let updateTagIds: string[] = [];
let updateFavorite: boolean | undefined;
let first = true;
state.forEach((performer: GQL.SlimPerformerDataFragment) => {
const performerTagIDs = (performer.tags ?? []).map((p) => p.id).sort();
if (first) {
updateTagIds = performerTagIDs;
first = false;
updateFavorite = performer.favorite;
} else {
if (!_.isEqual(performerTagIDs, updateTagIds)) {
updateTagIds = [];
}
if (performer.favorite !== updateFavorite) {
updateFavorite = undefined;
}
}
});
if (tagMode === GQL.BulkUpdateIdMode.Set) {
setTagIds(updateTagIds);
}
setFavorite(updateFavorite);
}, [props.selected, tagMode]);
useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = favorite === undefined;
}
}, [favorite, checkboxRef]);
function renderMultiSelect(
type: "performers" | "tags",
ids: string[] | undefined
) {
let mode = GQL.BulkUpdateIdMode.Add;
switch (type) {
case "tags":
mode = tagMode;
break;
}
return (
<MultiSet
type={type}
disabled={isUpdating}
onUpdate={(items) => {
const itemIDs = items.map((i) => i.id);
switch (type) {
case "tags":
setTagIds(itemIDs);
break;
}
}}
onSetMode={(newMode) => {
switch (type) {
case "tags":
setTagMode(newMode);
break;
}
}}
ids={ids ?? []}
mode={mode}
/>
);
}
function cycleFavorite() {
if (favorite) {
setFavorite(undefined);
} else if (favorite === undefined) {
setFavorite(false);
} else {
setFavorite(true);
}
}
function render() {
return (
<Modal
show
icon="pencil-alt"
header="Edit Performers"
accept={{ onClick: onSave, text: "Apply" }}
cancel={{
onClick: () => props.onClose(false),
text: "Cancel",
variant: "secondary",
}}
isRunning={isUpdating}
>
<Form>
<Form.Group controlId="tags">
<Form.Label>Tags</Form.Label>
{renderMultiSelect("tags", tagIds)}
</Form.Group>
<Form.Group controlId="favorite">
<Form.Check
type="checkbox"
label="Favorite"
checked={favorite}
ref={checkboxRef}
onChange={() => cycleFavorite()}
/>
</Form.Group>
</Form>
</Modal>
);
}
return render();
};

View File

@@ -1,9 +1,17 @@
import React from "react";
import { Link } from "react-router-dom";
import { FormattedNumber, FormattedPlural, FormattedMessage } from "react-intl";
import { FormattedMessage } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { NavUtils, TextUtils } from "src/utils";
import { BasicCard, CountryFlag, TruncatedText } from "src/components/Shared";
import {
BasicCard,
CountryFlag,
HoverPopover,
Icon,
TagLink,
TruncatedText,
} from "src/components/Shared";
import { Button, ButtonGroup } from "react-bootstrap";
interface IPerformerCardProps {
performer: GQL.PerformerDataFragment;
@@ -34,6 +42,50 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
);
}
function maybeRenderScenesPopoverButton() {
if (!performer.scene_count) return;
return (
<Link to={NavUtils.makePerformerScenesUrl(performer)}>
<Button className="minimal">
<Icon icon="play-circle" />
<span>{performer.scene_count}</span>
</Button>
</Link>
);
}
function maybeRenderTagPopoverButton() {
if (performer.tags.length <= 0) return;
const popoverContent = performer.tags.map((tag) => (
<TagLink key={tag.id} tagType="performer" tag={tag} />
));
return (
<HoverPopover placement="bottom" content={popoverContent}>
<Button className="minimal">
<Icon icon="tag" />
<span>{performer.tags.length}</span>
</Button>
</HoverPopover>
);
}
function maybeRenderPopoverButtonGroup() {
if (performer.scene_count || performer.tags.length > 0) {
return (
<>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderScenesPopoverButton()}
{maybeRenderTagPopoverButton()}
</ButtonGroup>
</>
);
}
}
return (
<BasicCard
className="performer-card"
@@ -57,19 +109,7 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
<Link to={NavUtils.makePerformersCountryUrl(performer)}>
<CountryFlag country={performer.country} />
</Link>
<div className="text-muted">
Stars in&nbsp;
<FormattedNumber value={performer.scene_count ?? 0} />
&nbsp;
<Link to={NavUtils.makePerformerScenesUrl(performer)}>
<FormattedPlural
value={performer.scene_count ?? 0}
one="scene"
other="scenes"
/>
</Link>
.
</div>
{maybeRenderPopoverButtonGroup()}
</>
}
selected={selected}

View File

@@ -1,5 +1,6 @@
import React from "react";
import { useIntl } from "react-intl";
import { TagLink } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql";
import { genderToString } from "src/core/StashService";
import { TextUtils } from "src/utils";
@@ -15,6 +16,25 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
// Network state
const intl = useIntl();
function renderTagsField() {
if (!performer.tags?.length) {
return;
}
return (
<dl className="row">
<dt className="col-3 col-xl-2">Tags</dt>
<dd className="col-9 col-xl-10">
<ul className="pl-0">
{(performer.tags ?? []).map((tag) => (
<TagLink key={tag.id} tagType="performer" tag={tag} />
))}
</ul>
</dd>
</dl>
);
}
function renderStashIDs() {
if (!performer.stash_ids?.length) {
return;
@@ -101,6 +121,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
TextUtils.instagramURL
)}
/>
{renderTagsField()}
{renderStashIDs()}
</>
);

View File

@@ -7,6 +7,7 @@ import {
Col,
InputGroup,
Row,
Badge,
} from "react-bootstrap";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
@@ -20,6 +21,7 @@ import {
mutateReloadScrapers,
usePerformerUpdate,
usePerformerCreate,
useTagCreate,
queryScrapePerformerURL,
} from "src/core/StashService";
import {
@@ -28,6 +30,8 @@ import {
ImageInput,
ScrapePerformerSuggest,
LoadingIndicator,
CollapseButton,
TagSelect,
} from "src/components/Shared";
import { ImageUtils } from "src/utils";
import { useToast } from "src/hooks";
@@ -64,6 +68,8 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
scrapePerformerDetails,
setScrapePerformerDetails,
] = useState<GQL.ScrapedPerformerDataFragment>();
const [newTags, setNewTags] = useState<GQL.ScrapedSceneTag[]>();
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Network state
@@ -81,6 +87,8 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true);
const [createTag] = useTagCreate({ name: "" });
const genderOptions = [""].concat(getGenderStrings());
const schema = yup.object({
@@ -100,6 +108,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
url: yup.string().optional(),
twitter: yup.string().optional(),
instagram: yup.string().optional(),
tag_ids: yup.array(yup.string().required()).optional(),
stash_ids: yup.mixed<GQL.StashIdInput>().optional(),
image: yup.string().optional().nullable(),
});
@@ -121,6 +130,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
url: performer.url ?? "",
twitter: performer.twitter ?? "",
instagram: performer.instagram ?? "",
tag_ids: (performer.tags ?? []).map((t) => t.id),
stash_ids: performer.stash_ids ?? undefined,
image: undefined,
};
@@ -154,6 +164,75 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
return genderToString(retEnum);
}
function renderNewTags() {
if (!newTags || newTags.length === 0) {
return;
}
const ret = (
<>
{newTags.map((t) => (
<Badge
className="tag-item"
variant="secondary"
key={t.name}
onClick={() => createNewTag(t)}
>
{t.name}
<Button className="minimal ml-2">
<Icon className="fa-fw" icon="plus" />
</Button>
</Badge>
))}
</>
);
const minCollapseLength = 10;
if (newTags.length >= minCollapseLength) {
return (
<CollapseButton text={`Missing (${newTags.length})`}>
{ret}
</CollapseButton>
);
}
return ret;
}
async function createNewTag(toCreate: GQL.ScrapedSceneTag) {
let tagInput: GQL.TagCreateInput = { name: "" };
try {
tagInput = Object.assign(tagInput, toCreate);
const result = await createTag({
variables: tagInput,
});
// add the new tag to the new tags value
const newTagIds = formik.values.tag_ids.concat([
result.data!.tagCreate!.id,
]);
formik.setFieldValue("tag_ids", newTagIds);
// remove the tag from the list
const newTagsClone = newTags!.concat();
const pIndex = newTagsClone.indexOf(toCreate);
newTagsClone.splice(pIndex, 1);
setNewTags(newTagsClone);
Toast.success({
content: (
<span>
Created tag: <b>{toCreate.name}</b>
</span>
),
});
} catch (e) {
Toast.error(e);
}
}
function updatePerformerEditStateFromScraper(
state: Partial<GQL.ScrapedPerformerDataFragment>
) {
@@ -210,6 +289,13 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
translateScrapedGender(state.gender ?? undefined)
);
}
if (state.tags) {
// map tags to their ids and filter out those not found
const newTagIds = state.tags.map((t) => t.stored_id).filter((t) => t);
formik.setFieldValue("tag_ids", newTagIds as string[]);
setNewTags(state.tags.filter((t) => !t.stored_id));
}
// image is a base64 string
// #404: don't overwrite image if it has been modified by the user
@@ -354,7 +440,13 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
if (!scrapePerformerDetails) return {};
// image is not supported
const { __typename, image: _image, ...ret } = scrapePerformerDetails;
// remove tags as well
const {
__typename,
image: _image,
tags: _tags,
...ret
} = scrapePerformerDetails;
return ret;
}
@@ -484,10 +576,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
return;
}
const currentPerformer: Partial<GQL.PerformerDataFragment> = {
const currentPerformer: Partial<GQL.PerformerUpdateInput> = {
...formik.values,
gender: stringToGender(formik.values.gender),
image_path: formik.values.image ?? performer.image_path,
image: formik.values.image ?? performer.image_path,
};
return (
@@ -570,6 +662,28 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
);
}
function renderTagsField() {
return (
<Form.Row>
<Form.Group as={Col} sm="6">
<Form.Label>Tags</Form.Label>
<TagSelect
menuPortalTarget={document.body}
isMulti
onSelect={(items) =>
formik.setFieldValue(
"tag_ids",
items.map((item) => item.id)
)
}
ids={formik.values.tag_ids}
/>
{renderNewTags()}
</Form.Group>
</Form.Row>
);
}
const removeStashID = (stashID: GQL.StashIdInput) => {
formik.setFieldValue(
"stash_ids",
@@ -805,6 +919,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
/>
</Form.Group>
</Form.Row>
{renderTagsField()}
{renderStashIDs()}
{renderButtons()}

View File

@@ -11,8 +11,12 @@ import {
getGenderStrings,
genderToString,
stringToGender,
useTagCreate,
} from "src/core/StashService";
import { Form } from "react-bootstrap";
import { TagSelect } from "src/components/Shared";
import { useToast } from "src/hooks";
import _ from "lodash";
function renderScrapedGender(
result: ScrapeResult<string>,
@@ -62,8 +66,54 @@ function renderScrapedGenderRow(
);
}
function renderScrapedTags(
result: ScrapeResult<string[]>,
isNew?: boolean,
onChange?: (value: string[]) => void
) {
const resultValue = isNew ? result.newValue : result.originalValue;
const value = resultValue ?? [];
return (
<TagSelect
isMulti
className="form-control react-select"
isDisabled={!isNew}
onSelect={(items) => {
if (onChange) {
onChange(items.map((i) => i.id));
}
}}
ids={value}
/>
);
}
function renderScrapedTagsRow(
result: ScrapeResult<string[]>,
onChange: (value: ScrapeResult<string[]>) => void,
newTags: GQL.ScrapedSceneTag[],
onCreateNew?: (value: GQL.ScrapedSceneTag) => void
) {
return (
<ScrapeDialogRow
title="Tags"
result={result}
renderOriginalField={() => renderScrapedTags(result)}
renderNewField={() =>
renderScrapedTags(result, true, (value) =>
onChange(result.cloneWithValue(value))
)
}
newValues={newTags}
onChange={onChange}
onCreateNew={onCreateNew}
/>
);
}
interface IPerformerScrapeDialogProps {
performer: Partial<GQL.PerformerDataFragment>;
performer: Partial<GQL.PerformerUpdateInput>;
scraped: GQL.ScrapedPerformer;
onClose: (scrapedPerformer?: GQL.ScrapedPerformer) => void;
@@ -151,8 +201,64 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
)
);
const [createTag] = useTagCreate({ name: "" });
const Toast = useToast();
interface IHasStoredID {
stored_id?: string | null;
}
function mapStoredIdObjects(
scrapedObjects?: IHasStoredID[]
): string[] | undefined {
if (!scrapedObjects) {
return undefined;
}
const ret = scrapedObjects
.map((p) => p.stored_id)
.filter((p) => {
return p !== undefined && p !== null;
}) as string[];
if (ret.length === 0) {
return undefined;
}
// sort by id numerically
ret.sort((a, b) => {
return parseInt(a, 10) - parseInt(b, 10);
});
return ret;
}
function sortIdList(idList?: string[] | null) {
if (!idList) {
return;
}
const ret = _.clone(idList);
// sort by id numerically
ret.sort((a, b) => {
return parseInt(a, 10) - parseInt(b, 10);
});
return ret;
}
const [tags, setTags] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(
sortIdList(props.performer.tag_ids ?? undefined),
mapStoredIdObjects(props.scraped.tags ?? undefined)
)
);
const [newTags, setNewTags] = useState<GQL.ScrapedSceneTag[]>(
props.scraped.tags?.filter((t) => !t.stored_id) ?? []
);
const [image, setImage] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.performer.image_path, props.scraped.image)
new ScrapeResult<string>(props.performer.image, props.scraped.image)
);
const allFields = [
@@ -173,6 +279,7 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
instagram,
gender,
image,
tags,
];
// don't show the dialog if nothing was scraped
if (allFields.every((r) => !r.scraped)) {
@@ -180,6 +287,41 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
return <></>;
}
async function createNewTag(toCreate: GQL.ScrapedSceneTag) {
let tagInput: GQL.TagCreateInput = { name: "" };
try {
tagInput = Object.assign(tagInput, toCreate);
const result = await createTag({
variables: tagInput,
});
// add the new tag to the new tags value
const tagClone = tags.cloneWithValue(tags.newValue);
if (!tagClone.newValue) {
tagClone.newValue = [];
}
tagClone.newValue.push(result.data!.tagCreate!.id);
setTags(tagClone);
// remove the tag from the list
const newTagsClone = newTags.concat();
const pIndex = newTagsClone.indexOf(toCreate);
newTagsClone.splice(pIndex, 1);
setNewTags(newTagsClone);
Toast.success({
content: (
<span>
Created tag: <b>{toCreate.name}</b>
</span>
),
});
} catch (e) {
Toast.error(e);
}
}
function makeNewScrapedItem(): GQL.ScrapedPerformer {
return {
name: name.getNewValue(),
@@ -198,6 +340,12 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
twitter: twitter.getNewValue(),
instagram: instagram.getNewValue(),
gender: gender.getNewValue(),
tags: tags.getNewValue()?.map((m) => {
return {
stored_id: m,
name: "",
};
}),
image: image.getNewValue(),
};
}
@@ -281,6 +429,12 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
result={instagram}
onChange={(value) => setInstagram(value)}
/>
{renderScrapedTagsRow(
tags,
(value) => setTags(value),
newTags,
createNewTag
)}
<ScrapedImageRow
title="Performer Image"
className="performer-image"

View File

@@ -17,8 +17,17 @@ import { DisplayMode } from "src/models/list-filter/types";
import { ExportDialog, DeleteEntityDialog } from "src/components/Shared";
import { PerformerCard } from "./PerformerCard";
import { PerformerListTable } from "./PerformerListTable";
import { EditPerformersDialog } from "./EditPerformersDialog";
export const PerformerList: React.FC = () => {
interface IPerformerList {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
persistState?: PersistanceLevel;
}
export const PerformerList: React.FC<IPerformerList> = ({
filterHook,
persistState,
}) => {
const history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
@@ -82,6 +91,17 @@ export const PerformerList: React.FC = () => {
}
}
function renderEditPerformersDialog(
selectedPerformers: SlimPerformerDataFragment[],
onClose: (applied: boolean) => void
) {
return (
<>
<EditPerformersDialog selected={selectedPerformers} onClose={onClose} />
</>
);
}
const renderDeleteDialog = (
selectedPerformers: SlimPerformerDataFragment[],
onClose: (confirmed: boolean) => void
@@ -98,9 +118,11 @@ export const PerformerList: React.FC = () => {
const listData = usePerformersList({
otherOperations,
renderContent,
renderEditDialog: renderEditPerformersDialog,
filterHook,
addKeybinds,
selectable: true,
persistState: PersistanceLevel.ALL,
persistState,
renderDeleteDialog,
});

View File

@@ -1,11 +1,18 @@
import React from "react";
import { Route, Switch } from "react-router-dom";
import { PersistanceLevel } from "src/hooks/ListHook";
import { Performer } from "./PerformerDetails/Performer";
import { PerformerList } from "./PerformerList";
const Performers = () => (
<Switch>
<Route exact path="/performers" component={PerformerList} />
<Route
exact
path="/performers"
render={(props) => (
<PerformerList persistState={PersistanceLevel.ALL} {...props} />
)}
/>
<Route path="/performers/:id/:tab?" component={Performer} />
</Switch>
);

View File

@@ -32,6 +32,7 @@ interface ITypeProps {
| "parent_studios"
| "tags"
| "sceneTags"
| "performerTags"
| "movies";
}
interface IFilterProps {
@@ -43,6 +44,7 @@ interface IFilterProps {
isMulti?: boolean;
isClearable?: boolean;
isDisabled?: boolean;
menuPortalTarget?: HTMLElement | null;
}
interface ISelectProps<T extends boolean> {
className?: string;
@@ -60,6 +62,7 @@ interface ISelectProps<T extends boolean> {
placeholder?: string;
showDropdown?: boolean;
groupHeader?: string;
menuPortalTarget?: HTMLElement | null;
closeMenuOnSelect?: boolean;
noOptionsMessage?: string | null;
}
@@ -109,6 +112,7 @@ const SelectComponent = <T extends boolean>({
placeholder,
showDropdown = true,
groupHeader,
menuPortalTarget,
closeMenuOnSelect = true,
noOptionsMessage = type !== "tags" ? "None" : null,
}: ISelectProps<T> & ITypeProps) => {
@@ -158,6 +162,7 @@ const SelectComponent = <T extends boolean>({
isLoading,
styles,
closeMenuOnSelect,
menuPortalTarget,
components: {
IndicatorSeparator: () => null,
...((!showDropdown || isDisabled) && { DropdownIndicator: () => null }),

View File

@@ -14,6 +14,7 @@ import { NavUtils, TextUtils } from "src/utils";
interface IProps {
tag?: Partial<TagDataFragment>;
tagType?: "performer" | "scene";
performer?: Partial<PerformerDataFragment>;
marker?: Partial<SceneMarkerDataFragment>;
movie?: Partial<MovieDataFragment>;
@@ -26,7 +27,11 @@ export const TagLink: React.FC<IProps> = (props: IProps) => {
let link: string = "#";
let title: string = "";
if (props.tag) {
if (!props.tagType || props.tagType === "scene") {
link = NavUtils.makeTagScenesUrl(props.tag);
} else {
link = NavUtils.makeTagPerformersUrl(props.tag);
}
title = props.tag.name || "";
} else if (props.performer) {
link = NavUtils.makePerformerScenesUrl(props.performer);

View File

@@ -47,6 +47,19 @@ export const TagCard: React.FC<IProps> = ({
);
}
function maybeRenderPerformersPopoverButton() {
if (!tag.performer_count) return;
return (
<Link to={NavUtils.makeTagPerformersUrl(tag)}>
<Button className="minimal">
<Icon icon="user" />
<span>{tag.performer_count}</span>
</Button>
</Link>
);
}
function maybeRenderPopoverButtonGroup() {
if (tag) {
return (
@@ -55,6 +68,7 @@ export const TagCard: React.FC<IProps> = ({
<ButtonGroup className="card-popovers">
{maybeRenderScenesPopoverButton()}
{maybeRenderSceneMarkersPopoverButton()}
{maybeRenderPerformersPopoverButton()}
</ButtonGroup>
</>
);

View File

@@ -22,6 +22,7 @@ import { useToast } from "src/hooks";
import { TagScenesPanel } from "./TagScenesPanel";
import { TagMarkersPanel } from "./TagMarkersPanel";
import { TagImagesPanel } from "./TagImagesPanel";
import { TagPerformersPanel } from "./TagPerformersPanel";
interface ITabParams {
id?: string;
@@ -51,7 +52,10 @@ export const Tag: React.FC = () => {
const [createTag] = useTagCreate(getTagInput() as GQL.TagUpdateInput);
const [deleteTag] = useTagDestroy(getTagInput() as GQL.TagUpdateInput);
const activeTabKey = tab === "markers" || tab === "images" ? tab : "scenes";
const activeTabKey =
tab === "markers" || tab === "images" || tab === "performers"
? tab
: "scenes";
const setActiveTabKey = (newTab: string | null) => {
if (tab !== newTab) {
const tabParam = newTab === "scenes" ? "" : `/${newTab}`;
@@ -260,6 +264,9 @@ export const Tag: React.FC = () => {
<Tab eventKey="markers" title="Markers">
<TagMarkersPanel tag={tag} />
</Tab>
<Tab eventKey="performers" title="Performers">
<TagPerformersPanel tag={tag} />
</Tab>
</Tabs>
</div>
)}

View File

@@ -0,0 +1,12 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { tagFilterHook } from "src/core/tags";
import { PerformerList } from "src/components/Performers/PerformerList";
interface ITagPerformersPanel {
tag: GQL.TagDataFragment;
}
export const TagPerformersPanel: React.FC<ITagPerformersPanel> = ({ tag }) => {
return <PerformerList filterHook={tagFilterHook(tag)} />;
};

View File

@@ -298,6 +298,15 @@ export const usePerformerUpdate = () =>
GQL.usePerformerUpdateMutation({
update: deleteCache(performerMutationImpactedQueries),
});
export const useBulkPerformerUpdate = (input: GQL.BulkPerformerUpdateInput) =>
GQL.useBulkPerformerUpdateMutation({
variables: {
input,
},
update: deleteCache(performerMutationImpactedQueries),
});
export const usePerformerDestroy = () =>
GQL.usePerformerDestroyMutation({
refetchQueries: getQueryNames([

View File

@@ -709,6 +709,7 @@ CareerLength
Tattoos
Piercings
Aliases
Tags (see Tag fields)
Image
```

View File

@@ -24,6 +24,7 @@ export type CriterionType =
| "movieIsMissing"
| "tags"
| "sceneTags"
| "performerTags"
| "performers"
| "studios"
| "movies"
@@ -43,7 +44,10 @@ export type CriterionType =
| "gender"
| "parent_studios"
| "scene_count"
| "marker_count";
| "marker_count"
| "image_count"
| "gallery_count"
| "performer_count";
type Option = string | number | IOptionType;
export type CriterionValue = string | number | ILabeledId[];
@@ -83,6 +87,8 @@ export abstract class Criterion {
return "Tags";
case "sceneTags":
return "Scene Tags";
case "performerTags":
return "Performer Tags";
case "performers":
return "Performers";
case "studios":
@@ -123,6 +129,12 @@ export abstract class Criterion {
return "Scene Count";
case "marker_count":
return "Marker Count";
case "image_count":
return "Image Count";
case "gallery_count":
return "Gallery Count";
case "performer_count":
return "Performer Count";
}
}

View File

@@ -14,13 +14,16 @@ export class TagsCriterion extends Criterion {
public options: IOptionType[] = [];
public value: ILabeledId[] = [];
constructor(type: "tags" | "sceneTags") {
constructor(type: "tags" | "sceneTags" | "performerTags") {
super();
this.type = type;
this.parameterName = type;
if (type === "sceneTags") {
this.parameterName = "scene_tags";
}
if (type === "performerTags") {
this.parameterName = "performer_tags";
}
}
public encodeValue() {
@@ -39,3 +42,8 @@ export class SceneTagsCriterionOption implements ICriterionOption {
public label: string = Criterion.getLabel("sceneTags");
public value: CriterionType = "sceneTags";
}
export class PerformerTagsCriterionOption implements ICriterionOption {
public label: string = Criterion.getLabel("performerTags");
public value: CriterionType = "performerTags";
}

View File

@@ -43,6 +43,9 @@ export function makeCriteria(type: CriterionType = "none") {
case "o_counter":
case "scene_count":
case "marker_count":
case "image_count":
case "gallery_count":
case "performer_count":
return new NumberCriterion(type, type);
case "resolution":
return new ResolutionCriterion();
@@ -72,6 +75,8 @@ export function makeCriteria(type: CriterionType = "none") {
return new TagsCriterion("tags");
case "sceneTags":
return new TagsCriterion("sceneTags");
case "performerTags":
return new TagsCriterion("performerTags");
case "performers":
return new PerformersCriterion();
case "studios":

View File

@@ -64,6 +64,7 @@ import {
ParentStudiosCriterionOption,
} from "./criteria/studios";
import {
PerformerTagsCriterionOption,
SceneTagsCriterionOption,
TagsCriterion,
TagsCriterionOption,
@@ -146,6 +147,7 @@ export class ListFilterModel {
new HasMarkersCriterionOption(),
new SceneIsMissingCriterionOption(),
new TagsCriterionOption(),
new PerformerTagsCriterionOption(),
new PerformersCriterionOption(),
new StudiosCriterionOption(),
new MoviesCriterionOption(),
@@ -172,6 +174,7 @@ export class ListFilterModel {
new ResolutionCriterionOption(),
new ImageIsMissingCriterionOption(),
new TagsCriterionOption(),
new PerformerTagsCriterionOption(),
new PerformersCriterionOption(),
new StudiosCriterionOption(),
];
@@ -206,6 +209,7 @@ export class ListFilterModel {
new FavoriteCriterionOption(),
new GenderCriterionOption(),
new PerformerIsMissingCriterionOption(),
new TagsCriterionOption(),
...numberCriteria
.concat(stringCriteria)
.map((c) => ListFilterModel.createCriterionOption(c)),
@@ -245,6 +249,7 @@ export class ListFilterModel {
new AverageResolutionCriterionOption(),
new GalleryIsMissingCriterionOption(),
new TagsCriterionOption(),
new PerformerTagsCriterionOption(),
new PerformersCriterionOption(),
new StudiosCriterionOption(),
];
@@ -277,13 +282,20 @@ export class ListFilterModel {
// issues
this.sortByOptions = [
"name",
"scenes_count" /* , "scene_markers_count"*/,
"scenes_count",
"images_count",
"galleries_count",
"performers_count",
/* "scene_markers_count" */
];
this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List];
this.criterionOptions = [
new NoneCriterionOption(),
new TagIsMissingCriterionOption(),
ListFilterModel.createCriterionOption("scene_count"),
ListFilterModel.createCriterionOption("image_count"),
ListFilterModel.createCriterionOption("gallery_count"),
ListFilterModel.createCriterionOption("performer_count"),
// marker count has been disabled for now due to performance issues
// ListFilterModel.createCriterionOption("marker_count"),
];
@@ -527,6 +539,14 @@ export class ListFilterModel {
};
break;
}
case "performerTags": {
const performerTagsCrit = criterion as TagsCriterion;
result.performer_tags = {
value: performerTagsCrit.value.map((tag) => tag.id),
modifier: performerTagsCrit.modifier,
};
break;
}
case "performers": {
const perfCrit = criterion as PerformersCriterion;
result.performers = {
@@ -650,6 +670,15 @@ export class ListFilterModel {
}
case "performerIsMissing":
result.is_missing = (criterion as IsMissingCriterion).value;
break;
case "tags": {
const tagsCrit = criterion as TagsCriterion;
result.tags = {
value: tagsCrit.value.map((tag) => tag.id),
modifier: tagsCrit.modifier,
};
break;
}
// no default
}
});
@@ -778,6 +807,14 @@ export class ListFilterModel {
};
break;
}
case "performerTags": {
const performerTagsCrit = criterion as TagsCriterion;
result.performer_tags = {
value: performerTagsCrit.value.map((tag) => tag.id),
modifier: performerTagsCrit.modifier,
};
break;
}
case "performers": {
const perfCrit = criterion as PerformersCriterion;
result.performers = {
@@ -929,6 +966,14 @@ export class ListFilterModel {
};
break;
}
case "performerTags": {
const performerTagsCrit = criterion as TagsCriterion;
result.performer_tags = {
value: performerTagsCrit.value.map((tag) => tag.id),
modifier: performerTagsCrit.modifier,
};
break;
}
case "performers": {
const perfCrit = criterion as PerformersCriterion;
result.performers = {
@@ -967,6 +1012,30 @@ export class ListFilterModel {
};
break;
}
case "image_count": {
const countCrit = criterion as NumberCriterion;
result.image_count = {
value: countCrit.value,
modifier: countCrit.modifier,
};
break;
}
case "gallery_count": {
const countCrit = criterion as NumberCriterion;
result.gallery_count = {
value: countCrit.value,
modifier: countCrit.modifier,
};
break;
}
case "performer_count": {
const countCrit = criterion as NumberCriterion;
result.performer_count = {
value: countCrit.value,
modifier: countCrit.modifier,
};
break;
}
// disabled due to performance issues
// case "marker_count": {
// const countCrit = criterion as NumberCriterion;

View File

@@ -76,6 +76,15 @@ const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => {
return `/scenes?${filter.makeQueryParameters()}`;
};
const makeTagPerformersUrl = (tag: Partial<GQL.TagDataFragment>) => {
if (!tag.id) return "#";
const filter = new ListFilterModel(FilterMode.Performers);
const criterion = new TagsCriterion("tags");
criterion.value = [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }];
filter.criteria.push(criterion);
return `/performers?${filter.makeQueryParameters()}`;
};
const makeTagSceneMarkersUrl = (tag: Partial<GQL.TagDataFragment>) => {
if (!tag.id) return "#";
const filter = new ListFilterModel(FilterMode.SceneMarkers);
@@ -98,6 +107,7 @@ export default {
makeStudioScenesUrl,
makeTagSceneMarkersUrl,
makeTagScenesUrl,
makeTagPerformersUrl,
makeSceneMarkerUrl,
makeMovieScenesUrl,
makeChildStudiosUrl,