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 name
gender gender
image_path image_path
favorite
tags {
id
name
}
stash_ids { stash_ids {
endpoint endpoint
stash_id stash_id

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ type Performer {
piercings: String piercings: String
aliases: String aliases: String
favorite: Boolean! favorite: Boolean!
tags: [Tag!]!
image_path: String # Resolver image_path: String # Resolver
scene_count: Int # Resolver scene_count: Int # Resolver
@@ -52,6 +53,7 @@ input PerformerCreateInput {
twitter: String twitter: String
instagram: String instagram: String
favorite: Boolean favorite: Boolean
tag_ids: [ID!]
"""This should be base64 encoded""" """This should be base64 encoded"""
image: String image: String
stash_ids: [StashIDInput!] stash_ids: [StashIDInput!]
@@ -76,11 +78,34 @@ input PerformerUpdateInput {
twitter: String twitter: String
instagram: String instagram: String
favorite: Boolean favorite: Boolean
tag_ids: [ID!]
"""This should be base64 encoded""" """This should be base64 encoded"""
image: String image: String
stash_ids: [StashIDInput!] 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 { input PerformerDestroyInput {
id: ID! id: ID!
} }

View File

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

View File

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

View File

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

View File

@@ -138,6 +138,17 @@ func (r *performerResolver) ImagePath(ctx context.Context, obj *models.Performer
return &imagePath, nil 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) { func (r *performerResolver) SceneCount(ctx context.Context, obj *models.Performer) (ret *int, err error) {
var res int var res int
if err := r.withReadTxn(ctx, func(repo models.ReaderRepository) error { 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 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) { func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string) baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
imagePath := urlbuilders.NewTagURLBuilder(baseURL, obj.ID).GetTagImageURL() 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 return err
} }
if len(input.TagIds) > 0 {
if err := r.updatePerformerTags(qb, performer.ID, input.TagIds); err != nil {
return err
}
}
// update image table // update image table
if len(imageData) > 0 { if len(imageData) > 0 {
if err := qb.UpdateImage(performer.ID, imageData); err != nil { 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 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 // update image table
if len(imageData) > 0 { if len(imageData) > 0 {
if err := qb.UpdateImage(performer.ID, imageData); err != nil { 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 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) { func (r *mutationResolver) PerformerDestroy(ctx context.Context, input models.PerformerDestroyInput) (bool, error) {
id, err := strconv.Atoi(input.ID) id, err := strconv.Atoi(input.ID)
if err != nil { if err != nil {

View File

@@ -253,7 +253,7 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input models.Bul
// Save the tags // Save the tags
if translator.hasField("tag_ids") { if translator.hasField("tag_ids") {
tagIDs, err := adjustSceneTagIDs(qb, sceneID, *input.TagIds) tagIDs, err := adjustTagIDs(qb, sceneID, *input.TagIds)
if err != nil { if err != nil {
return err return err
} }
@@ -330,7 +330,11 @@ func adjustScenePerformerIDs(qb models.SceneReader, sceneID int, ids models.Bulk
return adjustIDs(ret, ids), nil 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) ret, err = qb.GetTagIDs(sceneID)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@@ -21,7 +21,7 @@ import (
var DB *sqlx.DB var DB *sqlx.DB
var dbPath string var dbPath string
var appSchemaVersion uint = 18 var appSchemaVersion uint = 19
var databaseSchemaVersion uint var databaseSchemaVersion uint
const sqlite3Driver = "sqlite3ex" 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 ( import (
"fmt" "fmt"
"github.com/json-iterator/go"
"os" "os"
jsoniter "github.com/json-iterator/go"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
) )
@@ -26,6 +26,7 @@ type Performer struct {
Piercings string `json:"piercings,omitempty"` Piercings string `json:"piercings,omitempty"`
Aliases string `json:"aliases,omitempty"` Aliases string `json:"aliases,omitempty"`
Favorite bool `json:"favorite,omitempty"` Favorite bool `json:"favorite,omitempty"`
Tags []string `json:"tags,omitempty"`
Image string `json:"image,omitempty"` Image string `json:"image,omitempty"`
CreatedAt models.JSONTime `json:"created_at,omitempty"` CreatedAt models.JSONTime `json:"created_at,omitempty"`
UpdatedAt models.JSONTime `json:"updated_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 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) performerJSON, err := t.json.getPerformer(p.Checksum)
if err != nil { if err != nil {
logger.Debugf("[performers] error reading performer json: %s", err.Error()) 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 return r0, r1
} }
// GetTagIDs provides a mock function with given fields: galleryID // GetSceneIDs provides a mock function with given fields: galleryID
func (_m *GalleryReaderWriter) GetTagIDs(galleryID int) ([]int, error) { func (_m *GalleryReaderWriter) GetSceneIDs(galleryID int) ([]int, error) {
ret := _m.Called(galleryID) ret := _m.Called(galleryID)
var r0 []int var r0 []int
@@ -323,8 +323,8 @@ func (_m *GalleryReaderWriter) GetTagIDs(galleryID int) ([]int, error) {
return r0, r1 return r0, r1
} }
// GetSceneIDs provides a mock function with given fields: galleryID // GetTagIDs provides a mock function with given fields: galleryID
func (_m *GalleryReaderWriter) GetSceneIDs(galleryID int) ([]int, error) { func (_m *GalleryReaderWriter) GetTagIDs(galleryID int) ([]int, error) {
ret := _m.Called(galleryID) ret := _m.Called(galleryID)
var r0 []int var r0 []int
@@ -464,20 +464,6 @@ func (_m *GalleryReaderWriter) UpdatePerformers(galleryID int, performerIDs []in
return r0 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 // UpdateScenes provides a mock function with given fields: galleryID, sceneIDs
func (_m *GalleryReaderWriter) UpdateScenes(galleryID int, sceneIDs []int) error { func (_m *GalleryReaderWriter) UpdateScenes(galleryID int, sceneIDs []int) error {
ret := _m.Called(galleryID, sceneIDs) ret := _m.Called(galleryID, sceneIDs)
@@ -491,3 +477,17 @@ func (_m *GalleryReaderWriter) UpdateScenes(galleryID int, sceneIDs []int) error
return r0 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 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 // Create provides a mock function with given fields: newPerformer
func (_m *PerformerReaderWriter) Create(newPerformer models.Performer) (*models.Performer, error) { func (_m *PerformerReaderWriter) Create(newPerformer models.Performer) (*models.Performer, error) {
ret := _m.Called(newPerformer) ret := _m.Called(newPerformer)
@@ -337,6 +358,29 @@ func (_m *PerformerReaderWriter) GetStashIDs(performerID int) ([]*models.StashID
return r0, r1 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 // Query provides a mock function with given fields: performerFilter, findFilter
func (_m *PerformerReaderWriter) Query(performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) { func (_m *PerformerReaderWriter) Query(performerFilter *models.PerformerFilterType, findFilter *models.FindFilterType) ([]*models.Performer, int, error) {
ret := _m.Called(performerFilter, findFilter) ret := _m.Called(performerFilter, findFilter)
@@ -440,3 +484,17 @@ func (_m *PerformerReaderWriter) UpdateStashIDs(performerID int, stashIDs []mode
return r0 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 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 // FindByMovieID provides a mock function with given fields: movieID
func (_m *SceneReaderWriter) FindByMovieID(movieID int) ([]*models.Scene, error) { func (_m *SceneReaderWriter) FindByMovieID(movieID int) ([]*models.Scene, error) {
ret := _m.Called(movieID) ret := _m.Called(movieID)
@@ -392,29 +415,6 @@ func (_m *SceneReaderWriter) FindByPerformerID(performerID int) ([]*models.Scene
return r0, r1 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 // FindMany provides a mock function with given fields: ids
func (_m *SceneReaderWriter) FindMany(ids []int) ([]*models.Scene, error) { func (_m *SceneReaderWriter) FindMany(ids []int) ([]*models.Scene, error) {
ret := _m.Called(ids) ret := _m.Called(ids)
@@ -461,6 +461,29 @@ func (_m *SceneReaderWriter) GetCover(sceneID int) ([]byte, error) {
return r0, r1 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 // GetMovies provides a mock function with given fields: sceneID
func (_m *SceneReaderWriter) GetMovies(sceneID int) ([]models.MoviesScenes, error) { func (_m *SceneReaderWriter) GetMovies(sceneID int) ([]models.MoviesScenes, error) {
ret := _m.Called(sceneID) ret := _m.Called(sceneID)
@@ -507,8 +530,31 @@ func (_m *SceneReaderWriter) GetPerformerIDs(sceneID int) ([]int, error) {
return r0, r1 return r0, r1
} }
// GetGalleryIDs provides a mock function with given fields: sceneID // GetStashIDs provides a mock function with given fields: sceneID
func (_m *SceneReaderWriter) GetGalleryIDs(sceneID int) ([]int, error) { 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) ret := _m.Called(sceneID)
var r0 []int var r0 []int
@@ -530,52 +576,6 @@ func (_m *SceneReaderWriter) GetGalleryIDs(sceneID int) ([]int, error) {
return r0, r1 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 // IncrementOCounter provides a mock function with given fields: id
func (_m *SceneReaderWriter) IncrementOCounter(id int) (int, error) { func (_m *SceneReaderWriter) IncrementOCounter(id int) (int, error) {
ret := _m.Called(id) ret := _m.Called(id)
@@ -766,6 +766,20 @@ func (_m *SceneReaderWriter) UpdateFull(updatedScene models.Scene) (*models.Scen
return r0, r1 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 // UpdateMovies provides a mock function with given fields: sceneID, movies
func (_m *SceneReaderWriter) UpdateMovies(sceneID int, movies []models.MoviesScenes) error { func (_m *SceneReaderWriter) UpdateMovies(sceneID int, movies []models.MoviesScenes) error {
ret := _m.Called(sceneID, movies) ret := _m.Called(sceneID, movies)
@@ -794,20 +808,6 @@ func (_m *SceneReaderWriter) UpdatePerformers(sceneID int, performerIDs []int) e
return r0 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 // UpdateStashIDs provides a mock function with given fields: sceneID, stashIDs
func (_m *SceneReaderWriter) UpdateStashIDs(sceneID int, stashIDs []models.StashID) error { func (_m *SceneReaderWriter) UpdateStashIDs(sceneID int, stashIDs []models.StashID) error {
ret := _m.Called(sceneID, stashIDs) ret := _m.Called(sceneID, stashIDs)

View File

@@ -245,6 +245,29 @@ func (_m *TagReaderWriter) FindByNames(names []string, nocase bool) ([]*models.T
return r0, r1 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 // FindBySceneID provides a mock function with given fields: sceneID
func (_m *TagReaderWriter) FindBySceneID(sceneID int) ([]*models.Tag, error) { func (_m *TagReaderWriter) FindBySceneID(sceneID int) ([]*models.Tag, error) {
ret := _m.Called(sceneID) ret := _m.Called(sceneID)

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ package performer
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"strings"
"github.com/stashapp/stash/pkg/manager/jsonschema" "github.com/stashapp/stash/pkg/manager/jsonschema"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
@@ -11,15 +12,24 @@ import (
type Importer struct { type Importer struct {
ReaderWriter models.PerformerReaderWriter ReaderWriter models.PerformerReaderWriter
TagWriter models.TagReaderWriter
Input jsonschema.Performer Input jsonschema.Performer
MissingRefBehaviour models.ImportMissingRefEnum
ID int
performer models.Performer performer models.Performer
imageData []byte imageData []byte
tags []*models.Tag
} }
func (i *Importer) PreImport() error { func (i *Importer) PreImport() error {
i.performer = performerJSONToPerformer(i.Input) i.performer = performerJSONToPerformer(i.Input)
if err := i.populateTags(); err != nil {
return err
}
var err error var err error
if len(i.Input.Image) > 0 { if len(i.Input.Image) > 0 {
_, i.imageData, err = utils.ProcessBase64Image(i.Input.Image) _, i.imageData, err = utils.ProcessBase64Image(i.Input.Image)
@@ -31,7 +41,82 @@ func (i *Importer) PreImport() error {
return nil 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 { 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 len(i.imageData) > 0 {
if err := i.ReaderWriter.UpdateImage(id, i.imageData); err != nil { if err := i.ReaderWriter.UpdateImage(id, i.imageData); err != nil {
return fmt.Errorf("error setting performer image: %s", err.Error()) return fmt.Errorf("error setting performer image: %s", err.Error())

View File

@@ -3,6 +3,8 @@ package performer
import ( import (
"errors" "errors"
"github.com/stretchr/testify/mock"
"github.com/stashapp/stash/pkg/manager/jsonschema" "github.com/stashapp/stash/pkg/manager/jsonschema"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/models/mocks"
@@ -16,9 +18,15 @@ const invalidImage = "aW1hZ2VCeXRlcw&&"
const ( const (
existingPerformerID = 100 existingPerformerID = 100
existingTagID = 105
errTagsID = 106
existingPerformerName = "existingPerformerName" existingPerformerName = "existingPerformerName"
performerNameErr = "performerNameErr" performerNameErr = "performerNameErr"
existingTagName = "existingTagName"
existingTagErr = "existingTagErr"
missingTagName = "missingTagName"
) )
func TestImporterName(t *testing.T) { func TestImporterName(t *testing.T) {
@@ -53,6 +61,91 @@ func TestImporterPreImport(t *testing.T) {
assert.Equal(t, expectedPerformer, i.performer) 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) { func TestImporterPostImport(t *testing.T) {
readerWriter := &mocks.PerformerReaderWriter{} readerWriter := &mocks.PerformerReaderWriter{}
@@ -111,6 +204,32 @@ func TestImporterFindExistingID(t *testing.T) {
readerWriter.AssertExpectations(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) { func TestCreate(t *testing.T) {
readerWriter := &mocks.PerformerReaderWriter{} readerWriter := &mocks.PerformerReaderWriter{}

View File

@@ -95,7 +95,7 @@ type mappedSceneScraperConfig struct {
mappedConfig mappedConfig
Tags mappedConfig `yaml:"Tags"` Tags mappedConfig `yaml:"Tags"`
Performers mappedConfig `yaml:"Performers"` Performers mappedPerformerScraperConfig `yaml:"Performers"`
Studio mappedConfig `yaml:"Studio"` Studio mappedConfig `yaml:"Studio"`
Movies mappedConfig `yaml:"Movies"` Movies mappedConfig `yaml:"Movies"`
} }
@@ -211,10 +211,54 @@ func (s *mappedGalleryScraperConfig) UnmarshalYAML(unmarshal func(interface{}) e
type mappedPerformerScraperConfig struct { type mappedPerformerScraperConfig struct {
mappedConfig mappedConfig
Tags mappedConfig `yaml:"Tags"`
} }
type _mappedPerformerScraperConfig mappedPerformerScraperConfig
const (
mappedScraperConfigPerformerTags = "Tags"
)
func (s *mappedPerformerScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { 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 { type mappedMovieScraperConfig struct {
@@ -647,9 +691,23 @@ func (s mappedScraper) scrapePerformer(q mappedQuery) (*models.ScrapedPerformer,
return nil, nil return nil, nil
} }
performerTagsMap := performerMap.Tags
results := performerMap.process(q, s.Common) results := performerMap.process(q, s.Common)
if len(results) > 0 { if len(results) > 0 {
results[0].apply(&ret) 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 return &ret, nil
@@ -687,19 +745,34 @@ func (s mappedScraper) scrapeScene(q mappedQuery) (*models.ScrapedScene, error)
sceneStudioMap := sceneScraperConfig.Studio sceneStudioMap := sceneScraperConfig.Studio
sceneMoviesMap := sceneScraperConfig.Movies sceneMoviesMap := sceneScraperConfig.Movies
scenePerformerTagsMap := scenePerformersMap.Tags
logger.Debug(`Processing scene:`) logger.Debug(`Processing scene:`)
results := sceneMap.process(q, s.Common) results := sceneMap.process(q, s.Common)
if len(results) > 0 { if len(results) > 0 {
results[0].apply(&ret) 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 // now apply the performers and tags
if scenePerformersMap != nil { if scenePerformersMap.mappedConfig != nil {
logger.Debug(`Processing scene performers:`) logger.Debug(`Processing scene performers:`)
performerResults := scenePerformersMap.process(q, s.Common) performerResults := scenePerformersMap.process(q, s.Common)
for _, p := range performerResults { for _, p := range performerResults {
performer := &models.ScrapedScenePerformer{} performer := &models.ScrapedScenePerformer{}
p.apply(performer) 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) ret.Performers = append(ret.Performers, performer)
} }
} }

View File

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

View File

@@ -100,6 +100,13 @@ func (s *stashScraper) scrapePerformerByFragment(scrapedPerformer models.Scraped
return nil, err 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 // need to copy back to a scraped performer
ret := models.ScrapedPerformer{} ret := models.ScrapedPerformer{}
err = copier.Copy(&ret, q.FindPerformer) err = copier.Copy(&ret, q.FindPerformer)

View File

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

View File

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

View File

@@ -259,6 +259,8 @@ func (qb *galleryQueryBuilder) Query(galleryFilter *models.GalleryFilterType, fi
query.addHaving(havingClause) query.addHaving(havingClause)
} }
handleGalleryPerformerTagsCriterion(&query, galleryFilter.PerformerTags)
query.sortAndPagination = qb.getGallerySort(findFilter) + getPagination(findFilter) query.sortAndPagination = qb.getGallerySort(findFilter) + getPagination(findFilter)
idsResult, countResult, err := query.executeFind() idsResult, countResult, err := query.executeFind()
if err != nil { 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 { func (qb *galleryQueryBuilder) getGallerySort(findFilter *models.FindFilterType) string {
var sort string var sort string
var direction 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 Count
// TODO All // TODO All
// TODO Query // TODO Query

View File

@@ -360,6 +360,8 @@ func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilt
query.addHaving(havingClause) query.addHaving(havingClause)
} }
handleImagePerformerTagsCriterion(&query, imageFilter.PerformerTags)
query.sortAndPagination = qb.getImageSort(findFilter) + getPagination(findFilter) query.sortAndPagination = qb.getImageSort(findFilter) + getPagination(findFilter)
idsResult, countResult, err := query.executeFind() idsResult, countResult, err := query.executeFind()
if err != nil { if err != nil {
@@ -379,6 +381,31 @@ func (qb *imageQueryBuilder) Query(imageFilter *models.ImageFilterType, findFilt
return images, countResult, nil 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 { func (qb *imageQueryBuilder) getImageSort(findFilter *models.FindFilterType) string {
if findFilter == nil { if findFilter == nil {
return " ORDER BY images.path ASC " 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) { func TestImageQuerySorting(t *testing.T) {
withTxn(func(r models.Repository) error { withTxn(func(r models.Repository) error {
sort := titleField sort := titleField

View File

@@ -11,6 +11,13 @@ import (
const performerTable = "performers" const performerTable = "performers"
const performerIDColumn = "performer_id" 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 { type performerQueryBuilder struct {
repository repository
@@ -153,6 +160,11 @@ func (qb *performerQueryBuilder) FindByNames(names []string, nocase bool) ([]*mo
return qb.queryPerformers(query, args) 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) { func (qb *performerQueryBuilder) Count() (int, error) {
return qb.runCountQuery(qb.buildCountQuery("SELECT performers.id FROM performers"), nil) 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 // TODO - need better handling of aliases
query.handleStringCriterionInput(performerFilter.Aliases, tableName+".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) query.sortAndPagination = qb.getPerformerSort(findFilter) + getPagination(findFilter)
idsResult, countResult, err := query.executeFind() idsResult, countResult, err := query.executeFind()
if err != nil { if err != nil {
@@ -361,6 +385,26 @@ func (qb *performerQueryBuilder) queryPerformers(query string, args []interface{
return []*models.Performer(ret), nil 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 { func (qb *performerQueryBuilder) imageRepository() *imageRepository {
return &imageRepository{ return &imageRepository{
repository: repository{ repository: repository{

View File

@@ -5,6 +5,7 @@ package sqlite_test
import ( import (
"database/sql" "database/sql"
"fmt" "fmt"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "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) { func TestPerformerStashIDs(t *testing.T) {
if err := withTxn(func(r models.Repository) error { if err := withTxn(func(r models.Repository) error {
qb := r.Performer() 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(sceneStudioCriterionHandler(qb, sceneFilter.Studios))
query.handleCriterionFunc(sceneMoviesCriterionHandler(qb, sceneFilter.Movies)) query.handleCriterionFunc(sceneMoviesCriterionHandler(qb, sceneFilter.Movies))
query.handleCriterionFunc(sceneStashIDsHandler(qb, sceneFilter.StashID)) query.handleCriterionFunc(sceneStashIDsHandler(qb, sceneFilter.StashID))
query.handleCriterionFunc(scenePerformerTagsCriterionHandler(qb, sceneFilter.PerformerTags))
return query 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 { func (qb *sceneQueryBuilder) getSceneSort(findFilter *models.FindFilterType) string {
if findFilter == nil { if findFilter == nil {
return " ORDER BY scenes.path, scenes.date ASC " 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) { func TestSceneQueryStudio(t *testing.T) {
withTxn(func(r models.Repository) error { withTxn(func(r models.Repository) error {
sqb := r.Scene() sqb := r.Scene()

View File

@@ -20,110 +20,240 @@ import (
"github.com/stashapp/stash/pkg/utils" "github.com/stashapp/stash/pkg/utils"
) )
const totalScenes = 12 const (
const totalImages = 6 // TODO - add one for zip file sceneIdxWithMovie = iota
const performersNameCase = 9 sceneIdxWithGallery
const performersNameNoCase = 2 sceneIdxWithPerformer
const moviesNameCase = 2 sceneIdxWithTwoPerformers
const moviesNameNoCase = 1 sceneIdxWithTag
const totalGalleries = 8 sceneIdxWithTwoTags
const tagsNameNoCase = 2 sceneIdxWithStudio
const tagsNameCase = 12 sceneIdxWithMarker
const studiosNameCase = 6 sceneIdxWithPerformerTag
const studiosNameNoCase = 1 sceneIdxWithPerformerTwoTags
// new indexes above
lastSceneIdx
var sceneIDs []int totalScenes = lastSceneIdx + 3
var imageIDs []int )
var performerIDs []int
var movieIDs []int
var galleryIDs []int
var tagIDs []int
var studioIDs []int
var markerIDs []int
var tagNames []string const (
var studioNames []string imageIdxWithGallery = iota
var movieNames []string imageIdxWithPerformer
var performerNames []string imageIdxWithTwoPerformers
imageIdxWithTag
imageIdxWithTwoTags
imageIdxWithStudio
imageIdxInZip // TODO - not implemented
imageIdxWithPerformerTag
imageIdxWithPerformerTwoTags
// new indexes above
totalImages
)
const sceneIdxWithMovie = 0 const (
const sceneIdxWithGallery = 1 performerIdxWithScene = iota
const sceneIdxWithPerformer = 2 performerIdx1WithScene
const sceneIdxWithTwoPerformers = 3 performerIdx2WithScene
const sceneIdxWithTag = 4 performerIdxWithImage
const sceneIdxWithTwoTags = 5 performerIdx1WithImage
const sceneIdxWithStudio = 6 performerIdx2WithImage
const sceneIdxWithMarker = 7 performerIdxWithTag
performerIdxWithTwoTags
performerIdxWithGallery
performerIdx1WithGallery
performerIdx2WithGallery
// new indexes above
// performers with dup names start from the end
performerIdx1WithDupName
performerIdxWithDupName
const imageIdxWithGallery = 0 performersNameCase = performerIdx1WithDupName
const imageIdxWithPerformer = 1 performersNameNoCase = 2
const imageIdxWithTwoPerformers = 2 )
const imageIdxWithTag = 3
const imageIdxWithTwoTags = 4
const imageIdxWithStudio = 5
const imageIdxInZip = 6
const performerIdxWithScene = 0 const (
const performerIdx1WithScene = 1 movieIdxWithScene = iota
const performerIdx2WithScene = 2 movieIdxWithStudio
const performerIdxWithImage = 3 // movies with dup names start from the end
const performerIdx1WithImage = 4 movieIdxWithDupName
const performerIdx2WithImage = 5
const performerIdxWithGallery = 6
const performerIdx1WithGallery = 7
const performerIdx2WithGallery = 8
// performers with dup names start from the end moviesNameCase = movieIdxWithDupName
const performerIdx1WithDupName = 9 moviesNameNoCase = 1
const performerIdxWithDupName = 10 )
const movieIdxWithScene = 0 const (
const movieIdxWithStudio = 1 galleryIdxWithScene = iota
galleryIdxWithImage
galleryIdxWithPerformer
galleryIdxWithTwoPerformers
galleryIdxWithTag
galleryIdxWithTwoTags
galleryIdxWithStudio
galleryIdxWithPerformerTag
galleryIdxWithPerformerTwoTags
// new indexes above
lastGalleryIdx
// movies with dup names start from the end totalGalleries = lastGalleryIdx + 1
const movieIdxWithDupName = 2 )
const galleryIdxWithScene = 0 const (
const galleryIdxWithImage = 1 tagIdxWithScene = iota
const galleryIdxWithPerformer = 2 tagIdx1WithScene
const galleryIdxWithTwoPerformers = 3 tagIdx2WithScene
const galleryIdxWithTag = 4 tagIdxWithPrimaryMarker
const galleryIdxWithTwoTags = 5 tagIdxWithMarker
const galleryIdxWithStudio = 6 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 tagsNameNoCase = 2
const tagIdx1WithScene = 1 tagsNameCase = tagIdx1WithDupName
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
// tags with dup names start from the end const (
const tagIdx1WithDupName = 12 studioIdxWithScene = iota
const tagIdxWithDupName = 13 studioIdxWithMovie
studioIdxWithChildStudio
studioIdxWithParentStudio
studioIdxWithImage
studioIdxWithGallery
// new indexes above
// studios with dup names start from the end
studioIdxWithDupName
const studioIdxWithScene = 0 studiosNameCase = studioIdxWithDupName
const studioIdxWithMovie = 1 studiosNameNoCase = 1
const studioIdxWithChildStudio = 2 )
const studioIdxWithParentStudio = 3
const studioIdxWithImage = 4
const studioIdxWithGallery = 5
// studios with dup names start from the end const (
const studioIdxWithDupName = 6 markerIdxWithScene = iota
)
const markerIdxWithScene = 0 const (
pathField = "Path"
checksumField = "Checksum"
titleField = "Title"
zipPath = "zipPath.zip"
)
const pathField = "Path" var (
const checksumField = "Checksum" sceneIDs []int
const titleField = "Title" imageIDs []int
const zipPath = "zipPath.zip" 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) { func TestMain(m *testing.M) {
ret := runTests(m) ret := runTests(m)
@@ -205,12 +335,16 @@ func populateDB() error {
return fmt.Errorf("error creating studios: %s", err.Error()) return fmt.Errorf("error creating studios: %s", err.Error())
} }
if err := linkSceneGallery(r.Scene(), sceneIdxWithGallery, galleryIdxWithScene); err != nil { if err := linkPerformerTags(r.Performer()); err != nil {
return fmt.Errorf("error linking scene to gallery: %s", err.Error()) return fmt.Errorf("error linking performer tags: %s", err.Error())
} }
if err := linkSceneMovie(r.Scene(), sceneIdxWithMovie, movieIdxWithScene); err != nil { if err := linkSceneGalleries(r.Scene()); err != nil {
return fmt.Errorf("error scene to movie: %s", err.Error()) 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 { if err := linkScenePerformers(r.Scene()); err != nil {
@@ -221,11 +355,11 @@ func populateDB() error {
return fmt.Errorf("error linking scene tags: %s", err.Error()) return fmt.Errorf("error linking scene tags: %s", err.Error())
} }
if err := linkSceneStudio(r.Scene(), sceneIdxWithStudio, studioIdxWithScene); err != nil { if err := linkSceneStudios(r.Scene()); err != nil {
return fmt.Errorf("error linking scene studio: %s", err.Error()) 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()) 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()) 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()) return fmt.Errorf("error linking image studio: %s", err.Error())
} }
if err := linkMovieStudio(r.Movie(), movieIdxWithStudio, studioIdxWithMovie); err != nil { if err := linkMovieStudios(r.Movie()); err != nil {
return fmt.Errorf("error linking movie studio: %s", err.Error()) return fmt.Errorf("error linking movie studios: %s", err.Error())
} }
if err := linkStudioParent(r.Studio(), studioIdxWithChildStudio, studioIdxWithParentStudio); err != nil { if err := linkStudiosParent(r.Studio()); err != nil {
return fmt.Errorf("error linking studio parent: %s", err.Error()) return fmt.Errorf("error linking studios parent: %s", err.Error())
} }
if err := linkGalleryPerformers(r.Gallery()); err != nil { if err := linkGalleryPerformers(r.Gallery()); err != nil {
@@ -512,6 +646,30 @@ func getTagMarkerCount(id int) int {
return 0 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 //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 { func createTags(tqb models.TagReaderWriter, n int, o int) error {
const namePlain = "Name" const namePlain = "Name"
@@ -624,7 +782,33 @@ func createMarker(mqb models.SceneMarkerReaderWriter, sceneIdx, primaryTagIdx in
return nil 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] sceneID := sceneIDs[sceneIndex]
movies, err := qb.GetMovies(sceneID) movies, err := qb.GetMovies(sceneID)
if err != nil { if err != nil {
@@ -636,52 +820,32 @@ func linkSceneMovie(qb models.SceneReaderWriter, sceneIndex, movieIndex int) err
SceneID: sceneID, SceneID: sceneID,
}) })
return qb.UpdateMovies(sceneID, movies) return qb.UpdateMovies(sceneID, movies)
})
} }
func linkScenePerformers(qb models.SceneReaderWriter) error { func linkScenePerformers(qb models.SceneReaderWriter) error {
if err := linkScenePerformer(qb, sceneIdxWithPerformer, performerIdxWithScene); err != nil { return doLinks(scenePerformerLinks, func(sceneIndex, performerIndex int) error {
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 {
_, err := scene.AddPerformer(qb, sceneIDs[sceneIndex], performerIDs[performerIndex]) _, err := scene.AddPerformer(qb, sceneIDs[sceneIndex], performerIDs[performerIndex])
return err 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]) _, err := scene.AddGallery(qb, sceneIDs[sceneIndex], galleryIDs[galleryIndex])
return err return err
})
} }
func linkSceneTags(qb models.SceneReaderWriter) error { func linkSceneTags(qb models.SceneReaderWriter) error {
if err := linkSceneTag(qb, sceneIdxWithTag, tagIdxWithScene); err != nil { return doLinks(sceneTagLinks, func(sceneIndex, tagIndex int) error {
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 {
_, err := scene.AddTag(qb, sceneIDs[sceneIndex], tagIDs[tagIndex]) _, err := scene.AddTag(qb, sceneIDs[sceneIndex], tagIDs[tagIndex])
return err 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{ scene := models.ScenePartial{
ID: sceneIDs[sceneIndex], ID: sceneIDs[sceneIndex],
StudioID: &sql.NullInt64{Int64: int64(studioIDs[studioIndex]), Valid: true}, 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) _, err := sqb.Update(scene)
return err 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]) return gallery.AddImage(gqb, galleryIDs[galleryIndex], imageIDs[imageIndex])
})
} }
func linkImageTags(iqb models.ImageReaderWriter) error { func linkImageTags(iqb models.ImageReaderWriter) error {
if err := linkImageTag(iqb, imageIdxWithTag, tagIdxWithImage); err != nil { return doLinks(imageTagLinks, func(imageIndex, tagIndex int) error {
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 {
imageID := imageIDs[imageIndex] imageID := imageIDs[imageIndex]
tags, err := iqb.GetTagIDs(imageID) tags, err := iqb.GetTagIDs(imageID)
if err != nil { if err != nil {
@@ -719,9 +873,11 @@ func linkImageTag(iqb models.ImageReaderWriter, imageIndex, tagIndex int) error
tags = append(tags, tagIDs[tagIndex]) tags = append(tags, tagIDs[tagIndex])
return iqb.UpdateTags(imageID, tags) 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{ image := models.ImagePartial{
ID: imageIDs[imageIndex], ID: imageIDs[imageIndex],
StudioID: &sql.NullInt64{Int64: int64(studioIDs[studioIndex]), Valid: true}, 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) _, err := qb.Update(image)
return err return err
})
} }
func linkImagePerformers(qb models.ImageReaderWriter) error { func linkImagePerformers(qb models.ImageReaderWriter) error {
if err := linkImagePerformer(qb, imageIdxWithPerformer, performerIdxWithImage); err != nil { return doLinks(imagePerformerLinks, func(imageIndex, performerIndex int) error {
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 {
imageID := imageIDs[imageIndex] imageID := imageIDs[imageIndex]
performers, err := iqb.GetPerformerIDs(imageID) performers, err := qb.GetPerformerIDs(imageID)
if err != nil { if err != nil {
return err return err
} }
performers = append(performers, performerIDs[performerIndex]) performers = append(performers, performerIDs[performerIndex])
return iqb.UpdatePerformers(imageID, performers) return qb.UpdatePerformers(imageID, performers)
})
} }
func linkMovieStudio(mqb models.MovieWriter, movieIndex, studioIndex int) error { func linkGalleryPerformers(qb models.GalleryReaderWriter) error {
movie := models.MoviePartial{ return doLinks(galleryPerformerLinks, func(galleryIndex, performerIndex int) error {
ID: movieIDs[movieIndex], galleryID := imageIDs[galleryIndex]
StudioID: &sql.NullInt64{Int64: int64(studioIDs[studioIndex]), Valid: true}, performers, err := qb.GetPerformerIDs(galleryID)
} if err != nil {
_, err := mqb.Update(movie)
return err 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.UpdatePerformers(galleryID, performers)
return qb.UpdateImage(tagIDs[tagIndex], models.DefaultTagImage) })
} }
func linkGalleryTags(iqb models.GalleryReaderWriter) error { func linkGalleryTags(iqb models.GalleryReaderWriter) error {
if err := linkGalleryTag(iqb, galleryIdxWithTag, tagIdxWithGallery); err != nil { return doLinks(galleryTagLinks, func(galleryIndex, tagIndex int) error {
return err galleryID := imageIDs[galleryIndex]
}
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]
tags, err := iqb.GetTagIDs(galleryID) tags, err := iqb.GetTagIDs(galleryID)
if err != nil { if err != nil {
return err return err
@@ -805,6 +927,35 @@ func linkGalleryTag(iqb models.GalleryReaderWriter, galleryIndex, tagIndex int)
tags = append(tags, tagIDs[tagIndex]) tags = append(tags, tagIDs[tagIndex])
return iqb.UpdateTags(galleryID, tags) 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 { func linkGalleryStudio(qb models.GalleryWriter, galleryIndex, studioIndex int) error {
@@ -816,29 +967,3 @@ func linkGalleryStudio(qb models.GalleryWriter, galleryIndex, studioIndex int) e
return err 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) 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) { func (qb *tagQueryBuilder) FindByImageID(imageID int) ([]*models.Tag, error) {
query := ` query := `
SELECT tags.* FROM tags SELECT tags.* FROM tags
@@ -211,6 +223,12 @@ func (qb *tagQueryBuilder) Query(tagFilter *models.TagFilterType, findFilter *mo
query.body += ` query.body += `
left join tags_image on tags_image.tag_id = tags.id 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_tags on scenes_tags.tag_id = tags.id
left join scenes on scenes_tags.scene_id = scenes.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 { // if markerCount := tagFilter.MarkerCount; markerCount != nil {
// clause, count := getIntCriterionWhereClause("count(distinct scene_markers.id)", *markerCount) // clause, count := getIntCriterionWhereClause("count(distinct scene_markers.id)", *markerCount)
// query.addHaving(clause) // 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) { func TestTagUpdateTagImage(t *testing.T) {
if err := withTxn(func(r models.Repository) error { if err := withTxn(func(r models.Repository) error {
qb := r.Tag() qb := r.Tag()

View File

@@ -1,3 +1,6 @@
### ✨ New Features
* Added Performer tags.
### 🎨 Improvements ### 🎨 Improvements
* Improved performer details and edit UI pages. * Improved performer details and edit UI pages.
* Resolve python executable to `python3` or `python` for python script scrapers. * 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 !== "parent_studios" &&
criterion.type !== "tags" && criterion.type !== "tags" &&
criterion.type !== "sceneTags" && criterion.type !== "sceneTags" &&
criterion.type !== "performerTags" &&
criterion.type !== "movies" criterion.type !== "movies"
) )
return; 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 React from "react";
import { Link } from "react-router-dom"; 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 * as GQL from "src/core/generated-graphql";
import { NavUtils, TextUtils } from "src/utils"; 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 { interface IPerformerCardProps {
performer: GQL.PerformerDataFragment; 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 ( return (
<BasicCard <BasicCard
className="performer-card" className="performer-card"
@@ -57,19 +109,7 @@ export const PerformerCard: React.FC<IPerformerCardProps> = ({
<Link to={NavUtils.makePerformersCountryUrl(performer)}> <Link to={NavUtils.makePerformersCountryUrl(performer)}>
<CountryFlag country={performer.country} /> <CountryFlag country={performer.country} />
</Link> </Link>
<div className="text-muted"> {maybeRenderPopoverButtonGroup()}
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>
</> </>
} }
selected={selected} selected={selected}

View File

@@ -1,5 +1,6 @@
import React from "react"; import React from "react";
import { useIntl } from "react-intl"; import { useIntl } from "react-intl";
import { TagLink } from "src/components/Shared";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
import { genderToString } from "src/core/StashService"; import { genderToString } from "src/core/StashService";
import { TextUtils } from "src/utils"; import { TextUtils } from "src/utils";
@@ -15,6 +16,25 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
// Network state // Network state
const intl = useIntl(); 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() { function renderStashIDs() {
if (!performer.stash_ids?.length) { if (!performer.stash_ids?.length) {
return; return;
@@ -101,6 +121,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
TextUtils.instagramURL TextUtils.instagramURL
)} )}
/> />
{renderTagsField()}
{renderStashIDs()} {renderStashIDs()}
</> </>
); );

View File

@@ -7,6 +7,7 @@ import {
Col, Col,
InputGroup, InputGroup,
Row, Row,
Badge,
} from "react-bootstrap"; } from "react-bootstrap";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
@@ -20,6 +21,7 @@ import {
mutateReloadScrapers, mutateReloadScrapers,
usePerformerUpdate, usePerformerUpdate,
usePerformerCreate, usePerformerCreate,
useTagCreate,
queryScrapePerformerURL, queryScrapePerformerURL,
} from "src/core/StashService"; } from "src/core/StashService";
import { import {
@@ -28,6 +30,8 @@ import {
ImageInput, ImageInput,
ScrapePerformerSuggest, ScrapePerformerSuggest,
LoadingIndicator, LoadingIndicator,
CollapseButton,
TagSelect,
} from "src/components/Shared"; } from "src/components/Shared";
import { ImageUtils } from "src/utils"; import { ImageUtils } from "src/utils";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
@@ -64,6 +68,8 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
scrapePerformerDetails, scrapePerformerDetails,
setScrapePerformerDetails, setScrapePerformerDetails,
] = useState<GQL.ScrapedPerformerDataFragment>(); ] = useState<GQL.ScrapedPerformerDataFragment>();
const [newTags, setNewTags] = useState<GQL.ScrapedSceneTag[]>();
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
// Network state // Network state
@@ -81,6 +87,8 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true); const imageEncoding = ImageUtils.usePasteImage(onImageLoad, true);
const [createTag] = useTagCreate({ name: "" });
const genderOptions = [""].concat(getGenderStrings()); const genderOptions = [""].concat(getGenderStrings());
const schema = yup.object({ const schema = yup.object({
@@ -100,6 +108,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
url: yup.string().optional(), url: yup.string().optional(),
twitter: yup.string().optional(), twitter: yup.string().optional(),
instagram: yup.string().optional(), instagram: yup.string().optional(),
tag_ids: yup.array(yup.string().required()).optional(),
stash_ids: yup.mixed<GQL.StashIdInput>().optional(), stash_ids: yup.mixed<GQL.StashIdInput>().optional(),
image: yup.string().optional().nullable(), image: yup.string().optional().nullable(),
}); });
@@ -121,6 +130,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
url: performer.url ?? "", url: performer.url ?? "",
twitter: performer.twitter ?? "", twitter: performer.twitter ?? "",
instagram: performer.instagram ?? "", instagram: performer.instagram ?? "",
tag_ids: (performer.tags ?? []).map((t) => t.id),
stash_ids: performer.stash_ids ?? undefined, stash_ids: performer.stash_ids ?? undefined,
image: undefined, image: undefined,
}; };
@@ -154,6 +164,75 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
return genderToString(retEnum); 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( function updatePerformerEditStateFromScraper(
state: Partial<GQL.ScrapedPerformerDataFragment> state: Partial<GQL.ScrapedPerformerDataFragment>
) { ) {
@@ -210,6 +289,13 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
translateScrapedGender(state.gender ?? undefined) 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 // image is a base64 string
// #404: don't overwrite image if it has been modified by the user // #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 {}; if (!scrapePerformerDetails) return {};
// image is not supported // image is not supported
const { __typename, image: _image, ...ret } = scrapePerformerDetails; // remove tags as well
const {
__typename,
image: _image,
tags: _tags,
...ret
} = scrapePerformerDetails;
return ret; return ret;
} }
@@ -484,10 +576,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
return; return;
} }
const currentPerformer: Partial<GQL.PerformerDataFragment> = { const currentPerformer: Partial<GQL.PerformerUpdateInput> = {
...formik.values, ...formik.values,
gender: stringToGender(formik.values.gender), gender: stringToGender(formik.values.gender),
image_path: formik.values.image ?? performer.image_path, image: formik.values.image ?? performer.image_path,
}; };
return ( 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) => { const removeStashID = (stashID: GQL.StashIdInput) => {
formik.setFieldValue( formik.setFieldValue(
"stash_ids", "stash_ids",
@@ -805,6 +919,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
/> />
</Form.Group> </Form.Group>
</Form.Row> </Form.Row>
{renderTagsField()}
{renderStashIDs()} {renderStashIDs()}
{renderButtons()} {renderButtons()}

View File

@@ -11,8 +11,12 @@ import {
getGenderStrings, getGenderStrings,
genderToString, genderToString,
stringToGender, stringToGender,
useTagCreate,
} from "src/core/StashService"; } from "src/core/StashService";
import { Form } from "react-bootstrap"; import { Form } from "react-bootstrap";
import { TagSelect } from "src/components/Shared";
import { useToast } from "src/hooks";
import _ from "lodash";
function renderScrapedGender( function renderScrapedGender(
result: ScrapeResult<string>, 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 { interface IPerformerScrapeDialogProps {
performer: Partial<GQL.PerformerDataFragment>; performer: Partial<GQL.PerformerUpdateInput>;
scraped: GQL.ScrapedPerformer; scraped: GQL.ScrapedPerformer;
onClose: (scrapedPerformer?: GQL.ScrapedPerformer) => void; 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>>( 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 = [ const allFields = [
@@ -173,6 +279,7 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
instagram, instagram,
gender, gender,
image, image,
tags,
]; ];
// don't show the dialog if nothing was scraped // don't show the dialog if nothing was scraped
if (allFields.every((r) => !r.scraped)) { if (allFields.every((r) => !r.scraped)) {
@@ -180,6 +287,41 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
return <></>; 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 { function makeNewScrapedItem(): GQL.ScrapedPerformer {
return { return {
name: name.getNewValue(), name: name.getNewValue(),
@@ -198,6 +340,12 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
twitter: twitter.getNewValue(), twitter: twitter.getNewValue(),
instagram: instagram.getNewValue(), instagram: instagram.getNewValue(),
gender: gender.getNewValue(), gender: gender.getNewValue(),
tags: tags.getNewValue()?.map((m) => {
return {
stored_id: m,
name: "",
};
}),
image: image.getNewValue(), image: image.getNewValue(),
}; };
} }
@@ -281,6 +429,12 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
result={instagram} result={instagram}
onChange={(value) => setInstagram(value)} onChange={(value) => setInstagram(value)}
/> />
{renderScrapedTagsRow(
tags,
(value) => setTags(value),
newTags,
createNewTag
)}
<ScrapedImageRow <ScrapedImageRow
title="Performer Image" title="Performer Image"
className="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 { ExportDialog, DeleteEntityDialog } from "src/components/Shared";
import { PerformerCard } from "./PerformerCard"; import { PerformerCard } from "./PerformerCard";
import { PerformerListTable } from "./PerformerListTable"; 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 history = useHistory();
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false); const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = 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 = ( const renderDeleteDialog = (
selectedPerformers: SlimPerformerDataFragment[], selectedPerformers: SlimPerformerDataFragment[],
onClose: (confirmed: boolean) => void onClose: (confirmed: boolean) => void
@@ -98,9 +118,11 @@ export const PerformerList: React.FC = () => {
const listData = usePerformersList({ const listData = usePerformersList({
otherOperations, otherOperations,
renderContent, renderContent,
renderEditDialog: renderEditPerformersDialog,
filterHook,
addKeybinds, addKeybinds,
selectable: true, selectable: true,
persistState: PersistanceLevel.ALL, persistState,
renderDeleteDialog, renderDeleteDialog,
}); });

View File

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

View File

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

View File

@@ -14,6 +14,7 @@ import { NavUtils, TextUtils } from "src/utils";
interface IProps { interface IProps {
tag?: Partial<TagDataFragment>; tag?: Partial<TagDataFragment>;
tagType?: "performer" | "scene";
performer?: Partial<PerformerDataFragment>; performer?: Partial<PerformerDataFragment>;
marker?: Partial<SceneMarkerDataFragment>; marker?: Partial<SceneMarkerDataFragment>;
movie?: Partial<MovieDataFragment>; movie?: Partial<MovieDataFragment>;
@@ -26,7 +27,11 @@ export const TagLink: React.FC<IProps> = (props: IProps) => {
let link: string = "#"; let link: string = "#";
let title: string = ""; let title: string = "";
if (props.tag) { if (props.tag) {
if (!props.tagType || props.tagType === "scene") {
link = NavUtils.makeTagScenesUrl(props.tag); link = NavUtils.makeTagScenesUrl(props.tag);
} else {
link = NavUtils.makeTagPerformersUrl(props.tag);
}
title = props.tag.name || ""; title = props.tag.name || "";
} else if (props.performer) { } else if (props.performer) {
link = NavUtils.makePerformerScenesUrl(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() { function maybeRenderPopoverButtonGroup() {
if (tag) { if (tag) {
return ( return (
@@ -55,6 +68,7 @@ export const TagCard: React.FC<IProps> = ({
<ButtonGroup className="card-popovers"> <ButtonGroup className="card-popovers">
{maybeRenderScenesPopoverButton()} {maybeRenderScenesPopoverButton()}
{maybeRenderSceneMarkersPopoverButton()} {maybeRenderSceneMarkersPopoverButton()}
{maybeRenderPerformersPopoverButton()}
</ButtonGroup> </ButtonGroup>
</> </>
); );

View File

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

View File

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

View File

@@ -24,6 +24,7 @@ export type CriterionType =
| "movieIsMissing" | "movieIsMissing"
| "tags" | "tags"
| "sceneTags" | "sceneTags"
| "performerTags"
| "performers" | "performers"
| "studios" | "studios"
| "movies" | "movies"
@@ -43,7 +44,10 @@ export type CriterionType =
| "gender" | "gender"
| "parent_studios" | "parent_studios"
| "scene_count" | "scene_count"
| "marker_count"; | "marker_count"
| "image_count"
| "gallery_count"
| "performer_count";
type Option = string | number | IOptionType; type Option = string | number | IOptionType;
export type CriterionValue = string | number | ILabeledId[]; export type CriterionValue = string | number | ILabeledId[];
@@ -83,6 +87,8 @@ export abstract class Criterion {
return "Tags"; return "Tags";
case "sceneTags": case "sceneTags":
return "Scene Tags"; return "Scene Tags";
case "performerTags":
return "Performer Tags";
case "performers": case "performers":
return "Performers"; return "Performers";
case "studios": case "studios":
@@ -123,6 +129,12 @@ export abstract class Criterion {
return "Scene Count"; return "Scene Count";
case "marker_count": case "marker_count":
return "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 options: IOptionType[] = [];
public value: ILabeledId[] = []; public value: ILabeledId[] = [];
constructor(type: "tags" | "sceneTags") { constructor(type: "tags" | "sceneTags" | "performerTags") {
super(); super();
this.type = type; this.type = type;
this.parameterName = type; this.parameterName = type;
if (type === "sceneTags") { if (type === "sceneTags") {
this.parameterName = "scene_tags"; this.parameterName = "scene_tags";
} }
if (type === "performerTags") {
this.parameterName = "performer_tags";
}
} }
public encodeValue() { public encodeValue() {
@@ -39,3 +42,8 @@ export class SceneTagsCriterionOption implements ICriterionOption {
public label: string = Criterion.getLabel("sceneTags"); public label: string = Criterion.getLabel("sceneTags");
public value: CriterionType = "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 "o_counter":
case "scene_count": case "scene_count":
case "marker_count": case "marker_count":
case "image_count":
case "gallery_count":
case "performer_count":
return new NumberCriterion(type, type); return new NumberCriterion(type, type);
case "resolution": case "resolution":
return new ResolutionCriterion(); return new ResolutionCriterion();
@@ -72,6 +75,8 @@ export function makeCriteria(type: CriterionType = "none") {
return new TagsCriterion("tags"); return new TagsCriterion("tags");
case "sceneTags": case "sceneTags":
return new TagsCriterion("sceneTags"); return new TagsCriterion("sceneTags");
case "performerTags":
return new TagsCriterion("performerTags");
case "performers": case "performers":
return new PerformersCriterion(); return new PerformersCriterion();
case "studios": case "studios":

View File

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

View File

@@ -76,6 +76,15 @@ const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => {
return `/scenes?${filter.makeQueryParameters()}`; 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>) => { const makeTagSceneMarkersUrl = (tag: Partial<GQL.TagDataFragment>) => {
if (!tag.id) return "#"; if (!tag.id) return "#";
const filter = new ListFilterModel(FilterMode.SceneMarkers); const filter = new ListFilterModel(FilterMode.SceneMarkers);
@@ -98,6 +107,7 @@ export default {
makeStudioScenesUrl, makeStudioScenesUrl,
makeTagSceneMarkersUrl, makeTagSceneMarkersUrl,
makeTagScenesUrl, makeTagScenesUrl,
makeTagPerformersUrl,
makeSceneMarkerUrl, makeSceneMarkerUrl,
makeMovieScenesUrl, makeMovieScenesUrl,
makeChildStudiosUrl, makeChildStudiosUrl,