diff --git a/Makefile b/Makefile index 8ec8d74f3..609278e1f 100644 --- a/Makefile +++ b/Makefile @@ -51,6 +51,7 @@ generate-dataloaders: go run github.com/vektah/dataloaden PerformerLoader github.com/gofrs/uuid.UUID "*github.com/stashapp/stash-box/pkg/models.Performer"; \ go run github.com/vektah/dataloaden ImageLoader github.com/gofrs/uuid.UUID "*github.com/stashapp/stash-box/pkg/models.Image"; \ go run github.com/vektah/dataloaden FingerprintsLoader github.com/gofrs/uuid.UUID "[]*github.com/stashapp/stash-box/pkg/models.Fingerprint"; \ + go run github.com/vektah/dataloaden SubmittedFingerprintsLoader github.com/gofrs/uuid.UUID "[]*github.com/stashapp/stash-box/pkg/models.Fingerprint"; \ go run github.com/vektah/dataloaden BodyModificationsLoader github.com/gofrs/uuid.UUID "[]*github.com/stashapp/stash-box/pkg/models.BodyModification"; \ go run github.com/vektah/dataloaden TagCategoryLoader github.com/gofrs/uuid.UUID "*github.com/stashapp/stash-box/pkg/models.TagCategory"; \ go run github.com/vektah/dataloaden SiteLoader github.com/gofrs/uuid.UUID "*github.com/stashapp/stash-box/pkg/models.Site"; diff --git a/frontend/src/graphql/types.ts b/frontend/src/graphql/types.ts index f41c885c8..4614c70bf 100644 --- a/frontend/src/graphql/types.ts +++ b/frontend/src/graphql/types.ts @@ -1270,6 +1270,10 @@ export type Scene = { urls: Array; }; +export type SceneFingerprintsArgs = { + is_submitted?: InputMaybe; +}; + export type SceneCreateInput = { code?: InputMaybe; date: Scalars["String"]; @@ -1382,6 +1386,8 @@ export type SceneQueryInput = { favorites?: InputMaybe; /** Filter to only include scenes with these fingerprints */ fingerprints?: InputMaybe; + /** Filter to scenes with fingerprints submitted by the user */ + has_fingerprint_submissions?: InputMaybe; page?: Scalars["Int"]; /** Filter to only include scenes with this studio as primary or parent */ parentStudio?: InputMaybe; diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index 9c47ea7fd..8c3963ac4 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -78,7 +78,7 @@ type Scene { tags: [Tag!]! images: [Image!]! performers: [PerformerAppearance!]! - fingerprints: [Fingerprint!]! + fingerprints(is_submitted: Boolean = False): [Fingerprint!]! duration: Int director: String code: String @@ -209,6 +209,8 @@ input SceneQueryInput { fingerprints: MultiStringCriterionInput """Filter by favorited entity""" favorites: FavoriteFilter + """Filter to scenes with fingerprints submitted by the user""" + has_fingerprint_submissions: Boolean = False page: Int! = 1 per_page: Int! = 25 diff --git a/pkg/api/resolver_model_scene.go b/pkg/api/resolver_model_scene.go index 98dbff302..5396676b3 100644 --- a/pkg/api/resolver_model_scene.go +++ b/pkg/api/resolver_model_scene.go @@ -110,7 +110,11 @@ func (r *sceneResolver) Performers(ctx context.Context, obj *models.Scene) ([]*m return ret, nil } -func (r *sceneResolver) Fingerprints(ctx context.Context, obj *models.Scene) ([]*models.Fingerprint, error) { + +func (r *sceneResolver) Fingerprints(ctx context.Context, obj *models.Scene, isSubmitted *bool) ([]*models.Fingerprint, error) { + if isSubmitted != nil && *isSubmitted { + return dataloader.For(ctx).SubmittedSceneFingerprintsByID.Load(obj.ID) + } return dataloader.For(ctx).SceneFingerprintsByID.Load(obj.ID) } diff --git a/pkg/dataloader/loaders.go b/pkg/dataloader/loaders.go index 551286f2b..b220aa974 100644 --- a/pkg/dataloader/loaders.go +++ b/pkg/dataloader/loaders.go @@ -17,24 +17,25 @@ const ( ) type Loaders struct { - SceneFingerprintsByID FingerprintsLoader - ImageByID ImageLoader - PerformerByID PerformerLoader - PerformerAliasesByID StringsLoader - PerformerImageIDsByID UUIDsLoader - PerformerMergeIDsByID UUIDsLoader - PerformerPiercingsByID BodyModificationsLoader - PerformerTattoosByID BodyModificationsLoader - PerformerUrlsByID URLLoader - SceneImageIDsByID UUIDsLoader - SceneAppearancesByID SceneAppearancesLoader - SceneUrlsByID URLLoader - StudioImageIDsByID UUIDsLoader - StudioUrlsByID URLLoader - SceneTagIDsByID UUIDsLoader - SiteByID SiteLoader - TagByID TagLoader - TagCategoryByID TagCategoryLoader + SceneFingerprintsByID FingerprintsLoader + SubmittedSceneFingerprintsByID FingerprintsLoader + ImageByID ImageLoader + PerformerByID PerformerLoader + PerformerAliasesByID StringsLoader + PerformerImageIDsByID UUIDsLoader + PerformerMergeIDsByID UUIDsLoader + PerformerPiercingsByID BodyModificationsLoader + PerformerTattoosByID BodyModificationsLoader + PerformerUrlsByID URLLoader + SceneImageIDsByID UUIDsLoader + SceneAppearancesByID SceneAppearancesLoader + SceneUrlsByID URLLoader + StudioImageIDsByID UUIDsLoader + StudioUrlsByID URLLoader + SceneTagIDsByID UUIDsLoader + SiteByID SiteLoader + TagByID TagLoader + TagCategoryByID TagCategoryLoader } func Middleware(fac models.Repo) func(next http.Handler) http.Handler { @@ -63,7 +64,15 @@ func GetLoaders(ctx context.Context, fac models.Repo) *Loaders { wait: 1 * time.Millisecond, fetch: func(ids []uuid.UUID) ([][]*models.Fingerprint, []error) { qb := fac.Scene() - return qb.GetAllFingerprints(currentUser.ID, ids) + return qb.GetAllFingerprints(currentUser.ID, ids, false) + }, + }, + SubmittedSceneFingerprintsByID: FingerprintsLoader{ + maxBatch: 100, + wait: 1 * time.Millisecond, + fetch: func(ids []uuid.UUID) ([][]*models.Fingerprint, []error) { + qb := fac.Scene() + return qb.GetAllFingerprints(currentUser.ID, ids, true) }, }, PerformerByID: PerformerLoader{ diff --git a/pkg/dataloader/submittedfingerprintsloader_gen.go b/pkg/dataloader/submittedfingerprintsloader_gen.go new file mode 100644 index 000000000..7fee155bb --- /dev/null +++ b/pkg/dataloader/submittedfingerprintsloader_gen.go @@ -0,0 +1,226 @@ +// Code generated by github.com/vektah/dataloaden, DO NOT EDIT. + +package dataloader + +import ( + "sync" + "time" + + "github.com/gofrs/uuid" + "github.com/stashapp/stash-box/pkg/models" +) + +// SubmittedFingerprintsLoaderConfig captures the config to create a new SubmittedFingerprintsLoader +type SubmittedFingerprintsLoaderConfig struct { + // Fetch is a method that provides the data for the loader + Fetch func(keys []uuid.UUID) ([][]*models.Fingerprint, []error) + + // Wait is how long wait before sending a batch + Wait time.Duration + + // MaxBatch will limit the maximum number of keys to send in one batch, 0 = not limit + MaxBatch int +} + +// NewSubmittedFingerprintsLoader creates a new SubmittedFingerprintsLoader given a fetch, wait, and maxBatch +func NewSubmittedFingerprintsLoader(config SubmittedFingerprintsLoaderConfig) *SubmittedFingerprintsLoader { + return &SubmittedFingerprintsLoader{ + fetch: config.Fetch, + wait: config.Wait, + maxBatch: config.MaxBatch, + } +} + +// SubmittedFingerprintsLoader batches and caches requests +type SubmittedFingerprintsLoader struct { + // this method provides the data for the loader + fetch func(keys []uuid.UUID) ([][]*models.Fingerprint, []error) + + // how long to done before sending a batch + wait time.Duration + + // this will limit the maximum number of keys to send in one batch, 0 = no limit + maxBatch int + + // INTERNAL + + // lazily created cache + cache map[uuid.UUID][]*models.Fingerprint + + // the current batch. keys will continue to be collected until timeout is hit, + // then everything will be sent to the fetch method and out to the listeners + batch *submittedFingerprintsLoaderBatch + + // mutex to prevent races + mu sync.Mutex +} + +type submittedFingerprintsLoaderBatch struct { + keys []uuid.UUID + data [][]*models.Fingerprint + error []error + closing bool + done chan struct{} +} + +// Load a Fingerprint by key, batching and caching will be applied automatically +func (l *SubmittedFingerprintsLoader) Load(key uuid.UUID) ([]*models.Fingerprint, error) { + return l.LoadThunk(key)() +} + +// LoadThunk returns a function that when called will block waiting for a Fingerprint. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *SubmittedFingerprintsLoader) LoadThunk(key uuid.UUID) func() ([]*models.Fingerprint, error) { + l.mu.Lock() + if it, ok := l.cache[key]; ok { + l.mu.Unlock() + return func() ([]*models.Fingerprint, error) { + return it, nil + } + } + if l.batch == nil { + l.batch = &submittedFingerprintsLoaderBatch{done: make(chan struct{})} + } + batch := l.batch + pos := batch.keyIndex(l, key) + l.mu.Unlock() + + return func() ([]*models.Fingerprint, error) { + <-batch.done + + var data []*models.Fingerprint + if pos < len(batch.data) { + data = batch.data[pos] + } + + var err error + // its convenient to be able to return a single error for everything + if len(batch.error) == 1 { + err = batch.error[0] + } else if batch.error != nil { + err = batch.error[pos] + } + + if err == nil { + l.mu.Lock() + l.unsafeSet(key, data) + l.mu.Unlock() + } + + return data, err + } +} + +// LoadAll fetches many keys at once. It will be broken into appropriate sized +// sub batches depending on how the loader is configured +func (l *SubmittedFingerprintsLoader) LoadAll(keys []uuid.UUID) ([][]*models.Fingerprint, []error) { + results := make([]func() ([]*models.Fingerprint, error), len(keys)) + + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + + fingerprints := make([][]*models.Fingerprint, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + fingerprints[i], errors[i] = thunk() + } + return fingerprints, errors +} + +// LoadAllThunk returns a function that when called will block waiting for a Fingerprints. +// This method should be used if you want one goroutine to make requests to many +// different data loaders without blocking until the thunk is called. +func (l *SubmittedFingerprintsLoader) LoadAllThunk(keys []uuid.UUID) func() ([][]*models.Fingerprint, []error) { + results := make([]func() ([]*models.Fingerprint, error), len(keys)) + for i, key := range keys { + results[i] = l.LoadThunk(key) + } + return func() ([][]*models.Fingerprint, []error) { + fingerprints := make([][]*models.Fingerprint, len(keys)) + errors := make([]error, len(keys)) + for i, thunk := range results { + fingerprints[i], errors[i] = thunk() + } + return fingerprints, errors + } +} + +// Prime the cache with the provided key and value. If the key already exists, no change is made +// and false is returned. +// (To forcefully prime the cache, clear the key first with loader.clear(key).prime(key, value).) +func (l *SubmittedFingerprintsLoader) Prime(key uuid.UUID, value []*models.Fingerprint) bool { + l.mu.Lock() + var found bool + if _, found = l.cache[key]; !found { + // make a copy when writing to the cache, its easy to pass a pointer in from a loop var + // and end up with the whole cache pointing to the same value. + cpy := make([]*models.Fingerprint, len(value)) + copy(cpy, value) + l.unsafeSet(key, cpy) + } + l.mu.Unlock() + return !found +} + +// Clear the value at key from the cache, if it exists +func (l *SubmittedFingerprintsLoader) Clear(key uuid.UUID) { + l.mu.Lock() + delete(l.cache, key) + l.mu.Unlock() +} + +func (l *SubmittedFingerprintsLoader) unsafeSet(key uuid.UUID, value []*models.Fingerprint) { + if l.cache == nil { + l.cache = map[uuid.UUID][]*models.Fingerprint{} + } + l.cache[key] = value +} + +// keyIndex will return the location of the key in the batch, if its not found +// it will add the key to the batch +func (b *submittedFingerprintsLoaderBatch) keyIndex(l *SubmittedFingerprintsLoader, key uuid.UUID) int { + for i, existingKey := range b.keys { + if key == existingKey { + return i + } + } + + pos := len(b.keys) + b.keys = append(b.keys, key) + if pos == 0 { + go b.startTimer(l) + } + + if l.maxBatch != 0 && pos >= l.maxBatch-1 { + if !b.closing { + b.closing = true + l.batch = nil + go b.end(l) + } + } + + return pos +} + +func (b *submittedFingerprintsLoaderBatch) startTimer(l *SubmittedFingerprintsLoader) { + time.Sleep(l.wait) + l.mu.Lock() + + // we must have hit a batch limit and are already finalizing this batch + if b.closing { + l.mu.Unlock() + return + } + + l.batch = nil + l.mu.Unlock() + + b.end(l) +} + +func (b *submittedFingerprintsLoaderBatch) end(l *SubmittedFingerprintsLoader) { + b.data, b.error = l.fetch(b.keys) + close(b.done) +} diff --git a/pkg/models/generated_exec.go b/pkg/models/generated_exec.go index a2a5145d6..6373ec417 100644 --- a/pkg/models/generated_exec.go +++ b/pkg/models/generated_exec.go @@ -407,7 +407,7 @@ type ComplexityRoot struct { Director func(childComplexity int) int Duration func(childComplexity int) int Edits func(childComplexity int) int - Fingerprints func(childComplexity int) int + Fingerprints func(childComplexity int, isSubmitted *bool) int ID func(childComplexity int) int Images func(childComplexity int) int Performers func(childComplexity int) int @@ -782,7 +782,7 @@ type SceneResolver interface { Tags(ctx context.Context, obj *Scene) ([]*Tag, error) Images(ctx context.Context, obj *Scene) ([]*Image, error) Performers(ctx context.Context, obj *Scene) ([]*PerformerAppearance, error) - Fingerprints(ctx context.Context, obj *Scene) ([]*Fingerprint, error) + Fingerprints(ctx context.Context, obj *Scene, isSubmitted *bool) ([]*Fingerprint, error) Duration(ctx context.Context, obj *Scene) (*int, error) Director(ctx context.Context, obj *Scene) (*string, error) Code(ctx context.Context, obj *Scene) (*string, error) @@ -2998,7 +2998,12 @@ func (e *executableSchema) Complexity(typeName, field string, childComplexity in break } - return e.complexity.Scene.Fingerprints(childComplexity), true + args, err := ec.field_Scene_fingerprints_args(context.TODO(), rawArgs) + if err != nil { + return 0, false + } + + return e.complexity.Scene.Fingerprints(childComplexity, args["is_submitted"].(*bool)), true case "Scene.id": if e.complexity.Scene.ID == nil { @@ -4768,7 +4773,7 @@ type Scene { tags: [Tag!]! images: [Image!]! performers: [PerformerAppearance!]! - fingerprints: [Fingerprint!]! + fingerprints(is_submitted: Boolean = False): [Fingerprint!]! duration: Int director: String code: String @@ -4899,6 +4904,8 @@ input SceneQueryInput { fingerprints: MultiStringCriterionInput """Filter by favorited entity""" favorites: FavoriteFilter + """Filter to scenes with fingerprints submitted by the user""" + has_fingerprint_submissions: Boolean = False page: Int! = 1 per_page: Int! = 25 @@ -6754,6 +6761,21 @@ func (ec *executionContext) field_Query_searchTag_args(ctx context.Context, rawA return args, nil } +func (ec *executionContext) field_Scene_fingerprints_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { + var err error + args := map[string]interface{}{} + var arg0 *bool + if tmp, ok := rawArgs["is_submitted"]; ok { + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("is_submitted")) + arg0, err = ec.unmarshalOBoolean2ᚖbool(ctx, tmp) + if err != nil { + return nil, err + } + } + args["is_submitted"] = arg0 + return args, nil +} + func (ec *executionContext) field___Type_enumValues_args(ctx context.Context, rawArgs map[string]interface{}) (map[string]interface{}, error) { var err error args := map[string]interface{}{} @@ -22421,7 +22443,7 @@ func (ec *executionContext) _Scene_fingerprints(ctx context.Context, field graph }() resTmp, err := ec.ResolverMiddleware(ctx, func(rctx context.Context) (interface{}, error) { ctx = rctx // use context from middleware stack in children - return ec.resolvers.Scene().Fingerprints(rctx, obj) + return ec.resolvers.Scene().Fingerprints(rctx, obj, fc.Args["is_submitted"].(*bool)) }) if err != nil { ec.Error(ctx, err) @@ -22464,6 +22486,17 @@ func (ec *executionContext) fieldContext_Scene_fingerprints(ctx context.Context, return nil, fmt.Errorf("no field named %q was found under type Fingerprint", field.Name) }, } + defer func() { + if r := recover(); r != nil { + err = ec.Recover(ctx, r) + ec.Error(ctx, err) + } + }() + ctx = graphql.WithFieldContext(ctx, fc) + if fc.Args, err = ec.field_Scene_fingerprints_args(ctx, field.ArgumentMap(ec.Variables)); err != nil { + ec.Error(ctx, err) + return + } return fc, nil } @@ -32847,6 +32880,9 @@ func (ec *executionContext) unmarshalInputSceneQueryInput(ctx context.Context, o asMap[k] = v } + if _, present := asMap["has_fingerprint_submissions"]; !present { + asMap["has_fingerprint_submissions"] = "False" + } if _, present := asMap["page"]; !present { asMap["page"] = 1 } @@ -32950,6 +32986,14 @@ func (ec *executionContext) unmarshalInputSceneQueryInput(ctx context.Context, o if err != nil { return it, err } + case "has_fingerprint_submissions": + var err error + + ctx := graphql.WithPathContext(ctx, graphql.NewPathWithField("has_fingerprint_submissions")) + it.HasFingerprintSubmissions, err = ec.unmarshalOBoolean2ᚖbool(ctx, v) + if err != nil { + return it, err + } case "page": var err error diff --git a/pkg/models/generated_models.go b/pkg/models/generated_models.go index 3493859a9..ccc00238a 100644 --- a/pkg/models/generated_models.go +++ b/pkg/models/generated_models.go @@ -520,11 +520,13 @@ type SceneQueryInput struct { // Filter to only include scenes with these fingerprints Fingerprints *MultiStringCriterionInput `json:"fingerprints"` // Filter by favorited entity - Favorites *FavoriteFilter `json:"favorites"` - Page int `json:"page"` - PerPage int `json:"per_page"` - Direction SortDirectionEnum `json:"direction"` - Sort SceneSortEnum `json:"sort"` + Favorites *FavoriteFilter `json:"favorites"` + // Filter to scenes with fingerprints submitted by the user + HasFingerprintSubmissions *bool `json:"has_fingerprint_submissions"` + Page int `json:"page"` + PerPage int `json:"per_page"` + Direction SortDirectionEnum `json:"direction"` + Sort SceneSortEnum `json:"sort"` } type SceneUpdateInput struct { diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 161ec0baa..343449888 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -27,7 +27,7 @@ type SceneRepo interface { // GetAllFingerprints returns fingerprints for each of the scene ids provided. // currentUserID is used to populate the UserSubmitted field. - GetAllFingerprints(currentUserID uuid.UUID, ids []uuid.UUID) ([][]*Fingerprint, []error) + GetAllFingerprints(currentUserID uuid.UUID, ids []uuid.UUID, onlySubmitted bool) ([][]*Fingerprint, []error) GetPerformers(id uuid.UUID) (PerformersScenes, error) GetAllAppearances(ids []uuid.UUID) ([]PerformersScenes, []error) GetURLs(id uuid.UUID) ([]*URL, error) diff --git a/pkg/sqlx/querybuilder_scene.go b/pkg/sqlx/querybuilder_scene.go index 49bba1f35..fc1aceb46 100644 --- a/pkg/sqlx/querybuilder_scene.go +++ b/pkg/sqlx/querybuilder_scene.go @@ -494,6 +494,17 @@ func (qb *sceneQueryBuilder) buildQuery(filter models.SceneQueryInput, userID uu query.Pagination = getPagination(filter.Page, filter.PerPage) } + if filter.HasFingerprintSubmissions != nil && *filter.HasFingerprintSubmissions { + query.Body += ` + JOIN ( + SELECT scene_id + FROM scene_fingerprints + WHERE user_id = ? + ) T ON scenes.id = T.scene_id + ` + query.AddArg(userID) + } + query.Eq("scenes.deleted", false) return query, nil @@ -585,7 +596,7 @@ func (qb *sceneQueryBuilder) GetFingerprints(id uuid.UUID) (models.SceneFingerpr return joins, err } -func (qb *sceneQueryBuilder) GetAllFingerprints(currentUserID uuid.UUID, ids []uuid.UUID) ([][]*models.Fingerprint, []error) { +func (qb *sceneQueryBuilder) GetAllFingerprints(currentUserID uuid.UUID, ids []uuid.UUID, onlySubmitted bool) ([][]*models.Fingerprint, []error) { query := ` SELECT f.scene_id, @@ -598,15 +609,22 @@ func (qb *sceneQueryBuilder) GetAllFingerprints(currentUserID uuid.UUID, ids []u bool_or(f.user_id = :userid) as user_submitted FROM scene_fingerprints f WHERE f.scene_id IN (:sceneids) + ` + + if onlySubmitted { + query += "AND f.user_id = :userid" + } + + query += ` GROUP BY f.scene_id, f.algorithm, f.hash ORDER BY submissions DESC` - m := make(map[uuid.UUID][]*models.Fingerprint) - arg := map[string]interface{}{ "userid": currentUserID, "sceneids": ids, } + m := make(map[uuid.UUID][]*models.Fingerprint) + query, args, err := sqlx.Named(query, arg) if err != nil { return nil, utils.DuplicateError(err, len(ids))