From 3346f8dcca6b1f4b310373304ac0d4ad47598dd1 Mon Sep 17 00:00:00 2001 From: InfiniteTF Date: Sat, 24 Oct 2020 05:31:39 +0200 Subject: [PATCH] Stash-box tagger integration (#454) --- gqlgen.yml | 2 + graphql/documents/data/performer-slim.graphql | 4 + graphql/documents/data/performer.graphql | 4 + graphql/documents/data/scene-slim.graphql | 5 + graphql/documents/data/scene.graphql | 5 + graphql/documents/data/scrapers.graphql | 46 ++ graphql/documents/data/studio-slim.graphql | 6 +- graphql/documents/data/studio.graphql | 4 + graphql/documents/mutations/performer.graphql | 6 +- graphql/documents/mutations/scene.graphql | 2 + graphql/documents/mutations/stash-box.graphql | 3 + graphql/documents/mutations/studio.graphql | 12 +- graphql/documents/queries/performer.graphql | 2 +- .../queries/scrapers/scrapers.graphql | 4 +- graphql/documents/queries/studio.graphql | 2 +- graphql/schema/schema.graphql | 4 +- graphql/schema/types/filters.graphql | 8 +- graphql/schema/types/performer.graphql | 5 +- graphql/schema/types/scene.graphql | 2 + graphql/schema/types/scraper.graphql | 15 + graphql/schema/types/stash-box.graphql | 15 + graphql/schema/types/studio.graphql | 6 +- pkg/api/resolver_model_performer.go | 5 + pkg/api/resolver_model_scene.go | 5 + pkg/api/resolver_model_studio.go | 5 + pkg/api/resolver_mutation_performer.go | 34 ++ pkg/api/resolver_mutation_scene.go | 15 + pkg/api/resolver_mutation_stash_box.go | 22 + pkg/api/resolver_mutation_studio.go | 33 ++ pkg/database/database.go | 2 +- .../migrations/14_stash_box_ids.up.sql | 20 + pkg/models/model_joins.go | 5 + pkg/models/model_scraped_item.go | 66 +-- pkg/models/querybuilder_joins.go | 114 +++++ pkg/models/querybuilder_performer.go | 8 + pkg/models/querybuilder_scene.go | 8 + pkg/models/querybuilder_studio.go | 10 +- pkg/scraper/stashbox/stash_box.go | 110 ++++- ui/v2.5/package.json | 3 + .../src/components/Changelog/versions/v040.md | 1 + ui/v2.5/src/components/Help/Manual.tsx | 6 + ui/v2.5/src/components/List/ListFilter.tsx | 4 + ui/v2.5/src/components/MainNavbar.tsx | 4 + .../PerformerDetailsPanel.tsx | 63 +++ .../Scenes/SceneDetails/SceneEditPanel.tsx | 49 ++ .../SceneDetails/SceneFileInfoPanel.tsx | 34 ++ ui/v2.5/src/components/Scenes/SceneList.tsx | 4 + .../Settings/SettingsConfigurationPanel.tsx | 4 +- .../components/Shared/LoadingIndicator.tsx | 10 +- ui/v2.5/src/components/Shared/Modal.tsx | 11 +- ui/v2.5/src/components/Shared/MultiSet.tsx | 3 +- ui/v2.5/src/components/Shared/Select.tsx | 13 +- ui/v2.5/src/components/Shared/SuccessIcon.tsx | 12 + ui/v2.5/src/components/Shared/index.ts | 1 + ui/v2.5/src/components/Shared/styles.scss | 8 +- .../Studios/StudioDetails/Studio.tsx | 36 ++ ui/v2.5/src/components/Tagger/Config.tsx | 268 +++++++++++ .../src/components/Tagger/PerformerModal.tsx | 177 +++++++ .../src/components/Tagger/PerformerResult.tsx | 155 ++++++ .../components/Tagger/StashSearchResult.tsx | 394 ++++++++++++++++ .../src/components/Tagger/StudioResult.tsx | 161 +++++++ ui/v2.5/src/components/Tagger/Tagger.tsx | 445 ++++++++++++++++++ ui/v2.5/src/components/Tagger/index.ts | 1 + ui/v2.5/src/components/Tagger/queries.ts | 239 ++++++++++ ui/v2.5/src/components/Tagger/styles.scss | 99 ++++ ui/v2.5/src/components/Tagger/utils.ts | 177 +++++++ ui/v2.5/src/core/StashService.ts | 20 + ui/v2.5/src/docs/en/Tagger.md | 5 + ui/v2.5/src/index.scss | 16 +- ui/v2.5/src/locale/en.json | 3 +- .../models/list-filter/criteria/is-missing.ts | 1 + ui/v2.5/src/models/list-filter/filter.ts | 1 + ui/v2.5/src/models/list-filter/types.ts | 1 + ui/v2.5/src/utils/country.ts | 6 + ui/v2.5/yarn.lock | 27 ++ 75 files changed, 3007 insertions(+), 79 deletions(-) create mode 100644 graphql/documents/mutations/stash-box.graphql create mode 100644 pkg/api/resolver_mutation_stash_box.go create mode 100644 pkg/database/migrations/14_stash_box_ids.up.sql create mode 100644 ui/v2.5/src/components/Shared/SuccessIcon.tsx create mode 100644 ui/v2.5/src/components/Tagger/Config.tsx create mode 100755 ui/v2.5/src/components/Tagger/PerformerModal.tsx create mode 100755 ui/v2.5/src/components/Tagger/PerformerResult.tsx create mode 100755 ui/v2.5/src/components/Tagger/StashSearchResult.tsx create mode 100755 ui/v2.5/src/components/Tagger/StudioResult.tsx create mode 100755 ui/v2.5/src/components/Tagger/Tagger.tsx create mode 100644 ui/v2.5/src/components/Tagger/index.ts create mode 100644 ui/v2.5/src/components/Tagger/queries.ts create mode 100644 ui/v2.5/src/components/Tagger/styles.scss create mode 100644 ui/v2.5/src/components/Tagger/utils.ts create mode 100644 ui/v2.5/src/docs/en/Tagger.md diff --git a/gqlgen.yml b/gqlgen.yml index 05551cb7b..2b2402034 100644 --- a/gqlgen.yml +++ b/gqlgen.yml @@ -52,3 +52,5 @@ models: model: github.com/stashapp/stash/pkg/models.ScrapedMovie ScrapedMovieStudio: model: github.com/stashapp/stash/pkg/models.ScrapedMovieStudio + StashID: + model: github.com/stashapp/stash/pkg/models.StashID diff --git a/graphql/documents/data/performer-slim.graphql b/graphql/documents/data/performer-slim.graphql index c2abc6023..09aeb5c16 100644 --- a/graphql/documents/data/performer-slim.graphql +++ b/graphql/documents/data/performer-slim.graphql @@ -3,4 +3,8 @@ fragment SlimPerformerData on Performer { name gender image_path + stash_ids { + endpoint + stash_id + } } diff --git a/graphql/documents/data/performer.graphql b/graphql/documents/data/performer.graphql index cc5e6d2f1..24ce512ad 100644 --- a/graphql/documents/data/performer.graphql +++ b/graphql/documents/data/performer.graphql @@ -20,4 +20,8 @@ fragment PerformerData on Performer { favorite image_path scene_count + stash_ids { + stash_id + endpoint + } } diff --git a/graphql/documents/data/scene-slim.graphql b/graphql/documents/data/scene-slim.graphql index 535d79c87..732c19280 100644 --- a/graphql/documents/data/scene-slim.graphql +++ b/graphql/documents/data/scene-slim.graphql @@ -68,4 +68,9 @@ fragment SlimSceneData on Scene { favorite image_path } + + stash_ids { + endpoint + stash_id + } } diff --git a/graphql/documents/data/scene.graphql b/graphql/documents/data/scene.graphql index aa02db41a..43161efcb 100644 --- a/graphql/documents/data/scene.graphql +++ b/graphql/documents/data/scene.graphql @@ -56,4 +56,9 @@ fragment SceneData on Scene { performers { ...PerformerData } + + stash_ids { + endpoint + stash_id + } } diff --git a/graphql/documents/data/scrapers.graphql b/graphql/documents/data/scrapers.graphql index 6cfbe2f7f..fe2bf7f7b 100644 --- a/graphql/documents/data/scrapers.graphql +++ b/graphql/documents/data/scrapers.graphql @@ -36,6 +36,8 @@ fragment ScrapedScenePerformerData on ScrapedScenePerformer { tattoos piercings aliases + remote_site_id + images } fragment ScrapedMovieStudioData on ScrapedMovieStudio { @@ -77,6 +79,7 @@ fragment ScrapedSceneStudioData on ScrapedSceneStudio { stored_id name url + remote_site_id } fragment ScrapedSceneTagData on ScrapedSceneTag { @@ -137,3 +140,46 @@ fragment ScrapedGalleryData on ScrapedGallery { ...ScrapedScenePerformerData } } + +fragment ScrapedStashBoxSceneData on ScrapedScene { + title + details + url + date + image + remote_site_id + duration + + file { + size + duration + video_codec + audio_codec + width + height + framerate + bitrate + } + + fingerprints { + hash + algorithm + duration + } + + studio { + ...ScrapedSceneStudioData + } + + tags { + ...ScrapedSceneTagData + } + + performers { + ...ScrapedScenePerformerData + } + + movies { + ...ScrapedSceneMovieData + } +} diff --git a/graphql/documents/data/studio-slim.graphql b/graphql/documents/data/studio-slim.graphql index 0ce2ec675..e42a284fd 100644 --- a/graphql/documents/data/studio-slim.graphql +++ b/graphql/documents/data/studio-slim.graphql @@ -2,4 +2,8 @@ fragment SlimStudioData on Studio { id name image_path -} \ No newline at end of file + stash_ids { + endpoint + stash_id + } +} diff --git a/graphql/documents/data/studio.graphql b/graphql/documents/data/studio.graphql index 890f9c43f..d2f60a44b 100644 --- a/graphql/documents/data/studio.graphql +++ b/graphql/documents/data/studio.graphql @@ -21,4 +21,8 @@ fragment StudioData on Studio { } image_path scene_count + stash_ids { + stash_id + endpoint + } } diff --git a/graphql/documents/mutations/performer.graphql b/graphql/documents/mutations/performer.graphql index f8af53180..48b490f9d 100644 --- a/graphql/documents/mutations/performer.graphql +++ b/graphql/documents/mutations/performer.graphql @@ -16,6 +16,7 @@ mutation PerformerCreate( $twitter: String, $instagram: String, $favorite: Boolean, + $stash_ids: [StashIDInput!], $image: String) { performerCreate(input: { @@ -36,6 +37,7 @@ mutation PerformerCreate( twitter: $twitter, instagram: $instagram, favorite: $favorite, + stash_ids: $stash_ids, image: $image }) { ...PerformerData @@ -61,6 +63,7 @@ mutation PerformerUpdate( $twitter: String, $instagram: String, $favorite: Boolean, + $stash_ids: [StashIDInput!], $image: String) { performerUpdate(input: { @@ -82,6 +85,7 @@ mutation PerformerUpdate( twitter: $twitter, instagram: $instagram, favorite: $favorite, + stash_ids: $stash_ids, image: $image }) { ...PerformerData @@ -90,4 +94,4 @@ mutation PerformerUpdate( mutation PerformerDestroy($id: ID!) { performerDestroy(input: { id: $id }) -} \ No newline at end of file +} diff --git a/graphql/documents/mutations/scene.graphql b/graphql/documents/mutations/scene.graphql index 0915b48cd..98c0b8a08 100644 --- a/graphql/documents/mutations/scene.graphql +++ b/graphql/documents/mutations/scene.graphql @@ -10,6 +10,7 @@ mutation SceneUpdate( $performer_ids: [ID!] = [], $movies: [SceneMovieInput!] = [], $tag_ids: [ID!] = [], + $stash_ids: [StashIDInput!], $cover_image: String) { sceneUpdate(input: { @@ -24,6 +25,7 @@ mutation SceneUpdate( performer_ids: $performer_ids, movies: $movies, tag_ids: $tag_ids, + stash_ids: $stash_ids, cover_image: $cover_image }) { ...SceneData diff --git a/graphql/documents/mutations/stash-box.graphql b/graphql/documents/mutations/stash-box.graphql new file mode 100644 index 000000000..24a9dc169 --- /dev/null +++ b/graphql/documents/mutations/stash-box.graphql @@ -0,0 +1,3 @@ +mutation SubmitStashBoxFingerprints($input: StashBoxFingerprintSubmissionInput!) { + submitStashBoxFingerprints(input: $input) +} diff --git a/graphql/documents/mutations/studio.graphql b/graphql/documents/mutations/studio.graphql index 539b96358..9e4a473b5 100644 --- a/graphql/documents/mutations/studio.graphql +++ b/graphql/documents/mutations/studio.graphql @@ -1,10 +1,11 @@ mutation StudioCreate( $name: String!, $url: String, - $image: String + $image: String, + $stash_ids: [StashIDInput!], $parent_id: ID) { - studioCreate(input: { name: $name, url: $url, image: $image, parent_id: $parent_id }) { + studioCreate(input: { name: $name, url: $url, image: $image, stash_ids: $stash_ids, parent_id: $parent_id }) { ...StudioData } } @@ -13,14 +14,15 @@ mutation StudioUpdate( $id: ID! $name: String, $url: String, - $image: String + $image: String, + $stash_ids: [StashIDInput!], $parent_id: ID) { - studioUpdate(input: { id: $id, name: $name, url: $url, image: $image, parent_id: $parent_id }) { + studioUpdate(input: { id: $id, name: $name, url: $url, image: $image, stash_ids: $stash_ids, parent_id: $parent_id }) { ...StudioData } } mutation StudioDestroy($id: ID!) { studioDestroy(input: { id: $id }) -} \ No newline at end of file +} diff --git a/graphql/documents/queries/performer.graphql b/graphql/documents/queries/performer.graphql index ead379f75..dec46bd2d 100644 --- a/graphql/documents/queries/performer.graphql +++ b/graphql/documents/queries/performer.graphql @@ -11,4 +11,4 @@ query FindPerformer($id: ID!) { findPerformer(id: $id) { ...PerformerData } -} \ No newline at end of file +} diff --git a/graphql/documents/queries/scrapers/scrapers.graphql b/graphql/documents/queries/scrapers/scrapers.graphql index aa2086daf..bb9d99284 100644 --- a/graphql/documents/queries/scrapers/scrapers.graphql +++ b/graphql/documents/queries/scrapers/scrapers.graphql @@ -92,6 +92,6 @@ query ScrapeMovieURL($url: String!) { query QueryStashBoxScene($input: StashBoxQueryInput!) { queryStashBoxScene(input: $input) { - ...ScrapedSceneData + ...ScrapedStashBoxSceneData } -} \ No newline at end of file +} diff --git a/graphql/documents/queries/studio.graphql b/graphql/documents/queries/studio.graphql index f18f67e93..d999343d2 100644 --- a/graphql/documents/queries/studio.graphql +++ b/graphql/documents/queries/studio.graphql @@ -11,4 +11,4 @@ query FindStudio($id: ID!) { findStudio(id: $id) { ...StudioData } -} \ No newline at end of file +} diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index d0308c4e2..7ae7a95aa 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -98,7 +98,6 @@ type Query { """List available plugin operations""" pluginTasks: [PluginTask!] - # Config """Returns the current, complete configuration""" configuration: ConfigResult! @@ -222,6 +221,9 @@ type Mutation { reloadPlugins: Boolean! stopJob: Boolean! + + """ Submit fingerprints to stash-box instance """ + submitStashBoxFingerprints(input: StashBoxFingerprintSubmissionInput!): Boolean! } type Subscription { diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index e8baffb2e..5b911d784 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -50,6 +50,8 @@ input PerformerFilterType { gender: GenderCriterionInput """Filter to only include performers missing this property""" is_missing: String + """Filter by StashID""" + stash_id: String } input SceneMarkerFilterType { @@ -86,6 +88,8 @@ input SceneFilterType { tags: MultiCriterionInput """Filter to only include scenes with these performers""" performers: MultiCriterionInput + """Filter by StashID""" + stash_id: String } input MovieFilterType { @@ -98,6 +102,8 @@ input MovieFilterType { input StudioFilterType { """Filter to only include studios with this parent studio""" parents: MultiCriterionInput + """Filter by StashID""" + stash_id: String """Filter to only include studios missing this property""" is_missing: String } @@ -192,4 +198,4 @@ input MultiCriterionInput { input GenderCriterionInput { value: GenderEnum modifier: CriterionModifier! -} \ No newline at end of file +} diff --git a/graphql/schema/types/performer.graphql b/graphql/schema/types/performer.graphql index c872a9d27..fd5c08fc4 100644 --- a/graphql/schema/types/performer.graphql +++ b/graphql/schema/types/performer.graphql @@ -31,6 +31,7 @@ type Performer { image_path: String # Resolver scene_count: Int # Resolver scenes: [Scene!]! + stash_ids: [StashID!]! } input PerformerCreateInput { @@ -53,6 +54,7 @@ input PerformerCreateInput { favorite: Boolean """This should be base64 encoded""" image: String + stash_ids: [StashIDInput!] } input PerformerUpdateInput { @@ -76,6 +78,7 @@ input PerformerUpdateInput { favorite: Boolean """This should be base64 encoded""" image: String + stash_ids: [StashIDInput!] } input PerformerDestroyInput { @@ -85,4 +88,4 @@ input PerformerDestroyInput { type FindPerformersResultType { count: Int! performers: [Performer!]! -} \ No newline at end of file +} diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index 3844c6a8e..23bacf926 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -44,6 +44,7 @@ type Scene { movies: [SceneMovie!]! tags: [Tag!]! performers: [Performer!]! + stash_ids: [StashID!]! } input SceneMovieInput { @@ -66,6 +67,7 @@ input SceneUpdateInput { tag_ids: [ID!] """This should be base64 encoded""" cover_image: String + stash_ids: [StashIDInput!] } enum BulkUpdateIdMode { diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 110e1700c..7066ac4b4 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -45,6 +45,9 @@ type ScrapedScenePerformer { tattoos: String piercings: String aliases: String + + remote_site_id: String + images: [String!] } type ScrapedSceneMovie { @@ -65,6 +68,8 @@ type ScrapedSceneStudio { stored_id: ID name: String! url: String + + remote_site_id: String } type ScrapedSceneTag { @@ -88,6 +93,10 @@ type ScrapedScene { tags: [ScrapedSceneTag!] performers: [ScrapedScenePerformer!] movies: [ScrapedSceneMovie!] + + remote_site_id: String + duration: Int + fingerprints: [StashBoxFingerprint!] } type ScrapedGallery { @@ -109,3 +118,9 @@ input StashBoxQueryInput { """Query by query string""" q: String } + +type StashBoxFingerprint { + algorithm: String! + hash: String! + duration: Int! +} diff --git a/graphql/schema/types/stash-box.graphql b/graphql/schema/types/stash-box.graphql index 0ca3686ee..471db19b1 100644 --- a/graphql/schema/types/stash-box.graphql +++ b/graphql/schema/types/stash-box.graphql @@ -9,3 +9,18 @@ input StashBoxInput { api_key: String! name: String! } + +type StashID { + endpoint: String! + stash_id: String! +} + +input StashIDInput { + endpoint: String! + stash_id: String! +} + +input StashBoxFingerprintSubmissionInput { + scene_ids: [String!]! + stash_box_index: Int! +} diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index 90309b8c5..051776e03 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -5,8 +5,10 @@ type Studio { url: String parent_studio: Studio child_studios: [Studio!]! + image_path: String # Resolver scene_count: Int # Resolver + stash_ids: [StashID!]! } input StudioCreateInput { @@ -15,6 +17,7 @@ input StudioCreateInput { parent_id: ID """This should be base64 encoded""" image: String + stash_ids: [StashIDInput!] } input StudioUpdateInput { @@ -24,6 +27,7 @@ input StudioUpdateInput { parent_id: ID, """This should be base64 encoded""" image: String + stash_ids: [StashIDInput!] } input StudioDestroyInput { @@ -33,4 +37,4 @@ input StudioDestroyInput { type FindStudiosResultType { count: Int! studios: [Studio!]! -} \ No newline at end of file +} diff --git a/pkg/api/resolver_model_performer.go b/pkg/api/resolver_model_performer.go index 29a4d2d90..c322e4943 100644 --- a/pkg/api/resolver_model_performer.go +++ b/pkg/api/resolver_model_performer.go @@ -148,3 +148,8 @@ func (r *performerResolver) Scenes(ctx context.Context, obj *models.Performer) ( qb := models.NewSceneQueryBuilder() return qb.FindByPerformerID(obj.ID) } + +func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer) ([]*models.StashID, error) { + qb := models.NewJoinsQueryBuilder() + return qb.GetPerformerStashIDs(obj.ID) +} diff --git a/pkg/api/resolver_model_scene.go b/pkg/api/resolver_model_scene.go index 9d2c26e3b..656e2ef66 100644 --- a/pkg/api/resolver_model_scene.go +++ b/pkg/api/resolver_model_scene.go @@ -150,3 +150,8 @@ func (r *sceneResolver) Performers(ctx context.Context, obj *models.Scene) ([]*m qb := models.NewPerformerQueryBuilder() return qb.FindBySceneID(obj.ID, nil) } + +func (r *sceneResolver) StashIds(ctx context.Context, obj *models.Scene) ([]*models.StashID, error) { + qb := models.NewJoinsQueryBuilder() + return qb.GetSceneStashIDs(obj.ID) +} diff --git a/pkg/api/resolver_model_studio.go b/pkg/api/resolver_model_studio.go index f068fa12c..cfe7d5cc5 100644 --- a/pkg/api/resolver_model_studio.go +++ b/pkg/api/resolver_model_studio.go @@ -46,3 +46,8 @@ func (r *studioResolver) ChildStudios(ctx context.Context, obj *models.Studio) ( qb := models.NewStudioQueryBuilder() return qb.FindChildren(obj.ID, nil) } + +func (r *studioResolver) StashIds(ctx context.Context, obj *models.Studio) ([]*models.StashID, error) { + qb := models.NewJoinsQueryBuilder() + return qb.GetStudioStashIDs(obj.ID) +} diff --git a/pkg/api/resolver_mutation_performer.go b/pkg/api/resolver_mutation_performer.go index efb47025a..0ef185dc0 100644 --- a/pkg/api/resolver_mutation_performer.go +++ b/pkg/api/resolver_mutation_performer.go @@ -88,6 +88,8 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per // Start the transaction and save the performer tx := database.DB.MustBeginTx(ctx, nil) qb := models.NewPerformerQueryBuilder() + jqb := models.NewJoinsQueryBuilder() + performer, err := qb.Create(newPerformer, tx) if err != nil { _ = tx.Rollback() @@ -102,6 +104,21 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per } } + // Save the stash_ids + if input.StashIds != nil { + var stashIDJoins []models.StashID + for _, stashID := range input.StashIds { + newJoin := models.StashID{ + StashID: stashID.StashID, + Endpoint: stashID.Endpoint, + } + stashIDJoins = append(stashIDJoins, newJoin) + } + if err := jqb.UpdatePerformerStashIDs(performer.ID, stashIDJoins, tx); err != nil { + return nil, err + } + } + // Commit if err := tx.Commit(); err != nil { return nil, err @@ -187,6 +204,8 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per // Start the transaction and save the performer tx := database.DB.MustBeginTx(ctx, nil) qb := models.NewPerformerQueryBuilder() + jqb := models.NewJoinsQueryBuilder() + performer, err := qb.Update(updatedPerformer, tx) if err != nil { tx.Rollback() @@ -207,6 +226,21 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per } } + // Save the stash_ids + if input.StashIds != nil { + var stashIDJoins []models.StashID + for _, stashID := range input.StashIds { + newJoin := models.StashID{ + StashID: stashID.StashID, + Endpoint: stashID.Endpoint, + } + stashIDJoins = append(stashIDJoins, newJoin) + } + if err := jqb.UpdatePerformerStashIDs(performerID, stashIDJoins, tx); err != nil { + return nil, err + } + } + // Commit if err := tx.Commit(); err != nil { return nil, err diff --git a/pkg/api/resolver_mutation_scene.go b/pkg/api/resolver_mutation_scene.go index 1e4d78b1e..ff2d77a04 100644 --- a/pkg/api/resolver_mutation_scene.go +++ b/pkg/api/resolver_mutation_scene.go @@ -204,6 +204,21 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, tx *sqlx.T } } + // Save the stash_ids + if input.StashIds != nil { + var stashIDJoins []models.StashID + for _, stashID := range input.StashIds { + newJoin := models.StashID{ + StashID: stashID.StashID, + Endpoint: stashID.Endpoint, + } + stashIDJoins = append(stashIDJoins, newJoin) + } + if err := jqb.UpdateSceneStashIDs(sceneID, stashIDJoins, tx); err != nil { + return nil, err + } + } + return scene, nil } diff --git a/pkg/api/resolver_mutation_stash_box.go b/pkg/api/resolver_mutation_stash_box.go new file mode 100644 index 000000000..5dd1acfde --- /dev/null +++ b/pkg/api/resolver_mutation_stash_box.go @@ -0,0 +1,22 @@ +package api + +import ( + "context" + "fmt" + + "github.com/stashapp/stash/pkg/manager/config" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/scraper/stashbox" +) + +func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input models.StashBoxFingerprintSubmissionInput) (bool, error) { + boxes := config.GetStashBoxes() + + if input.StashBoxIndex < 0 || input.StashBoxIndex >= len(boxes) { + return false, fmt.Errorf("invalid stash_box_index %d", input.StashBoxIndex) + } + + client := stashbox.NewClient(*boxes[input.StashBoxIndex]) + + return client.SubmitStashBoxFingerprints(input.SceneIds, boxes[input.StashBoxIndex].Endpoint) +} diff --git a/pkg/api/resolver_mutation_studio.go b/pkg/api/resolver_mutation_studio.go index 1069137f6..fed7319bf 100644 --- a/pkg/api/resolver_mutation_studio.go +++ b/pkg/api/resolver_mutation_studio.go @@ -48,6 +48,8 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio // Start the transaction and save the studio tx := database.DB.MustBeginTx(ctx, nil) qb := models.NewStudioQueryBuilder() + jqb := models.NewJoinsQueryBuilder() + studio, err := qb.Create(newStudio, tx) if err != nil { _ = tx.Rollback() @@ -62,6 +64,21 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio } } + // Save the stash_ids + if input.StashIds != nil { + var stashIDJoins []models.StashID + for _, stashID := range input.StashIds { + newJoin := models.StashID{ + StashID: stashID.StashID, + Endpoint: stashID.Endpoint, + } + stashIDJoins = append(stashIDJoins, newJoin) + } + if err := jqb.UpdateStudioStashIDs(studio.ID, stashIDJoins, tx); err != nil { + return nil, err + } + } + // Commit if err := tx.Commit(); err != nil { return nil, err @@ -109,6 +126,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio // Start the transaction and save the studio tx := database.DB.MustBeginTx(ctx, nil) qb := models.NewStudioQueryBuilder() + jqb := models.NewJoinsQueryBuilder() if err := manager.ValidateModifyStudio(updatedStudio, tx); err != nil { tx.Rollback() @@ -135,6 +153,21 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio } } + // Save the stash_ids + if input.StashIds != nil { + var stashIDJoins []models.StashID + for _, stashID := range input.StashIds { + newJoin := models.StashID{ + StashID: stashID.StashID, + Endpoint: stashID.Endpoint, + } + stashIDJoins = append(stashIDJoins, newJoin) + } + if err := jqb.UpdateStudioStashIDs(studioID, stashIDJoins, tx); err != nil { + return nil, err + } + } + // Commit if err := tx.Commit(); err != nil { return nil, err diff --git a/pkg/database/database.go b/pkg/database/database.go index 083cea314..622c8f276 100644 --- a/pkg/database/database.go +++ b/pkg/database/database.go @@ -19,7 +19,7 @@ import ( var DB *sqlx.DB var dbPath string -var appSchemaVersion uint = 13 +var appSchemaVersion uint = 14 var databaseSchemaVersion uint const sqlite3Driver = "sqlite3ex" diff --git a/pkg/database/migrations/14_stash_box_ids.up.sql b/pkg/database/migrations/14_stash_box_ids.up.sql new file mode 100644 index 000000000..f652e92b3 --- /dev/null +++ b/pkg/database/migrations/14_stash_box_ids.up.sql @@ -0,0 +1,20 @@ +CREATE TABLE `scene_stash_ids` ( + `scene_id` integer, + `endpoint` varchar(255), + `stash_id` varchar(36), + foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE +); + +CREATE TABLE `performer_stash_ids` ( + `performer_id` integer, + `endpoint` varchar(255), + `stash_id` varchar(36), + foreign key(`performer_id`) references `performers`(`id`) on delete CASCADE +); + +CREATE TABLE `studio_stash_ids` ( + `studio_id` integer, + `endpoint` varchar(255), + `stash_id` varchar(36), + foreign key(`studio_id`) references `studios`(`id`) on delete CASCADE +); diff --git a/pkg/models/model_joins.go b/pkg/models/model_joins.go index 09c98131e..9c522f70a 100644 --- a/pkg/models/model_joins.go +++ b/pkg/models/model_joins.go @@ -23,6 +23,11 @@ type SceneMarkersTags struct { TagID int `db:"tag_id" json:"tag_id"` } +type StashID struct { + StashID string `db:"stash_id" json:"stash_id"` + Endpoint string `db:"endpoint" json:"endpoint"` +} + type PerformersImages struct { PerformerID int `db:"performer_id" json:"performer_id"` ImageID int `db:"image_id" json:"image_id"` diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 8d7ca3b72..db1e05b7c 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -64,16 +64,19 @@ type ScrapedPerformerStash struct { } type ScrapedScene struct { - Title *string `graphql:"title" json:"title"` - Details *string `graphql:"details" json:"details"` - URL *string `graphql:"url" json:"url"` - Date *string `graphql:"date" json:"date"` - Image *string `graphql:"image" json:"image"` - File *SceneFileType `graphql:"file" json:"file"` - Studio *ScrapedSceneStudio `graphql:"studio" json:"studio"` - Movies []*ScrapedSceneMovie `graphql:"movies" json:"movies"` - Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"` - Performers []*ScrapedScenePerformer `graphql:"performers" json:"performers"` + Title *string `graphql:"title" json:"title"` + Details *string `graphql:"details" json:"details"` + URL *string `graphql:"url" json:"url"` + Date *string `graphql:"date" json:"date"` + Image *string `graphql:"image" json:"image"` + RemoteSiteID *string `graphql:"remote_site_id" json:"remote_site_id"` + Duration *int `graphql:"duration" json:"duration"` + File *SceneFileType `graphql:"file" json:"file"` + Fingerprints []*StashBoxFingerprint `graphql:"fingerprints" json:"fingerprints"` + Studio *ScrapedSceneStudio `graphql:"studio" json:"studio"` + Movies []*ScrapedSceneMovie `graphql:"movies" json:"movies"` + Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"` + Performers []*ScrapedScenePerformer `graphql:"performers" json:"performers"` } // stash doesn't return image, and we need id @@ -103,30 +106,33 @@ type ScrapedGalleryStash struct { type ScrapedScenePerformer struct { // Set if performer matched - ID *string `graphql:"id" json:"id"` - Name string `graphql:"name" json:"name"` - Gender *string `graphql:"gender" json:"gender"` - URL *string `graphql:"url" json:"url"` - Twitter *string `graphql:"twitter" json:"twitter"` - Instagram *string `graphql:"instagram" json:"instagram"` - Birthdate *string `graphql:"birthdate" json:"birthdate"` - Ethnicity *string `graphql:"ethnicity" json:"ethnicity"` - Country *string `graphql:"country" json:"country"` - EyeColor *string `graphql:"eye_color" json:"eye_color"` - Height *string `graphql:"height" json:"height"` - Measurements *string `graphql:"measurements" json:"measurements"` - FakeTits *string `graphql:"fake_tits" json:"fake_tits"` - CareerLength *string `graphql:"career_length" json:"career_length"` - Tattoos *string `graphql:"tattoos" json:"tattoos"` - Piercings *string `graphql:"piercings" json:"piercings"` - Aliases *string `graphql:"aliases" json:"aliases"` + ID *string `graphql:"id" json:"id"` + Name string `graphql:"name" json:"name"` + Gender *string `graphql:"gender" json:"gender"` + URL *string `graphql:"url" json:"url"` + Twitter *string `graphql:"twitter" json:"twitter"` + Instagram *string `graphql:"instagram" json:"instagram"` + Birthdate *string `graphql:"birthdate" json:"birthdate"` + Ethnicity *string `graphql:"ethnicity" json:"ethnicity"` + Country *string `graphql:"country" json:"country"` + EyeColor *string `graphql:"eye_color" json:"eye_color"` + Height *string `graphql:"height" json:"height"` + Measurements *string `graphql:"measurements" json:"measurements"` + FakeTits *string `graphql:"fake_tits" json:"fake_tits"` + CareerLength *string `graphql:"career_length" json:"career_length"` + Tattoos *string `graphql:"tattoos" json:"tattoos"` + Piercings *string `graphql:"piercings" json:"piercings"` + Aliases *string `graphql:"aliases" json:"aliases"` + RemoteSiteID *string `graphql:"remote_site_id" json:"remote_site_id"` + Images []string `graphql:"images" json:"images"` } type ScrapedSceneStudio struct { // Set if studio matched - ID *string `graphql:"id" json:"id"` - Name string `graphql:"name" json:"name"` - URL *string `graphql:"url" json:"url"` + ID *string `graphql:"id" json:"id"` + Name string `graphql:"name" json:"name"` + URL *string `graphql:"url" json:"url"` + RemoteSiteID *string `graphql:"remote_site_id" json:"remote_site_id"` } type ScrapedSceneMovie struct { diff --git a/pkg/models/querybuilder_joins.go b/pkg/models/querybuilder_joins.go index db9973da9..3e3cbbd54 100644 --- a/pkg/models/querybuilder_joins.go +++ b/pkg/models/querybuilder_joins.go @@ -366,6 +366,18 @@ func (qb *JoinsQueryBuilder) DestroyScenesMarkers(sceneID int, tx *sqlx.Tx) erro return err } +func (qb *JoinsQueryBuilder) CreateStashIDs(entityName string, entityID int, newJoins []StashID, tx *sqlx.Tx) error { + query := "INSERT INTO " + entityName + "_stash_ids (" + entityName + "_id, endpoint, stash_id) VALUES (?, ?, ?)" + ensureTx(tx) + for _, join := range newJoins { + _, err := tx.Exec(query, entityID, join.Endpoint, join.StashID) + if err != nil { + return err + } + } + return nil +} + func (qb *JoinsQueryBuilder) GetImagePerformers(imageID int, tx *sqlx.Tx) ([]PerformersImages, error) { ensureTx(tx) @@ -885,3 +897,105 @@ func (qb *JoinsQueryBuilder) DestroyGalleriesTags(galleryID int, tx *sqlx.Tx) er return err } + +func (qb *JoinsQueryBuilder) GetSceneStashIDs(sceneID int) ([]*StashID, error) { + rows, err := database.DB.Queryx(`SELECT stash_id, endpoint from scene_stash_ids WHERE scene_id = ?`, sceneID) + + if err != nil && err != sql.ErrNoRows { + return nil, err + } + defer rows.Close() + + stashIDs := []*StashID{} + for rows.Next() { + stashID := StashID{} + if err := rows.StructScan(&stashID); err != nil { + return nil, err + } + stashIDs = append(stashIDs, &stashID) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return stashIDs, nil +} + +func (qb *JoinsQueryBuilder) GetPerformerStashIDs(performerID int) ([]*StashID, error) { + rows, err := database.DB.Queryx(`SELECT stash_id, endpoint from performer_stash_ids WHERE performer_id = ?`, performerID) + + if err != nil && err != sql.ErrNoRows { + return nil, err + } + defer rows.Close() + + stashIDs := []*StashID{} + for rows.Next() { + stashID := StashID{} + if err := rows.StructScan(&stashID); err != nil { + return nil, err + } + stashIDs = append(stashIDs, &stashID) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return stashIDs, nil +} + +func (qb *JoinsQueryBuilder) GetStudioStashIDs(studioID int) ([]*StashID, error) { + rows, err := database.DB.Queryx(`SELECT stash_id, endpoint from studio_stash_ids WHERE studio_id = ?`, studioID) + + if err != nil && err != sql.ErrNoRows { + return nil, err + } + defer rows.Close() + + stashIDs := []*StashID{} + for rows.Next() { + stashID := StashID{} + if err := rows.StructScan(&stashID); err != nil { + return nil, err + } + stashIDs = append(stashIDs, &stashID) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return stashIDs, nil +} + +func (qb *JoinsQueryBuilder) UpdateSceneStashIDs(sceneID int, updatedJoins []StashID, tx *sqlx.Tx) error { + ensureTx(tx) + + _, err := tx.Exec("DELETE FROM scene_stash_ids WHERE scene_id = ?", sceneID) + if err != nil { + return err + } + return qb.CreateStashIDs("scene", sceneID, updatedJoins, tx) +} + +func (qb *JoinsQueryBuilder) UpdatePerformerStashIDs(performerID int, updatedJoins []StashID, tx *sqlx.Tx) error { + ensureTx(tx) + + _, err := tx.Exec("DELETE FROM performer_stash_ids WHERE performer_id = ?", performerID) + if err != nil { + return err + } + return qb.CreateStashIDs("performer", performerID, updatedJoins, tx) +} + +func (qb *JoinsQueryBuilder) UpdateStudioStashIDs(studioID int, updatedJoins []StashID, tx *sqlx.Tx) error { + ensureTx(tx) + + _, err := tx.Exec("DELETE FROM studio_stash_ids WHERE studio_id = ?", studioID) + if err != nil { + return err + } + return qb.CreateStashIDs("studio", studioID, updatedJoins, tx) +} diff --git a/pkg/models/querybuilder_performer.go b/pkg/models/querybuilder_performer.go index 073c5e464..8729fd5da 100644 --- a/pkg/models/querybuilder_performer.go +++ b/pkg/models/querybuilder_performer.go @@ -224,6 +224,14 @@ func (qb *PerformerQueryBuilder) Query(performerFilter *PerformerFilterType, fin } } + if stashIDFilter := performerFilter.StashID; stashIDFilter != nil { + query.body += ` + JOIN performer_stash_ids on performer_stash_ids.performer_id = performers.id + ` + query.addWhere("performer_stash_ids.stash_id = ?") + query.addArg(stashIDFilter) + } + query.handleStringCriterionInput(performerFilter.Ethnicity, tableName+".ethnicity") query.handleStringCriterionInput(performerFilter.Country, tableName+".country") query.handleStringCriterionInput(performerFilter.EyeColor, tableName+".eye_color") diff --git a/pkg/models/querybuilder_scene.go b/pkg/models/querybuilder_scene.go index 9252b490c..ad336f816 100644 --- a/pkg/models/querybuilder_scene.go +++ b/pkg/models/querybuilder_scene.go @@ -404,6 +404,14 @@ func (qb *SceneQueryBuilder) Query(sceneFilter *SceneFilterType, findFilter *Fin query.addHaving(havingClause) } + if stashIDFilter := sceneFilter.StashID; stashIDFilter != nil { + query.body += ` + JOIN scene_stash_ids on scene_stash_ids.scene_id = scenes.id + ` + query.addWhere("scene_stash_ids.stash_id = ?") + query.addArg(stashIDFilter) + } + query.sortAndPagination = qb.getSceneSort(findFilter) + getPagination(findFilter) idsResult, countResult := query.executeFind() diff --git a/pkg/models/querybuilder_studio.go b/pkg/models/querybuilder_studio.go index e713215ac..e8b36e9b4 100644 --- a/pkg/models/querybuilder_studio.go +++ b/pkg/models/querybuilder_studio.go @@ -18,7 +18,7 @@ func (qb *StudioQueryBuilder) Create(newStudio Studio, tx *sqlx.Tx) (*Studio, er ensureTx(tx) result, err := tx.NamedExec( `INSERT INTO studios (checksum, name, url, parent_id, created_at, updated_at) - VALUES (:checksum, :name, :url, :parent_id, :created_at, :updated_at) + VALUES (:checksum, :name, :url, :parent_id, :created_at, :updated_at) `, newStudio, ) @@ -182,6 +182,14 @@ func (qb *StudioQueryBuilder) Query(studioFilter *StudioFilterType, findFilter * havingClauses = appendClause(havingClauses, havingClause) } + if stashIDFilter := studioFilter.StashID; stashIDFilter != nil { + body += ` + JOIN studio_stash_ids on studio_stash_ids.studio_id = studios.id + ` + whereClauses = append(whereClauses, "studio_stash_ids.stash_id = ?") + args = append(args, stashIDFilter) + } + if isMissingFilter := studioFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { switch *isMissingFilter { case "image": diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index d2c9a3e78..34c9d8519 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -113,6 +113,76 @@ func (c Client) findStashBoxScenesByFingerprints(fingerprints []string) ([]*mode return ret, nil } +func (c Client) SubmitStashBoxFingerprints(sceneIDs []string, endpoint string) (bool, error) { + qb := models.NewSceneQueryBuilder() + jqb := models.NewJoinsQueryBuilder() + + var fingerprints []graphql.FingerprintSubmission + + for _, sceneID := range sceneIDs { + idInt, _ := strconv.Atoi(sceneID) + scene, err := qb.Find(idInt) + if err != nil { + return false, err + } + + if scene == nil { + return false, fmt.Errorf("scene with id %d not found", idInt) + } + + stashIDs, err := jqb.GetSceneStashIDs(idInt) + if err != nil { + return false, err + } + + sceneStashID := "" + for _, stashID := range stashIDs { + if stashID.Endpoint == endpoint { + sceneStashID = stashID.StashID + } + } + + if sceneStashID != "" { + if scene.Checksum.Valid && scene.Duration.Valid { + fingerprint := graphql.FingerprintInput{ + Hash: scene.Checksum.String, + Algorithm: graphql.FingerprintAlgorithmMd5, + Duration: int(scene.Duration.Float64), + } + fingerprints = append(fingerprints, graphql.FingerprintSubmission{ + SceneID: sceneStashID, + Fingerprint: &fingerprint, + }) + } + + if scene.OSHash.Valid && scene.Duration.Valid { + fingerprint := graphql.FingerprintInput{ + Hash: scene.OSHash.String, + Algorithm: graphql.FingerprintAlgorithmOshash, + Duration: int(scene.Duration.Float64), + } + fingerprints = append(fingerprints, graphql.FingerprintSubmission{ + SceneID: sceneStashID, + Fingerprint: &fingerprint, + }) + } + } + } + + return c.submitStashBoxFingerprints(fingerprints) +} + +func (c Client) submitStashBoxFingerprints(fingerprints []graphql.FingerprintSubmission) (bool, error) { + for _, fingerprint := range fingerprints { + _, err := c.client.SubmitFingerprint(context.TODO(), fingerprint) + if err != nil { + return false, err + } + } + + return true, nil +} + func findURL(urls []*graphql.URLFragment, urlType string) *string { for _, u := range urls { if u.Type == urlType { @@ -209,6 +279,11 @@ func fetchImage(url string) (*string, error) { } func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *models.ScrapedScenePerformer { + id := p.ID + images := []string{} + for _, image := range p.Images { + images = append(images, image.URL) + } sp := &models.ScrapedScenePerformer{ Name: p.Name, Country: p.Country, @@ -217,11 +292,13 @@ func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *mode Tattoos: formatBodyModifications(p.Tattoos), Piercings: formatBodyModifications(p.Piercings), Twitter: findURL(p.Urls, "TWITTER"), + RemoteSiteID: &id, + Images: images, // TODO - Image - should be returned as a set of URLs. Will need a // graphql schema change to accommodate this. Leave off for now. } - if p.Height != nil { + if p.Height != nil && *p.Height > 0 { hs := strconv.Itoa(*p.Height) sp.Height = &hs } @@ -259,12 +336,29 @@ func getFirstImage(images []*graphql.ImageFragment) *string { return ret } +func getFingerprints(scene *graphql.SceneFragment) []*models.StashBoxFingerprint { + fingerprints := []*models.StashBoxFingerprint{} + for _, fp := range scene.Fingerprints { + fingerprint := models.StashBoxFingerprint{ + Algorithm: fp.Algorithm.String(), + Hash: fp.Hash, + Duration: fp.Duration, + } + fingerprints = append(fingerprints, &fingerprint) + } + return fingerprints +} + func sceneFragmentToScrapedScene(s *graphql.SceneFragment) (*models.ScrapedScene, error) { + stashID := s.ID ss := &models.ScrapedScene{ - Title: s.Title, - Date: s.Date, - Details: s.Details, - URL: findURL(s.Urls, "STUDIO"), + Title: s.Title, + Date: s.Date, + Details: s.Details, + URL: findURL(s.Urls, "STUDIO"), + Duration: s.Duration, + RemoteSiteID: &stashID, + Fingerprints: getFingerprints(s), // Image // stash_id } @@ -276,9 +370,11 @@ func sceneFragmentToScrapedScene(s *graphql.SceneFragment) (*models.ScrapedScene } if s.Studio != nil { + studioID := s.Studio.ID ss.Studio = &models.ScrapedSceneStudio{ - Name: s.Studio.Name, - URL: findURL(s.Studio.Urls, "HOME"), + Name: s.Studio.Name, + URL: findURL(s.Studio.Urls, "HOME"), + RemoteSiteID: &studioID, } err := models.MatchScrapedSceneStudio(ss.Studio) diff --git a/ui/v2.5/package.json b/ui/v2.5/package.json index 7cff411e1..efeebffc2 100644 --- a/ui/v2.5/package.json +++ b/ui/v2.5/package.json @@ -35,6 +35,7 @@ "@types/mousetrap": "^1.6.3", "apollo-upload-client": "^14.1.2", "axios": "0.20.0", + "base64-blob": "^1.4.1", "bootstrap": "^4.5.2", "classnames": "^2.2.6", "flag-icon-css": "^3.5.0", @@ -59,6 +60,7 @@ "react-photo-gallery": "^8.0.0", "react-router-bootstrap": "^0.25.0", "react-router-dom": "^5.2.0", + "react-router-hash-link": "^2.1.0", "react-select": "^3.1.0", "string.prototype.replaceall": "^1.0.3", "subscriptions-transport-ws": "^0.9.18", @@ -80,6 +82,7 @@ "@types/react-images": "^0.5.3", "@types/react-router-bootstrap": "^0.24.5", "@types/react-router-dom": "5.1.5", + "@types/react-router-hash-link": "^1.2.1", "@types/react-select": "3.0.19", "@typescript-eslint/eslint-plugin": "^2.30.0", "@typescript-eslint/parser": "^2.30.0", diff --git a/ui/v2.5/src/components/Changelog/versions/v040.md b/ui/v2.5/src/components/Changelog/versions/v040.md index 71f6bbfa7..ff31963a0 100644 --- a/ui/v2.5/src/components/Changelog/versions/v040.md +++ b/ui/v2.5/src/components/Changelog/versions/v040.md @@ -1,4 +1,5 @@ ### ✨ New Features +* Add stash-box tagger to scenes page. * Add filters tab in scene page. * Add selectable streaming quality profiles in the scene player. * Add scrapers list setting page. diff --git a/ui/v2.5/src/components/Help/Manual.tsx b/ui/v2.5/src/components/Help/Manual.tsx index 64c0e2cb3..a7c762e27 100644 --- a/ui/v2.5/src/components/Help/Manual.tsx +++ b/ui/v2.5/src/components/Help/Manual.tsx @@ -9,6 +9,7 @@ import Interface from "src/docs/en/Interface.md"; import Galleries from "src/docs/en/Galleries.md"; import Scraping from "src/docs/en/Scraping.md"; import Plugins from "src/docs/en/Plugins.md"; +import Tagger from "src/docs/en/Tagger.md"; import Contributing from "src/docs/en/Contributing.md"; import SceneFilenameParser from "src/docs/en/SceneFilenameParser.md"; import KeyboardShortcuts from "src/docs/en/KeyboardShortcuts.md"; @@ -75,6 +76,11 @@ export const Manual: React.FC = ({ show, onClose }) => { title: "Plugins", content: Plugins, }, + { + key: "Tagger.md", + title: "Scene Tagger", + content: Tagger, + }, { key: "KeyboardShortcuts.md", title: "Keyboard Shortcuts", diff --git a/ui/v2.5/src/components/List/ListFilter.tsx b/ui/v2.5/src/components/List/ListFilter.tsx index 3fd99087d..060015535 100644 --- a/ui/v2.5/src/components/List/ListFilter.tsx +++ b/ui/v2.5/src/components/List/ListFilter.tsx @@ -254,6 +254,8 @@ export const ListFilter: React.FC = ( return "list"; case DisplayMode.Wall: return "square"; + case DisplayMode.Tagger: + return "tags"; } } function getLabel(option: DisplayMode) { @@ -264,6 +266,8 @@ export const ListFilter: React.FC = ( return "List"; case DisplayMode.Wall: return "Wall"; + case DisplayMode.Tagger: + return "Tagger"; } } diff --git a/ui/v2.5/src/components/MainNavbar.tsx b/ui/v2.5/src/components/MainNavbar.tsx index fad078742..c10e799fc 100644 --- a/ui/v2.5/src/components/MainNavbar.tsx +++ b/ui/v2.5/src/components/MainNavbar.tsx @@ -53,6 +53,10 @@ const messages = defineMessages({ id: "galleries", defaultMessage: "Galleries", }, + sceneTagger: { + id: "sceneTagger", + defaultMessage: "Scene Tagger", + }, }); const menuItems: IMenuItem[] = [ diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index c4a85d440..04b8f2d0c 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -82,6 +82,7 @@ export const PerformerDetailsPanel: React.FC = ({ const [twitter, setTwitter] = useState(); const [instagram, setInstagram] = useState(); const [gender, setGender] = useState(undefined); + const [stashIDs, setStashIDs] = useState([]); // Network state const [isLoading, setIsLoading] = useState(false); @@ -121,6 +122,9 @@ export const PerformerDetailsPanel: React.FC = ({ setGender( genderToString((state as GQL.PerformerDataFragment).gender ?? undefined) ); + if ((state as GQL.PerformerDataFragment).stash_ids !== undefined) { + setStashIDs((state as GQL.PerformerDataFragment).stash_ids); + } } function translateScrapedGender(scrapedGender?: string) { @@ -288,6 +292,10 @@ export const PerformerDetailsPanel: React.FC = ({ instagram, image, gender: stringToGender(gender), + stash_ids: stashIDs.map((s) => ({ + stash_id: s.stash_id, + endpoint: s.endpoint, + })), }; if (!isNew) { @@ -623,6 +631,60 @@ export const PerformerDetailsPanel: React.FC = ({ }); } + const removeStashID = (stashID: GQL.StashIdInput) => { + setStashIDs( + stashIDs.filter( + (s) => + !(s.endpoint === stashID.endpoint && s.stash_id === stashID.stash_id) + ) + ); + }; + + function renderStashIDs() { + if (!performer.stash_ids?.length) { + return; + } + + return ( + + StashIDs + +
    + {stashIDs.map((stashID) => { + const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? ( + + {stashID.stash_id} + + ) : ( + stashID.stash_id + ); + return ( +
  • + {isEditing && ( + + )} + {link} +
  • + ); + })} +
+ + + ); + } + const formatHeight = () => { if (isEditing) { return height; @@ -720,6 +782,7 @@ export const PerformerDetailsPanel: React.FC = ({ isEditing: !!isEditing, onChange: setInstagram, })} + {renderStashIDs()} diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index d2cba1629..1d2fff20e 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -55,6 +55,7 @@ export const SceneEditPanel: React.FC = (props: IProps) => { >(new Map()); const [tagIds, setTagIds] = useState(); const [coverImage, setCoverImage] = useState(); + const [stashIDs, setStashIDs] = useState([]); const Scrapers = useListSceneScrapers(); const [queryableScrapers, setQueryableScrapers] = useState([]); @@ -174,6 +175,7 @@ export const SceneEditPanel: React.FC = (props: IProps) => { setMovieSceneIndexes(movieSceneIdx); setPerformerIds(perfIds); setTagIds(tIds); + setStashIDs(state?.stash_ids ?? []); } useEffect(() => { @@ -198,6 +200,10 @@ export const SceneEditPanel: React.FC = (props: IProps) => { movies: makeMovieInputs(), tag_ids: tagIds, cover_image: coverImage, + stash_ids: stashIDs.map((s) => ({ + stash_id: s.stash_id, + endpoint: s.endpoint, + })), }; } @@ -233,6 +239,15 @@ export const SceneEditPanel: React.FC = (props: IProps) => { setIsLoading(false); } + const removeStashID = (stashID: GQL.StashIdInput) => { + setStashIDs( + stashIDs.filter( + (s) => + !(s.endpoint === stashID.endpoint && s.stash_id === stashID.stash_id) + ) + ); + }; + function renderTableMovies() { return ( = (props: IProps) => { +
+ + StashIDs +
    + {stashIDs.map((stashID) => { + const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? ( + + {stashID.stash_id} + + ) : ( + stashID.stash_id + ); + return ( +
  • + + {link} +
  • + ); + })} +
+
+
Details diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx index 6f464e6de..4edff654b 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx @@ -189,6 +189,39 @@ export const SceneFileInfoPanel: React.FC = ( ); } + function renderStashIDs() { + if (!props.scene.stash_ids.length) { + return; + } + + return ( +
+ StashIDs +
    + {props.scene.stash_ids.map((stashID) => { + const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? ( + + {stashID.stash_id} + + ) : ( + stashID.stash_id + ); + return ( +
  • + {link} +
  • + ); + })} +
+
+ ); + } + return (
{renderOSHash()} @@ -203,6 +236,7 @@ export const SceneFileInfoPanel: React.FC = ( {renderVideoCodec()} {renderAudioCodec()} {renderUrl()} + {renderStashIDs()}
); }; diff --git a/ui/v2.5/src/components/Scenes/SceneList.tsx b/ui/v2.5/src/components/Scenes/SceneList.tsx index 5d1f80253..e7d54cd0f 100644 --- a/ui/v2.5/src/components/Scenes/SceneList.tsx +++ b/ui/v2.5/src/components/Scenes/SceneList.tsx @@ -10,6 +10,7 @@ import { useScenesList } from "src/hooks"; import { ListFilterModel } from "src/models/list-filter/filter"; import { DisplayMode } from "src/models/list-filter/types"; import { showWhenSelected } from "src/hooks/ListHook"; +import Tagger from "src/components/Tagger"; import { WallPanel } from "../Wall/WallPanel"; import { SceneCard } from "./SceneCard"; import { SceneListTable } from "./SceneListTable"; @@ -214,6 +215,9 @@ export const SceneList: React.FC = ({ if (filter.displayMode === DisplayMode.Wall) { return ; } + if (filter.displayMode === DisplayMode.Tagger) { + return ; + } } function renderContent( diff --git a/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx b/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx index 67e4599d6..1de60ed8e 100644 --- a/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsConfigurationPanel.tsx @@ -691,10 +691,12 @@ export const SettingsConfigurationPanel: React.FC = () => {

- + +

Stash-box integration

+
diff --git a/ui/v2.5/src/components/Shared/LoadingIndicator.tsx b/ui/v2.5/src/components/Shared/LoadingIndicator.tsx index 5fb99232e..d87defbcf 100644 --- a/ui/v2.5/src/components/Shared/LoadingIndicator.tsx +++ b/ui/v2.5/src/components/Shared/LoadingIndicator.tsx @@ -5,6 +5,7 @@ import cx from "classnames"; interface ILoadingProps { message?: string; inline?: boolean; + small?: boolean; } const CLASSNAME = "LoadingIndicator"; @@ -13,12 +14,15 @@ const CLASSNAME_MESSAGE = `${CLASSNAME}-message`; const LoadingIndicator: React.FC = ({ message, inline = false, + small = false, }) => ( -
- +
+ Loading... -

{message ?? "Loading..."}

+ {message !== "" && ( +

{message ?? "Loading..."}

+ )}
); diff --git a/ui/v2.5/src/components/Shared/Modal.tsx b/ui/v2.5/src/components/Shared/Modal.tsx index 9b0710159..94d83c9f1 100644 --- a/ui/v2.5/src/components/Shared/Modal.tsx +++ b/ui/v2.5/src/components/Shared/Modal.tsx @@ -19,6 +19,7 @@ interface IModal { isRunning?: boolean; disabled?: boolean; modalProps?: ModalProps; + dialogClassName?: string; } const ModalComponent: React.FC = ({ @@ -32,8 +33,15 @@ const ModalComponent: React.FC = ({ isRunning, disabled, modalProps, + dialogClassName, }) => ( - + {icon ? : ""} {header ?? ""} @@ -46,6 +54,7 @@ const ModalComponent: React.FC = ({ disabled={isRunning} variant={cancel.variant ?? "primary"} onClick={cancel.onClick} + className="mr-2" > {cancel.text ?? "Cancel"} diff --git a/ui/v2.5/src/components/Shared/MultiSet.tsx b/ui/v2.5/src/components/Shared/MultiSet.tsx index 907d43f9c..ed004431e 100644 --- a/ui/v2.5/src/components/Shared/MultiSet.tsx +++ b/ui/v2.5/src/components/Shared/MultiSet.tsx @@ -8,7 +8,8 @@ import { FilterSelect } from "./Select"; type ValidTypes = | GQL.SlimPerformerDataFragment | GQL.Tag - | GQL.SlimStudioDataFragment; + | GQL.SlimStudioDataFragment + | GQL.SlimMovieDataFragment; interface IMultiSetProps { type: "performers" | "studios" | "tags"; diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index e31b0e8c6..08ca5a61d 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -20,10 +20,11 @@ import { useToast } from "src/hooks"; import { ListFilterModel } from "src/models/list-filter/filter"; import { FilterMode } from "src/models/list-filter/types"; -type ValidTypes = +export type ValidTypes = | GQL.SlimPerformerDataFragment | GQL.Tag - | GQL.SlimStudioDataFragment; + | GQL.SlimStudioDataFragment + | GQL.SlimMovieDataFragment; type Option = { value: string; label: string }; interface ITypeProps { @@ -63,13 +64,9 @@ interface ISelectProps { groupHeader?: string; closeMenuOnSelect?: boolean; } -interface IFilterItem { - id: string; - name?: string | null; -} interface IFilterComponentProps extends IFilterProps { - items: Array; - onCreate?: (name: string) => Promise<{ item: IFilterItem; message: string }>; + items: Array; + onCreate?: (name: string) => Promise<{ item: ValidTypes; message: string }>; } interface IFilterSelectProps extends Omit {} diff --git a/ui/v2.5/src/components/Shared/SuccessIcon.tsx b/ui/v2.5/src/components/Shared/SuccessIcon.tsx new file mode 100644 index 000000000..3a92ba8d3 --- /dev/null +++ b/ui/v2.5/src/components/Shared/SuccessIcon.tsx @@ -0,0 +1,12 @@ +import React from "react"; +import { Icon } from "src/components/Shared"; + +interface ISuccessIconProps { + className?: string; +} + +const SuccessIcon: React.FC = ({ className }) => ( + +); + +export default SuccessIcon; diff --git a/ui/v2.5/src/components/Shared/index.ts b/ui/v2.5/src/components/Shared/index.ts index 1645f096b..eef9380c6 100644 --- a/ui/v2.5/src/components/Shared/index.ts +++ b/ui/v2.5/src/components/Shared/index.ts @@ -19,4 +19,5 @@ export { default as LoadingIndicator } from "./LoadingIndicator"; export { ImageInput } from "./ImageInput"; export { SweatDrops } from "./SweatDrops"; export { default as CountryFlag } from "./CountryFlag"; +export { default as SuccessIcon } from "./SuccessIcon"; export { default as ErrorMessage } from "./ErrorMessage"; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 05c11c2ae..8b92d96c8 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -16,7 +16,13 @@ } &.inline { - height: inherit; + display: inline; + margin-left: 0.5rem; + } + + &.small .spinner-border { + height: 1rem; + width: 1rem; } } diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index cfaa82596..5fc0c40a8 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -184,6 +184,41 @@ export const Studio: React.FC = () => { ); } + function renderStashIDs() { + if (!studio.stash_ids?.length) { + return; + } + + return ( + + StashIDs + +
    + {studio.stash_ids.map((stashID) => { + const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0]; + const link = base ? ( + + {stashID.stash_id} + + ) : ( + stashID.stash_id + ); + return ( +
  • + {link} +
  • + ); + })} +
+ + + ); + } + function onToggleEdit() { setIsEditing(!isEditing); updateStudioData(studio); @@ -263,6 +298,7 @@ export const Studio: React.FC = () => { Parent Studio {renderStudio()} + {!isEditing && renderStashIDs()} ; +} + +interface IConfigProps { + show: boolean; + config: ITaggerConfig; + setConfig: Dispatch; +} + +const Config: React.FC = ({ show, config, setConfig }) => { + const stashConfig = useConfiguration(); + const [blacklistInput, setBlacklistInput] = useState(""); + + useEffect(() => { + localForage.getItem("tagger").then((data) => { + setConfig({ + blacklist: data?.blacklist ?? DEFAULT_BLACKLIST, + showMales: data?.showMales ?? false, + mode: data?.mode ?? "auto", + setCoverImage: data?.setCoverImage ?? true, + setTags: data?.setTags ?? false, + tagOperation: data?.tagOperation ?? "merge", + selectedEndpoint: data?.selectedEndpoint, + fingerprintQueue: data?.fingerprintQueue ?? {}, + }); + }); + }, [setConfig]); + + useEffect(() => { + localForage.setItem("tagger", config); + }, [config]); + + const handleInstanceSelect = (e: React.ChangeEvent) => { + const selectedEndpoint = e.currentTarget.value; + setConfig({ + ...config, + selectedEndpoint, + }); + }; + + const removeBlacklist = (index: number) => { + setConfig({ + ...config, + blacklist: [ + ...config.blacklist.slice(0, index), + ...config.blacklist.slice(index + 1), + ], + }); + }; + + const handleBlacklistAddition = () => { + setConfig({ + ...config, + blacklist: [...config.blacklist, blacklistInput], + }); + setBlacklistInput(""); + }; + + const stashBoxes = stashConfig.data?.configuration.general.stashBoxes ?? []; + + return ( + + +
+

Configuration

+
+
+ + ) => + setConfig({ ...config, showMales: e.currentTarget.checked }) + } + /> + + Toggle whether male performers will be available to tag. + + + + ) => + setConfig({ + ...config, + setCoverImage: e.currentTarget.checked, + }) + } + /> + Replace the scene cover if one is found. + + +
+ ) => + setConfig({ ...config, setTags: e.currentTarget.checked }) + } + /> + ) => + setConfig({ + ...config, + tagOperation: e.currentTarget.value, + }) + } + disabled={!config.setTags} + > + + + +
+ + Attach tags to scene, either by overwriting or merging with + existing tags on scene. + +
+ + +
+ Query Mode: + ) => + setConfig({ + ...config, + mode: e.currentTarget.value as ParseMode, + }) + } + > + + + + + + +
+ {ModeDesc[config.mode]} +
+
+
+
Blacklist
+ + ) => + setBlacklistInput(e.currentTarget.value) + } + /> + + + + +
+ Blacklist items are excluded from queries. Note that they are + regular expressions and also case-insensitive. Certain characters + must be escaped with a backslash: [\^$.|?*+() +
+ {config.blacklist.map((item, index) => ( + + {item.toString()} + + + ))} + + + + Active stash-box instance: + + + {!stashBoxes.length && } + {stashConfig.data?.configuration.general.stashBoxes.map((i) => ( + + ))} + + +
+
+
+
+ ); +}; + +export default Config; diff --git a/ui/v2.5/src/components/Tagger/PerformerModal.tsx b/ui/v2.5/src/components/Tagger/PerformerModal.tsx new file mode 100755 index 000000000..cfcbe5850 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/PerformerModal.tsx @@ -0,0 +1,177 @@ +import React, { useState } from "react"; +import { Button } from "react-bootstrap"; +import cx from "classnames"; + +import { LoadingIndicator, Icon, Modal } from "src/components/Shared"; +import * as GQL from "src/core/generated-graphql"; +import { genderToString } from "src/core/StashService"; +import { IStashBoxPerformer } from "./utils"; + +interface IPerformerModalProps { + performer: IStashBoxPerformer; + modalVisible: boolean; + showModal: (show: boolean) => void; + handlePerformerCreate: (imageIndex: number) => void; +} + +const PerformerModal: React.FC = ({ + modalVisible, + performer, + handlePerformerCreate, + showModal, +}) => { + const [imageIndex, setImageIndex] = useState(0); + const [imageState, setImageState] = useState< + "loading" | "error" | "loaded" | "empty" + >("empty"); + const [loadDict, setLoadDict] = useState>({}); + + const { images } = performer; + + const changeImage = (index: number) => { + setImageIndex(index); + if (!loadDict[index]) setImageState("loading"); + }; + const setPrev = () => + changeImage(imageIndex === 0 ? images.length - 1 : imageIndex - 1); + const setNext = () => + changeImage(imageIndex === images.length - 1 ? 0 : imageIndex + 1); + + const handleLoad = (index: number) => { + setLoadDict({ + ...loadDict, + [index]: true, + }); + setImageState("loaded"); + }; + const handleError = () => setImageState("error"); + + return ( + handlePerformerCreate(imageIndex), + }} + cancel={{ onClick: () => showModal(false), variant: "secondary" }} + onHide={() => showModal(false)} + dialogClassName="performer-create-modal" + > +
+
+
+ Performer information +
+
+ Name: + {performer.name} +
+
+ Gender: + + {performer.gender && genderToString(performer.gender)} + +
+
+ Birthdate: + + {performer.birthdate ?? "Unknown"} + +
+
+ Ethnicity: + + {performer.ethnicity} + +
+
+ Country: + + {performer.country ?? ""} + +
+
+ Eye Color: + + {performer.eye_color} + +
+
+ Height: + {performer.height} +
+
+ Measurements: + + {performer.measurements} + +
+ {performer?.gender !== GQL.GenderEnum.Male && ( +
+ Fake Tits: + {performer.fake_tits} +
+ )} +
+ Career Length: + + {performer.career_length} + +
+
+ Tattoos: + {performer.tattoos} +
+
+ Piercings: + {performer.piercings} +
+
+ {images.length > 0 && ( +
+
+ handleLoad(imageIndex)} + onError={handleError} + /> + {imageState === "loading" && ( + + )} + {imageState === "error" && ( +
+ Error loading image. +
+ )} +
+
+ +
+ Select performer image +
+ {imageIndex + 1} of {images.length} +
+ +
+
+ )} +
+
+ ); +}; + +export default PerformerModal; diff --git a/ui/v2.5/src/components/Tagger/PerformerResult.tsx b/ui/v2.5/src/components/Tagger/PerformerResult.tsx new file mode 100755 index 000000000..74aa9ddff --- /dev/null +++ b/ui/v2.5/src/components/Tagger/PerformerResult.tsx @@ -0,0 +1,155 @@ +import React, { useEffect, useState } from "react"; +import { Button, ButtonGroup } from "react-bootstrap"; +import cx from "classnames"; + +import { SuccessIcon, PerformerSelect } from "src/components/Shared"; +import * as GQL from "src/core/generated-graphql"; +import { ValidTypes } from "src/components/Shared/Select"; +import { IStashBoxPerformer } from "./utils"; + +import PerformerModal from "./PerformerModal"; + +export type PerformerOperation = + | { type: "create"; data: IStashBoxPerformer } + | { type: "update"; data: GQL.SlimPerformerDataFragment } + | { type: "existing"; data: GQL.PerformerDataFragment } + | { type: "skip" }; + +interface IPerformerResultProps { + performer: IStashBoxPerformer; + setPerformer: (data: PerformerOperation) => void; +} + +const PerformerResult: React.FC = ({ + performer, + setPerformer, +}) => { + const [selectedPerformer, setSelectedPerformer] = useState(); + const [selectedSource, setSelectedSource] = useState< + "create" | "existing" | "skip" | undefined + >(); + const [modalVisible, showModal] = useState(false); + const { data: performerData } = GQL.useFindPerformerQuery({ + variables: { id: performer.id ?? "" }, + skip: !performer.id, + }); + const { data: stashData, loading: stashLoading } = GQL.useFindPerformersQuery( + { + variables: { + performer_filter: { + stash_id: performer.stash_id, + }, + }, + } + ); + + useEffect(() => { + if (stashData?.findPerformers.performers.length) + setPerformer({ + type: "existing", + data: stashData.findPerformers.performers[0], + }); + else if (performerData?.findPerformer) { + setSelectedPerformer(performerData.findPerformer.id); + setSelectedSource("existing"); + setPerformer({ + type: "update", + data: performerData.findPerformer, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stashData, performerData]); + + const handlePerformerSelect = (performers: ValidTypes[]) => { + if (performers.length) { + setSelectedSource("existing"); + setSelectedPerformer(performers[0].id); + setPerformer({ + type: "update", + data: performers[0] as GQL.SlimPerformerDataFragment, + }); + } else { + setSelectedSource(undefined); + setSelectedPerformer(null); + } + }; + + const handlePerformerCreate = (imageIndex: number) => { + const selectedImage = performer.images[imageIndex]; + const images = selectedImage ? [selectedImage] : []; + setSelectedSource("create"); + setPerformer({ + type: "create", + data: { + ...performer, + images, + }, + }); + showModal(false); + }; + + const handlePerformerSkip = () => { + setSelectedSource("skip"); + setPerformer({ + type: "skip", + }); + }; + + if (stashLoading) return
Loading performer
; + + if (stashData?.findPerformers.performers?.[0]?.id) { + return ( +
+
+ Performer: + {performer.name} +
+ + + Matched: + + + {stashData.findPerformers.performers[0].name} + +
+ ); + } + return ( +
+ +
+ Performer: + {performer.name} +
+ + + + + +
+ ); +}; + +export default PerformerResult; diff --git a/ui/v2.5/src/components/Tagger/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx new file mode 100755 index 000000000..b830fdde9 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/StashSearchResult.tsx @@ -0,0 +1,394 @@ +import React, { useCallback, useState } from "react"; +import cx from "classnames"; +import { Button } from "react-bootstrap"; +import { uniq } from "lodash"; +import { blobToBase64 } from "base64-blob"; + +import * as GQL from "src/core/generated-graphql"; +import { LoadingIndicator, SuccessIcon } from "src/components/Shared"; +import PerformerResult, { PerformerOperation } from "./PerformerResult"; +import StudioResult, { StudioOperation } from "./StudioResult"; +import { IStashBoxScene } from "./utils"; +import { + useCreateTag, + useCreatePerformer, + useCreateStudio, + useUpdatePerformerStashID, + useUpdateStudioStashID, +} from "./queries"; + +const getDurationStatus = ( + scene: IStashBoxScene, + stashDuration: number | undefined | null +) => { + const fingerprintDuration = + scene.fingerprints.map((f) => f.duration)?.[0] ?? null; + const sceneDuration = scene.duration || fingerprintDuration; + if (!sceneDuration || !stashDuration) return ""; + const diff = Math.abs(sceneDuration - stashDuration); + if (diff < 5) { + return ( +
+ + Duration is a match +
+ ); + } + return
Duration off by {Math.floor(diff)}s
; +}; + +const getFingerprintStatus = ( + scene: IStashBoxScene, + stashChecksum?: string +) => { + if (scene.fingerprints.some((f) => f.hash === stashChecksum)) + return ( +
+ + Checksum is a match +
+ ); +}; + +interface IStashSearchResultProps { + scene: IStashBoxScene; + stashScene: GQL.SlimSceneDataFragment; + isActive: boolean; + setActive: () => void; + showMales: boolean; + setScene: (scene: GQL.SlimSceneDataFragment) => void; + setCoverImage: boolean; + tagOperation: string; + endpoint: string; + queueFingerprintSubmission: (sceneId: string, endpoint: string) => void; +} + +const StashSearchResult: React.FC = ({ + scene, + stashScene, + isActive, + setActive, + showMales, + setScene, + setCoverImage, + tagOperation, + endpoint, + queueFingerprintSubmission, +}) => { + const [studio, setStudio] = useState(); + const [performers, setPerformers] = useState< + Record + >({}); + const [saveState, setSaveState] = useState(""); + const [error, setError] = useState<{ message?: string; details?: string }>( + {} + ); + + const createStudio = useCreateStudio(); + const createPerformer = useCreatePerformer(); + const createTag = useCreateTag(); + const updatePerformerStashID = useUpdatePerformerStashID(); + const updateStudioStashID = useUpdateStudioStashID(); + const [updateScene] = GQL.useSceneUpdateMutation({ + onError: (errors) => errors, + }); + const { data: allTags } = GQL.useAllTagsForFilterQuery(); + + const setPerformer = useCallback( + (performerData: PerformerOperation, performerID: string) => + setPerformers({ ...performers, [performerID]: performerData }), + [performers] + ); + + const handleSave = async () => { + setError({}); + let performerIDs = []; + let studioID = null; + + if (!studio) return; + + if (studio.type === "create") { + setSaveState("Creating studio"); + const newStudio = { + name: studio.data.name, + stash_ids: [ + { + endpoint, + stash_id: scene.studio.stash_id, + }, + ], + url: studio.data.url, + }; + const studioCreateResult = await createStudio( + newStudio, + scene.studio.stash_id + ); + + if (!studioCreateResult?.data?.studioCreate) { + setError({ + message: `Failed to save studio "${newStudio.name}"`, + details: studioCreateResult?.errors?.[0].message, + }); + return setSaveState(""); + } + studioID = studioCreateResult.data.studioCreate.id; + } else if (studio.type === "update") { + setSaveState("Saving studio stashID"); + const res = await updateStudioStashID(studio.data.id, [ + ...studio.data.stash_ids, + { stash_id: scene.studio.stash_id, endpoint }, + ]); + if (!res?.data?.studioUpdate) { + setError({ + message: `Failed to save stashID to studio "${studio.data.name}"`, + details: res?.errors?.[0].message, + }); + return setSaveState(""); + } + studioID = res.data.studioUpdate.id; + } else if (studio.type === "existing") { + studioID = studio.data.id; + } + + setSaveState("Saving performers"); + performerIDs = await Promise.all( + Object.keys(performers).map(async (stashID) => { + const performer = performers[stashID]; + if (performer.type === "skip") return "Skip"; + + let performerID = performer.data.id; + + if (performer.type === "create") { + const imgurl = performer.data.images[0]; + let imgData = null; + if (imgurl) { + const img = await fetch(imgurl, { + mode: "cors", + cache: "no-store", + }); + if (img.status === 200) { + const blob = await img.blob(); + imgData = await blobToBase64(blob); + } + } + + const performerInput = { + name: performer.data.name, + gender: performer.data.gender, + country: performer.data.country, + height: performer.data.height, + ethnicity: performer.data.ethnicity, + birthdate: performer.data.birthdate, + eye_color: performer.data.eye_color, + fake_tits: performer.data.fake_tits, + measurements: performer.data.measurements, + career_length: performer.data.career_length, + tattoos: performer.data.tattoos, + piercings: performer.data.piercings, + twitter: performer.data.twitter, + instagram: performer.data.instagram, + image: imgData, + stash_ids: [ + { + endpoint, + stash_id: stashID, + }, + ], + }; + + const res = await createPerformer(performerInput, stashID); + if (!res?.data?.performerCreate) { + setError({ + message: `Failed to save performer "${performerInput.name}"`, + details: res?.errors?.[0].message, + }); + return null; + } + performerID = res.data?.performerCreate.id; + } + + if (performer.type === "update") { + const stashIDs = performer.data.stash_ids; + await updatePerformerStashID(performer.data.id, [ + ...stashIDs, + { stash_id: stashID, endpoint }, + ]); + } + + return performerID; + }) + ); + + if (!performerIDs.some((id) => !id)) { + setSaveState("Updating scene"); + const imgurl = scene.images[0]; + let imgData = null; + if (imgurl && setCoverImage) { + const img = await fetch(imgurl, { + mode: "cors", + cache: "no-store", + }); + if (img.status === 200) { + const blob = await img.blob(); + imgData = await blobToBase64(blob); + } + } + + const tagIDs: string[] = + tagOperation === "merge" + ? stashScene?.tags?.map((t) => t.id) ?? [] + : []; + const tags = scene.tags ?? []; + if (tags.length > 0) { + const tagDict: Record = (allTags?.allTagsSlim ?? []) + .filter((t) => t.name) + .reduce((dict, t) => ({ ...dict, [t.name.toLowerCase()]: t.id }), {}); + const newTags: string[] = []; + tags.forEach((tag) => { + if (tagDict[tag.name.toLowerCase()]) + tagIDs.push(tagDict[tag.name.toLowerCase()]); + else newTags.push(tag.name); + }); + + const createdTags = await Promise.all( + newTags.map((tag) => createTag(tag)) + ); + createdTags.forEach((createdTag) => { + if (createdTag?.data?.tagCreate?.id) + tagIDs.push(createdTag.data.tagCreate.id); + }); + } + + const sceneUpdateResult = await updateScene({ + variables: { + id: stashScene.id ?? "", + title: scene.title, + details: scene.details, + date: scene.date, + performer_ids: performerIDs.filter((id) => id !== "Skip") as string[], + studio_id: studioID, + cover_image: imgData, + url: scene.url, + ...(tagIDs ? { tag_ids: uniq(tagIDs) } : {}), + stash_ids: [ + ...(stashScene?.stash_ids ?? []), + { + endpoint, + stash_id: scene.stash_id, + }, + ], + }, + }); + + if (!sceneUpdateResult?.data?.sceneUpdate) { + setError({ + message: "Failed to save scene", + details: sceneUpdateResult?.errors?.[0].message, + }); + } else if (sceneUpdateResult.data?.sceneUpdate) + setScene(sceneUpdateResult.data.sceneUpdate); + + queueFingerprintSubmission(stashScene.id, endpoint); + } + + setSaveState(""); + }; + + const classname = cx("row no-gutters mt-2 search-result", { + "selected-result": isActive, + }); + + const sceneTitle = scene.url ? ( + + {scene?.title} + + ) : ( + {scene?.title} + ); + + const saveEnabled = + Object.keys(performers ?? []).length === + scene.performers.filter((p) => p.gender !== "MALE" || showMales).length && + Object.keys(performers ?? []).every((id) => performers?.[id].type) && + saveState === ""; + + return ( + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions +
  • !isActive && setActive()} + > +
    +
    + +
    +

    + {sceneTitle} +

    +
    + {scene?.studio?.name} • {scene?.date} +
    +
    + Performers: {scene?.performers?.map((p) => p.name).join(", ")} +
    + {getDurationStatus(scene, stashScene.file?.duration)} + {getFingerprintStatus( + scene, + stashScene.checksum ?? stashScene.oshash ?? undefined + )} +
    +
    +
    + {isActive && ( +
    + + {scene.performers + .filter((p) => p.gender !== "MALE" || showMales) + .map((performer) => ( + + setPerformer(data, performer.stash_id) + } + key={`${scene.stash_id}${performer.stash_id}`} + /> + ))} +
    + {error.message && ( + + + Error: + + {error.message} + + )} + {saveState && ( + + {saveState} + + )} + +
    +
    + )} +
  • + ); +}; + +export default StashSearchResult; diff --git a/ui/v2.5/src/components/Tagger/StudioResult.tsx b/ui/v2.5/src/components/Tagger/StudioResult.tsx new file mode 100755 index 000000000..f082ad87f --- /dev/null +++ b/ui/v2.5/src/components/Tagger/StudioResult.tsx @@ -0,0 +1,161 @@ +import React, { useEffect, useState, Dispatch, SetStateAction } from "react"; +import { Button, ButtonGroup } from "react-bootstrap"; +import cx from "classnames"; + +import { SuccessIcon, Modal, StudioSelect } from "src/components/Shared"; +import * as GQL from "src/core/generated-graphql"; +import { ValidTypes } from "src/components/Shared/Select"; +import { IStashBoxStudio } from "./utils"; + +export type StudioOperation = + | { type: "create"; data: IStashBoxStudio } + | { type: "update"; data: GQL.SlimStudioDataFragment } + | { type: "existing"; data: GQL.StudioDataFragment } + | { type: "skip" }; + +interface IStudioResultProps { + studio: IStashBoxStudio | null; + setStudio: Dispatch>; +} + +const StudioResult: React.FC = ({ studio, setStudio }) => { + const [selectedStudio, setSelectedStudio] = useState(); + const [modalVisible, showModal] = useState(false); + const [selectedSource, setSelectedSource] = useState< + "create" | "existing" | "skip" | undefined + >(); + const { data: studioData } = GQL.useFindStudioQuery({ + variables: { id: studio?.id ?? "" }, + skip: !studio?.id, + }); + const { + data: stashIDData, + loading: loadingStashID, + } = GQL.useFindStudiosQuery({ + variables: { + studio_filter: { + stash_id: studio?.stash_id, + }, + }, + }); + + useEffect(() => { + if (stashIDData?.findStudios.studios?.[0]) + setStudio({ + type: "existing", + data: stashIDData.findStudios.studios[0], + }); + else if (studioData?.findStudio) { + setSelectedSource("existing"); + setSelectedStudio(studioData.findStudio.id); + setStudio({ + type: "update", + data: studioData.findStudio, + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [stashIDData, studioData]); + + const handleStudioSelect = (newStudio: ValidTypes[]) => { + if (newStudio.length) { + setSelectedSource("existing"); + setSelectedStudio(newStudio[0].id); + setStudio({ + type: "update", + data: newStudio[0] as GQL.SlimStudioDataFragment, + }); + } else { + setSelectedSource(undefined); + setSelectedStudio(null); + } + }; + + const handleStudioCreate = () => { + if (!studio) return; + setSelectedSource("create"); + setStudio({ + type: "create", + data: studio, + }); + showModal(false); + }; + + const handleStudioSkip = () => { + setSelectedSource("skip"); + setStudio({ type: "skip" }); + }; + + if (loadingStashID) return
    Loading studio
    ; + + if (stashIDData?.findStudios.studios.length) { + return ( +
    +
    + Studio: + {studio?.name} +
    + + + Matched: + + + {stashIDData.findStudios.studios[0].name} + +
    + ); + } + + return ( +
    + showModal(false), variant: "secondary" }} + > +
    + Name: + {studio?.name} +
    +
    + URL: + {studio?.url ?? ""} +
    +
    + Logo: + + + +
    +
    + +
    + Studio: + {studio?.name} +
    + + + + + +
    + ); +}; + +export default StudioResult; diff --git a/ui/v2.5/src/components/Tagger/Tagger.tsx b/ui/v2.5/src/components/Tagger/Tagger.tsx new file mode 100755 index 000000000..3acb9ac4a --- /dev/null +++ b/ui/v2.5/src/components/Tagger/Tagger.tsx @@ -0,0 +1,445 @@ +import React, { useState } from "react"; +import { Button, Card, Form, InputGroup } from "react-bootstrap"; +import { Link } from "react-router-dom"; +import { HashLink } from "react-router-hash-link"; + +import * as GQL from "src/core/generated-graphql"; +import { LoadingIndicator } from "src/components/Shared"; +import { + stashBoxQuery, + stashBoxBatchQuery, + useConfiguration, +} from "src/core/StashService"; + +import StashSearchResult from "./StashSearchResult"; +import Config, { ITaggerConfig, initialConfig, ParseMode } from "./Config"; +import { + parsePath, + selectScenes, + IStashBoxScene, + sortScenesByDuration, +} from "./utils"; + +const dateRegex = /\.(\d\d)\.(\d\d)\.(\d\d)\./; +function prepareQueryString( + scene: Partial, + paths: string[], + filename: string, + mode: ParseMode, + blacklist: string[] +) { + if ((mode === "auto" && scene.date && scene.studio) || mode === "metadata") { + let str = [ + scene.date, + scene.studio?.name ?? "", + (scene?.performers ?? []).map((p) => p.name).join(" "), + scene?.title ? scene.title.replace(/[^a-zA-Z0-9 ]+/g, "") : "", + ] + .filter((s) => s !== "") + .join(" "); + blacklist.forEach((b) => { + str = str.replace(new RegExp(b, "gi"), " "); + }); + return str; + } + let s = ""; + if (mode === "auto" || mode === "filename") { + s = filename; + } else if (mode === "path") { + s = [...paths, filename].join(" "); + } else { + s = paths[paths.length - 1]; + } + blacklist.forEach((b) => { + s = s.replace(new RegExp(b, "i"), ""); + }); + const date = s.match(dateRegex); + s = s.replace(/-/g, " "); + if (date) { + s = s.replace(date[0], ` 20${date[1]}-${date[2]}-${date[3]} `); + } + return s.replace(/\./g, " "); +} + +interface ITaggerListProps { + scenes: GQL.SlimSceneDataFragment[]; + selectedEndpoint: { endpoint: string; index: number }; + config: ITaggerConfig; + queueFingerprintSubmission: (sceneId: string, endpoint: string) => void; + clearSubmissionQueue: (endpoint: string) => void; +} + +const TaggerList: React.FC = ({ + scenes, + selectedEndpoint, + config, + queueFingerprintSubmission, + clearSubmissionQueue, +}) => { + const [loading, setLoading] = useState(false); + const [queryString, setQueryString] = useState>({}); + + const [searchResults, setSearchResults] = useState< + Record + >({}); + const [selectedResult, setSelectedResult] = useState< + Record + >(); + const [taggedScenes, setTaggedScenes] = useState< + Record> + >({}); + const [loadingFingerprints, setLoadingFingerprints] = useState(false); + const [fingerprints, setFingerprints] = useState< + Record + >({}); + const fingerprintQueue = + config.fingerprintQueue[selectedEndpoint.endpoint] ?? []; + + const doBoxSearch = (sceneID: string, searchVal: string) => { + stashBoxQuery(searchVal, selectedEndpoint.index).then((queryData) => { + const s = selectScenes(queryData.data?.queryStashBoxScene); + setSearchResults({ + ...searchResults, + [sceneID]: s, + }); + setLoading(false); + }); + + setLoading(true); + }; + + const [ + submitFingerPrints, + { loading: submittingFingerprints }, + ] = GQL.useSubmitStashBoxFingerprintsMutation({ + onCompleted: (result) => { + if (result.submitStashBoxFingerprints) + clearSubmissionQueue(selectedEndpoint.endpoint); + }, + }); + + const handleFingerprintSubmission = () => { + submitFingerPrints({ + variables: { + input: { + stash_box_index: selectedEndpoint.index, + scene_ids: fingerprintQueue, + }, + }, + }); + }; + + const handleTaggedScene = (scene: Partial) => { + setTaggedScenes({ + ...taggedScenes, + [scene.id as string]: scene, + }); + }; + + const handleFingerprintSearch = async () => { + setLoadingFingerprints(true); + const newFingerprints = { ...fingerprints }; + + const sceneIDs = scenes + .filter( + (s) => fingerprints[s.id] === undefined && s.stash_ids.length === 0 + ) + .map((s) => s.id); + + const results = await stashBoxBatchQuery(sceneIDs, selectedEndpoint.index); + selectScenes(results.data?.queryStashBoxScene).forEach((scene) => { + scene.fingerprints?.forEach((f) => { + newFingerprints[f.hash] = scene; + }); + }); + + setFingerprints(newFingerprints); + setLoadingFingerprints(false); + }; + + const canFingerprintSearch = () => + scenes.some( + (s) => s.stash_ids.length === 0 && fingerprints[s.id] === undefined + ); + + const getFingerprintCount = () => { + const count = scenes.filter( + (s) => s.stash_ids.length === 0 && fingerprints[s.id] + ).length; + return `${count > 0 ? count : "No"} new fingerprint matches found`; + }; + + const renderScenes = () => + scenes.map((scene) => { + const { paths, file, ext } = parsePath(scene.path); + const originalDir = scene.path.slice( + 0, + scene.path.length - file.length - ext.length + ); + const defaultQueryString = prepareQueryString( + scene, + paths, + file, + config.mode, + config.blacklist + ); + const modifiedQuery = queryString[scene.id]; + const fingerprintMatch = + fingerprints[scene.checksum ?? ""] ?? + fingerprints[scene.oshash ?? ""] ?? + null; + const isTagged = taggedScenes[scene.id]; + const hasStashIDs = scene.stash_ids.length > 0; + + let maincontent; + if (!isTagged && hasStashIDs) { + maincontent = ( +
    Scene already tagged
    + ); + } else if (!isTagged && !hasStashIDs) { + maincontent = ( + + ) => + setQueryString({ + ...queryString, + [scene.id]: e.currentTarget.value, + }) + } + onKeyPress={(e: React.KeyboardEvent) => + e.key === "Enter" && + doBoxSearch( + scene.id, + queryString[scene.id] || defaultQueryString + ) + } + /> + + + + + ); + } else if (isTagged) { + maincontent = ( +
    + Scene successfully tagged: + + {taggedScenes[scene.id].title} + +
    + ); + } + + let searchResult; + if (searchResults[scene.id]?.length === 0) + searchResult = ( +
    No results found.
    + ); + else if (fingerprintMatch && !isTagged && !hasStashIDs) { + searchResult = ( + {}} + setScene={handleTaggedScene} + scene={fingerprintMatch} + setCoverImage={config.setCoverImage} + tagOperation={config.tagOperation} + endpoint={selectedEndpoint.endpoint} + queueFingerprintSubmission={queueFingerprintSubmission} + /> + ); + } else if (searchResults[scene.id] && !isTagged && !fingerprintMatch) { + searchResult = ( +
      + {sortScenesByDuration(searchResults[scene.id]).map( + (sceneResult, i) => + sceneResult && ( + + setSelectedResult({ + ...selectedResult, + [scene.id]: i, + }) + } + setCoverImage={config.setCoverImage} + tagOperation={config.tagOperation} + setScene={handleTaggedScene} + endpoint={selectedEndpoint.endpoint} + queueFingerprintSubmission={queueFingerprintSubmission} + /> + ) + )} +
    + ); + } + + return ( +
    +
    +
    + + {originalDir} + + {`${file}.${ext}`} + +
    +
    {maincontent}
    +
    + {searchResult} +
    + ); + }); + + return ( + +
    +
    + Path +
    +
    + Query +
    +
    + {fingerprintQueue.length > 0 && ( + + )} +
    +
    + +
    +
    + {renderScenes()} +
    + ); +}; + +interface ITaggerProps { + scenes: GQL.SlimSceneDataFragment[]; +} + +export const Tagger: React.FC = ({ scenes }) => { + const stashConfig = useConfiguration(); + const [config, setConfig] = useState(initialConfig); + const [showConfig, setShowConfig] = useState(false); + + const savedEndpointIndex = + stashConfig.data?.configuration.general.stashBoxes.findIndex( + (s) => s.endpoint === config.selectedEndpoint + ) ?? -1; + const selectedEndpointIndex = + savedEndpointIndex === -1 && + stashConfig.data?.configuration.general.stashBoxes.length + ? 0 + : savedEndpointIndex; + const selectedEndpoint = + stashConfig.data?.configuration.general.stashBoxes[selectedEndpointIndex]; + + const queueFingerprintSubmission = (sceneId: string, endpoint: string) => { + setConfig({ + ...config, + fingerprintQueue: { + ...config.fingerprintQueue, + [endpoint]: [...(config.fingerprintQueue[endpoint] ?? []), sceneId], + }, + }); + }; + + const clearSubmissionQueue = (endpoint: string) => { + setConfig({ + ...config, + fingerprintQueue: { + ...config.fingerprintQueue, + [endpoint]: [], + }, + }); + }; + + return ( +
    + {selectedEndpointIndex !== -1 && selectedEndpoint ? ( + <> +
    + +
    + + + + + ) : ( +
    +

    + To use the scene tagger a stash-box instance needs to be configured. +

    +
    + Please see{" "} + + el.scrollIntoView({ behavior: "smooth", block: "center" }) + } + > + Settings. + +
    +
    + )} +
    + ); +}; diff --git a/ui/v2.5/src/components/Tagger/index.ts b/ui/v2.5/src/components/Tagger/index.ts new file mode 100644 index 000000000..05d179c57 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/index.ts @@ -0,0 +1 @@ +export { Tagger as default } from "./Tagger"; diff --git a/ui/v2.5/src/components/Tagger/queries.ts b/ui/v2.5/src/components/Tagger/queries.ts new file mode 100644 index 000000000..92f0e8379 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/queries.ts @@ -0,0 +1,239 @@ +import * as GQL from "src/core/generated-graphql"; +import { sortBy } from "lodash"; + +export const useUpdatePerformerStashID = () => { + const [updatePerformer] = GQL.usePerformerUpdateMutation({ + onError: (errors) => errors, + }); + + const updatePerformerHandler = ( + performerID: string, + stashIDs: GQL.StashIdInput[] + ) => + updatePerformer({ + variables: { + id: performerID, + stash_ids: stashIDs.map((s) => ({ + stash_id: s.stash_id, + endpoint: s.endpoint, + })), + }, + update: (store, updatedPerformer) => { + if (!updatedPerformer.data?.performerUpdate) return; + const newStashID = stashIDs[stashIDs.length - 1].stash_id; + + store.writeQuery< + GQL.FindPerformersQuery, + GQL.FindPerformersQueryVariables + >({ + query: GQL.FindPerformersDocument, + variables: { + performer_filter: { + stash_id: newStashID, + }, + }, + data: { + findPerformers: { + count: 1, + performers: [updatedPerformer.data.performerUpdate], + __typename: "FindPerformersResultType", + }, + }, + }); + }, + }); + + return updatePerformerHandler; +}; + +export const useCreatePerformer = () => { + const [createPerformer] = GQL.usePerformerCreateMutation({ + onError: (errors) => errors, + }); + + const handleCreate = (performer: GQL.PerformerCreateInput, stashID: string) => + createPerformer({ + variables: performer, + update: (store, newPerformer) => { + if (!newPerformer?.data?.performerCreate) return; + + const currentQuery = store.readQuery< + GQL.AllPerformersForFilterQuery, + GQL.AllPerformersForFilterQueryVariables + >({ + query: GQL.AllPerformersForFilterDocument, + }); + const allPerformersSlim = sortBy( + [ + ...(currentQuery?.allPerformersSlim ?? []), + newPerformer.data.performerCreate, + ], + ["name"] + ); + if (allPerformersSlim.length > 1) { + store.writeQuery< + GQL.AllPerformersForFilterQuery, + GQL.AllPerformersForFilterQueryVariables + >({ + query: GQL.AllPerformersForFilterDocument, + data: { + allPerformersSlim, + }, + }); + } + + store.writeQuery< + GQL.FindPerformersQuery, + GQL.FindPerformersQueryVariables + >({ + query: GQL.FindPerformersDocument, + variables: { + performer_filter: { + stash_id: stashID, + }, + }, + data: { + findPerformers: { + count: 1, + performers: [newPerformer.data.performerCreate], + __typename: "FindPerformersResultType", + }, + }, + }); + }, + }); + + return handleCreate; +}; + +export const useUpdateStudioStashID = () => { + const [updateStudio] = GQL.useStudioUpdateMutation({ + onError: (errors) => errors, + }); + + const handleUpdate = (studioID: string, stashIDs: GQL.StashIdInput[]) => + updateStudio({ + variables: { + id: studioID, + stash_ids: stashIDs.map((s) => ({ + stash_id: s.stash_id, + endpoint: s.endpoint, + })), + }, + update: (store, result) => { + if (!result.data?.studioUpdate) return; + const newStashID = stashIDs[stashIDs.length - 1].stash_id; + + store.writeQuery({ + query: GQL.FindStudiosDocument, + variables: { + studio_filter: { + stash_id: newStashID, + }, + }, + data: { + findStudios: { + count: 1, + studios: [result.data.studioUpdate], + __typename: "FindStudiosResultType", + }, + }, + }); + }, + }); + + return handleUpdate; +}; + +export const useCreateStudio = () => { + const [createStudio] = GQL.useStudioCreateMutation({ + onError: (errors) => errors, + }); + + const handleCreate = (studio: GQL.StudioCreateInput, stashID: string) => + createStudio({ + variables: studio, + update: (store, result) => { + if (!result?.data?.studioCreate) return; + + const currentQuery = store.readQuery< + GQL.AllStudiosForFilterQuery, + GQL.AllStudiosForFilterQueryVariables + >({ + query: GQL.AllStudiosForFilterDocument, + }); + const allStudiosSlim = sortBy( + [...(currentQuery?.allStudiosSlim ?? []), result.data.studioCreate], + ["name"] + ); + if (allStudiosSlim.length > 1) { + store.writeQuery< + GQL.AllStudiosForFilterQuery, + GQL.AllStudiosForFilterQueryVariables + >({ + query: GQL.AllStudiosForFilterDocument, + data: { + allStudiosSlim, + }, + }); + } + + store.writeQuery({ + query: GQL.FindStudiosDocument, + variables: { + studio_filter: { + stash_id: stashID, + }, + }, + data: { + findStudios: { + count: 1, + studios: [result.data.studioCreate], + __typename: "FindStudiosResultType", + }, + }, + }); + }, + }); + + return handleCreate; +}; + +export const useCreateTag = () => { + const [createTag] = GQL.useTagCreateMutation({ + onError: (errors) => errors, + }); + + const handleCreate = (tag: string) => + createTag({ + variables: { + name: tag, + }, + update: (store, result) => { + if (!result.data?.tagCreate) return; + + const currentQuery = store.readQuery< + GQL.AllTagsForFilterQuery, + GQL.AllTagsForFilterQueryVariables + >({ + query: GQL.AllTagsForFilterDocument, + }); + const allTagsSlim = sortBy( + [...(currentQuery?.allTagsSlim ?? []), result.data.tagCreate], + ["name"] + ); + + store.writeQuery< + GQL.AllTagsForFilterQuery, + GQL.AllTagsForFilterQueryVariables + >({ + query: GQL.AllTagsForFilterDocument, + data: { + allTagsSlim, + }, + }); + }, + }); + + return handleCreate; +}; diff --git a/ui/v2.5/src/components/Tagger/styles.scss b/ui/v2.5/src/components/Tagger/styles.scss new file mode 100644 index 000000000..13f321cb9 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/styles.scss @@ -0,0 +1,99 @@ +.tagger-container { + max-width: 1400px; + // min-width: 1200px; +} + +.tagger-table { + overflow: visible; +} + +.search-item { + background-color: #495b68; + margin-left: -20px; + margin-right: -20px; + padding: 1rem; +} + +.search-result { + background-color: rgba(61, 80, 92, 0.3); + padding: 0.5rem 1rem; + + &:hover { + background-color: hsl(204, 20, 30); + cursor: pointer; + } +} + +.selected-result { + background-color: hsl(204, 20, 30); + border-radius: 3px; + + &:hover { + cursor: default; + } +} + +.scene-select { + &:hover { + cursor: pointer; + } +} + +.scene-image { + max-height: 10rem; + max-width: 14rem; + min-width: 168px; + padding: 0 1rem; +} + +.scene-metadata { + margin-right: 1rem; + width: calc(100% - 17rem); +} + +.select-existing { + width: 2rem; +} + +.performer-select, +.studio-select { + width: 14rem; + + // stylelint-disable-next-line selector-class-pattern + &-active .react-select__control { + background-color: #137cbd; + } +} + +.entity-name { + flex: 1; + margin-right: auto; +} + +.scene-link { + color: $text-color; + font-weight: 500; +} + +.performer-create-modal { + font-size: 1.2rem; + max-width: 768px; + + .image-selection { + height: 450px; + text-align: center; + + .performer-image { + height: 85%; + } + + img { + max-height: 100%; + max-width: 100%; + } + } + + .LoadingIndicator { + height: 100%; + } +} diff --git a/ui/v2.5/src/components/Tagger/utils.ts b/ui/v2.5/src/components/Tagger/utils.ts new file mode 100644 index 000000000..2ac4f3038 --- /dev/null +++ b/ui/v2.5/src/components/Tagger/utils.ts @@ -0,0 +1,177 @@ +import * as GQL from "src/core/generated-graphql"; +import { getCountryByISO } from "src/utils/country"; + +const toTitleCase = (phrase: string) => { + return phrase + .toLowerCase() + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +}; + +export const parsePath = (filePath: string) => { + const path = filePath.toLowerCase(); + const isWin = /^([a-z]:|\\\\)/.test(path); + const normalizedPath = isWin + ? path.replace(/^[a-z]:/, "").replace(/\\/g, "/") + : path; + const pathComponents = normalizedPath + .split("/") + .filter((component) => component.trim().length > 0); + const fileName = pathComponents[pathComponents.length - 1]; + + const ext = fileName.match(/\.[a-z0-9]*$/)?.[0] ?? ""; + const file = fileName.slice(0, ext.length * -1); + const paths = + pathComponents.length > 2 + ? pathComponents.slice(0, pathComponents.length - 2) + : []; + + return { paths, file, ext }; +}; + +export interface IStashBoxFingerprint { + hash: string; + algorithm: string; + duration: number; +} + +export interface IStashBoxPerformer { + id?: string; + stash_id: string; + name: string; + gender?: GQL.GenderEnum; + url?: string; + twitter?: string; + instagram?: string; + birthdate?: string; + ethnicity?: string; + country?: string; + eye_color?: string; + height?: string; + measurements?: string; + fake_tits?: string; + career_length?: string; + tattoos?: string; + piercings?: string; + aliases?: string; + images: string[]; +} + +export interface IStashBoxTag { + id?: string; + name: string; +} + +export interface IStashBoxStudio { + id?: string; + stash_id: string; + name: string; + url?: string; + image?: string; +} + +export interface IStashBoxScene { + stash_id: string; + title: string; + date: string; + duration: number; + details?: string; + url?: string; + + studio: IStashBoxStudio; + images: string[]; + tags: IStashBoxTag[]; + performers: IStashBoxPerformer[]; + fingerprints: IStashBoxFingerprint[]; +} + +const selectStudio = (studio: GQL.ScrapedSceneStudio): IStashBoxStudio => ({ + id: studio?.stored_id ?? undefined, + stash_id: studio.remote_site_id!, + name: studio.name, + url: studio.url ?? undefined, +}); + +const selectFingerprints = ( + scene: GQL.ScrapedScene | null +): IStashBoxFingerprint[] => scene?.fingerprints ?? []; + +const selectTags = (tags: GQL.ScrapedSceneTag[]): IStashBoxTag[] => + tags.map((t) => ({ + id: t.stored_id ?? undefined, + name: t.name ?? "", + })); + +const selectPerformers = ( + performers: GQL.ScrapedScenePerformer[] +): IStashBoxPerformer[] => + performers.map((p) => ({ + id: p.stored_id ?? undefined, + stash_id: p.remote_site_id!, + name: p.name ?? "", + gender: (p.gender ?? GQL.GenderEnum.Female) as GQL.GenderEnum, + url: p.url ?? undefined, + twitter: p.twitter ?? undefined, + instagram: p.instagram ?? undefined, + birthdate: p.birthdate ?? undefined, + ethnicity: p.ethnicity ? toTitleCase(p.ethnicity) : undefined, + country: getCountryByISO(p.country) ?? undefined, + eye_color: p.eye_color ? toTitleCase(p.eye_color) : undefined, + height: p.height ?? undefined, + measurements: p.measurements ?? undefined, + fake_tits: p.fake_tits ? toTitleCase(p.fake_tits) : undefined, + career_length: p.career_length ?? undefined, + tattoos: p.tattoos ? toTitleCase(p.tattoos) : undefined, + piercings: p.piercings ? toTitleCase(p.piercings) : undefined, + aliases: p.aliases ?? undefined, + images: p.images ?? [], + })); + +export const selectScenes = ( + scenes?: (GQL.ScrapedScene | null)[] +): IStashBoxScene[] => { + const result = (scenes ?? []) + .filter((s) => s !== null) + .map( + (s) => + ({ + stash_id: s?.remote_site_id!, + title: s?.title ?? "", + date: s?.date ?? "", + duration: s?.duration ?? 0, + details: s?.details, + url: s?.url, + images: s?.image ? [s.image] : [], + studio: selectStudio(s?.studio!), + fingerprints: selectFingerprints(s), + performers: selectPerformers(s?.performers ?? []), + tags: selectTags(s?.tags ?? []), + } as IStashBoxScene) + ); + + return result; +}; + +export const sortScenesByDuration = ( + scenes: IStashBoxScene[], + targetDuration?: number +) => + scenes.sort((a, b) => { + const adur = + a?.duration ?? a?.fingerprints.map((f) => f.duration)?.[0] ?? null; + const bdur = + b?.duration ?? b?.fingerprints.map((f) => f.duration)?.[0] ?? null; + if (!adur && !bdur) return 0; + if (adur && !bdur) return -1; + if (!adur && bdur) return 1; + + if (!targetDuration) return 0; + + const aDiff = Math.abs((adur ?? 0) - targetDuration); + const bDiff = Math.abs((bdur ?? 0) - targetDuration); + + if (aDiff < bDiff) return -1; + if (aDiff > bDiff) return 1; + return 0; + }); diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index ff36ab89d..0f5f4739a 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -866,3 +866,23 @@ export const stringToGender = (value?: string, caseInsensitive?: boolean) => { }; export const getGenderStrings = () => Array.from(stringGenderMap.keys()); + +export const stashBoxQuery = (searchVal: string, stashBoxIndex: number) => + client?.query< + GQL.QueryStashBoxSceneQuery, + GQL.QueryStashBoxSceneQueryVariables + >({ + query: GQL.QueryStashBoxSceneDocument, + variables: { input: { q: searchVal, stash_box_index: stashBoxIndex } }, + }); + +export const stashBoxBatchQuery = (sceneIds: string[], stashBoxIndex: number) => + client?.query< + GQL.QueryStashBoxSceneQuery, + GQL.QueryStashBoxSceneQueryVariables + >({ + query: GQL.QueryStashBoxSceneDocument, + variables: { + input: { scene_ids: sceneIds, stash_box_index: stashBoxIndex }, + }, + }); diff --git a/ui/v2.5/src/docs/en/Tagger.md b/ui/v2.5/src/docs/en/Tagger.md new file mode 100644 index 000000000..2831904cd --- /dev/null +++ b/ui/v2.5/src/docs/en/Tagger.md @@ -0,0 +1,5 @@ +# Scene Tagger + +The search works by matching the query against a scene’s title_, release date_, _studio name_, and _performer names_. An important thing to note is that it only returns a match *if all query terms are a match*. + +As an example, if a scene is titled `"A Trip to the Mall"`, a search for `"Trip to the Mall 1080p"` will *not* match, however `"trip mall"` would. Usually a few pieces of info is enough, for instance performer name + release date or studio name. diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index f675330db..0df2cb7e2 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -17,6 +17,7 @@ @import "src/components/Tags/styles.scss"; @import "src/components/Wall/styles.scss"; @import "../node_modules/flag-icon-css/css/flag-icon.min.css"; +@import "src/components/Tagger/styles.scss"; /* stylelint-disable */ #root { @@ -67,10 +68,15 @@ code, } .input-control, -.input-control:focus { +.input-control:focus, +.input-control:disabled { background-color: $secondary; } +.input-control:disabled { + opacity: 0.8; +} + textarea.text-input { line-height: 2.5ex; min-height: 12ex; @@ -228,14 +234,6 @@ div.dropdown-menu { .dropdown-item { display: flex; - - & > :not(:last-child) { - margin-right: 7px; - } - - & > :last-child { - margin-right: 0; - } } } diff --git a/ui/v2.5/src/locale/en.json b/ui/v2.5/src/locale/en.json index 14191bcfa..ac6e34be9 100644 --- a/ui/v2.5/src/locale/en.json +++ b/ui/v2.5/src/locale/en.json @@ -10,5 +10,6 @@ "scenes": "Scenes", "studios": "Studios", "tags": "Tags", - "up-dir": "Up a directory" + "up-dir": "Up a directory", + "sceneTagger": "Scene Tagger" } diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index caa24bda3..afa3ada1f 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -63,6 +63,7 @@ export class PerformerIsMissingCriterion extends IsMissingCriterion { "gender", "scenes", "image", + "stash_id", ]; } diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index a53b8e1a6..de035effe 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -127,6 +127,7 @@ export class ListFilterModel { DisplayMode.Grid, DisplayMode.List, DisplayMode.Wall, + DisplayMode.Tagger, ]; this.criterionOptions = [ new NoneCriterionOption(), diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index 6fcd7b067..5e00a172f 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -4,6 +4,7 @@ export enum DisplayMode { Grid, List, Wall, + Tagger, } export enum FilterMode { diff --git a/ui/v2.5/src/utils/country.ts b/ui/v2.5/src/utils/country.ts index 915ea68dd..f94d23ca2 100644 --- a/ui/v2.5/src/utils/country.ts +++ b/ui/v2.5/src/utils/country.ts @@ -27,4 +27,10 @@ const getISOCountry = (country: string | null | undefined) => { }; }; +export const getCountryByISO = (iso: string | null | undefined) => { + if (!iso) return null; + + return Countries.getName(iso, "en") ?? null; +}; + export default getISOCountry; diff --git a/ui/v2.5/yarn.lock b/ui/v2.5/yarn.lock index 73ac7ce7f..b0e319e9d 100644 --- a/ui/v2.5/yarn.lock +++ b/ui/v2.5/yarn.lock @@ -3148,6 +3148,14 @@ "@types/react" "*" "@types/react-router" "*" +"@types/react-router-hash-link@^1.2.1": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@types/react-router-hash-link/-/react-router-hash-link-1.2.1.tgz#fba7dc351cef2985791023018b7a5dbd0653c843" + integrity sha512-jdzPGE8jFGq7fHUpPaKrJvLW1Yhoe5MQCrmgeesC+eSLseMj3cGCTYMDA4BNWG8JQmwO8NTYt/oT3uBZ77pmBA== + dependencies: + "@types/react" "*" + "@types/react-router-dom" "*" + "@types/react-router@*": version "5.1.3" resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.3.tgz#7c7ca717399af64d8733d8cb338dd43641b96f2d" @@ -4060,6 +4068,11 @@ axobject-query@^2.1.2: resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" integrity sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA== +b64-to-blob@^1.2.19: + version "1.2.19" + resolved "https://registry.yarnpkg.com/b64-to-blob/-/b64-to-blob-1.2.19.tgz#157d85fdc8811665b9a35d29ffbc6a522ba28fbe" + integrity sha512-L3nSu8GgF4iEyNYakCQSfL2F5GI5aCXcot9mNTf+4N0/BMhpxqqHyOb6jIR24iq2xLjQZLG8FOt3gnUcV+9NVg== + babel-code-frame@^6.22.0: version "6.26.0" resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b" @@ -4306,6 +4319,13 @@ balanced-match@^1.0.0: resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767" integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c= +base64-blob@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/base64-blob/-/base64-blob-1.4.1.tgz#f8dfc16c22b24ee499e2782719bcce800132c18a" + integrity sha512-n5Ov4cPTbLBTX1PiFbaB5AmK7LMigO9HWh5Lzx+Kcx/yx1MppeeLYtAH8aLv1m++WNoHQnr+xbGSqcZinopwlw== + dependencies: + b64-to-blob "^1.2.19" + base64-js@^1.0.2: version "1.3.1" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1" @@ -12733,6 +12753,13 @@ react-router-dom@^5.2.0: tiny-invariant "^1.0.2" tiny-warning "^1.0.0" +react-router-hash-link@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/react-router-hash-link/-/react-router-hash-link-2.1.0.tgz#69cc93df0945480adff14e9e501aea5f356896a8" + integrity sha512-U/WizkZwV2IoxLScRJX5CHJWreXjv/kCmjT/LpfYiFdXGnrKgPd0KqcA4KfmQbkwO411OwDmUKKz+bOKoMkzKg== + dependencies: + prop-types "^15.6.0" + react-router@5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293"