Movie/Group tags (#4969)

* Combine common tag control code into hook
* Combine common scraped tag row code into hook
This commit is contained in:
WithoutPants 2024-06-18 11:24:15 +10:00 committed by GitHub
parent f9a624b803
commit fda4776d30
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
63 changed files with 1586 additions and 450 deletions

View File

@ -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

View File

@ -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 {

View File

@ -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
}

View File

@ -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!]!

View File

@ -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 {

View File

@ -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 {

View File

@ -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{}

View File

@ -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) {

View File

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

View File

@ -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,
}

View File

@ -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"`

View File

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

View File

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

View File

@ -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
}

View File

@ -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

View File

@ -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

View File

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

View File

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

View File

@ -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

View File

@ -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 {

View File

@ -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()

View File

@ -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)
}

View File

@ -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
}

View File

@ -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

View File

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

View File

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

View File

@ -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:

View File

@ -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},
&timestampCriterionHandler{movieFilter.CreatedAt, "movies.created_at", nil},
&timestampCriterionHandler{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)
}

View File

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

View File

@ -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
}

View File

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

View File

@ -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 {

View File

@ -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 (

View File

@ -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)
}

View File

@ -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 {

View File

@ -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
})
}

View File

@ -11,6 +11,10 @@ fragment MovieData on Movie {
...SlimStudioData
}
tags {
...SlimTagData
}
synopsis
urls
front_image_path

View File

@ -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 {

View File

@ -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

View File

@ -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() {

View File

@ -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}

View File

@ -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() {

View File

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

View File

@ -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()}
/>
);

View File

@ -305,6 +305,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
return (
<MovieDetailsPanel
movie={movie}
collapsed={collapsed}
fullWidth={!collapsed && !compactExpandedDetails}
/>
);

View File

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

View File

@ -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

View File

@ -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);
}}
/>
);

View File

@ -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 (

View File

@ -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"

View File

@ -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() {

View File

@ -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}

View File

@ -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,
};
}

View File

@ -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 ?? "");
}

View File

@ -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>

View File

@ -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={

View File

@ -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} />;
};

View File

@ -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,
};
}

View File

@ -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",

View File

@ -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"),
];

View File

@ -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"),

View File

@ -172,6 +172,7 @@ export type CriterionType =
| "image_count"
| "gallery_count"
| "performer_count"
| "movie_count"
| "death_year"
| "url"
| "interactive"

View File

@ -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,