mirror of https://github.com/stashapp/stash.git
Movie/Group tags (#4969)
* Combine common tag control code into hook * Combine common scraped tag row code into hook
This commit is contained in:
parent
f9a624b803
commit
fda4776d30
|
@ -334,6 +334,10 @@ input MovieFilterType {
|
|||
url: StringCriterionInput
|
||||
"Filter to only include movies where performer appears in a scene"
|
||||
performers: MultiCriterionInput
|
||||
"Filter to only include movies with these tags"
|
||||
tags: HierarchicalMultiCriterionInput
|
||||
"Filter by tag count"
|
||||
tag_count: IntCriterionInput
|
||||
"Filter by date"
|
||||
date: DateCriterionInput
|
||||
"Filter by creation time"
|
||||
|
@ -494,6 +498,9 @@ input TagFilterType {
|
|||
"Filter by number of performers with this tag"
|
||||
performer_count: IntCriterionInput
|
||||
|
||||
"Filter by number of movies with this tag"
|
||||
movie_count: IntCriterionInput
|
||||
|
||||
"Filter by number of markers with this tag"
|
||||
marker_count: IntCriterionInput
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ type Movie {
|
|||
synopsis: String
|
||||
url: String @deprecated(reason: "Use urls")
|
||||
urls: [String!]!
|
||||
tags: [Tag!]!
|
||||
created_at: Time!
|
||||
updated_at: Time!
|
||||
|
||||
|
@ -34,6 +35,7 @@ input MovieCreateInput {
|
|||
synopsis: String
|
||||
url: String @deprecated(reason: "Use urls")
|
||||
urls: [String!]
|
||||
tag_ids: [ID!]
|
||||
"This should be a URL or a base64 encoded data URL"
|
||||
front_image: String
|
||||
"This should be a URL or a base64 encoded data URL"
|
||||
|
@ -53,6 +55,7 @@ input MovieUpdateInput {
|
|||
synopsis: String
|
||||
url: String @deprecated(reason: "Use urls")
|
||||
urls: [String!]
|
||||
tag_ids: [ID!]
|
||||
"This should be a URL or a base64 encoded data URL"
|
||||
front_image: String
|
||||
"This should be a URL or a base64 encoded data URL"
|
||||
|
@ -67,6 +70,7 @@ input BulkMovieUpdateInput {
|
|||
studio_id: ID
|
||||
director: String
|
||||
urls: BulkUpdateStrings
|
||||
tag_ids: BulkUpdateIds
|
||||
}
|
||||
|
||||
input MovieDestroyInput {
|
||||
|
|
|
@ -11,6 +11,7 @@ type ScrapedMovie {
|
|||
urls: [String!]
|
||||
synopsis: String
|
||||
studio: ScrapedStudio
|
||||
tags: [ScrapedTag!]
|
||||
|
||||
"This should be a base64 encoded data URL"
|
||||
front_image: String
|
||||
|
@ -28,4 +29,5 @@ input ScrapedMovieInput {
|
|||
url: String @deprecated(reason: "use urls")
|
||||
urls: [String!]
|
||||
synopsis: String
|
||||
# not including tags for the input
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ type Tag {
|
|||
image_count(depth: Int): Int! # Resolver
|
||||
gallery_count(depth: Int): Int! # Resolver
|
||||
performer_count(depth: Int): Int! # Resolver
|
||||
movie_count(depth: Int): Int! # Resolver
|
||||
parents: [Tag!]!
|
||||
children: [Tag!]!
|
||||
|
||||
|
|
|
@ -57,6 +57,20 @@ func (r *movieResolver) Studio(ctx context.Context, obj *models.Movie) (ret *mod
|
|||
return loaders.From(ctx).StudioByID.Load(*obj.StudioID)
|
||||
}
|
||||
|
||||
func (r movieResolver) Tags(ctx context.Context, obj *models.Movie) (ret []*models.Tag, err error) {
|
||||
if !obj.TagIDs.Loaded() {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
return obj.LoadTagIDs(ctx, r.repository.Movie)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
var errs []error
|
||||
ret, errs = loaders.From(ctx).TagByID.LoadAll(obj.TagIDs.List())
|
||||
return ret, firstError(errs)
|
||||
}
|
||||
|
||||
func (r *movieResolver) FrontImagePath(ctx context.Context, obj *models.Movie) (*string, error) {
|
||||
var hasImage bool
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/stashapp/stash/pkg/gallery"
|
||||
"github.com/stashapp/stash/pkg/image"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/movie"
|
||||
"github.com/stashapp/stash/pkg/performer"
|
||||
"github.com/stashapp/stash/pkg/scene"
|
||||
)
|
||||
|
@ -107,6 +108,17 @@ func (r *tagResolver) PerformerCount(ctx context.Context, obj *models.Tag, depth
|
|||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *tagResolver) MovieCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = movie.CountByTagID(ctx, r.repository.Movie, obj.ID, depth)
|
||||
return err
|
||||
}); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *tagResolver) ImagePath(ctx context.Context, obj *models.Tag) (*string, error) {
|
||||
var hasImage bool
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
|
|
|
@ -50,6 +50,11 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp
|
|||
return nil, fmt.Errorf("converting studio id: %w", err)
|
||||
}
|
||||
|
||||
newMovie.TagIDs, err = translator.relatedIds(input.TagIds)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
|
||||
if input.Urls != nil {
|
||||
newMovie.URLs = models.NewRelatedStrings(input.Urls)
|
||||
} else if input.URL != nil {
|
||||
|
@ -140,6 +145,11 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp
|
|||
return nil, fmt.Errorf("converting studio id: %w", err)
|
||||
}
|
||||
|
||||
updatedMovie.TagIDs, err = translator.updateIds(input.TagIds, "tag_ids")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
|
||||
updatedMovie.URLs = translator.optionalURLs(input.Urls, input.URL)
|
||||
|
||||
var frontimageData []byte
|
||||
|
@ -211,6 +221,12 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("converting studio id: %w", err)
|
||||
}
|
||||
|
||||
updatedMovie.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("converting tag ids: %w", err)
|
||||
}
|
||||
|
||||
updatedMovie.URLs = translator.optionalURLsBulk(input.Urls, nil)
|
||||
|
||||
ret := []*models.Movie{}
|
||||
|
|
|
@ -144,6 +144,23 @@ func filterPerformerTags(p []*models.ScrapedPerformer) {
|
|||
}
|
||||
}
|
||||
|
||||
// filterMovieTags removes tags matching excluded tag patterns from the provided scraped movies
|
||||
func filterMovieTags(p []*models.ScrapedMovie) {
|
||||
excludeRegexps := compileRegexps(manager.GetInstance().Config.GetScraperExcludeTagPatterns())
|
||||
|
||||
var ignoredTags []string
|
||||
|
||||
for _, s := range p {
|
||||
var ignored []string
|
||||
s.Tags, ignored = filterTags(excludeRegexps, s.Tags)
|
||||
ignoredTags = sliceutil.AppendUniques(ignoredTags, ignored)
|
||||
}
|
||||
|
||||
if len(ignoredTags) > 0 {
|
||||
logger.Debugf("Scraping ignored tags: %s", strings.Join(ignoredTags, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
func (r *queryResolver) ScrapeSceneURL(ctx context.Context, url string) (*scraper.ScrapedScene, error) {
|
||||
content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeScene)
|
||||
if err != nil {
|
||||
|
@ -186,7 +203,14 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models
|
|||
return nil, err
|
||||
}
|
||||
|
||||
return marshalScrapedMovie(content)
|
||||
ret, err := marshalScrapedMovie(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
filterMovieTags([]*models.ScrapedMovie{ret})
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.Source, input ScrapeSingleSceneInput) ([]*scraper.ScrapedScene, error) {
|
||||
|
|
|
@ -1107,6 +1107,7 @@ func (t *ExportTask) exportMovie(ctx context.Context, wg *sync.WaitGroup, jobCha
|
|||
r := t.repository
|
||||
movieReader := r.Movie
|
||||
studioReader := r.Studio
|
||||
tagReader := r.Tag
|
||||
|
||||
for m := range jobChan {
|
||||
if err := m.LoadURLs(ctx, r.Movie); err != nil {
|
||||
|
@ -1121,6 +1122,14 @@ func (t *ExportTask) exportMovie(ctx context.Context, wg *sync.WaitGroup, jobCha
|
|||
continue
|
||||
}
|
||||
|
||||
tags, err := tagReader.FindByMovieID(ctx, m.ID)
|
||||
if err != nil {
|
||||
logger.Errorf("[movies] <%s> error getting image tag names: %v", m.Name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
newMovieJSON.Tags = tag.GetNames(tags)
|
||||
|
||||
if t.includeDependencies {
|
||||
if m.StudioID != nil {
|
||||
t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *m.StudioID)
|
||||
|
|
|
@ -351,6 +351,7 @@ func (t *ImportTask) ImportMovies(ctx context.Context) {
|
|||
movieImporter := &movie.Importer{
|
||||
ReaderWriter: r.Movie,
|
||||
StudioWriter: r.Studio,
|
||||
TagWriter: r.Tag,
|
||||
Input: *movieJSON,
|
||||
MissingRefBehaviour: t.MissingRefBehaviour,
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ type Movie struct {
|
|||
BackImage string `json:"back_image,omitempty"`
|
||||
URLs []string `json:"urls,omitempty"`
|
||||
Studio string `json:"studio,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CreatedAt json.JSONTime `json:"created_at,omitempty"`
|
||||
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
|
||||
|
||||
|
|
|
@ -312,6 +312,29 @@ func (_m *MovieReaderWriter) GetFrontImage(ctx context.Context, movieID int) ([]
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// GetTagIDs provides a mock function with given fields: ctx, relatedID
|
||||
func (_m *MovieReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]int, error) {
|
||||
ret := _m.Called(ctx, relatedID)
|
||||
|
||||
var r0 []int
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int) []int); ok {
|
||||
r0 = rf(ctx, relatedID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]int)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
|
||||
r1 = rf(ctx, relatedID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// GetURLs provides a mock function with given fields: ctx, relatedID
|
||||
func (_m *MovieReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) {
|
||||
ret := _m.Called(ctx, relatedID)
|
||||
|
|
|
@ -266,6 +266,29 @@ func (_m *TagReaderWriter) FindByImageID(ctx context.Context, imageID int) ([]*m
|
|||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByMovieID provides a mock function with given fields: ctx, movieID
|
||||
func (_m *TagReaderWriter) FindByMovieID(ctx context.Context, movieID int) ([]*models.Tag, error) {
|
||||
ret := _m.Called(ctx, movieID)
|
||||
|
||||
var r0 []*models.Tag
|
||||
if rf, ok := ret.Get(0).(func(context.Context, int) []*models.Tag); ok {
|
||||
r0 = rf(ctx, movieID)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Tag)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
|
||||
r1 = rf(ctx, movieID)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByName provides a mock function with given fields: ctx, name, nocase
|
||||
func (_m *TagReaderWriter) FindByName(ctx context.Context, name string, nocase bool) (*models.Tag, error) {
|
||||
ret := _m.Called(ctx, name, nocase)
|
||||
|
|
|
@ -19,7 +19,8 @@ type Movie struct {
|
|||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
URLs RelatedStrings `json:"urls"`
|
||||
URLs RelatedStrings `json:"urls"`
|
||||
TagIDs RelatedIDs `json:"tag_ids"`
|
||||
}
|
||||
|
||||
func NewMovie() Movie {
|
||||
|
@ -30,9 +31,15 @@ func NewMovie() Movie {
|
|||
}
|
||||
}
|
||||
|
||||
func (g *Movie) LoadURLs(ctx context.Context, l URLLoader) error {
|
||||
return g.URLs.load(func() ([]string, error) {
|
||||
return l.GetURLs(ctx, g.ID)
|
||||
func (m *Movie) LoadURLs(ctx context.Context, l URLLoader) error {
|
||||
return m.URLs.load(func() ([]string, error) {
|
||||
return l.GetURLs(ctx, m.ID)
|
||||
})
|
||||
}
|
||||
|
||||
func (m *Movie) LoadTagIDs(ctx context.Context, l TagIDLoader) error {
|
||||
return m.TagIDs.load(func() ([]int, error) {
|
||||
return l.GetTagIDs(ctx, m.ID)
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -47,6 +54,7 @@ type MoviePartial struct {
|
|||
Director OptionalString
|
||||
Synopsis OptionalString
|
||||
URLs *UpdateStrings
|
||||
TagIDs *UpdateIDs
|
||||
CreatedAt OptionalTime
|
||||
UpdatedAt OptionalTime
|
||||
}
|
||||
|
|
|
@ -371,6 +371,7 @@ type ScrapedMovie struct {
|
|||
URLs []string `json:"urls"`
|
||||
Synopsis *string `json:"synopsis"`
|
||||
Studio *ScrapedStudio `json:"studio"`
|
||||
Tags []*ScrapedTag `json:"tags"`
|
||||
// This should be a base64 encoded data URL
|
||||
FrontImage *string `json:"front_image"`
|
||||
// This should be a base64 encoded data URL
|
||||
|
|
|
@ -17,6 +17,10 @@ type MovieFilterType struct {
|
|||
URL *StringCriterionInput `json:"url"`
|
||||
// Filter to only include movies where performer appears in a scene
|
||||
Performers *MultiCriterionInput `json:"performers"`
|
||||
// Filter to only include performers with these tags
|
||||
Tags *HierarchicalMultiCriterionInput `json:"tags"`
|
||||
// Filter by tag count
|
||||
TagCount *IntCriterionInput `json:"tag_count"`
|
||||
// Filter by date
|
||||
Date *DateCriterionInput `json:"date"`
|
||||
// Filter by related scenes that meet this criteria
|
||||
|
|
|
@ -65,6 +65,7 @@ type MovieReader interface {
|
|||
MovieQueryer
|
||||
MovieCounter
|
||||
URLLoader
|
||||
TagIDLoader
|
||||
|
||||
All(ctx context.Context) ([]*Movie, error)
|
||||
GetFrontImage(ctx context.Context, movieID int) ([]byte, error)
|
||||
|
|
|
@ -20,6 +20,7 @@ type TagFinder interface {
|
|||
FindByImageID(ctx context.Context, imageID int) ([]*Tag, error)
|
||||
FindByGalleryID(ctx context.Context, galleryID int) ([]*Tag, error)
|
||||
FindByPerformerID(ctx context.Context, performerID int) ([]*Tag, error)
|
||||
FindByMovieID(ctx context.Context, movieID int) ([]*Tag, error)
|
||||
FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*Tag, error)
|
||||
FindByName(ctx context.Context, name string, nocase bool) (*Tag, error)
|
||||
FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error)
|
||||
|
|
|
@ -20,6 +20,8 @@ type TagFilterType struct {
|
|||
GalleryCount *IntCriterionInput `json:"gallery_count"`
|
||||
// Filter by number of performers with this tag
|
||||
PerformerCount *IntCriterionInput `json:"performer_count"`
|
||||
// Filter by number of movies with this tag
|
||||
MovieCount *IntCriterionInput `json:"movie_count"`
|
||||
// Filter by number of markers with this tag
|
||||
MarkerCount *IntCriterionInput `json:"marker_count"`
|
||||
// Filter by parent tags
|
||||
|
|
|
@ -3,9 +3,11 @@ package movie
|
|||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/jsonschema"
|
||||
"github.com/stashapp/stash/pkg/sliceutil"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
|
@ -17,6 +19,7 @@ type ImporterReaderWriter interface {
|
|||
type Importer struct {
|
||||
ReaderWriter ImporterReaderWriter
|
||||
StudioWriter models.StudioFinderCreator
|
||||
TagWriter models.TagFinderCreator
|
||||
Input jsonschema.Movie
|
||||
MissingRefBehaviour models.ImportMissingRefEnum
|
||||
|
||||
|
@ -32,6 +35,10 @@ func (i *Importer) PreImport(ctx context.Context) error {
|
|||
return err
|
||||
}
|
||||
|
||||
if err := i.populateTags(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var err error
|
||||
if len(i.Input.FrontImage) > 0 {
|
||||
i.frontImageData, err = utils.ProcessBase64Image(i.Input.FrontImage)
|
||||
|
@ -49,6 +56,74 @@ func (i *Importer) PreImport(ctx context.Context) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func (i *Importer) populateTags(ctx context.Context) error {
|
||||
if len(i.Input.Tags) > 0 {
|
||||
|
||||
tags, err := importTags(ctx, i.TagWriter, i.Input.Tags, i.MissingRefBehaviour)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, p := range tags {
|
||||
i.movie.TagIDs.Add(p.ID)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func importTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string, missingRefBehaviour models.ImportMissingRefEnum) ([]*models.Tag, error) {
|
||||
tags, err := tagWriter.FindByNames(ctx, names, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pluckedNames []string
|
||||
for _, tag := range tags {
|
||||
pluckedNames = append(pluckedNames, tag.Name)
|
||||
}
|
||||
|
||||
missingTags := sliceutil.Filter(names, func(name string) bool {
|
||||
return !sliceutil.Contains(pluckedNames, name)
|
||||
})
|
||||
|
||||
if len(missingTags) > 0 {
|
||||
if missingRefBehaviour == models.ImportMissingRefEnumFail {
|
||||
return nil, fmt.Errorf("tags [%s] not found", strings.Join(missingTags, ", "))
|
||||
}
|
||||
|
||||
if missingRefBehaviour == models.ImportMissingRefEnumCreate {
|
||||
createdTags, err := createTags(ctx, tagWriter, missingTags)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating tags: %v", err)
|
||||
}
|
||||
|
||||
tags = append(tags, createdTags...)
|
||||
}
|
||||
|
||||
// ignore if MissingRefBehaviour set to Ignore
|
||||
}
|
||||
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
func createTags(ctx context.Context, tagWriter models.TagFinderCreator, names []string) ([]*models.Tag, error) {
|
||||
var ret []*models.Tag
|
||||
for _, name := range names {
|
||||
newTag := models.NewTag()
|
||||
newTag.Name = name
|
||||
|
||||
err := tagWriter.Create(ctx, &newTag)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret = append(ret, &newTag)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func (i *Importer) movieJSONToMovie(movieJSON jsonschema.Movie) models.Movie {
|
||||
newMovie := models.Movie{
|
||||
Name: movieJSON.Name,
|
||||
|
@ -57,6 +132,8 @@ func (i *Importer) movieJSONToMovie(movieJSON jsonschema.Movie) models.Movie {
|
|||
Synopsis: movieJSON.Synopsis,
|
||||
CreatedAt: movieJSON.CreatedAt.GetTime(),
|
||||
UpdatedAt: movieJSON.UpdatedAt.GetTime(),
|
||||
|
||||
TagIDs: models.NewRelatedIDs([]int{}),
|
||||
}
|
||||
|
||||
if len(movieJSON.URLs) > 0 {
|
||||
|
|
|
@ -26,6 +26,13 @@ const (
|
|||
missingStudioName = "existingStudioName"
|
||||
|
||||
errImageID = 3
|
||||
|
||||
existingTagID = 105
|
||||
errTagsID = 106
|
||||
|
||||
existingTagName = "existingTagName"
|
||||
existingTagErr = "existingTagErr"
|
||||
missingTagName = "missingTagName"
|
||||
)
|
||||
|
||||
var testCtx = context.Background()
|
||||
|
@ -157,6 +164,97 @@ func TestImporterPreImportWithMissingStudioCreateErr(t *testing.T) {
|
|||
db.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestImporterPreImportWithTag(t *testing.T) {
|
||||
db := mocks.NewDatabase()
|
||||
|
||||
i := Importer{
|
||||
ReaderWriter: db.Movie,
|
||||
TagWriter: db.Tag,
|
||||
MissingRefBehaviour: models.ImportMissingRefEnumFail,
|
||||
Input: jsonschema.Movie{
|
||||
Tags: []string{
|
||||
existingTagName,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
db.Tag.On("FindByNames", testCtx, []string{existingTagName}, false).Return([]*models.Tag{
|
||||
{
|
||||
ID: existingTagID,
|
||||
Name: existingTagName,
|
||||
},
|
||||
}, nil).Once()
|
||||
db.Tag.On("FindByNames", testCtx, []string{existingTagErr}, false).Return(nil, errors.New("FindByNames error")).Once()
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, existingTagID, i.movie.TagIDs.List()[0])
|
||||
|
||||
i.Input.Tags = []string{existingTagErr}
|
||||
err = i.PreImport(testCtx)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
db.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestImporterPreImportWithMissingTag(t *testing.T) {
|
||||
db := mocks.NewDatabase()
|
||||
|
||||
i := Importer{
|
||||
ReaderWriter: db.Movie,
|
||||
TagWriter: db.Tag,
|
||||
Input: jsonschema.Movie{
|
||||
Tags: []string{
|
||||
missingTagName,
|
||||
},
|
||||
},
|
||||
MissingRefBehaviour: models.ImportMissingRefEnumFail,
|
||||
}
|
||||
|
||||
db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Times(3)
|
||||
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Run(func(args mock.Arguments) {
|
||||
t := args.Get(1).(*models.Tag)
|
||||
t.ID = existingTagID
|
||||
}).Return(nil)
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
i.MissingRefBehaviour = models.ImportMissingRefEnumIgnore
|
||||
err = i.PreImport(testCtx)
|
||||
assert.Nil(t, err)
|
||||
|
||||
i.MissingRefBehaviour = models.ImportMissingRefEnumCreate
|
||||
err = i.PreImport(testCtx)
|
||||
assert.Nil(t, err)
|
||||
assert.Equal(t, existingTagID, i.movie.TagIDs.List()[0])
|
||||
|
||||
db.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestImporterPreImportWithMissingTagCreateErr(t *testing.T) {
|
||||
db := mocks.NewDatabase()
|
||||
|
||||
i := Importer{
|
||||
ReaderWriter: db.Movie,
|
||||
TagWriter: db.Tag,
|
||||
Input: jsonschema.Movie{
|
||||
Tags: []string{
|
||||
missingTagName,
|
||||
},
|
||||
},
|
||||
MissingRefBehaviour: models.ImportMissingRefEnumCreate,
|
||||
}
|
||||
|
||||
db.Tag.On("FindByNames", testCtx, []string{missingTagName}, false).Return(nil, nil).Once()
|
||||
db.Tag.On("Create", testCtx, mock.AnythingOfType("*models.Tag")).Return(errors.New("Create error"))
|
||||
|
||||
err := i.PreImport(testCtx)
|
||||
assert.NotNil(t, err)
|
||||
|
||||
db.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestImporterPostImport(t *testing.T) {
|
||||
db := mocks.NewDatabase()
|
||||
|
||||
|
|
|
@ -18,3 +18,15 @@ func CountByStudioID(ctx context.Context, r models.MovieQueryer, id int, depth *
|
|||
|
||||
return r.QueryCount(ctx, filter, nil)
|
||||
}
|
||||
|
||||
func CountByTagID(ctx context.Context, r models.MovieQueryer, id int, depth *int) (int, error) {
|
||||
filter := &models.MovieFilterType{
|
||||
Tags: &models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{strconv.Itoa(id)},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
Depth: depth,
|
||||
},
|
||||
}
|
||||
|
||||
return r.QueryCount(ctx, filter, nil)
|
||||
}
|
||||
|
|
|
@ -284,11 +284,13 @@ type mappedMovieScraperConfig struct {
|
|||
mappedConfig
|
||||
|
||||
Studio mappedConfig `yaml:"Studio"`
|
||||
Tags mappedConfig `yaml:"Tags"`
|
||||
}
|
||||
type _mappedMovieScraperConfig mappedMovieScraperConfig
|
||||
|
||||
const (
|
||||
mappedScraperConfigMovieStudio = "Studio"
|
||||
mappedScraperConfigMovieTags = "Tags"
|
||||
)
|
||||
|
||||
func (s *mappedMovieScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
|
||||
|
@ -303,9 +305,11 @@ func (s *mappedMovieScraperConfig) UnmarshalYAML(unmarshal func(interface{}) err
|
|||
thisMap := make(map[string]interface{})
|
||||
|
||||
thisMap[mappedScraperConfigMovieStudio] = parentMap[mappedScraperConfigMovieStudio]
|
||||
|
||||
delete(parentMap, mappedScraperConfigMovieStudio)
|
||||
|
||||
thisMap[mappedScraperConfigMovieTags] = parentMap[mappedScraperConfigMovieTags]
|
||||
delete(parentMap, mappedScraperConfigMovieTags)
|
||||
|
||||
// re-unmarshal the sub-fields
|
||||
yml, err := yaml.Marshal(thisMap)
|
||||
if err != nil {
|
||||
|
@ -1086,6 +1090,7 @@ func (s mappedScraper) scrapeMovie(ctx context.Context, q mappedQuery) (*models.
|
|||
movieMap := movieScraperConfig.mappedConfig
|
||||
|
||||
movieStudioMap := movieScraperConfig.Studio
|
||||
movieTagsMap := movieScraperConfig.Tags
|
||||
|
||||
results := movieMap.process(ctx, q, s.Common)
|
||||
|
||||
|
@ -1100,7 +1105,19 @@ func (s mappedScraper) scrapeMovie(ctx context.Context, q mappedQuery) (*models.
|
|||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 && ret.Studio == nil {
|
||||
// now apply the tags
|
||||
if movieTagsMap != nil {
|
||||
logger.Debug(`Processing movie tags:`)
|
||||
tagResults := movieTagsMap.process(ctx, q, s.Common)
|
||||
|
||||
for _, p := range tagResults {
|
||||
tag := &models.ScrapedTag{}
|
||||
p.apply(tag)
|
||||
ret.Tags = append(ret.Tags, tag)
|
||||
}
|
||||
}
|
||||
|
||||
if len(results) == 0 && ret.Studio == nil && len(ret.Tags) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
|
|
|
@ -71,13 +71,24 @@ func (c Cache) postScrapePerformer(ctx context.Context, p models.ScrapedPerforme
|
|||
}
|
||||
|
||||
func (c Cache) postScrapeMovie(ctx context.Context, m models.ScrapedMovie) (ScrapedContent, error) {
|
||||
if m.Studio != nil {
|
||||
r := c.repository
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
return match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, nil)
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
r := c.repository
|
||||
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
|
||||
tqb := r.TagFinder
|
||||
tags, err := postProcessTags(ctx, tqb, m.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.Tags = tags
|
||||
|
||||
if m.Studio != nil {
|
||||
if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// post-process - set the image if applicable
|
||||
|
|
|
@ -30,7 +30,7 @@ const (
|
|||
dbConnTimeout = 30
|
||||
)
|
||||
|
||||
var appSchemaVersion uint = 60
|
||||
var appSchemaVersion uint = 61
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsBox embed.FS
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
CREATE TABLE `movies_tags` (
|
||||
`movie_id` integer NOT NULL,
|
||||
`tag_id` integer NOT NULL,
|
||||
foreign key(`movie_id`) references `movies`(`id`) on delete CASCADE,
|
||||
foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE,
|
||||
PRIMARY KEY(`movie_id`, `tag_id`)
|
||||
);
|
||||
|
||||
CREATE INDEX `index_movies_tags_on_tag_id` on `movies_tags` (`tag_id`);
|
||||
CREATE INDEX `index_movies_tags_on_movie_id` on `movies_tags` (`movie_id`);
|
|
@ -23,6 +23,8 @@ const (
|
|||
movieFrontImageBlobColumn = "front_image_blob"
|
||||
movieBackImageBlobColumn = "back_image_blob"
|
||||
|
||||
moviesTagsTable = "movies_tags"
|
||||
|
||||
movieURLsTable = "movie_urls"
|
||||
movieURLColumn = "url"
|
||||
)
|
||||
|
@ -98,6 +100,7 @@ func (r *movieRowRecord) fromPartial(o models.MoviePartial) {
|
|||
type movieRepositoryType struct {
|
||||
repository
|
||||
scenes repository
|
||||
tags joinRepository
|
||||
}
|
||||
|
||||
var (
|
||||
|
@ -110,11 +113,21 @@ var (
|
|||
tableName: moviesScenesTable,
|
||||
idColumn: movieIDColumn,
|
||||
},
|
||||
tags: joinRepository{
|
||||
repository: repository{
|
||||
tableName: moviesTagsTable,
|
||||
idColumn: movieIDColumn,
|
||||
},
|
||||
fkColumn: tagIDColumn,
|
||||
foreignTable: tagTable,
|
||||
orderBy: "tags.name ASC",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
type MovieStore struct {
|
||||
blobJoinQueryBuilder
|
||||
tagRelationshipStore
|
||||
|
||||
tableMgr *table
|
||||
}
|
||||
|
@ -125,6 +138,11 @@ func NewMovieStore(blobStore *BlobStore) *MovieStore {
|
|||
blobStore: blobStore,
|
||||
joinTable: movieTable,
|
||||
},
|
||||
tagRelationshipStore: tagRelationshipStore{
|
||||
idRelationshipStore: idRelationshipStore{
|
||||
joinTable: moviesTagsTableMgr,
|
||||
},
|
||||
},
|
||||
|
||||
tableMgr: movieTableMgr,
|
||||
}
|
||||
|
@ -154,6 +172,10 @@ func (qb *MovieStore) Create(ctx context.Context, newObject *models.Movie) error
|
|||
}
|
||||
}
|
||||
|
||||
if err := qb.tagRelationshipStore.createRelationships(ctx, id, newObject.TagIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
updated, err := qb.find(ctx, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("finding after create: %w", err)
|
||||
|
@ -185,6 +207,10 @@ func (qb *MovieStore) UpdatePartial(ctx context.Context, id int, partial models.
|
|||
}
|
||||
}
|
||||
|
||||
if err := qb.tagRelationshipStore.modifyRelationships(ctx, id, partial.TagIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return qb.find(ctx, id)
|
||||
}
|
||||
|
||||
|
@ -202,6 +228,10 @@ func (qb *MovieStore) Update(ctx context.Context, updatedObject *models.Movie) e
|
|||
}
|
||||
}
|
||||
|
||||
if err := qb.tagRelationshipStore.replaceRelationships(ctx, updatedObject.ID, updatedObject.TagIDs); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -430,6 +460,7 @@ var movieSortOptions = sortOptions{
|
|||
"random",
|
||||
"rating",
|
||||
"scenes_count",
|
||||
"tag_count",
|
||||
"updated_at",
|
||||
}
|
||||
|
||||
|
@ -451,6 +482,8 @@ func (qb *MovieStore) getMovieSort(findFilter *models.FindFilterType) (string, e
|
|||
|
||||
sortQuery := ""
|
||||
switch sort {
|
||||
case "tag_count":
|
||||
sortQuery += getCountSort(movieTable, moviesTagsTable, movieIDColumn, direction)
|
||||
case "scenes_count": // generic getSort won't work for this
|
||||
sortQuery += getCountSort(movieTable, moviesScenesTable, movieIDColumn, direction)
|
||||
default:
|
||||
|
|
|
@ -63,6 +63,8 @@ func (qb *movieFilterHandler) criterionHandler() criterionHandler {
|
|||
qb.urlsCriterionHandler(movieFilter.URL),
|
||||
studioCriterionHandler(movieTable, movieFilter.Studios),
|
||||
qb.performersCriterionHandler(movieFilter.Performers),
|
||||
qb.tagsCriterionHandler(movieFilter.Tags),
|
||||
qb.tagCountCriterionHandler(movieFilter.TagCount),
|
||||
&dateCriterionHandler{movieFilter.Date, "movies.date", nil},
|
||||
×tampCriterionHandler{movieFilter.CreatedAt, "movies.created_at", nil},
|
||||
×tampCriterionHandler{movieFilter.UpdatedAt, "movies.updated_at", nil},
|
||||
|
@ -162,3 +164,28 @@ func (qb *movieFilterHandler) performersCriterionHandler(performers *models.Mult
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *movieFilterHandler) tagsCriterionHandler(tags *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
|
||||
h := joinedHierarchicalMultiCriterionHandlerBuilder{
|
||||
primaryTable: movieTable,
|
||||
foreignTable: tagTable,
|
||||
foreignFK: "tag_id",
|
||||
|
||||
relationsTable: "tags_relations",
|
||||
joinAs: "movie_tag",
|
||||
joinTable: moviesTagsTable,
|
||||
primaryFK: movieIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(tags)
|
||||
}
|
||||
|
||||
func (qb *movieFilterHandler) tagCountCriterionHandler(count *models.IntCriterionInput) criterionHandlerFunc {
|
||||
h := countCriterionHandlerBuilder{
|
||||
primaryTable: movieTable,
|
||||
joinTable: moviesTagsTable,
|
||||
primaryFK: movieIDColumn,
|
||||
}
|
||||
|
||||
return h.handler(count)
|
||||
}
|
||||
|
|
|
@ -9,6 +9,7 @@ import (
|
|||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
|
||||
|
@ -17,7 +18,12 @@ import (
|
|||
|
||||
func loadMovieRelationships(ctx context.Context, expected models.Movie, actual *models.Movie) error {
|
||||
if expected.URLs.Loaded() {
|
||||
if err := actual.LoadURLs(ctx, db.Gallery); err != nil {
|
||||
if err := actual.LoadURLs(ctx, db.Movie); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if expected.TagIDs.Loaded() {
|
||||
if err := actual.LoadTagIDs(ctx, db.Movie); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
@ -25,6 +31,337 @@ func loadMovieRelationships(ctx context.Context, expected models.Movie, actual *
|
|||
return nil
|
||||
}
|
||||
|
||||
func Test_MovieStore_Create(t *testing.T) {
|
||||
var (
|
||||
name = "name"
|
||||
url = "url"
|
||||
aliases = "alias1, alias2"
|
||||
director = "director"
|
||||
rating = 60
|
||||
duration = 34
|
||||
synopsis = "synopsis"
|
||||
date, _ = models.ParseDate("2003-02-01")
|
||||
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
newObject models.Movie
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"full",
|
||||
models.Movie{
|
||||
Name: name,
|
||||
Duration: &duration,
|
||||
Date: &date,
|
||||
Rating: &rating,
|
||||
StudioID: &studioIDs[studioIdxWithMovie],
|
||||
Director: director,
|
||||
Synopsis: synopsis,
|
||||
URLs: models.NewRelatedStrings([]string{url}),
|
||||
TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithMovie]}),
|
||||
Aliases: aliases,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid tag id",
|
||||
models.Movie{
|
||||
Name: name,
|
||||
TagIDs: models.NewRelatedIDs([]int{invalidID}),
|
||||
},
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
qb := db.Movie
|
||||
|
||||
for _, tt := range tests {
|
||||
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
p := tt.newObject
|
||||
if err := qb.Create(ctx, &p); (err != nil) != tt.wantErr {
|
||||
t.Errorf("MovieStore.Create() error = %v, wantErr = %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Zero(p.ID)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NotZero(p.ID)
|
||||
|
||||
copy := tt.newObject
|
||||
copy.ID = p.ID
|
||||
|
||||
// load relationships
|
||||
if err := loadMovieRelationships(ctx, copy, &p); err != nil {
|
||||
t.Errorf("loadMovieRelationships() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(copy, p)
|
||||
|
||||
// ensure can find the movie
|
||||
found, err := qb.Find(ctx, p.ID)
|
||||
if err != nil {
|
||||
t.Errorf("MovieStore.Find() error = %v", err)
|
||||
}
|
||||
|
||||
if !assert.NotNil(found) {
|
||||
return
|
||||
}
|
||||
|
||||
// load relationships
|
||||
if err := loadMovieRelationships(ctx, copy, found); err != nil {
|
||||
t.Errorf("loadMovieRelationships() error = %v", err)
|
||||
return
|
||||
}
|
||||
assert.Equal(copy, *found)
|
||||
|
||||
return
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_movieQueryBuilder_Update(t *testing.T) {
|
||||
var (
|
||||
name = "name"
|
||||
url = "url"
|
||||
aliases = "alias1, alias2"
|
||||
director = "director"
|
||||
rating = 60
|
||||
duration = 34
|
||||
synopsis = "synopsis"
|
||||
date, _ = models.ParseDate("2003-02-01")
|
||||
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
updatedObject *models.Movie
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"full",
|
||||
&models.Movie{
|
||||
ID: movieIDs[movieIdxWithTag],
|
||||
Name: name,
|
||||
Duration: &duration,
|
||||
Date: &date,
|
||||
Rating: &rating,
|
||||
StudioID: &studioIDs[studioIdxWithMovie],
|
||||
Director: director,
|
||||
Synopsis: synopsis,
|
||||
URLs: models.NewRelatedStrings([]string{url}),
|
||||
TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithMovie]}),
|
||||
Aliases: aliases,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"clear tag ids",
|
||||
&models.Movie{
|
||||
ID: movieIDs[movieIdxWithTag],
|
||||
Name: name,
|
||||
TagIDs: models.NewRelatedIDs([]int{}),
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid studio id",
|
||||
&models.Movie{
|
||||
ID: movieIDs[movieIdxWithScene],
|
||||
Name: name,
|
||||
StudioID: &invalidID,
|
||||
},
|
||||
true,
|
||||
},
|
||||
{
|
||||
"invalid tag id",
|
||||
&models.Movie{
|
||||
ID: movieIDs[movieIdxWithScene],
|
||||
Name: name,
|
||||
TagIDs: models.NewRelatedIDs([]int{invalidID}),
|
||||
},
|
||||
true,
|
||||
},
|
||||
}
|
||||
|
||||
qb := db.Movie
|
||||
for _, tt := range tests {
|
||||
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
copy := *tt.updatedObject
|
||||
|
||||
if err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr {
|
||||
t.Errorf("movieQueryBuilder.Update() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
|
||||
s, err := qb.Find(ctx, tt.updatedObject.ID)
|
||||
if err != nil {
|
||||
t.Errorf("movieQueryBuilder.Find() error = %v", err)
|
||||
}
|
||||
|
||||
// load relationships
|
||||
if err := loadMovieRelationships(ctx, copy, s); err != nil {
|
||||
t.Errorf("loadMovieRelationships() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(copy, *s)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func clearMoviePartial() models.MoviePartial {
|
||||
// leave mandatory fields
|
||||
return models.MoviePartial{
|
||||
Aliases: models.OptionalString{Set: true, Null: true},
|
||||
Synopsis: models.OptionalString{Set: true, Null: true},
|
||||
Director: models.OptionalString{Set: true, Null: true},
|
||||
Duration: models.OptionalInt{Set: true, Null: true},
|
||||
URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet},
|
||||
Date: models.OptionalDate{Set: true, Null: true},
|
||||
Rating: models.OptionalInt{Set: true, Null: true},
|
||||
StudioID: models.OptionalInt{Set: true, Null: true},
|
||||
TagIDs: &models.UpdateIDs{Mode: models.RelationshipUpdateModeSet},
|
||||
}
|
||||
}
|
||||
|
||||
func Test_movieQueryBuilder_UpdatePartial(t *testing.T) {
|
||||
var (
|
||||
name = "name"
|
||||
url = "url"
|
||||
aliases = "alias1, alias2"
|
||||
director = "director"
|
||||
rating = 60
|
||||
duration = 34
|
||||
synopsis = "synopsis"
|
||||
date, _ = models.ParseDate("2003-02-01")
|
||||
createdAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
updatedAt = time.Date(2001, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
id int
|
||||
partial models.MoviePartial
|
||||
want models.Movie
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
"full",
|
||||
movieIDs[movieIdxWithScene],
|
||||
models.MoviePartial{
|
||||
Name: models.NewOptionalString(name),
|
||||
Director: models.NewOptionalString(director),
|
||||
Synopsis: models.NewOptionalString(synopsis),
|
||||
Aliases: models.NewOptionalString(aliases),
|
||||
URLs: &models.UpdateStrings{
|
||||
Values: []string{url},
|
||||
Mode: models.RelationshipUpdateModeSet,
|
||||
},
|
||||
Date: models.NewOptionalDate(date),
|
||||
Duration: models.NewOptionalInt(duration),
|
||||
Rating: models.NewOptionalInt(rating),
|
||||
StudioID: models.NewOptionalInt(studioIDs[studioIdxWithMovie]),
|
||||
CreatedAt: models.NewOptionalTime(createdAt),
|
||||
UpdatedAt: models.NewOptionalTime(updatedAt),
|
||||
TagIDs: &models.UpdateIDs{
|
||||
IDs: []int{tagIDs[tagIdx1WithMovie], tagIDs[tagIdx1WithDupName]},
|
||||
Mode: models.RelationshipUpdateModeSet,
|
||||
},
|
||||
},
|
||||
models.Movie{
|
||||
ID: movieIDs[movieIdxWithScene],
|
||||
Name: name,
|
||||
Director: director,
|
||||
Synopsis: synopsis,
|
||||
Aliases: aliases,
|
||||
URLs: models.NewRelatedStrings([]string{url}),
|
||||
Date: &date,
|
||||
Duration: &duration,
|
||||
Rating: &rating,
|
||||
StudioID: &studioIDs[studioIdxWithMovie],
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithMovie]}),
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"clear all",
|
||||
movieIDs[movieIdxWithScene],
|
||||
clearMoviePartial(),
|
||||
models.Movie{
|
||||
ID: movieIDs[movieIdxWithScene],
|
||||
Name: movieNames[movieIdxWithScene],
|
||||
TagIDs: models.NewRelatedIDs([]int{}),
|
||||
},
|
||||
false,
|
||||
},
|
||||
{
|
||||
"invalid id",
|
||||
invalidID,
|
||||
models.MoviePartial{},
|
||||
models.Movie{},
|
||||
true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
qb := db.Movie
|
||||
|
||||
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
|
||||
assert := assert.New(t)
|
||||
|
||||
got, err := qb.UpdatePartial(ctx, tt.id, tt.partial)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("movieQueryBuilder.UpdatePartial() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
|
||||
// load relationships
|
||||
if err := loadMovieRelationships(ctx, tt.want, got); err != nil {
|
||||
t.Errorf("loadMovieRelationships() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(tt.want, *got)
|
||||
|
||||
s, err := qb.Find(ctx, tt.id)
|
||||
if err != nil {
|
||||
t.Errorf("movieQueryBuilder.Find() error = %v", err)
|
||||
}
|
||||
|
||||
// load relationships
|
||||
if err := loadMovieRelationships(ctx, tt.want, s); err != nil {
|
||||
t.Errorf("loadMovieRelationships() error = %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.Equal(tt.want, *s)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMovieFindByName(t *testing.T) {
|
||||
withTxn(func(ctx context.Context) error {
|
||||
mqb := db.Movie
|
||||
|
@ -280,12 +617,12 @@ func TestMovieQueryURLExcludes(t *testing.T) {
|
|||
Name: &nameCriterion,
|
||||
}
|
||||
|
||||
movies := queryMovie(ctx, t, mqb, &filter, nil)
|
||||
movies := queryMovies(ctx, t, &filter, nil)
|
||||
assert.Len(t, movies, 0, "Expected no movies to be found")
|
||||
|
||||
// query for movies that exclude the URL "ccc"
|
||||
urlCriterion.Value = "ccc"
|
||||
movies = queryMovie(ctx, t, mqb, &filter, nil)
|
||||
movies = queryMovies(ctx, t, &filter, nil)
|
||||
|
||||
if assert.Len(t, movies, 1, "Expected one movie to be found") {
|
||||
assert.Equal(t, movie.Name, movies[0].Name)
|
||||
|
@ -300,7 +637,7 @@ func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func
|
|||
t.Helper()
|
||||
sqb := db.Movie
|
||||
|
||||
movies := queryMovie(ctx, t, sqb, &filter, nil)
|
||||
movies := queryMovies(ctx, t, &filter, nil)
|
||||
|
||||
for _, movie := range movies {
|
||||
if err := movie.LoadURLs(ctx, sqb); err != nil {
|
||||
|
@ -319,7 +656,8 @@ func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func
|
|||
})
|
||||
}
|
||||
|
||||
func queryMovie(ctx context.Context, t *testing.T, sqb models.MovieReader, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) []*models.Movie {
|
||||
func queryMovies(ctx context.Context, t *testing.T, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) []*models.Movie {
|
||||
sqb := db.Movie
|
||||
movies, _, err := sqb.Query(ctx, movieFilter, findFilter)
|
||||
if err != nil {
|
||||
t.Errorf("Error querying movie: %s", err.Error())
|
||||
|
@ -328,6 +666,102 @@ func queryMovie(ctx context.Context, t *testing.T, sqb models.MovieReader, movie
|
|||
return movies
|
||||
}
|
||||
|
||||
func TestMovieQueryTags(t *testing.T) {
|
||||
withTxn(func(ctx context.Context) error {
|
||||
tagCriterion := models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(tagIDs[tagIdxWithMovie]),
|
||||
strconv.Itoa(tagIDs[tagIdx1WithMovie]),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludes,
|
||||
}
|
||||
|
||||
movieFilter := models.MovieFilterType{
|
||||
Tags: &tagCriterion,
|
||||
}
|
||||
|
||||
// ensure ids are correct
|
||||
movies := queryMovies(ctx, t, &movieFilter, nil)
|
||||
assert.Len(t, movies, 3)
|
||||
for _, movie := range movies {
|
||||
assert.True(t, movie.ID == movieIDs[movieIdxWithTag] || movie.ID == movieIDs[movieIdxWithTwoTags] || movie.ID == movieIDs[movieIdxWithThreeTags])
|
||||
}
|
||||
|
||||
tagCriterion = models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(tagIDs[tagIdx1WithMovie]),
|
||||
strconv.Itoa(tagIDs[tagIdx2WithMovie]),
|
||||
},
|
||||
Modifier: models.CriterionModifierIncludesAll,
|
||||
}
|
||||
|
||||
movies = queryMovies(ctx, t, &movieFilter, nil)
|
||||
|
||||
if assert.Len(t, movies, 2) {
|
||||
assert.Equal(t, sceneIDs[movieIdxWithTwoTags], movies[0].ID)
|
||||
assert.Equal(t, sceneIDs[movieIdxWithThreeTags], movies[1].ID)
|
||||
}
|
||||
|
||||
tagCriterion = models.HierarchicalMultiCriterionInput{
|
||||
Value: []string{
|
||||
strconv.Itoa(tagIDs[tagIdx1WithMovie]),
|
||||
},
|
||||
Modifier: models.CriterionModifierExcludes,
|
||||
}
|
||||
|
||||
q := getSceneStringValue(movieIdxWithTwoTags, titleField)
|
||||
findFilter := models.FindFilterType{
|
||||
Q: &q,
|
||||
}
|
||||
|
||||
movies = queryMovies(ctx, t, &movieFilter, &findFilter)
|
||||
assert.Len(t, movies, 0)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestMovieQueryTagCount(t *testing.T) {
|
||||
const tagCount = 1
|
||||
tagCountCriterion := models.IntCriterionInput{
|
||||
Value: tagCount,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
}
|
||||
|
||||
verifyMoviesTagCount(t, tagCountCriterion)
|
||||
|
||||
tagCountCriterion.Modifier = models.CriterionModifierNotEquals
|
||||
verifyMoviesTagCount(t, tagCountCriterion)
|
||||
|
||||
tagCountCriterion.Modifier = models.CriterionModifierGreaterThan
|
||||
verifyMoviesTagCount(t, tagCountCriterion)
|
||||
|
||||
tagCountCriterion.Modifier = models.CriterionModifierLessThan
|
||||
verifyMoviesTagCount(t, tagCountCriterion)
|
||||
}
|
||||
|
||||
func verifyMoviesTagCount(t *testing.T, tagCountCriterion models.IntCriterionInput) {
|
||||
withTxn(func(ctx context.Context) error {
|
||||
sqb := db.Movie
|
||||
movieFilter := models.MovieFilterType{
|
||||
TagCount: &tagCountCriterion,
|
||||
}
|
||||
|
||||
movies := queryMovies(ctx, t, &movieFilter, nil)
|
||||
assert.Greater(t, len(movies), 0)
|
||||
|
||||
for _, movie := range movies {
|
||||
ids, err := sqb.GetTagIDs(ctx, movie.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
verifyInt(t, len(ids), tagCountCriterion)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestMovieQuerySorting(t *testing.T) {
|
||||
sort := "scenes_count"
|
||||
direction := models.SortDirectionEnumDesc
|
||||
|
@ -337,8 +771,7 @@ func TestMovieQuerySorting(t *testing.T) {
|
|||
}
|
||||
|
||||
withTxn(func(ctx context.Context) error {
|
||||
sqb := db.Movie
|
||||
movies := queryMovie(ctx, t, sqb, nil, &findFilter)
|
||||
movies := queryMovies(ctx, t, nil, &findFilter)
|
||||
|
||||
// scenes should be in same order as indexes
|
||||
firstMovie := movies[0]
|
||||
|
@ -348,7 +781,7 @@ func TestMovieQuerySorting(t *testing.T) {
|
|||
// sort in descending order
|
||||
direction = models.SortDirectionEnumAsc
|
||||
|
||||
movies = queryMovie(ctx, t, sqb, nil, &findFilter)
|
||||
movies = queryMovies(ctx, t, nil, &findFilter)
|
||||
lastMovie := movies[len(movies)-1]
|
||||
|
||||
assert.Equal(t, movieIDs[movieIdxWithScene], lastMovie.ID)
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
package sqlite
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
)
|
||||
|
||||
type idRelationshipStore struct {
|
||||
joinTable *joinTable
|
||||
}
|
||||
|
||||
func (s *idRelationshipStore) createRelationships(ctx context.Context, id int, fkIDs models.RelatedIDs) error {
|
||||
if fkIDs.Loaded() {
|
||||
if err := s.joinTable.insertJoins(ctx, id, fkIDs.List()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *idRelationshipStore) modifyRelationships(ctx context.Context, id int, fkIDs *models.UpdateIDs) error {
|
||||
if fkIDs != nil {
|
||||
if err := s.joinTable.modifyJoins(ctx, id, fkIDs.IDs, fkIDs.Mode); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *idRelationshipStore) replaceRelationships(ctx context.Context, id int, fkIDs models.RelatedIDs) error {
|
||||
if fkIDs.Loaded() {
|
||||
if err := s.joinTable.replaceJoins(ctx, id, fkIDs.List()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
|
@ -150,9 +150,12 @@ const (
|
|||
const (
|
||||
movieIdxWithScene = iota
|
||||
movieIdxWithStudio
|
||||
movieIdxWithTag
|
||||
movieIdxWithTwoTags
|
||||
movieIdxWithThreeTags
|
||||
// movies with dup names start from the end
|
||||
// create 10 more basic movies (can remove this if we add more indexes)
|
||||
movieIdxWithDupName = movieIdxWithStudio + 10
|
||||
// create 7 more basic movies (can remove this if we add more indexes)
|
||||
movieIdxWithDupName = movieIdxWithStudio + 7
|
||||
|
||||
moviesNameCase = movieIdxWithDupName
|
||||
moviesNameNoCase = 1
|
||||
|
@ -214,6 +217,10 @@ const (
|
|||
tagIdxWithParentAndChild
|
||||
tagIdxWithGrandParent
|
||||
tagIdx2WithMarkers
|
||||
tagIdxWithMovie
|
||||
tagIdx1WithMovie
|
||||
tagIdx2WithMovie
|
||||
tagIdx3WithMovie
|
||||
// new indexes above
|
||||
// tags with dup names start from the end
|
||||
tagIdx1WithDupName
|
||||
|
@ -487,6 +494,12 @@ var (
|
|||
movieStudioLinks = [][2]int{
|
||||
{movieIdxWithStudio, studioIdxWithMovie},
|
||||
}
|
||||
|
||||
movieTags = linkMap{
|
||||
movieIdxWithTag: {tagIdxWithMovie},
|
||||
movieIdxWithTwoTags: {tagIdx1WithMovie, tagIdx2WithMovie},
|
||||
movieIdxWithThreeTags: {tagIdx1WithMovie, tagIdx2WithMovie, tagIdx3WithMovie},
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
|
@ -622,14 +635,14 @@ func populateDB() error {
|
|||
|
||||
// TODO - link folders to zip files
|
||||
|
||||
if err := createMovies(ctx, db.Movie, moviesNameCase, moviesNameNoCase); err != nil {
|
||||
return fmt.Errorf("error creating movies: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := createTags(ctx, db.Tag, tagsNameCase, tagsNameNoCase); err != nil {
|
||||
return fmt.Errorf("error creating tags: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := createMovies(ctx, db.Movie, moviesNameCase, moviesNameNoCase); err != nil {
|
||||
return fmt.Errorf("error creating movies: %s", err.Error())
|
||||
}
|
||||
|
||||
if err := createPerformers(ctx, performersNameCase, performersNameNoCase); err != nil {
|
||||
return fmt.Errorf("error creating performers: %s", err.Error())
|
||||
}
|
||||
|
@ -1321,6 +1334,8 @@ func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o in
|
|||
index := i
|
||||
name := namePlain
|
||||
|
||||
tids := indexesToIDs(tagIDs, movieTags[i])
|
||||
|
||||
if i >= n { // i<n tags get normal names
|
||||
name = nameNoCase // i>=n movies get dup names if case is not checked
|
||||
index = n + o - (i + 1) // for the name to be the same the number (index) must be the same also
|
||||
|
@ -1333,6 +1348,7 @@ func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o in
|
|||
URLs: models.NewRelatedStrings([]string{
|
||||
getMovieEmptyString(i, urlField),
|
||||
}),
|
||||
TagIDs: models.NewRelatedIDs(tids),
|
||||
}
|
||||
|
||||
err := mqb.Create(ctx, &movie)
|
||||
|
|
|
@ -155,6 +155,10 @@ func (t *table) join(j joiner, as string, parentIDCol string) {
|
|||
type joinTable struct {
|
||||
table
|
||||
fkColumn exp.IdentifierExpression
|
||||
|
||||
// required for ordering
|
||||
foreignTable *table
|
||||
orderBy exp.OrderedExpression
|
||||
}
|
||||
|
||||
func (t *joinTable) invert() *joinTable {
|
||||
|
@ -170,6 +174,13 @@ func (t *joinTable) invert() *joinTable {
|
|||
func (t *joinTable) get(ctx context.Context, id int) ([]int, error) {
|
||||
q := dialect.Select(t.fkColumn).From(t.table.table).Where(t.idColumn.Eq(id))
|
||||
|
||||
if t.orderBy != nil {
|
||||
if t.foreignTable != nil {
|
||||
q = q.InnerJoin(t.foreignTable.table, goqu.On(t.foreignTable.idColumn.Eq(t.fkColumn)))
|
||||
}
|
||||
q = q.Order(t.orderBy)
|
||||
}
|
||||
|
||||
const single = false
|
||||
var ret []int
|
||||
if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error {
|
||||
|
|
|
@ -36,6 +36,7 @@ var (
|
|||
studiosStashIDsJoinTable = goqu.T("studio_stash_ids")
|
||||
|
||||
moviesURLsJoinTable = goqu.T(movieURLsTable)
|
||||
moviesTagsJoinTable = goqu.T(moviesTagsTable)
|
||||
|
||||
tagsAliasesJoinTable = goqu.T(tagAliasesTable)
|
||||
tagRelationsJoinTable = goqu.T(tagRelationsTable)
|
||||
|
@ -330,6 +331,16 @@ var (
|
|||
},
|
||||
valueColumn: moviesURLsJoinTable.Col(movieURLColumn),
|
||||
}
|
||||
|
||||
moviesTagsTableMgr = &joinTable{
|
||||
table: table{
|
||||
table: moviesTagsJoinTable,
|
||||
idColumn: moviesTagsJoinTable.Col(movieIDColumn),
|
||||
},
|
||||
fkColumn: moviesTagsJoinTable.Col(tagIDColumn),
|
||||
foreignTable: tagTableMgr,
|
||||
orderBy: tagTableMgr.table.Col("name").Asc(),
|
||||
}
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
|
@ -424,6 +424,18 @@ func (qb *TagStore) FindByGalleryID(ctx context.Context, galleryID int) ([]*mode
|
|||
return qb.queryTags(ctx, query, args)
|
||||
}
|
||||
|
||||
func (qb *TagStore) FindByMovieID(ctx context.Context, movieID int) ([]*models.Tag, error) {
|
||||
query := `
|
||||
SELECT tags.* FROM tags
|
||||
LEFT JOIN movies_tags as movies_join on movies_join.tag_id = tags.id
|
||||
WHERE movies_join.movie_id = ?
|
||||
GROUP BY tags.id
|
||||
`
|
||||
query += qb.getDefaultTagSort()
|
||||
args := []interface{}{movieID}
|
||||
return qb.queryTags(ctx, query, args)
|
||||
}
|
||||
|
||||
func (qb *TagStore) FindBySceneMarkerID(ctx context.Context, sceneMarkerID int) ([]*models.Tag, error) {
|
||||
query := `
|
||||
SELECT tags.* FROM tags
|
||||
|
@ -615,6 +627,7 @@ var tagSortOptions = sortOptions{
|
|||
"galleries_count",
|
||||
"id",
|
||||
"images_count",
|
||||
"movies_count",
|
||||
"name",
|
||||
"performers_count",
|
||||
"random",
|
||||
|
@ -655,6 +668,8 @@ func (qb *TagStore) getTagSort(query *queryBuilder, findFilter *models.FindFilte
|
|||
sortQuery += getCountSort(tagTable, galleriesTagsTable, tagIDColumn, direction)
|
||||
case "performers_count":
|
||||
sortQuery += getCountSort(tagTable, performersTagsTable, tagIDColumn, direction)
|
||||
case "movies_count":
|
||||
sortQuery += getCountSort(tagTable, moviesTagsTable, tagIDColumn, direction)
|
||||
default:
|
||||
sortQuery += getSort(sort, direction, "tags")
|
||||
}
|
||||
|
@ -888,3 +903,17 @@ SELECT t.*, c.path FROM tags t INNER JOIN children c ON t.id = c.child_id
|
|||
|
||||
return qb.queryTagPaths(ctx, query, args)
|
||||
}
|
||||
|
||||
type tagRelationshipStore struct {
|
||||
idRelationshipStore
|
||||
}
|
||||
|
||||
func (s *tagRelationshipStore) CountByTagID(ctx context.Context, tagID int) (int, error) {
|
||||
joinTable := s.joinTable.table.table
|
||||
q := dialect.Select(goqu.COUNT("*")).From(joinTable).Where(joinTable.Col(tagIDColumn).Eq(tagID))
|
||||
return count(ctx, q)
|
||||
}
|
||||
|
||||
func (s *tagRelationshipStore) GetTagIDs(ctx context.Context, id int) ([]int, error) {
|
||||
return s.joinTable.get(ctx, id)
|
||||
}
|
||||
|
|
|
@ -66,6 +66,7 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler {
|
|||
qb.imageCountCriterionHandler(tagFilter.ImageCount),
|
||||
qb.galleryCountCriterionHandler(tagFilter.GalleryCount),
|
||||
qb.performerCountCriterionHandler(tagFilter.PerformerCount),
|
||||
qb.movieCountCriterionHandler(tagFilter.MovieCount),
|
||||
qb.markerCountCriterionHandler(tagFilter.MarkerCount),
|
||||
qb.parentsCriterionHandler(tagFilter.Parents),
|
||||
qb.childrenCriterionHandler(tagFilter.Children),
|
||||
|
@ -174,6 +175,17 @@ func (qb *tagFilterHandler) performerCountCriterionHandler(performerCount *model
|
|||
}
|
||||
}
|
||||
|
||||
func (qb *tagFilterHandler) movieCountCriterionHandler(movieCount *models.IntCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if movieCount != nil {
|
||||
f.addLeftJoin("movies_tags", "", "movies_tags.tag_id = tags.id")
|
||||
clause, args := getIntCriterionWhereClause("count(distinct movies_tags.movie_id)", *movieCount)
|
||||
|
||||
f.addHaving(clause, args...)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (qb *tagFilterHandler) markerCountCriterionHandler(markerCount *models.IntCriterionInput) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if markerCount != nil {
|
||||
|
|
|
@ -42,6 +42,33 @@ func TestMarkerFindBySceneMarkerID(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestTagFindByMovieID(t *testing.T) {
|
||||
withTxn(func(ctx context.Context) error {
|
||||
tqb := db.Tag
|
||||
|
||||
movieID := movieIDs[movieIdxWithTag]
|
||||
|
||||
tags, err := tqb.FindByMovieID(ctx, movieID)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Error finding tags: %s", err.Error())
|
||||
}
|
||||
|
||||
assert.Len(t, tags, 1)
|
||||
assert.Equal(t, tagIDs[tagIdxWithMovie], tags[0].ID)
|
||||
|
||||
tags, err = tqb.FindByMovieID(ctx, 0)
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Error finding tags: %s", err.Error())
|
||||
}
|
||||
|
||||
assert.Len(t, tags, 0)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
func TestTagFindByName(t *testing.T) {
|
||||
withTxn(func(ctx context.Context) error {
|
||||
tqb := db.Tag
|
||||
|
@ -203,6 +230,10 @@ func TestTagQuerySort(t *testing.T) {
|
|||
tags = queryTags(ctx, t, sqb, nil, findFilter)
|
||||
assert.Equal(tagIDs[tagIdx2WithPerformer], tags[0].ID)
|
||||
|
||||
sortBy = "movies_count"
|
||||
tags = queryTags(ctx, t, sqb, nil, findFilter)
|
||||
assert.Equal(tagIDs[tagIdx1WithMovie], tags[0].ID)
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
|
|
@ -11,6 +11,10 @@ fragment MovieData on Movie {
|
|||
...SlimStudioData
|
||||
}
|
||||
|
||||
tags {
|
||||
...SlimTagData
|
||||
}
|
||||
|
||||
synopsis
|
||||
urls
|
||||
front_image_path
|
||||
|
|
|
@ -98,6 +98,9 @@ fragment ScrapedMovieData on ScrapedMovie {
|
|||
studio {
|
||||
...ScrapedMovieStudioData
|
||||
}
|
||||
tags {
|
||||
...ScrapedSceneTagData
|
||||
}
|
||||
}
|
||||
|
||||
fragment ScrapedSceneMovieData on ScrapedMovie {
|
||||
|
@ -116,6 +119,9 @@ fragment ScrapedSceneMovieData on ScrapedMovie {
|
|||
studio {
|
||||
...ScrapedMovieStudioData
|
||||
}
|
||||
tags {
|
||||
...ScrapedSceneTagData
|
||||
}
|
||||
}
|
||||
|
||||
fragment ScrapedSceneStudioData on ScrapedStudio {
|
||||
|
|
|
@ -16,6 +16,8 @@ fragment TagData on Tag {
|
|||
gallery_count_all: gallery_count(depth: -1)
|
||||
performer_count
|
||||
performer_count_all: performer_count(depth: -1)
|
||||
movie_count
|
||||
movie_count_all: movie_count(depth: -1)
|
||||
|
||||
parents {
|
||||
...SlimTagData
|
||||
|
|
|
@ -36,9 +36,9 @@ import {
|
|||
yupUniqueStringList,
|
||||
} from "src/utils/yup";
|
||||
import { formikUtils } from "src/utils/form";
|
||||
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
|
||||
import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
|
||||
import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect";
|
||||
import { useTagsEdit } from "src/hooks/tagsEdit";
|
||||
|
||||
interface IProps {
|
||||
gallery: Partial<GQL.GalleryDataFragment>;
|
||||
|
@ -58,7 +58,6 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
|||
const [scenes, setScenes] = useState<Scene[]>([]);
|
||||
|
||||
const [performers, setPerformers] = useState<Performer[]>([]);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [studio, setStudio] = useState<Studio | null>(null);
|
||||
|
||||
const isNew = gallery.id === undefined;
|
||||
|
@ -110,6 +109,11 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
|||
onSubmit: (values) => onSave(schema.cast(values)),
|
||||
});
|
||||
|
||||
const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(
|
||||
gallery.tags,
|
||||
(ids) => formik.setFieldValue("tag_ids", ids)
|
||||
);
|
||||
|
||||
function onSetScenes(items: Scene[]) {
|
||||
setScenes(items);
|
||||
formik.setFieldValue(
|
||||
|
@ -126,14 +130,6 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
function onSetTags(items: Tag[]) {
|
||||
setTags(items);
|
||||
formik.setFieldValue(
|
||||
"tag_ids",
|
||||
items.map((item) => item.id)
|
||||
);
|
||||
}
|
||||
|
||||
function onSetStudio(item: Studio | null) {
|
||||
setStudio(item);
|
||||
formik.setFieldValue("studio_id", item ? item.id : null);
|
||||
|
@ -143,10 +139,6 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
|||
setPerformers(gallery.performers ?? []);
|
||||
}, [gallery.performers]);
|
||||
|
||||
useEffect(() => {
|
||||
setTags(gallery.tags ?? []);
|
||||
}, [gallery.tags]);
|
||||
|
||||
useEffect(() => {
|
||||
setStudio(gallery.studio ?? null);
|
||||
}, [gallery.studio]);
|
||||
|
@ -339,23 +331,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
if (galleryData?.tags?.length) {
|
||||
const idTags = galleryData.tags.filter((t) => {
|
||||
return t.stored_id !== undefined && t.stored_id !== null;
|
||||
});
|
||||
|
||||
if (idTags.length > 0) {
|
||||
onSetTags(
|
||||
idTags.map((p) => {
|
||||
return {
|
||||
id: p.stored_id!,
|
||||
name: p.name ?? "",
|
||||
aliases: [],
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
updateTagsStateFromScraper(galleryData.tags ?? undefined);
|
||||
}
|
||||
|
||||
async function onScrapeGalleryURL(url: string) {
|
||||
|
@ -437,16 +413,7 @@ export const GalleryEditPanel: React.FC<IProps> = ({
|
|||
|
||||
function renderTagsField() {
|
||||
const title = intl.formatMessage({ id: "tags" });
|
||||
const control = (
|
||||
<TagSelect
|
||||
isMulti
|
||||
onSelect={onSetTags}
|
||||
values={tags}
|
||||
hoverPlacement="right"
|
||||
/>
|
||||
);
|
||||
|
||||
return renderField("tag_ids", title, control, fullWidthProps);
|
||||
return renderField("tag_ids", title, tagsControl(), fullWidthProps);
|
||||
}
|
||||
|
||||
function renderDetailsField() {
|
||||
|
|
|
@ -15,18 +15,17 @@ import {
|
|||
import {
|
||||
ScrapedPerformersRow,
|
||||
ScrapedStudioRow,
|
||||
ScrapedTagsRow,
|
||||
} from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow";
|
||||
import { sortStoredIdObjects } from "src/utils/data";
|
||||
import { Performer } from "src/components/Performers/PerformerSelect";
|
||||
import {
|
||||
useCreateScrapedPerformer,
|
||||
useCreateScrapedStudio,
|
||||
useCreateScrapedTag,
|
||||
} from "src/components/Shared/ScrapeDialog/createObjects";
|
||||
import { uniq } from "lodash-es";
|
||||
import { Tag } from "src/components/Tags/TagSelect";
|
||||
import { Studio } from "src/components/Studios/StudioSelect";
|
||||
import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags";
|
||||
|
||||
interface IGalleryScrapeDialogProps {
|
||||
gallery: Partial<GQL.GalleryUpdateInput>;
|
||||
|
@ -99,19 +98,9 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = ({
|
|||
scraped.performers?.filter((t) => !t.stored_id) ?? []
|
||||
);
|
||||
|
||||
const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>(
|
||||
new ObjectListScrapeResult<GQL.ScrapedTag>(
|
||||
sortStoredIdObjects(
|
||||
galleryTags.map((t) => ({
|
||||
stored_id: t.id,
|
||||
name: t.name,
|
||||
}))
|
||||
),
|
||||
sortStoredIdObjects(scraped.tags ?? undefined)
|
||||
)
|
||||
);
|
||||
const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>(
|
||||
scraped.tags?.filter((t) => !t.stored_id) ?? []
|
||||
const { tags, newTags, scrapedTagsRow } = useScrapedTags(
|
||||
galleryTags,
|
||||
scraped.tags
|
||||
);
|
||||
|
||||
const [details, setDetails] = useState<ScrapeResult<string>>(
|
||||
|
@ -131,13 +120,6 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = ({
|
|||
setNewObjects: setNewPerformers,
|
||||
});
|
||||
|
||||
const createNewTag = useCreateScrapedTag({
|
||||
scrapeResult: tags,
|
||||
setScrapeResult: setTags,
|
||||
newObjects: newTags,
|
||||
setNewObjects: setNewTags,
|
||||
});
|
||||
|
||||
// don't show the dialog if nothing was scraped
|
||||
if (
|
||||
[
|
||||
|
@ -218,13 +200,7 @@ export const GalleryScrapeDialog: React.FC<IGalleryScrapeDialogProps> = ({
|
|||
newObjects={newPerformers}
|
||||
onCreateNew={createNewPerformer}
|
||||
/>
|
||||
<ScrapedTagsRow
|
||||
title={intl.formatMessage({ id: "tags" })}
|
||||
result={tags}
|
||||
onChange={(value) => setTags(value)}
|
||||
newObjects={newTags}
|
||||
onCreateNew={createNewTag}
|
||||
/>
|
||||
{scrapedTagsRow}
|
||||
<ScrapedTextAreaRow
|
||||
title={intl.formatMessage({ id: "details" })}
|
||||
result={details}
|
||||
|
|
|
@ -19,7 +19,6 @@ import {
|
|||
PerformerSelect,
|
||||
} from "src/components/Performers/PerformerSelect";
|
||||
import { formikUtils } from "src/utils/form";
|
||||
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
|
||||
import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
|
||||
import { galleryTitle } from "src/core/galleries";
|
||||
import {
|
||||
|
@ -27,6 +26,7 @@ import {
|
|||
GallerySelect,
|
||||
excludeFileBasedGalleries,
|
||||
} from "src/components/Galleries/GallerySelect";
|
||||
import { useTagsEdit } from "src/hooks/tagsEdit";
|
||||
|
||||
interface IProps {
|
||||
image: GQL.ImageDataFragment;
|
||||
|
@ -49,7 +49,6 @@ export const ImageEditPanel: React.FC<IProps> = ({
|
|||
|
||||
const [galleries, setGalleries] = useState<Gallery[]>([]);
|
||||
const [performers, setPerformers] = useState<Performer[]>([]);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [studio, setStudio] = useState<Studio | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -98,6 +97,10 @@ export const ImageEditPanel: React.FC<IProps> = ({
|
|||
onSubmit: (values) => onSave(schema.cast(values)),
|
||||
});
|
||||
|
||||
const { tagsControl } = useTagsEdit(image.tags, (ids) =>
|
||||
formik.setFieldValue("tag_ids", ids)
|
||||
);
|
||||
|
||||
function onSetGalleries(items: Gallery[]) {
|
||||
setGalleries(items);
|
||||
formik.setFieldValue(
|
||||
|
@ -114,14 +117,6 @@ export const ImageEditPanel: React.FC<IProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
function onSetTags(items: Tag[]) {
|
||||
setTags(items);
|
||||
formik.setFieldValue(
|
||||
"tag_ids",
|
||||
items.map((item) => item.id)
|
||||
);
|
||||
}
|
||||
|
||||
function onSetStudio(item: Studio | null) {
|
||||
setStudio(item);
|
||||
formik.setFieldValue("studio_id", item ? item.id : null);
|
||||
|
@ -131,10 +126,6 @@ export const ImageEditPanel: React.FC<IProps> = ({
|
|||
setPerformers(image.performers ?? []);
|
||||
}, [image.performers]);
|
||||
|
||||
useEffect(() => {
|
||||
setTags(image.tags ?? []);
|
||||
}, [image.tags]);
|
||||
|
||||
useEffect(() => {
|
||||
setStudio(image.studio ?? null);
|
||||
}, [image.studio]);
|
||||
|
@ -233,16 +224,7 @@ export const ImageEditPanel: React.FC<IProps> = ({
|
|||
|
||||
function renderTagsField() {
|
||||
const title = intl.formatMessage({ id: "tags" });
|
||||
const control = (
|
||||
<TagSelect
|
||||
isMulti
|
||||
onSelect={onSetTags}
|
||||
values={tags}
|
||||
hoverPlacement="right"
|
||||
/>
|
||||
);
|
||||
|
||||
return renderField("tag_ids", title, control, fullWidthProps);
|
||||
return renderField("tag_ids", title, tagsControl(), fullWidthProps);
|
||||
}
|
||||
|
||||
function renderDetailsField() {
|
||||
|
|
|
@ -9,11 +9,15 @@ import { useToast } from "src/hooks/Toast";
|
|||
import * as FormUtils from "src/utils/form";
|
||||
import { RatingSystem } from "../Shared/Rating/RatingSystem";
|
||||
import {
|
||||
getAggregateInputIDs,
|
||||
getAggregateInputValue,
|
||||
getAggregateRating,
|
||||
getAggregateStudioId,
|
||||
getAggregateTagIds,
|
||||
} from "src/utils/bulkUpdate";
|
||||
import { faPencilAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { isEqual } from "lodash-es";
|
||||
import { MultiSet } from "../Shared/MultiSet";
|
||||
|
||||
interface IListOperationProps {
|
||||
selected: GQL.MovieDataFragment[];
|
||||
|
@ -29,6 +33,12 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
|
|||
const [studioId, setStudioId] = useState<string | undefined>();
|
||||
const [director, setDirector] = useState<string | undefined>();
|
||||
|
||||
const [tagMode, setTagMode] = React.useState<GQL.BulkUpdateIdMode>(
|
||||
GQL.BulkUpdateIdMode.Add
|
||||
);
|
||||
const [tagIds, setTagIds] = useState<string[]>();
|
||||
const [existingTagIds, setExistingTagIds] = useState<string[]>();
|
||||
|
||||
const [updateMovies] = useBulkMovieUpdate(getMovieInput());
|
||||
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
@ -36,6 +46,7 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
|
|||
function getMovieInput(): GQL.BulkMovieUpdateInput {
|
||||
const aggregateRating = getAggregateRating(props.selected);
|
||||
const aggregateStudioId = getAggregateStudioId(props.selected);
|
||||
const aggregateTagIds = getAggregateTagIds(props.selected);
|
||||
|
||||
const movieInput: GQL.BulkMovieUpdateInput = {
|
||||
ids: props.selected.map((movie) => movie.id),
|
||||
|
@ -45,6 +56,7 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
|
|||
// if rating is undefined
|
||||
movieInput.rating100 = getAggregateInputValue(rating100, aggregateRating);
|
||||
movieInput.studio_id = getAggregateInputValue(studioId, aggregateStudioId);
|
||||
movieInput.tag_ids = getAggregateInputIDs(tagMode, tagIds, aggregateTagIds);
|
||||
|
||||
return movieInput;
|
||||
}
|
||||
|
@ -72,14 +84,18 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
|
|||
const state = props.selected;
|
||||
let updateRating: number | undefined;
|
||||
let updateStudioId: string | undefined;
|
||||
let updateTagIds: string[] = [];
|
||||
let updateDirector: string | undefined;
|
||||
let first = true;
|
||||
|
||||
state.forEach((movie: GQL.MovieDataFragment) => {
|
||||
const movieTagIDs = (movie.tags ?? []).map((p) => p.id).sort();
|
||||
|
||||
if (first) {
|
||||
first = false;
|
||||
updateRating = movie.rating100 ?? undefined;
|
||||
updateStudioId = movie.studio?.id ?? undefined;
|
||||
updateTagIds = movieTagIDs;
|
||||
updateDirector = movie.director ?? undefined;
|
||||
} else {
|
||||
if (movie.rating100 !== updateRating) {
|
||||
|
@ -91,11 +107,15 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
|
|||
if (movie.director !== updateDirector) {
|
||||
updateDirector = undefined;
|
||||
}
|
||||
if (!isEqual(movieTagIDs, updateTagIds)) {
|
||||
updateTagIds = [];
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
setRating(updateRating);
|
||||
setStudioId(updateStudioId);
|
||||
setExistingTagIds(updateTagIds);
|
||||
setDirector(updateDirector);
|
||||
}, [props.selected]);
|
||||
|
||||
|
@ -158,6 +178,20 @@ export const EditMoviesDialog: React.FC<IListOperationProps> = (
|
|||
placeholder={intl.formatMessage({ id: "director" })}
|
||||
/>
|
||||
</Form.Group>
|
||||
<Form.Group controlId="tags">
|
||||
<Form.Label>
|
||||
<FormattedMessage id="tags" />
|
||||
</Form.Label>
|
||||
<MultiSet
|
||||
type="tags"
|
||||
disabled={isUpdating}
|
||||
onUpdate={(itemIDs) => setTagIds(itemIDs)}
|
||||
onSetMode={(newMode) => setTagMode(newMode)}
|
||||
existingIds={existingTagIds ?? []}
|
||||
ids={tagIds ?? []}
|
||||
mode={tagMode}
|
||||
/>
|
||||
</Form.Group>
|
||||
</Form>
|
||||
</ModalComponent>
|
||||
);
|
||||
|
|
|
@ -4,11 +4,11 @@ import * as GQL from "src/core/generated-graphql";
|
|||
import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard";
|
||||
import { HoverPopover } from "../Shared/HoverPopover";
|
||||
import { Icon } from "../Shared/Icon";
|
||||
import { SceneLink } from "../Shared/TagLink";
|
||||
import { SceneLink, TagLink } from "../Shared/TagLink";
|
||||
import { TruncatedText } from "../Shared/TruncatedText";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { RatingBanner } from "../Shared/RatingBanner";
|
||||
import { faPlayCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons";
|
||||
import ScreenUtils from "src/utils/screen";
|
||||
|
||||
interface IProps {
|
||||
|
@ -20,37 +20,44 @@ interface IProps {
|
|||
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;
|
||||
}
|
||||
|
||||
export const MovieCard: React.FC<IProps> = (props: IProps) => {
|
||||
export const MovieCard: React.FC<IProps> = ({
|
||||
movie,
|
||||
sceneIndex,
|
||||
containerWidth,
|
||||
selecting,
|
||||
selected,
|
||||
onSelectedChanged,
|
||||
}) => {
|
||||
const [cardWidth, setCardWidth] = useState<number>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.containerWidth || ScreenUtils.isMobile()) return;
|
||||
if (!containerWidth || ScreenUtils.isMobile()) return;
|
||||
|
||||
let preferredCardWidth = 250;
|
||||
let fittedCardWidth = calculateCardWidth(
|
||||
props.containerWidth,
|
||||
containerWidth,
|
||||
preferredCardWidth!
|
||||
);
|
||||
setCardWidth(fittedCardWidth);
|
||||
}, [props, props.containerWidth]);
|
||||
}, [containerWidth]);
|
||||
|
||||
function maybeRenderSceneNumber() {
|
||||
if (!props.sceneIndex) return;
|
||||
if (!sceneIndex) return;
|
||||
|
||||
return (
|
||||
<>
|
||||
<hr />
|
||||
<span className="movie-scene-number">
|
||||
<FormattedMessage id="scene" /> #{props.sceneIndex}
|
||||
<FormattedMessage id="scene" /> #{sceneIndex}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderScenesPopoverButton() {
|
||||
if (props.movie.scenes.length === 0) return;
|
||||
if (movie.scenes.length === 0) return;
|
||||
|
||||
const popoverContent = props.movie.scenes.map((scene) => (
|
||||
const popoverContent = movie.scenes.map((scene) => (
|
||||
<SceneLink key={scene.id} scene={scene} />
|
||||
));
|
||||
|
||||
|
@ -62,20 +69,38 @@ export const MovieCard: React.FC<IProps> = (props: IProps) => {
|
|||
>
|
||||
<Button className="minimal">
|
||||
<Icon icon={faPlayCircle} />
|
||||
<span>{props.movie.scenes.length}</span>
|
||||
<span>{movie.scenes.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderTagPopoverButton() {
|
||||
if (movie.tags.length <= 0) return;
|
||||
|
||||
const popoverContent = movie.tags.map((tag) => (
|
||||
<TagLink key={tag.id} linkType="movie" tag={tag} />
|
||||
));
|
||||
|
||||
return (
|
||||
<HoverPopover placement="bottom" content={popoverContent}>
|
||||
<Button className="minimal tag-count">
|
||||
<Icon icon={faTag} />
|
||||
<span>{movie.tags.length}</span>
|
||||
</Button>
|
||||
</HoverPopover>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderPopoverButtonGroup() {
|
||||
if (props.sceneIndex || props.movie.scenes.length > 0) {
|
||||
if (sceneIndex || movie.scenes.length > 0 || movie.tags.length > 0) {
|
||||
return (
|
||||
<>
|
||||
{maybeRenderSceneNumber()}
|
||||
<hr />
|
||||
<ButtonGroup className="card-popovers">
|
||||
{maybeRenderScenesPopoverButton()}
|
||||
{maybeRenderTagPopoverButton()}
|
||||
</ButtonGroup>
|
||||
</>
|
||||
);
|
||||
|
@ -85,34 +110,34 @@ export const MovieCard: React.FC<IProps> = (props: IProps) => {
|
|||
return (
|
||||
<GridCard
|
||||
className="movie-card"
|
||||
url={`/movies/${props.movie.id}`}
|
||||
url={`/movies/${movie.id}`}
|
||||
width={cardWidth}
|
||||
title={props.movie.name}
|
||||
title={movie.name}
|
||||
linkClassName="movie-card-header"
|
||||
image={
|
||||
<>
|
||||
<img
|
||||
loading="lazy"
|
||||
className="movie-card-image"
|
||||
alt={props.movie.name ?? ""}
|
||||
src={props.movie.front_image_path ?? ""}
|
||||
alt={movie.name ?? ""}
|
||||
src={movie.front_image_path ?? ""}
|
||||
/>
|
||||
<RatingBanner rating={props.movie.rating100} />
|
||||
<RatingBanner rating={movie.rating100} />
|
||||
</>
|
||||
}
|
||||
details={
|
||||
<div className="movie-card__details">
|
||||
<span className="movie-card__date">{props.movie.date}</span>
|
||||
<span className="movie-card__date">{movie.date}</span>
|
||||
<TruncatedText
|
||||
className="movie-card__description"
|
||||
text={props.movie.synopsis}
|
||||
text={movie.synopsis}
|
||||
lineCount={3}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
selected={props.selected}
|
||||
selecting={props.selecting}
|
||||
onSelectedChanged={props.onSelectedChanged}
|
||||
selected={selected}
|
||||
selecting={selecting}
|
||||
onSelectedChanged={onSelectedChanged}
|
||||
popovers={maybeRenderPopoverButtonGroup()}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -305,6 +305,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
|
|||
return (
|
||||
<MovieDetailsPanel
|
||||
movie={movie}
|
||||
collapsed={collapsed}
|
||||
fullWidth={!collapsed && !compactExpandedDetails}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -5,19 +5,54 @@ import TextUtils from "src/utils/text";
|
|||
import { DetailItem } from "src/components/Shared/DetailItem";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DirectorLink } from "src/components/Shared/Link";
|
||||
import { TagLink } from "src/components/Shared/TagLink";
|
||||
|
||||
interface IMovieDetailsPanel {
|
||||
movie: GQL.MovieDataFragment;
|
||||
collapsed?: boolean;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({
|
||||
movie,
|
||||
collapsed,
|
||||
fullWidth,
|
||||
}) => {
|
||||
// Network state
|
||||
const intl = useIntl();
|
||||
|
||||
function renderTagsField() {
|
||||
if (!movie.tags.length) {
|
||||
return;
|
||||
}
|
||||
return (
|
||||
<ul className="pl-0">
|
||||
{(movie.tags ?? []).map((tag) => (
|
||||
<TagLink key={tag.id} linkType="movie" tag={tag} />
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderExtraDetails() {
|
||||
if (!collapsed) {
|
||||
return (
|
||||
<>
|
||||
<DetailItem
|
||||
id="synopsis"
|
||||
value={movie.synopsis}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem
|
||||
id="tags"
|
||||
value={renderTagsField()}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="detail-group">
|
||||
<DetailItem
|
||||
|
@ -57,7 +92,7 @@ export const MovieDetailsPanel: React.FC<IMovieDetailsPanel> = ({
|
|||
}
|
||||
fullWidth={fullWidth}
|
||||
/>
|
||||
<DetailItem id="synopsis" value={movie.synopsis} fullWidth={fullWidth} />
|
||||
{maybeRenderExtraDetails()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -25,6 +25,7 @@ import {
|
|||
yupUniqueStringList,
|
||||
} from "src/utils/yup";
|
||||
import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
|
||||
import { useTagsEdit } from "src/hooks/tagsEdit";
|
||||
|
||||
interface IMovieEditPanel {
|
||||
movie: Partial<GQL.MovieDataFragment>;
|
||||
|
@ -66,6 +67,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
duration: yup.number().integer().min(0).nullable().defined(),
|
||||
date: yupDateString(intl),
|
||||
studio_id: yup.string().required().nullable(),
|
||||
tag_ids: yup.array(yup.string().required()).defined(),
|
||||
director: yup.string().ensure(),
|
||||
urls: yupUniqueStringList(intl),
|
||||
synopsis: yup.string().ensure(),
|
||||
|
@ -79,6 +81,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
duration: movie?.duration ?? null,
|
||||
date: movie?.date ?? "",
|
||||
studio_id: movie?.studio?.id ?? null,
|
||||
tag_ids: (movie?.tags ?? []).map((t) => t.id),
|
||||
director: movie?.director ?? "",
|
||||
urls: movie?.urls ?? [],
|
||||
synopsis: movie?.synopsis ?? "",
|
||||
|
@ -93,6 +96,11 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
onSubmit: (values) => onSave(schema.cast(values)),
|
||||
});
|
||||
|
||||
const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(
|
||||
movie.tags,
|
||||
(ids) => formik.setFieldValue("tag_ids", ids)
|
||||
);
|
||||
|
||||
function onSetStudio(item: Studio | null) {
|
||||
setStudio(item);
|
||||
formik.setFieldValue("studio_id", item ? item.id : null);
|
||||
|
@ -159,6 +167,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
if (state.urls) {
|
||||
formik.setFieldValue("urls", state.urls);
|
||||
}
|
||||
updateTagsStateFromScraper(state.tags ?? undefined);
|
||||
|
||||
if (state.front_image) {
|
||||
// image is a base64 string
|
||||
|
@ -231,6 +240,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
<MovieScrapeDialog
|
||||
movie={currentMovie}
|
||||
movieStudio={studio}
|
||||
movieTags={tags}
|
||||
scraped={scrapedMovie}
|
||||
onClose={(m) => {
|
||||
onScrapeDialogClosed(m);
|
||||
|
@ -351,6 +361,11 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
return renderField("studio_id", title, control);
|
||||
}
|
||||
|
||||
function renderTagsField() {
|
||||
const title = intl.formatMessage({ id: "tags" });
|
||||
return renderField("tag_ids", title, tagsControl());
|
||||
}
|
||||
|
||||
// TODO: CSS class
|
||||
return (
|
||||
<div>
|
||||
|
@ -383,6 +398,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
|
|||
{renderInputField("director")}
|
||||
{renderURLListField("urls", onScrapeMovieURL, urlScrapable)}
|
||||
{renderInputField("synopsis", "textarea")}
|
||||
{renderTagsField()}
|
||||
</Form>
|
||||
|
||||
<DetailsEditNavbar
|
||||
|
|
|
@ -17,74 +17,79 @@ import { Studio } from "src/components/Studios/StudioSelect";
|
|||
import { useCreateScrapedStudio } from "src/components/Shared/ScrapeDialog/createObjects";
|
||||
import { ScrapedStudioRow } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow";
|
||||
import { uniq } from "lodash-es";
|
||||
import { Tag } from "src/components/Tags/TagSelect";
|
||||
import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags";
|
||||
|
||||
interface IMovieScrapeDialogProps {
|
||||
movie: Partial<GQL.MovieUpdateInput>;
|
||||
movieStudio: Studio | null;
|
||||
movieTags: Tag[];
|
||||
scraped: GQL.ScrapedMovie;
|
||||
|
||||
onClose: (scrapedMovie?: GQL.ScrapedMovie) => void;
|
||||
}
|
||||
|
||||
export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = (
|
||||
props: IMovieScrapeDialogProps
|
||||
) => {
|
||||
export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = ({
|
||||
movie,
|
||||
movieStudio,
|
||||
movieTags,
|
||||
scraped,
|
||||
onClose,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const [name, setName] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(props.movie.name, props.scraped.name)
|
||||
new ScrapeResult<string>(movie.name, scraped.name)
|
||||
);
|
||||
const [aliases, setAliases] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(props.movie.aliases, props.scraped.aliases)
|
||||
new ScrapeResult<string>(movie.aliases, scraped.aliases)
|
||||
);
|
||||
const [duration, setDuration] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(
|
||||
TextUtils.secondsToTimestamp(props.movie.duration || 0),
|
||||
TextUtils.secondsToTimestamp(movie.duration || 0),
|
||||
// convert seconds to string if it's a number
|
||||
props.scraped.duration && !isNaN(+props.scraped.duration)
|
||||
? TextUtils.secondsToTimestamp(parseInt(props.scraped.duration, 10))
|
||||
: props.scraped.duration
|
||||
scraped.duration && !isNaN(+scraped.duration)
|
||||
? TextUtils.secondsToTimestamp(parseInt(scraped.duration, 10))
|
||||
: scraped.duration
|
||||
)
|
||||
);
|
||||
const [date, setDate] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(props.movie.date, props.scraped.date)
|
||||
new ScrapeResult<string>(movie.date, scraped.date)
|
||||
);
|
||||
const [director, setDirector] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(props.movie.director, props.scraped.director)
|
||||
new ScrapeResult<string>(movie.director, scraped.director)
|
||||
);
|
||||
const [synopsis, setSynopsis] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(props.movie.synopsis, props.scraped.synopsis)
|
||||
new ScrapeResult<string>(movie.synopsis, scraped.synopsis)
|
||||
);
|
||||
const [studio, setStudio] = useState<ObjectScrapeResult<GQL.ScrapedStudio>>(
|
||||
new ObjectScrapeResult<GQL.ScrapedStudio>(
|
||||
props.movieStudio
|
||||
movieStudio
|
||||
? {
|
||||
stored_id: props.movieStudio.id,
|
||||
name: props.movieStudio.name,
|
||||
stored_id: movieStudio.id,
|
||||
name: movieStudio.name,
|
||||
}
|
||||
: undefined,
|
||||
props.scraped.studio?.stored_id ? props.scraped.studio : undefined
|
||||
scraped.studio?.stored_id ? scraped.studio : undefined
|
||||
)
|
||||
);
|
||||
const [urls, setURLs] = useState<ScrapeResult<string[]>>(
|
||||
new ScrapeResult<string[]>(
|
||||
props.movie.urls,
|
||||
props.scraped.urls
|
||||
? uniq((props.movie.urls ?? []).concat(props.scraped.urls ?? []))
|
||||
movie.urls,
|
||||
scraped.urls
|
||||
? uniq((movie.urls ?? []).concat(scraped.urls ?? []))
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
const [frontImage, setFrontImage] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(props.movie.front_image, props.scraped.front_image)
|
||||
new ScrapeResult<string>(movie.front_image, scraped.front_image)
|
||||
);
|
||||
const [backImage, setBackImage] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(props.movie.back_image, props.scraped.back_image)
|
||||
new ScrapeResult<string>(movie.back_image, scraped.back_image)
|
||||
);
|
||||
|
||||
const [newStudio, setNewStudio] = useState<GQL.ScrapedStudio | undefined>(
|
||||
props.scraped.studio && !props.scraped.studio.stored_id
|
||||
? props.scraped.studio
|
||||
: undefined
|
||||
scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined
|
||||
);
|
||||
|
||||
const createNewStudio = useCreateScrapedStudio({
|
||||
|
@ -93,6 +98,11 @@ export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = (
|
|||
setNewObject: setNewStudio,
|
||||
});
|
||||
|
||||
const { tags, newTags, scrapedTagsRow } = useScrapedTags(
|
||||
movieTags,
|
||||
scraped.tags
|
||||
);
|
||||
|
||||
const allFields = [
|
||||
name,
|
||||
aliases,
|
||||
|
@ -101,17 +111,21 @@ export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = (
|
|||
director,
|
||||
synopsis,
|
||||
studio,
|
||||
tags,
|
||||
urls,
|
||||
frontImage,
|
||||
backImage,
|
||||
];
|
||||
// don't show the dialog if nothing was scraped
|
||||
if (allFields.every((r) => !r.scraped) && !newStudio) {
|
||||
props.onClose();
|
||||
if (
|
||||
allFields.every((r) => !r.scraped) &&
|
||||
!newStudio &&
|
||||
newTags.length === 0
|
||||
) {
|
||||
onClose();
|
||||
return <></>;
|
||||
}
|
||||
|
||||
// todo: reenable
|
||||
function makeNewScrapedItem(): GQL.ScrapedMovie {
|
||||
const newStudioValue = studio.getNewValue();
|
||||
const durationString = duration.getNewValue();
|
||||
|
@ -124,6 +138,7 @@ export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = (
|
|||
director: director.getNewValue(),
|
||||
synopsis: synopsis.getNewValue(),
|
||||
studio: newStudioValue,
|
||||
tags: tags.getNewValue(),
|
||||
urls: urls.getNewValue(),
|
||||
front_image: frontImage.getNewValue(),
|
||||
back_image: backImage.getNewValue(),
|
||||
|
@ -176,6 +191,7 @@ export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = (
|
|||
result={urls}
|
||||
onChange={(value) => setURLs(value)}
|
||||
/>
|
||||
{scrapedTagsRow}
|
||||
<ScrapedImageRow
|
||||
title="Front Image"
|
||||
className="movie-image"
|
||||
|
@ -200,7 +216,7 @@ export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = (
|
|||
)}
|
||||
renderScrapeRows={renderScrapeRows}
|
||||
onClose={(apply) => {
|
||||
props.onClose(apply ? makeNewScrapedItem() : undefined);
|
||||
onClose(apply ? makeNewScrapedItem() : undefined);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Button, Form, Badge, Dropdown } from "react-bootstrap";
|
||||
import { Button, Form, Dropdown } from "react-bootstrap";
|
||||
import { FormattedMessage, useIntl } from "react-intl";
|
||||
import Mousetrap from "mousetrap";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
|
@ -8,13 +8,11 @@ import {
|
|||
useListPerformerScrapers,
|
||||
queryScrapePerformer,
|
||||
mutateReloadScrapers,
|
||||
useTagCreate,
|
||||
queryScrapePerformerURL,
|
||||
} from "src/core/StashService";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { ImageInput } from "src/components/Shared/ImageInput";
|
||||
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
|
||||
import { CollapseButton } from "src/components/Shared/CollapseButton";
|
||||
import { CountrySelect } from "src/components/Shared/CountrySelect";
|
||||
import { URLField } from "src/components/Shared/URLField";
|
||||
import ImageUtils from "src/utils/image";
|
||||
|
@ -38,7 +36,7 @@ import { PerformerScrapeDialog } from "./PerformerScrapeDialog";
|
|||
import PerformerScrapeModal from "./PerformerScrapeModal";
|
||||
import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal";
|
||||
import cx from "classnames";
|
||||
import { faPlus, faSyncAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import { faSyncAlt } from "@fortawesome/free-solid-svg-icons";
|
||||
import isEqual from "lodash-es/isEqual";
|
||||
import { formikUtils } from "src/utils/form";
|
||||
import {
|
||||
|
@ -48,7 +46,7 @@ import {
|
|||
yupDateString,
|
||||
yupUniqueAliases,
|
||||
} from "src/utils/yup";
|
||||
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
|
||||
import { useTagsEdit } from "src/hooks/tagsEdit";
|
||||
|
||||
const isScraper = (
|
||||
scraper: GQL.Scraper | GQL.StashBox
|
||||
|
@ -77,14 +75,11 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
|
||||
// Editing state
|
||||
const [scraper, setScraper] = useState<GQL.Scraper | IStashBox>();
|
||||
const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>();
|
||||
const [isScraperModalOpen, setIsScraperModalOpen] = useState<boolean>(false);
|
||||
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
|
||||
const Scrapers = useListPerformerScrapers();
|
||||
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
|
||||
|
||||
|
@ -92,7 +87,6 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
useState<GQL.ScrapedPerformer>();
|
||||
const { configuration: stashConfig } = React.useContext(ConfigurationContext);
|
||||
|
||||
const [createTag] = useTagCreate();
|
||||
const intl = useIntl();
|
||||
|
||||
const schema = yup.object({
|
||||
|
@ -163,17 +157,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
onSubmit: (values) => onSave(schema.cast(values)),
|
||||
});
|
||||
|
||||
function onSetTags(items: Tag[]) {
|
||||
setTags(items);
|
||||
formik.setFieldValue(
|
||||
"tag_ids",
|
||||
items.map((item) => item.id)
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setTags(performer.tags ?? []);
|
||||
}, [performer.tags]);
|
||||
const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(
|
||||
performer.tags,
|
||||
(ids) => formik.setFieldValue("tag_ids", ids)
|
||||
);
|
||||
|
||||
function translateScrapedGender(scrapedGender?: string) {
|
||||
if (!scrapedGender) {
|
||||
|
@ -207,43 +194,6 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
}
|
||||
}
|
||||
|
||||
async function createNewTag(toCreate: GQL.ScrapedTag) {
|
||||
const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" };
|
||||
try {
|
||||
const result = await createTag({
|
||||
variables: {
|
||||
input: tagInput,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.data?.tagCreate) {
|
||||
Toast.error(new Error("Failed to create tag"));
|
||||
return;
|
||||
}
|
||||
|
||||
// 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(
|
||||
<span>
|
||||
Created tag: <b>{toCreate.name}</b>
|
||||
</span>
|
||||
);
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function updatePerformerEditStateFromScraper(
|
||||
state: Partial<GQL.ScrapedPerformerDataFragment>
|
||||
) {
|
||||
|
@ -312,20 +262,7 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
formik.setFieldValue("circumcised", newCircumcised);
|
||||
}
|
||||
}
|
||||
if (state.tags) {
|
||||
// map tags to their ids and filter out those not found
|
||||
onSetTags(
|
||||
state.tags.map((p) => {
|
||||
return {
|
||||
id: p.stored_id!,
|
||||
name: p.name ?? "",
|
||||
aliases: [],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
setNewTags(state.tags.filter((t) => !t.stored_id));
|
||||
}
|
||||
updateTagsStateFromScraper(state.tags ?? undefined);
|
||||
|
||||
// image is a base64 string
|
||||
// #404: don't overwrite image if it has been modified by the user
|
||||
|
@ -702,59 +639,10 @@ export const PerformerEditPanel: React.FC<IPerformerDetails> = ({
|
|||
|
||||
return renderField("url", title, control);
|
||||
}
|
||||
|
||||
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={faPlus} />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
const minCollapseLength = 10;
|
||||
|
||||
if (newTags.length >= minCollapseLength) {
|
||||
return (
|
||||
<CollapseButton text={`Missing (${newTags.length})`}>
|
||||
{ret}
|
||||
</CollapseButton>
|
||||
);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function renderTagsField() {
|
||||
const title = intl.formatMessage({ id: "tags" });
|
||||
|
||||
const control = (
|
||||
<>
|
||||
<TagSelect
|
||||
menuPortalTarget={document.body}
|
||||
isMulti
|
||||
onSelect={onSetTags}
|
||||
values={tags}
|
||||
/>
|
||||
{renderNewTags()}
|
||||
</>
|
||||
);
|
||||
|
||||
return renderField("tag_ids", title, control);
|
||||
return renderField("tag_ids", title, tagsControl());
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
|
@ -21,14 +21,9 @@ import {
|
|||
stringToCircumcised,
|
||||
} from "src/utils/circumcised";
|
||||
import { IStashBox } from "./PerformerStashBoxModal";
|
||||
import {
|
||||
ObjectListScrapeResult,
|
||||
ScrapeResult,
|
||||
} from "src/components/Shared/ScrapeDialog/scrapeResult";
|
||||
import { ScrapedTagsRow } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow";
|
||||
import { sortStoredIdObjects } from "src/utils/data";
|
||||
import { ScrapeResult } from "src/components/Shared/ScrapeDialog/scrapeResult";
|
||||
import { Tag } from "src/components/Tags/TagSelect";
|
||||
import { useCreateScrapedTag } from "src/components/Shared/ScrapeDialog/createObjects";
|
||||
import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags";
|
||||
|
||||
function renderScrapedGender(
|
||||
result: ScrapeResult<string>,
|
||||
|
@ -304,29 +299,11 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
|
|||
)
|
||||
);
|
||||
|
||||
const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>(
|
||||
new ObjectListScrapeResult<GQL.ScrapedTag>(
|
||||
sortStoredIdObjects(
|
||||
props.performerTags.map((t) => ({
|
||||
stored_id: t.id,
|
||||
name: t.name,
|
||||
}))
|
||||
),
|
||||
sortStoredIdObjects(props.scraped.tags ?? undefined)
|
||||
)
|
||||
const { tags, newTags, scrapedTagsRow } = useScrapedTags(
|
||||
props.performerTags,
|
||||
props.scraped.tags
|
||||
);
|
||||
|
||||
const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>(
|
||||
props.scraped.tags?.filter((t) => !t.stored_id) ?? []
|
||||
);
|
||||
|
||||
const createNewTag = useCreateScrapedTag({
|
||||
scrapeResult: tags,
|
||||
setScrapeResult: setTags,
|
||||
newObjects: newTags,
|
||||
setNewObjects: setNewTags,
|
||||
});
|
||||
|
||||
const [image, setImage] = useState<ScrapeResult<string>>(
|
||||
new ScrapeResult<string>(
|
||||
props.performer.image,
|
||||
|
@ -525,13 +502,7 @@ export const PerformerScrapeDialog: React.FC<IPerformerScrapeDialogProps> = (
|
|||
result={details}
|
||||
onChange={(value) => setDetails(value)}
|
||||
/>
|
||||
<ScrapedTagsRow
|
||||
title={intl.formatMessage({ id: "tags" })}
|
||||
result={tags}
|
||||
onChange={(value) => setTags(value)}
|
||||
newObjects={newTags}
|
||||
onCreateNew={createNewTag}
|
||||
/>
|
||||
{scrapedTagsRow}
|
||||
<ScrapedImagesRow
|
||||
title={intl.formatMessage({ id: "performer_image" })}
|
||||
className="performer-image"
|
||||
|
|
|
@ -45,10 +45,10 @@ import {
|
|||
PerformerSelect,
|
||||
} from "src/components/Performers/PerformerSelect";
|
||||
import { formikUtils } from "src/utils/form";
|
||||
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
|
||||
import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
|
||||
import { Gallery, GallerySelect } from "src/components/Galleries/GallerySelect";
|
||||
import { Movie } from "src/components/Movies/MovieSelect";
|
||||
import { useTagsEdit } from "src/hooks/tagsEdit";
|
||||
|
||||
const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog"));
|
||||
const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal"));
|
||||
|
@ -76,7 +76,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
const [galleries, setGalleries] = useState<Gallery[]>([]);
|
||||
const [performers, setPerformers] = useState<Performer[]>([]);
|
||||
const [movies, setMovies] = useState<Movie[]>([]);
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [studio, setStudio] = useState<Studio | null>(null);
|
||||
|
||||
const Scrapers = useListSceneScrapers();
|
||||
|
@ -108,10 +107,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
setMovies(scene.movies?.map((m) => m.movie) ?? []);
|
||||
}, [scene.movies]);
|
||||
|
||||
useEffect(() => {
|
||||
setTags(scene.tags ?? []);
|
||||
}, [scene.tags]);
|
||||
|
||||
useEffect(() => {
|
||||
setStudio(scene.studio ?? null);
|
||||
}, [scene.studio]);
|
||||
|
@ -174,6 +169,11 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
onSubmit: (values) => onSave(schema.cast(values)),
|
||||
});
|
||||
|
||||
const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(
|
||||
scene.tags,
|
||||
(ids) => formik.setFieldValue("tag_ids", ids)
|
||||
);
|
||||
|
||||
const coverImagePreview = useMemo(() => {
|
||||
const sceneImage = scene.paths?.screenshot;
|
||||
const formImage = formik.values.cover_image;
|
||||
|
@ -214,14 +214,6 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
function onSetTags(items: Tag[]) {
|
||||
setTags(items);
|
||||
formik.setFieldValue(
|
||||
"tag_ids",
|
||||
items.map((item) => item.id)
|
||||
);
|
||||
}
|
||||
|
||||
function onSetStudio(item: Studio | null) {
|
||||
setStudio(item);
|
||||
formik.setFieldValue("studio_id", item ? item.id : null);
|
||||
|
@ -593,23 +585,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
if (updatedScene?.tags?.length) {
|
||||
const idTags = updatedScene.tags.filter((p) => {
|
||||
return p.stored_id !== undefined && p.stored_id !== null;
|
||||
});
|
||||
|
||||
if (idTags.length > 0) {
|
||||
onSetTags(
|
||||
idTags.map((p) => {
|
||||
return {
|
||||
id: p.stored_id!,
|
||||
name: p.name ?? "",
|
||||
aliases: [],
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
updateTagsStateFromScraper(updatedScene.tags ?? undefined);
|
||||
|
||||
if (updatedScene.image) {
|
||||
// image is a base64 string
|
||||
|
@ -771,16 +747,7 @@ export const SceneEditPanel: React.FC<IProps> = ({
|
|||
|
||||
function renderTagsField() {
|
||||
const title = intl.formatMessage({ id: "tags" });
|
||||
const control = (
|
||||
<TagSelect
|
||||
isMulti
|
||||
onSelect={onSetTags}
|
||||
values={tags}
|
||||
hoverPlacement="right"
|
||||
/>
|
||||
);
|
||||
|
||||
return renderField("tag_ids", title, control, fullWidthProps);
|
||||
return renderField("tag_ids", title, tagsControl(), fullWidthProps);
|
||||
}
|
||||
|
||||
function renderDetailsField() {
|
||||
|
|
|
@ -20,17 +20,16 @@ import {
|
|||
ScrapedMoviesRow,
|
||||
ScrapedPerformersRow,
|
||||
ScrapedStudioRow,
|
||||
ScrapedTagsRow,
|
||||
} from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow";
|
||||
import {
|
||||
useCreateScrapedMovie,
|
||||
useCreateScrapedPerformer,
|
||||
useCreateScrapedStudio,
|
||||
useCreateScrapedTag,
|
||||
} from "src/components/Shared/ScrapeDialog/createObjects";
|
||||
import { Tag } from "src/components/Tags/TagSelect";
|
||||
import { Studio } from "src/components/Studios/StudioSelect";
|
||||
import { Movie } from "src/components/Movies/MovieSelect";
|
||||
import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags";
|
||||
|
||||
interface ISceneScrapeDialogProps {
|
||||
scene: Partial<GQL.SceneUpdateInput>;
|
||||
|
@ -132,19 +131,9 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
|
|||
scraped.movies?.filter((t) => !t.stored_id) ?? []
|
||||
);
|
||||
|
||||
const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>(
|
||||
new ObjectListScrapeResult<GQL.ScrapedTag>(
|
||||
sortStoredIdObjects(
|
||||
sceneTags.map((t) => ({
|
||||
stored_id: t.id,
|
||||
name: t.name,
|
||||
}))
|
||||
),
|
||||
sortStoredIdObjects(scraped.tags ?? undefined)
|
||||
)
|
||||
);
|
||||
const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>(
|
||||
scraped.tags?.filter((t) => !t.stored_id) ?? []
|
||||
const { tags, newTags, scrapedTagsRow } = useScrapedTags(
|
||||
sceneTags,
|
||||
scraped.tags
|
||||
);
|
||||
|
||||
const [details, setDetails] = useState<ScrapeResult<string>>(
|
||||
|
@ -175,13 +164,6 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
|
|||
setNewObjects: setNewMovies,
|
||||
});
|
||||
|
||||
const createNewTag = useCreateScrapedTag({
|
||||
scrapeResult: tags,
|
||||
setScrapeResult: setTags,
|
||||
newObjects: newTags,
|
||||
setNewObjects: setNewTags,
|
||||
});
|
||||
|
||||
const intl = useIntl();
|
||||
|
||||
// don't show the dialog if nothing was scraped
|
||||
|
@ -278,13 +260,7 @@ export const SceneScrapeDialog: React.FC<ISceneScrapeDialogProps> = ({
|
|||
newObjects={newMovies}
|
||||
onCreateNew={createNewMovie}
|
||||
/>
|
||||
<ScrapedTagsRow
|
||||
title={intl.formatMessage({ id: "tags" })}
|
||||
result={tags}
|
||||
onChange={(value) => setTags(value)}
|
||||
newObjects={newTags}
|
||||
onCreateNew={createNewTag}
|
||||
/>
|
||||
{scrapedTagsRow}
|
||||
<ScrapedTextAreaRow
|
||||
title={intl.formatMessage({ id: "details" })}
|
||||
result={details}
|
||||
|
|
|
@ -0,0 +1,53 @@
|
|||
import { useState } from "react";
|
||||
import { useIntl } from "react-intl";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { ObjectListScrapeResult } from "./scrapeResult";
|
||||
import { sortStoredIdObjects } from "src/utils/data";
|
||||
import { Tag } from "src/components/Tags/TagSelect";
|
||||
import { useCreateScrapedTag } from "./createObjects";
|
||||
import { ScrapedTagsRow } from "./ScrapedObjectsRow";
|
||||
|
||||
export function useScrapedTags(
|
||||
existingTags: Tag[],
|
||||
scrapedTags?: GQL.Maybe<GQL.ScrapedTag[]>
|
||||
) {
|
||||
const intl = useIntl();
|
||||
const [tags, setTags] = useState<ObjectListScrapeResult<GQL.ScrapedTag>>(
|
||||
new ObjectListScrapeResult<GQL.ScrapedTag>(
|
||||
sortStoredIdObjects(
|
||||
existingTags.map((t) => ({
|
||||
stored_id: t.id,
|
||||
name: t.name,
|
||||
}))
|
||||
),
|
||||
sortStoredIdObjects(scrapedTags ?? undefined)
|
||||
)
|
||||
);
|
||||
|
||||
const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>(
|
||||
scrapedTags?.filter((t) => !t.stored_id) ?? []
|
||||
);
|
||||
|
||||
const createNewTag = useCreateScrapedTag({
|
||||
scrapeResult: tags,
|
||||
setScrapeResult: setTags,
|
||||
newObjects: newTags,
|
||||
setNewObjects: setNewTags,
|
||||
});
|
||||
|
||||
const scrapedTagsRow = (
|
||||
<ScrapedTagsRow
|
||||
title={intl.formatMessage({ id: "tags" })}
|
||||
result={tags}
|
||||
onChange={(value) => setTags(value)}
|
||||
newObjects={newTags}
|
||||
onCreateNew={createNewTag}
|
||||
/>
|
||||
);
|
||||
|
||||
return {
|
||||
tags,
|
||||
newTags,
|
||||
scrapedTagsRow,
|
||||
};
|
||||
}
|
|
@ -191,7 +191,7 @@ export const GalleryLink: React.FC<IGalleryLinkProps> = ({
|
|||
|
||||
interface ITagLinkProps {
|
||||
tag: INamedObject;
|
||||
linkType?: "scene" | "gallery" | "image" | "details" | "performer";
|
||||
linkType?: "scene" | "gallery" | "image" | "details" | "performer" | "movie";
|
||||
className?: string;
|
||||
hoverPlacement?: Placement;
|
||||
showHierarchyIcon?: boolean;
|
||||
|
@ -216,6 +216,8 @@ export const TagLink: React.FC<ITagLinkProps> = ({
|
|||
return NavUtils.makeTagGalleriesUrl(tag);
|
||||
case "image":
|
||||
return NavUtils.makeTagImagesUrl(tag);
|
||||
case "movie":
|
||||
return NavUtils.makeTagMoviesUrl(tag);
|
||||
case "details":
|
||||
return NavUtils.makeTagUrl(tag.id ?? "");
|
||||
}
|
||||
|
|
|
@ -223,6 +223,19 @@ export const TagCard: React.FC<IProps> = ({
|
|||
);
|
||||
}
|
||||
|
||||
function maybeRenderMoviesPopoverButton() {
|
||||
if (!tag.movie_count) return;
|
||||
|
||||
return (
|
||||
<PopoverCountButton
|
||||
className="movie-count"
|
||||
type="movie"
|
||||
count={tag.movie_count}
|
||||
url={NavUtils.makeTagMoviesUrl(tag)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function maybeRenderPopoverButtonGroup() {
|
||||
if (tag) {
|
||||
return (
|
||||
|
@ -232,6 +245,7 @@ export const TagCard: React.FC<IProps> = ({
|
|||
{maybeRenderScenesPopoverButton()}
|
||||
{maybeRenderImagesPopoverButton()}
|
||||
{maybeRenderGalleriesPopoverButton()}
|
||||
{maybeRenderMoviesPopoverButton()}
|
||||
{maybeRenderSceneMarkersPopoverButton()}
|
||||
{maybeRenderPerformersPopoverButton()}
|
||||
</ButtonGroup>
|
||||
|
|
|
@ -41,6 +41,7 @@ import {
|
|||
import { DetailImage } from "src/components/Shared/DetailImage";
|
||||
import { useLoadStickyHeader } from "src/hooks/detailsPanel";
|
||||
import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
|
||||
import { TagMoviesPanel } from "./TagMoviesPanel";
|
||||
|
||||
interface IProps {
|
||||
tag: GQL.TagDataFragment;
|
||||
|
@ -57,6 +58,7 @@ const validTabs = [
|
|||
"scenes",
|
||||
"images",
|
||||
"galleries",
|
||||
"movies",
|
||||
"markers",
|
||||
"performers",
|
||||
] as const;
|
||||
|
@ -101,6 +103,8 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
|
|||
(showAllCounts ? tag.image_count_all : tag.image_count) ?? 0;
|
||||
const galleryCount =
|
||||
(showAllCounts ? tag.gallery_count_all : tag.gallery_count) ?? 0;
|
||||
const movieCount =
|
||||
(showAllCounts ? tag.movie_count_all : tag.movie_count) ?? 0;
|
||||
const sceneMarkerCount =
|
||||
(showAllCounts ? tag.scene_marker_count_all : tag.scene_marker_count) ?? 0;
|
||||
const performerCount =
|
||||
|
@ -113,6 +117,8 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
|
|||
ret = "images";
|
||||
} else if (galleryCount != 0) {
|
||||
ret = "galleries";
|
||||
} else if (movieCount != 0) {
|
||||
ret = "movies";
|
||||
} else if (sceneMarkerCount != 0) {
|
||||
ret = "markers";
|
||||
} else if (performerCount != 0) {
|
||||
|
@ -121,7 +127,14 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
|
|||
}
|
||||
|
||||
return ret;
|
||||
}, [sceneCount, imageCount, galleryCount, sceneMarkerCount, performerCount]);
|
||||
}, [
|
||||
sceneCount,
|
||||
imageCount,
|
||||
galleryCount,
|
||||
sceneMarkerCount,
|
||||
performerCount,
|
||||
movieCount,
|
||||
]);
|
||||
|
||||
const setTabKey = useCallback(
|
||||
(newTabKey: string | null) => {
|
||||
|
@ -463,6 +476,21 @@ const TagPage: React.FC<IProps> = ({ tag, tabKey }) => {
|
|||
>
|
||||
<TagGalleriesPanel active={tabKey === "galleries"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="movies"
|
||||
title={
|
||||
<>
|
||||
{intl.formatMessage({ id: "movies" })}
|
||||
<Counter
|
||||
abbreviateCounter={abbreviateCounter}
|
||||
count={movieCount}
|
||||
hideZero
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<TagMoviesPanel active={tabKey === "movies"} tag={tag} />
|
||||
</Tab>
|
||||
<Tab
|
||||
eventKey="markers"
|
||||
title={
|
||||
|
|
|
@ -0,0 +1,12 @@
|
|||
import React from "react";
|
||||
import * as GQL from "src/core/generated-graphql";
|
||||
import { useTagFilterHook } from "src/core/tags";
|
||||
import { MovieList } from "src/components/Movies/MovieList";
|
||||
|
||||
export const TagMoviesPanel: React.FC<{
|
||||
active: boolean;
|
||||
tag: GQL.TagDataFragment;
|
||||
}> = ({ active, tag }) => {
|
||||
const filterHook = useTagFilterHook(tag);
|
||||
return <MovieList filterHook={filterHook} alterQuery={active} />;
|
||||
};
|
|
@ -0,0 +1,148 @@
|
|||
import * as GQL from "src/core/generated-graphql";
|
||||
import { useTagCreate } from "src/core/StashService";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Tag, TagSelect } from "src/components/Tags/TagSelect";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { useIntl } from "react-intl";
|
||||
import { Badge, Button } from "react-bootstrap";
|
||||
import { Icon } from "src/components/Shared/Icon";
|
||||
import { faPlus } from "@fortawesome/free-solid-svg-icons";
|
||||
import { CollapseButton } from "src/components/Shared/CollapseButton";
|
||||
|
||||
export function useTagsEdit(
|
||||
srcTags: Tag[] | undefined,
|
||||
setFieldValue: (ids: string[]) => void
|
||||
) {
|
||||
const intl = useIntl();
|
||||
const Toast = useToast();
|
||||
const [createTag] = useTagCreate();
|
||||
|
||||
const [tags, setTags] = useState<Tag[]>([]);
|
||||
const [newTags, setNewTags] = useState<GQL.ScrapedTag[]>();
|
||||
|
||||
function onSetTags(items: Tag[]) {
|
||||
setTags(items);
|
||||
setFieldValue(items.map((item) => item.id));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setTags(srcTags ?? []);
|
||||
}, [srcTags]);
|
||||
|
||||
async function createNewTag(toCreate: GQL.ScrapedTag) {
|
||||
const tagInput: GQL.TagCreateInput = { name: toCreate.name ?? "" };
|
||||
try {
|
||||
const result = await createTag({
|
||||
variables: {
|
||||
input: tagInput,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result.data?.tagCreate) {
|
||||
Toast.error(new Error("Failed to create tag"));
|
||||
return;
|
||||
}
|
||||
|
||||
// add the new tag to the new tags value
|
||||
const newTagIds = tags
|
||||
.map((t) => t.id)
|
||||
.concat([result.data.tagCreate.id]);
|
||||
setFieldValue(newTagIds);
|
||||
|
||||
// remove the tag from the list
|
||||
const newTagsClone = newTags!.concat();
|
||||
const pIndex = newTagsClone.indexOf(toCreate);
|
||||
newTagsClone.splice(pIndex, 1);
|
||||
|
||||
setNewTags(newTagsClone);
|
||||
|
||||
Toast.success(
|
||||
intl.formatMessage(
|
||||
{ id: "toast.created_entity" },
|
||||
{
|
||||
entity: intl.formatMessage({ id: "tag" }).toLocaleLowerCase(),
|
||||
entity_name: toCreate.name,
|
||||
}
|
||||
)
|
||||
);
|
||||
} catch (e) {
|
||||
Toast.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
function updateTagsStateFromScraper(
|
||||
scrapedTags?: Pick<GQL.ScrapedTag, "name" | "stored_id">[]
|
||||
) {
|
||||
if (scrapedTags) {
|
||||
// map tags to their ids and filter out those not found
|
||||
onSetTags(
|
||||
scrapedTags.map((p) => {
|
||||
return {
|
||||
id: p.stored_id!,
|
||||
name: p.name ?? "",
|
||||
aliases: [],
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
setNewTags(scrapedTags.filter((t) => !t.stored_id));
|
||||
}
|
||||
}
|
||||
|
||||
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={faPlus} />
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
|
||||
const minCollapseLength = 10;
|
||||
|
||||
if (newTags.length >= minCollapseLength) {
|
||||
return (
|
||||
<CollapseButton text={`Missing (${newTags.length})`}>
|
||||
{ret}
|
||||
</CollapseButton>
|
||||
);
|
||||
}
|
||||
|
||||
return ret;
|
||||
}
|
||||
|
||||
function tagsControl() {
|
||||
return (
|
||||
<>
|
||||
<TagSelect
|
||||
menuPortalTarget={document.body}
|
||||
isMulti
|
||||
onSelect={onSetTags}
|
||||
values={tags}
|
||||
/>
|
||||
{renderNewTags()}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
tags,
|
||||
onSetTags,
|
||||
tagsControl,
|
||||
updateTagsStateFromScraper,
|
||||
};
|
||||
}
|
|
@ -1118,6 +1118,7 @@
|
|||
"megabits_per_second": "{value} mbps",
|
||||
"metadata": "Metadata",
|
||||
"movie": "Movie",
|
||||
"movie_count": "Movie Count",
|
||||
"movie_scene_number": "Scene Number",
|
||||
"movies": "Movies",
|
||||
"name": "Name",
|
||||
|
|
|
@ -3,6 +3,7 @@ import {
|
|||
createDateCriterionOption,
|
||||
createMandatoryTimestampCriterionOption,
|
||||
createDurationCriterionOption,
|
||||
createMandatoryNumberCriterionOption,
|
||||
} from "./criteria/criterion";
|
||||
import { MovieIsMissingCriterionOption } from "./criteria/is-missing";
|
||||
import { StudiosCriterionOption } from "./criteria/studios";
|
||||
|
@ -10,10 +11,18 @@ import { PerformersCriterionOption } from "./criteria/performers";
|
|||
import { ListFilterOptions } from "./filter-options";
|
||||
import { DisplayMode } from "./types";
|
||||
import { RatingCriterionOption } from "./criteria/rating";
|
||||
import { TagsCriterionOption } from "./criteria/tags";
|
||||
|
||||
const defaultSortBy = "name";
|
||||
|
||||
const sortByOptions = ["name", "random", "date", "duration", "rating"]
|
||||
const sortByOptions = [
|
||||
"name",
|
||||
"random",
|
||||
"date",
|
||||
"duration",
|
||||
"rating",
|
||||
"tag_count",
|
||||
]
|
||||
.map(ListFilterOptions.createSortBy)
|
||||
.concat([
|
||||
{
|
||||
|
@ -33,6 +42,8 @@ const criterionOptions = [
|
|||
RatingCriterionOption,
|
||||
PerformersCriterionOption,
|
||||
createDateCriterionOption("date"),
|
||||
TagsCriterionOption,
|
||||
createMandatoryNumberCriterionOption("tag_count"),
|
||||
createMandatoryTimestampCriterionOption("created_at"),
|
||||
createMandatoryTimestampCriterionOption("updated_at"),
|
||||
];
|
||||
|
|
|
@ -35,6 +35,10 @@ const sortByOptions = ["name", "random"]
|
|||
messageID: "scene_count",
|
||||
value: "scenes_count",
|
||||
},
|
||||
{
|
||||
messageID: "movie_count",
|
||||
value: "movies_count",
|
||||
},
|
||||
{
|
||||
messageID: "marker_count",
|
||||
value: "scene_markers_count",
|
||||
|
@ -53,6 +57,7 @@ const criterionOptions = [
|
|||
createMandatoryNumberCriterionOption("image_count"),
|
||||
createMandatoryNumberCriterionOption("gallery_count"),
|
||||
createMandatoryNumberCriterionOption("performer_count"),
|
||||
createMandatoryNumberCriterionOption("movie_count"),
|
||||
createMandatoryNumberCriterionOption("marker_count"),
|
||||
ParentTagsCriterionOption,
|
||||
new MandatoryNumberCriterionOption("parent_tag_count", "parent_count"),
|
||||
|
|
|
@ -172,6 +172,7 @@ export type CriterionType =
|
|||
| "image_count"
|
||||
| "gallery_count"
|
||||
| "performer_count"
|
||||
| "movie_count"
|
||||
| "death_year"
|
||||
| "url"
|
||||
| "interactive"
|
||||
|
|
|
@ -78,7 +78,7 @@ const makePerformerImagesUrl = (
|
|||
};
|
||||
|
||||
export interface INamedObject {
|
||||
id?: string;
|
||||
id: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
|
@ -262,8 +262,7 @@ const makeChildTagsUrl = (tag: Partial<GQL.TagDataFragment>) => {
|
|||
return `/tags?${filter.makeQueryParameters()}`;
|
||||
};
|
||||
|
||||
const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => {
|
||||
if (!tag.id) return "#";
|
||||
function makeTagFilter(mode: GQL.FilterMode, tag: INamedObject) {
|
||||
const filter = new ListFilterModel(GQL.FilterMode.Scenes, undefined);
|
||||
const criterion = new TagsCriterion(TagsCriterionOption);
|
||||
criterion.value = {
|
||||
|
@ -272,59 +271,31 @@ const makeTagScenesUrl = (tag: Partial<GQL.TagDataFragment>) => {
|
|||
depth: 0,
|
||||
};
|
||||
filter.criteria.push(criterion);
|
||||
return `/scenes?${filter.makeQueryParameters()}`;
|
||||
return filter.makeQueryParameters();
|
||||
}
|
||||
|
||||
const makeTagScenesUrl = (tag: INamedObject) => {
|
||||
return `/scenes?${makeTagFilter(GQL.FilterMode.Scenes, tag)}`;
|
||||
};
|
||||
|
||||
const makeTagPerformersUrl = (tag: Partial<GQL.TagDataFragment>) => {
|
||||
if (!tag.id) return "#";
|
||||
const filter = new ListFilterModel(GQL.FilterMode.Performers, undefined);
|
||||
const criterion = new TagsCriterion(TagsCriterionOption);
|
||||
criterion.value = {
|
||||
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
|
||||
excluded: [],
|
||||
depth: 0,
|
||||
};
|
||||
filter.criteria.push(criterion);
|
||||
return `/performers?${filter.makeQueryParameters()}`;
|
||||
const makeTagPerformersUrl = (tag: INamedObject) => {
|
||||
return `/performers?${makeTagFilter(GQL.FilterMode.Performers, tag)}`;
|
||||
};
|
||||
|
||||
const makeTagSceneMarkersUrl = (tag: Partial<GQL.TagDataFragment>) => {
|
||||
if (!tag.id) return "#";
|
||||
const filter = new ListFilterModel(GQL.FilterMode.SceneMarkers, undefined);
|
||||
const criterion = new TagsCriterion(TagsCriterionOption);
|
||||
criterion.value = {
|
||||
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
|
||||
excluded: [],
|
||||
depth: 0,
|
||||
};
|
||||
filter.criteria.push(criterion);
|
||||
return `/scenes/markers?${filter.makeQueryParameters()}`;
|
||||
const makeTagSceneMarkersUrl = (tag: INamedObject) => {
|
||||
return `/scenes/markers?${makeTagFilter(GQL.FilterMode.SceneMarkers, tag)}`;
|
||||
};
|
||||
|
||||
const makeTagGalleriesUrl = (tag: Partial<GQL.TagDataFragment>) => {
|
||||
if (!tag.id) return "#";
|
||||
const filter = new ListFilterModel(GQL.FilterMode.Galleries, undefined);
|
||||
const criterion = new TagsCriterion(TagsCriterionOption);
|
||||
criterion.value = {
|
||||
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
|
||||
excluded: [],
|
||||
depth: 0,
|
||||
};
|
||||
filter.criteria.push(criterion);
|
||||
return `/galleries?${filter.makeQueryParameters()}`;
|
||||
const makeTagGalleriesUrl = (tag: INamedObject) => {
|
||||
return `/galleries?${makeTagFilter(GQL.FilterMode.Galleries, tag)}`;
|
||||
};
|
||||
|
||||
const makeTagImagesUrl = (tag: Partial<GQL.TagDataFragment>) => {
|
||||
if (!tag.id) return "#";
|
||||
const filter = new ListFilterModel(GQL.FilterMode.Images, undefined);
|
||||
const criterion = new TagsCriterion(TagsCriterionOption);
|
||||
criterion.value = {
|
||||
items: [{ id: tag.id, label: tag.name || `Tag ${tag.id}` }],
|
||||
excluded: [],
|
||||
depth: 0,
|
||||
};
|
||||
filter.criteria.push(criterion);
|
||||
return `/images?${filter.makeQueryParameters()}`;
|
||||
const makeTagImagesUrl = (tag: INamedObject) => {
|
||||
return `/images?${makeTagFilter(GQL.FilterMode.Images, tag)}`;
|
||||
};
|
||||
|
||||
const makeTagMoviesUrl = (tag: INamedObject) => {
|
||||
return `/movies?${makeTagFilter(GQL.FilterMode.Movies, tag)}`;
|
||||
};
|
||||
|
||||
type SceneMarkerDataFragment = Pick<GQL.SceneMarker, "id" | "seconds"> & {
|
||||
|
@ -441,6 +412,7 @@ const NavUtils = {
|
|||
makeTagPerformersUrl,
|
||||
makeTagGalleriesUrl,
|
||||
makeTagImagesUrl,
|
||||
makeTagMoviesUrl,
|
||||
makeScenesPHashMatchUrl,
|
||||
makeSceneMarkerUrl,
|
||||
makeMovieScenesUrl,
|
||||
|
|
Loading…
Reference in New Issue