mirror of
https://github.com/stashapp/stash.git
synced 2025-12-18 04:44:37 +03:00
Stash-box tagger integration (#454)
This commit is contained in:
@@ -52,3 +52,5 @@ models:
|
|||||||
model: github.com/stashapp/stash/pkg/models.ScrapedMovie
|
model: github.com/stashapp/stash/pkg/models.ScrapedMovie
|
||||||
ScrapedMovieStudio:
|
ScrapedMovieStudio:
|
||||||
model: github.com/stashapp/stash/pkg/models.ScrapedMovieStudio
|
model: github.com/stashapp/stash/pkg/models.ScrapedMovieStudio
|
||||||
|
StashID:
|
||||||
|
model: github.com/stashapp/stash/pkg/models.StashID
|
||||||
|
|||||||
@@ -3,4 +3,8 @@ fragment SlimPerformerData on Performer {
|
|||||||
name
|
name
|
||||||
gender
|
gender
|
||||||
image_path
|
image_path
|
||||||
|
stash_ids {
|
||||||
|
endpoint
|
||||||
|
stash_id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,4 +20,8 @@ fragment PerformerData on Performer {
|
|||||||
favorite
|
favorite
|
||||||
image_path
|
image_path
|
||||||
scene_count
|
scene_count
|
||||||
|
stash_ids {
|
||||||
|
stash_id
|
||||||
|
endpoint
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,4 +68,9 @@ fragment SlimSceneData on Scene {
|
|||||||
favorite
|
favorite
|
||||||
image_path
|
image_path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stash_ids {
|
||||||
|
endpoint
|
||||||
|
stash_id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -56,4 +56,9 @@ fragment SceneData on Scene {
|
|||||||
performers {
|
performers {
|
||||||
...PerformerData
|
...PerformerData
|
||||||
}
|
}
|
||||||
|
|
||||||
|
stash_ids {
|
||||||
|
endpoint
|
||||||
|
stash_id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ fragment ScrapedScenePerformerData on ScrapedScenePerformer {
|
|||||||
tattoos
|
tattoos
|
||||||
piercings
|
piercings
|
||||||
aliases
|
aliases
|
||||||
|
remote_site_id
|
||||||
|
images
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment ScrapedMovieStudioData on ScrapedMovieStudio {
|
fragment ScrapedMovieStudioData on ScrapedMovieStudio {
|
||||||
@@ -77,6 +79,7 @@ fragment ScrapedSceneStudioData on ScrapedSceneStudio {
|
|||||||
stored_id
|
stored_id
|
||||||
name
|
name
|
||||||
url
|
url
|
||||||
|
remote_site_id
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment ScrapedSceneTagData on ScrapedSceneTag {
|
fragment ScrapedSceneTagData on ScrapedSceneTag {
|
||||||
@@ -137,3 +140,46 @@ fragment ScrapedGalleryData on ScrapedGallery {
|
|||||||
...ScrapedScenePerformerData
|
...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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,4 +2,8 @@ fragment SlimStudioData on Studio {
|
|||||||
id
|
id
|
||||||
name
|
name
|
||||||
image_path
|
image_path
|
||||||
|
stash_ids {
|
||||||
|
endpoint
|
||||||
|
stash_id
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -21,4 +21,8 @@ fragment StudioData on Studio {
|
|||||||
}
|
}
|
||||||
image_path
|
image_path
|
||||||
scene_count
|
scene_count
|
||||||
|
stash_ids {
|
||||||
|
stash_id
|
||||||
|
endpoint
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ mutation PerformerCreate(
|
|||||||
$twitter: String,
|
$twitter: String,
|
||||||
$instagram: String,
|
$instagram: String,
|
||||||
$favorite: Boolean,
|
$favorite: Boolean,
|
||||||
|
$stash_ids: [StashIDInput!],
|
||||||
$image: String) {
|
$image: String) {
|
||||||
|
|
||||||
performerCreate(input: {
|
performerCreate(input: {
|
||||||
@@ -36,6 +37,7 @@ mutation PerformerCreate(
|
|||||||
twitter: $twitter,
|
twitter: $twitter,
|
||||||
instagram: $instagram,
|
instagram: $instagram,
|
||||||
favorite: $favorite,
|
favorite: $favorite,
|
||||||
|
stash_ids: $stash_ids,
|
||||||
image: $image
|
image: $image
|
||||||
}) {
|
}) {
|
||||||
...PerformerData
|
...PerformerData
|
||||||
@@ -61,6 +63,7 @@ mutation PerformerUpdate(
|
|||||||
$twitter: String,
|
$twitter: String,
|
||||||
$instagram: String,
|
$instagram: String,
|
||||||
$favorite: Boolean,
|
$favorite: Boolean,
|
||||||
|
$stash_ids: [StashIDInput!],
|
||||||
$image: String) {
|
$image: String) {
|
||||||
|
|
||||||
performerUpdate(input: {
|
performerUpdate(input: {
|
||||||
@@ -82,6 +85,7 @@ mutation PerformerUpdate(
|
|||||||
twitter: $twitter,
|
twitter: $twitter,
|
||||||
instagram: $instagram,
|
instagram: $instagram,
|
||||||
favorite: $favorite,
|
favorite: $favorite,
|
||||||
|
stash_ids: $stash_ids,
|
||||||
image: $image
|
image: $image
|
||||||
}) {
|
}) {
|
||||||
...PerformerData
|
...PerformerData
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ mutation SceneUpdate(
|
|||||||
$performer_ids: [ID!] = [],
|
$performer_ids: [ID!] = [],
|
||||||
$movies: [SceneMovieInput!] = [],
|
$movies: [SceneMovieInput!] = [],
|
||||||
$tag_ids: [ID!] = [],
|
$tag_ids: [ID!] = [],
|
||||||
|
$stash_ids: [StashIDInput!],
|
||||||
$cover_image: String) {
|
$cover_image: String) {
|
||||||
|
|
||||||
sceneUpdate(input: {
|
sceneUpdate(input: {
|
||||||
@@ -24,6 +25,7 @@ mutation SceneUpdate(
|
|||||||
performer_ids: $performer_ids,
|
performer_ids: $performer_ids,
|
||||||
movies: $movies,
|
movies: $movies,
|
||||||
tag_ids: $tag_ids,
|
tag_ids: $tag_ids,
|
||||||
|
stash_ids: $stash_ids,
|
||||||
cover_image: $cover_image
|
cover_image: $cover_image
|
||||||
}) {
|
}) {
|
||||||
...SceneData
|
...SceneData
|
||||||
|
|||||||
3
graphql/documents/mutations/stash-box.graphql
Normal file
3
graphql/documents/mutations/stash-box.graphql
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
mutation SubmitStashBoxFingerprints($input: StashBoxFingerprintSubmissionInput!) {
|
||||||
|
submitStashBoxFingerprints(input: $input)
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
mutation StudioCreate(
|
mutation StudioCreate(
|
||||||
$name: String!,
|
$name: String!,
|
||||||
$url: String,
|
$url: String,
|
||||||
$image: String
|
$image: String,
|
||||||
|
$stash_ids: [StashIDInput!],
|
||||||
$parent_id: ID) {
|
$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
|
...StudioData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,10 +14,11 @@ mutation StudioUpdate(
|
|||||||
$id: ID!
|
$id: ID!
|
||||||
$name: String,
|
$name: String,
|
||||||
$url: String,
|
$url: String,
|
||||||
$image: String
|
$image: String,
|
||||||
|
$stash_ids: [StashIDInput!],
|
||||||
$parent_id: ID) {
|
$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
|
...StudioData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,6 @@ query ScrapeMovieURL($url: String!) {
|
|||||||
|
|
||||||
query QueryStashBoxScene($input: StashBoxQueryInput!) {
|
query QueryStashBoxScene($input: StashBoxQueryInput!) {
|
||||||
queryStashBoxScene(input: $input) {
|
queryStashBoxScene(input: $input) {
|
||||||
...ScrapedSceneData
|
...ScrapedStashBoxSceneData
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -98,7 +98,6 @@ type Query {
|
|||||||
"""List available plugin operations"""
|
"""List available plugin operations"""
|
||||||
pluginTasks: [PluginTask!]
|
pluginTasks: [PluginTask!]
|
||||||
|
|
||||||
|
|
||||||
# Config
|
# Config
|
||||||
"""Returns the current, complete configuration"""
|
"""Returns the current, complete configuration"""
|
||||||
configuration: ConfigResult!
|
configuration: ConfigResult!
|
||||||
@@ -222,6 +221,9 @@ type Mutation {
|
|||||||
reloadPlugins: Boolean!
|
reloadPlugins: Boolean!
|
||||||
|
|
||||||
stopJob: Boolean!
|
stopJob: Boolean!
|
||||||
|
|
||||||
|
""" Submit fingerprints to stash-box instance """
|
||||||
|
submitStashBoxFingerprints(input: StashBoxFingerprintSubmissionInput!): Boolean!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Subscription {
|
type Subscription {
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ input PerformerFilterType {
|
|||||||
gender: GenderCriterionInput
|
gender: GenderCriterionInput
|
||||||
"""Filter to only include performers missing this property"""
|
"""Filter to only include performers missing this property"""
|
||||||
is_missing: String
|
is_missing: String
|
||||||
|
"""Filter by StashID"""
|
||||||
|
stash_id: String
|
||||||
}
|
}
|
||||||
|
|
||||||
input SceneMarkerFilterType {
|
input SceneMarkerFilterType {
|
||||||
@@ -86,6 +88,8 @@ input SceneFilterType {
|
|||||||
tags: MultiCriterionInput
|
tags: MultiCriterionInput
|
||||||
"""Filter to only include scenes with these performers"""
|
"""Filter to only include scenes with these performers"""
|
||||||
performers: MultiCriterionInput
|
performers: MultiCriterionInput
|
||||||
|
"""Filter by StashID"""
|
||||||
|
stash_id: String
|
||||||
}
|
}
|
||||||
|
|
||||||
input MovieFilterType {
|
input MovieFilterType {
|
||||||
@@ -98,6 +102,8 @@ input MovieFilterType {
|
|||||||
input StudioFilterType {
|
input StudioFilterType {
|
||||||
"""Filter to only include studios with this parent studio"""
|
"""Filter to only include studios with this parent studio"""
|
||||||
parents: MultiCriterionInput
|
parents: MultiCriterionInput
|
||||||
|
"""Filter by StashID"""
|
||||||
|
stash_id: String
|
||||||
"""Filter to only include studios missing this property"""
|
"""Filter to only include studios missing this property"""
|
||||||
is_missing: String
|
is_missing: String
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ type Performer {
|
|||||||
image_path: String # Resolver
|
image_path: String # Resolver
|
||||||
scene_count: Int # Resolver
|
scene_count: Int # Resolver
|
||||||
scenes: [Scene!]!
|
scenes: [Scene!]!
|
||||||
|
stash_ids: [StashID!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
input PerformerCreateInput {
|
input PerformerCreateInput {
|
||||||
@@ -53,6 +54,7 @@ input PerformerCreateInput {
|
|||||||
favorite: Boolean
|
favorite: Boolean
|
||||||
"""This should be base64 encoded"""
|
"""This should be base64 encoded"""
|
||||||
image: String
|
image: String
|
||||||
|
stash_ids: [StashIDInput!]
|
||||||
}
|
}
|
||||||
|
|
||||||
input PerformerUpdateInput {
|
input PerformerUpdateInput {
|
||||||
@@ -76,6 +78,7 @@ input PerformerUpdateInput {
|
|||||||
favorite: Boolean
|
favorite: Boolean
|
||||||
"""This should be base64 encoded"""
|
"""This should be base64 encoded"""
|
||||||
image: String
|
image: String
|
||||||
|
stash_ids: [StashIDInput!]
|
||||||
}
|
}
|
||||||
|
|
||||||
input PerformerDestroyInput {
|
input PerformerDestroyInput {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ type Scene {
|
|||||||
movies: [SceneMovie!]!
|
movies: [SceneMovie!]!
|
||||||
tags: [Tag!]!
|
tags: [Tag!]!
|
||||||
performers: [Performer!]!
|
performers: [Performer!]!
|
||||||
|
stash_ids: [StashID!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
input SceneMovieInput {
|
input SceneMovieInput {
|
||||||
@@ -66,6 +67,7 @@ input SceneUpdateInput {
|
|||||||
tag_ids: [ID!]
|
tag_ids: [ID!]
|
||||||
"""This should be base64 encoded"""
|
"""This should be base64 encoded"""
|
||||||
cover_image: String
|
cover_image: String
|
||||||
|
stash_ids: [StashIDInput!]
|
||||||
}
|
}
|
||||||
|
|
||||||
enum BulkUpdateIdMode {
|
enum BulkUpdateIdMode {
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ type ScrapedScenePerformer {
|
|||||||
tattoos: String
|
tattoos: String
|
||||||
piercings: String
|
piercings: String
|
||||||
aliases: String
|
aliases: String
|
||||||
|
|
||||||
|
remote_site_id: String
|
||||||
|
images: [String!]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScrapedSceneMovie {
|
type ScrapedSceneMovie {
|
||||||
@@ -65,6 +68,8 @@ type ScrapedSceneStudio {
|
|||||||
stored_id: ID
|
stored_id: ID
|
||||||
name: String!
|
name: String!
|
||||||
url: String
|
url: String
|
||||||
|
|
||||||
|
remote_site_id: String
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScrapedSceneTag {
|
type ScrapedSceneTag {
|
||||||
@@ -88,6 +93,10 @@ type ScrapedScene {
|
|||||||
tags: [ScrapedSceneTag!]
|
tags: [ScrapedSceneTag!]
|
||||||
performers: [ScrapedScenePerformer!]
|
performers: [ScrapedScenePerformer!]
|
||||||
movies: [ScrapedSceneMovie!]
|
movies: [ScrapedSceneMovie!]
|
||||||
|
|
||||||
|
remote_site_id: String
|
||||||
|
duration: Int
|
||||||
|
fingerprints: [StashBoxFingerprint!]
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScrapedGallery {
|
type ScrapedGallery {
|
||||||
@@ -109,3 +118,9 @@ input StashBoxQueryInput {
|
|||||||
"""Query by query string"""
|
"""Query by query string"""
|
||||||
q: String
|
q: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type StashBoxFingerprint {
|
||||||
|
algorithm: String!
|
||||||
|
hash: String!
|
||||||
|
duration: Int!
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,3 +9,18 @@ input StashBoxInput {
|
|||||||
api_key: String!
|
api_key: String!
|
||||||
name: 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!
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ type Studio {
|
|||||||
url: String
|
url: String
|
||||||
parent_studio: Studio
|
parent_studio: Studio
|
||||||
child_studios: [Studio!]!
|
child_studios: [Studio!]!
|
||||||
|
|
||||||
image_path: String # Resolver
|
image_path: String # Resolver
|
||||||
scene_count: Int # Resolver
|
scene_count: Int # Resolver
|
||||||
|
stash_ids: [StashID!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
input StudioCreateInput {
|
input StudioCreateInput {
|
||||||
@@ -15,6 +17,7 @@ input StudioCreateInput {
|
|||||||
parent_id: ID
|
parent_id: ID
|
||||||
"""This should be base64 encoded"""
|
"""This should be base64 encoded"""
|
||||||
image: String
|
image: String
|
||||||
|
stash_ids: [StashIDInput!]
|
||||||
}
|
}
|
||||||
|
|
||||||
input StudioUpdateInput {
|
input StudioUpdateInput {
|
||||||
@@ -24,6 +27,7 @@ input StudioUpdateInput {
|
|||||||
parent_id: ID,
|
parent_id: ID,
|
||||||
"""This should be base64 encoded"""
|
"""This should be base64 encoded"""
|
||||||
image: String
|
image: String
|
||||||
|
stash_ids: [StashIDInput!]
|
||||||
}
|
}
|
||||||
|
|
||||||
input StudioDestroyInput {
|
input StudioDestroyInput {
|
||||||
|
|||||||
@@ -148,3 +148,8 @@ func (r *performerResolver) Scenes(ctx context.Context, obj *models.Performer) (
|
|||||||
qb := models.NewSceneQueryBuilder()
|
qb := models.NewSceneQueryBuilder()
|
||||||
return qb.FindByPerformerID(obj.ID)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -150,3 +150,8 @@ func (r *sceneResolver) Performers(ctx context.Context, obj *models.Scene) ([]*m
|
|||||||
qb := models.NewPerformerQueryBuilder()
|
qb := models.NewPerformerQueryBuilder()
|
||||||
return qb.FindBySceneID(obj.ID, nil)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -46,3 +46,8 @@ func (r *studioResolver) ChildStudios(ctx context.Context, obj *models.Studio) (
|
|||||||
qb := models.NewStudioQueryBuilder()
|
qb := models.NewStudioQueryBuilder()
|
||||||
return qb.FindChildren(obj.ID, nil)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,6 +88,8 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
|||||||
// Start the transaction and save the performer
|
// Start the transaction and save the performer
|
||||||
tx := database.DB.MustBeginTx(ctx, nil)
|
tx := database.DB.MustBeginTx(ctx, nil)
|
||||||
qb := models.NewPerformerQueryBuilder()
|
qb := models.NewPerformerQueryBuilder()
|
||||||
|
jqb := models.NewJoinsQueryBuilder()
|
||||||
|
|
||||||
performer, err := qb.Create(newPerformer, tx)
|
performer, err := qb.Create(newPerformer, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tx.Rollback()
|
_ = 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
|
// Commit
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -187,6 +204,8 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
|||||||
// Start the transaction and save the performer
|
// Start the transaction and save the performer
|
||||||
tx := database.DB.MustBeginTx(ctx, nil)
|
tx := database.DB.MustBeginTx(ctx, nil)
|
||||||
qb := models.NewPerformerQueryBuilder()
|
qb := models.NewPerformerQueryBuilder()
|
||||||
|
jqb := models.NewJoinsQueryBuilder()
|
||||||
|
|
||||||
performer, err := qb.Update(updatedPerformer, tx)
|
performer, err := qb.Update(updatedPerformer, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
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
|
// Commit
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -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
|
return scene, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
22
pkg/api/resolver_mutation_stash_box.go
Normal file
22
pkg/api/resolver_mutation_stash_box.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
@@ -48,6 +48,8 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
|||||||
// Start the transaction and save the studio
|
// Start the transaction and save the studio
|
||||||
tx := database.DB.MustBeginTx(ctx, nil)
|
tx := database.DB.MustBeginTx(ctx, nil)
|
||||||
qb := models.NewStudioQueryBuilder()
|
qb := models.NewStudioQueryBuilder()
|
||||||
|
jqb := models.NewJoinsQueryBuilder()
|
||||||
|
|
||||||
studio, err := qb.Create(newStudio, tx)
|
studio, err := qb.Create(newStudio, tx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = tx.Rollback()
|
_ = 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
|
// Commit
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -109,6 +126,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
|||||||
// Start the transaction and save the studio
|
// Start the transaction and save the studio
|
||||||
tx := database.DB.MustBeginTx(ctx, nil)
|
tx := database.DB.MustBeginTx(ctx, nil)
|
||||||
qb := models.NewStudioQueryBuilder()
|
qb := models.NewStudioQueryBuilder()
|
||||||
|
jqb := models.NewJoinsQueryBuilder()
|
||||||
|
|
||||||
if err := manager.ValidateModifyStudio(updatedStudio, tx); err != nil {
|
if err := manager.ValidateModifyStudio(updatedStudio, tx); err != nil {
|
||||||
tx.Rollback()
|
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
|
// Commit
|
||||||
if err := tx.Commit(); err != nil {
|
if err := tx.Commit(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import (
|
|||||||
|
|
||||||
var DB *sqlx.DB
|
var DB *sqlx.DB
|
||||||
var dbPath string
|
var dbPath string
|
||||||
var appSchemaVersion uint = 13
|
var appSchemaVersion uint = 14
|
||||||
var databaseSchemaVersion uint
|
var databaseSchemaVersion uint
|
||||||
|
|
||||||
const sqlite3Driver = "sqlite3ex"
|
const sqlite3Driver = "sqlite3ex"
|
||||||
|
|||||||
20
pkg/database/migrations/14_stash_box_ids.up.sql
Normal file
20
pkg/database/migrations/14_stash_box_ids.up.sql
Normal file
@@ -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
|
||||||
|
);
|
||||||
@@ -23,6 +23,11 @@ type SceneMarkersTags struct {
|
|||||||
TagID int `db:"tag_id" json:"tag_id"`
|
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 {
|
type PerformersImages struct {
|
||||||
PerformerID int `db:"performer_id" json:"performer_id"`
|
PerformerID int `db:"performer_id" json:"performer_id"`
|
||||||
ImageID int `db:"image_id" json:"image_id"`
|
ImageID int `db:"image_id" json:"image_id"`
|
||||||
|
|||||||
@@ -69,7 +69,10 @@ type ScrapedScene struct {
|
|||||||
URL *string `graphql:"url" json:"url"`
|
URL *string `graphql:"url" json:"url"`
|
||||||
Date *string `graphql:"date" json:"date"`
|
Date *string `graphql:"date" json:"date"`
|
||||||
Image *string `graphql:"image" json:"image"`
|
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"`
|
File *SceneFileType `graphql:"file" json:"file"`
|
||||||
|
Fingerprints []*StashBoxFingerprint `graphql:"fingerprints" json:"fingerprints"`
|
||||||
Studio *ScrapedSceneStudio `graphql:"studio" json:"studio"`
|
Studio *ScrapedSceneStudio `graphql:"studio" json:"studio"`
|
||||||
Movies []*ScrapedSceneMovie `graphql:"movies" json:"movies"`
|
Movies []*ScrapedSceneMovie `graphql:"movies" json:"movies"`
|
||||||
Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"`
|
Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"`
|
||||||
@@ -120,6 +123,8 @@ type ScrapedScenePerformer struct {
|
|||||||
Tattoos *string `graphql:"tattoos" json:"tattoos"`
|
Tattoos *string `graphql:"tattoos" json:"tattoos"`
|
||||||
Piercings *string `graphql:"piercings" json:"piercings"`
|
Piercings *string `graphql:"piercings" json:"piercings"`
|
||||||
Aliases *string `graphql:"aliases" json:"aliases"`
|
Aliases *string `graphql:"aliases" json:"aliases"`
|
||||||
|
RemoteSiteID *string `graphql:"remote_site_id" json:"remote_site_id"`
|
||||||
|
Images []string `graphql:"images" json:"images"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScrapedSceneStudio struct {
|
type ScrapedSceneStudio struct {
|
||||||
@@ -127,6 +132,7 @@ type ScrapedSceneStudio struct {
|
|||||||
ID *string `graphql:"id" json:"id"`
|
ID *string `graphql:"id" json:"id"`
|
||||||
Name string `graphql:"name" json:"name"`
|
Name string `graphql:"name" json:"name"`
|
||||||
URL *string `graphql:"url" json:"url"`
|
URL *string `graphql:"url" json:"url"`
|
||||||
|
RemoteSiteID *string `graphql:"remote_site_id" json:"remote_site_id"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ScrapedSceneMovie struct {
|
type ScrapedSceneMovie struct {
|
||||||
|
|||||||
@@ -366,6 +366,18 @@ func (qb *JoinsQueryBuilder) DestroyScenesMarkers(sceneID int, tx *sqlx.Tx) erro
|
|||||||
return err
|
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) {
|
func (qb *JoinsQueryBuilder) GetImagePerformers(imageID int, tx *sqlx.Tx) ([]PerformersImages, error) {
|
||||||
ensureTx(tx)
|
ensureTx(tx)
|
||||||
|
|
||||||
@@ -885,3 +897,105 @@ func (qb *JoinsQueryBuilder) DestroyGalleriesTags(galleryID int, tx *sqlx.Tx) er
|
|||||||
|
|
||||||
return err
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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.Ethnicity, tableName+".ethnicity")
|
||||||
query.handleStringCriterionInput(performerFilter.Country, tableName+".country")
|
query.handleStringCriterionInput(performerFilter.Country, tableName+".country")
|
||||||
query.handleStringCriterionInput(performerFilter.EyeColor, tableName+".eye_color")
|
query.handleStringCriterionInput(performerFilter.EyeColor, tableName+".eye_color")
|
||||||
|
|||||||
@@ -404,6 +404,14 @@ func (qb *SceneQueryBuilder) Query(sceneFilter *SceneFilterType, findFilter *Fin
|
|||||||
query.addHaving(havingClause)
|
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)
|
query.sortAndPagination = qb.getSceneSort(findFilter) + getPagination(findFilter)
|
||||||
idsResult, countResult := query.executeFind()
|
idsResult, countResult := query.executeFind()
|
||||||
|
|
||||||
|
|||||||
@@ -182,6 +182,14 @@ func (qb *StudioQueryBuilder) Query(studioFilter *StudioFilterType, findFilter *
|
|||||||
havingClauses = appendClause(havingClauses, havingClause)
|
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 != "" {
|
if isMissingFilter := studioFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
|
||||||
switch *isMissingFilter {
|
switch *isMissingFilter {
|
||||||
case "image":
|
case "image":
|
||||||
|
|||||||
@@ -113,6 +113,76 @@ func (c Client) findStashBoxScenesByFingerprints(fingerprints []string) ([]*mode
|
|||||||
return ret, nil
|
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 {
|
func findURL(urls []*graphql.URLFragment, urlType string) *string {
|
||||||
for _, u := range urls {
|
for _, u := range urls {
|
||||||
if u.Type == urlType {
|
if u.Type == urlType {
|
||||||
@@ -209,6 +279,11 @@ func fetchImage(url string) (*string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *models.ScrapedScenePerformer {
|
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{
|
sp := &models.ScrapedScenePerformer{
|
||||||
Name: p.Name,
|
Name: p.Name,
|
||||||
Country: p.Country,
|
Country: p.Country,
|
||||||
@@ -217,11 +292,13 @@ func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *mode
|
|||||||
Tattoos: formatBodyModifications(p.Tattoos),
|
Tattoos: formatBodyModifications(p.Tattoos),
|
||||||
Piercings: formatBodyModifications(p.Piercings),
|
Piercings: formatBodyModifications(p.Piercings),
|
||||||
Twitter: findURL(p.Urls, "TWITTER"),
|
Twitter: findURL(p.Urls, "TWITTER"),
|
||||||
|
RemoteSiteID: &id,
|
||||||
|
Images: images,
|
||||||
// TODO - Image - should be returned as a set of URLs. Will need a
|
// TODO - Image - should be returned as a set of URLs. Will need a
|
||||||
// graphql schema change to accommodate this. Leave off for now.
|
// graphql schema change to accommodate this. Leave off for now.
|
||||||
}
|
}
|
||||||
|
|
||||||
if p.Height != nil {
|
if p.Height != nil && *p.Height > 0 {
|
||||||
hs := strconv.Itoa(*p.Height)
|
hs := strconv.Itoa(*p.Height)
|
||||||
sp.Height = &hs
|
sp.Height = &hs
|
||||||
}
|
}
|
||||||
@@ -259,12 +336,29 @@ func getFirstImage(images []*graphql.ImageFragment) *string {
|
|||||||
return ret
|
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) {
|
func sceneFragmentToScrapedScene(s *graphql.SceneFragment) (*models.ScrapedScene, error) {
|
||||||
|
stashID := s.ID
|
||||||
ss := &models.ScrapedScene{
|
ss := &models.ScrapedScene{
|
||||||
Title: s.Title,
|
Title: s.Title,
|
||||||
Date: s.Date,
|
Date: s.Date,
|
||||||
Details: s.Details,
|
Details: s.Details,
|
||||||
URL: findURL(s.Urls, "STUDIO"),
|
URL: findURL(s.Urls, "STUDIO"),
|
||||||
|
Duration: s.Duration,
|
||||||
|
RemoteSiteID: &stashID,
|
||||||
|
Fingerprints: getFingerprints(s),
|
||||||
// Image
|
// Image
|
||||||
// stash_id
|
// stash_id
|
||||||
}
|
}
|
||||||
@@ -276,9 +370,11 @@ func sceneFragmentToScrapedScene(s *graphql.SceneFragment) (*models.ScrapedScene
|
|||||||
}
|
}
|
||||||
|
|
||||||
if s.Studio != nil {
|
if s.Studio != nil {
|
||||||
|
studioID := s.Studio.ID
|
||||||
ss.Studio = &models.ScrapedSceneStudio{
|
ss.Studio = &models.ScrapedSceneStudio{
|
||||||
Name: s.Studio.Name,
|
Name: s.Studio.Name,
|
||||||
URL: findURL(s.Studio.Urls, "HOME"),
|
URL: findURL(s.Studio.Urls, "HOME"),
|
||||||
|
RemoteSiteID: &studioID,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := models.MatchScrapedSceneStudio(ss.Studio)
|
err := models.MatchScrapedSceneStudio(ss.Studio)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"@types/mousetrap": "^1.6.3",
|
"@types/mousetrap": "^1.6.3",
|
||||||
"apollo-upload-client": "^14.1.2",
|
"apollo-upload-client": "^14.1.2",
|
||||||
"axios": "0.20.0",
|
"axios": "0.20.0",
|
||||||
|
"base64-blob": "^1.4.1",
|
||||||
"bootstrap": "^4.5.2",
|
"bootstrap": "^4.5.2",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"flag-icon-css": "^3.5.0",
|
"flag-icon-css": "^3.5.0",
|
||||||
@@ -59,6 +60,7 @@
|
|||||||
"react-photo-gallery": "^8.0.0",
|
"react-photo-gallery": "^8.0.0",
|
||||||
"react-router-bootstrap": "^0.25.0",
|
"react-router-bootstrap": "^0.25.0",
|
||||||
"react-router-dom": "^5.2.0",
|
"react-router-dom": "^5.2.0",
|
||||||
|
"react-router-hash-link": "^2.1.0",
|
||||||
"react-select": "^3.1.0",
|
"react-select": "^3.1.0",
|
||||||
"string.prototype.replaceall": "^1.0.3",
|
"string.prototype.replaceall": "^1.0.3",
|
||||||
"subscriptions-transport-ws": "^0.9.18",
|
"subscriptions-transport-ws": "^0.9.18",
|
||||||
@@ -80,6 +82,7 @@
|
|||||||
"@types/react-images": "^0.5.3",
|
"@types/react-images": "^0.5.3",
|
||||||
"@types/react-router-bootstrap": "^0.24.5",
|
"@types/react-router-bootstrap": "^0.24.5",
|
||||||
"@types/react-router-dom": "5.1.5",
|
"@types/react-router-dom": "5.1.5",
|
||||||
|
"@types/react-router-hash-link": "^1.2.1",
|
||||||
"@types/react-select": "3.0.19",
|
"@types/react-select": "3.0.19",
|
||||||
"@typescript-eslint/eslint-plugin": "^2.30.0",
|
"@typescript-eslint/eslint-plugin": "^2.30.0",
|
||||||
"@typescript-eslint/parser": "^2.30.0",
|
"@typescript-eslint/parser": "^2.30.0",
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
### ✨ New Features
|
### ✨ New Features
|
||||||
|
* Add stash-box tagger to scenes page.
|
||||||
* Add filters tab in scene page.
|
* Add filters tab in scene page.
|
||||||
* Add selectable streaming quality profiles in the scene player.
|
* Add selectable streaming quality profiles in the scene player.
|
||||||
* Add scrapers list setting page.
|
* Add scrapers list setting page.
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import Interface from "src/docs/en/Interface.md";
|
|||||||
import Galleries from "src/docs/en/Galleries.md";
|
import Galleries from "src/docs/en/Galleries.md";
|
||||||
import Scraping from "src/docs/en/Scraping.md";
|
import Scraping from "src/docs/en/Scraping.md";
|
||||||
import Plugins from "src/docs/en/Plugins.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 Contributing from "src/docs/en/Contributing.md";
|
||||||
import SceneFilenameParser from "src/docs/en/SceneFilenameParser.md";
|
import SceneFilenameParser from "src/docs/en/SceneFilenameParser.md";
|
||||||
import KeyboardShortcuts from "src/docs/en/KeyboardShortcuts.md";
|
import KeyboardShortcuts from "src/docs/en/KeyboardShortcuts.md";
|
||||||
@@ -75,6 +76,11 @@ export const Manual: React.FC<IManualProps> = ({ show, onClose }) => {
|
|||||||
title: "Plugins",
|
title: "Plugins",
|
||||||
content: Plugins,
|
content: Plugins,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "Tagger.md",
|
||||||
|
title: "Scene Tagger",
|
||||||
|
content: Tagger,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: "KeyboardShortcuts.md",
|
key: "KeyboardShortcuts.md",
|
||||||
title: "Keyboard Shortcuts",
|
title: "Keyboard Shortcuts",
|
||||||
|
|||||||
@@ -254,6 +254,8 @@ export const ListFilter: React.FC<IListFilterProps> = (
|
|||||||
return "list";
|
return "list";
|
||||||
case DisplayMode.Wall:
|
case DisplayMode.Wall:
|
||||||
return "square";
|
return "square";
|
||||||
|
case DisplayMode.Tagger:
|
||||||
|
return "tags";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
function getLabel(option: DisplayMode) {
|
function getLabel(option: DisplayMode) {
|
||||||
@@ -264,6 +266,8 @@ export const ListFilter: React.FC<IListFilterProps> = (
|
|||||||
return "List";
|
return "List";
|
||||||
case DisplayMode.Wall:
|
case DisplayMode.Wall:
|
||||||
return "Wall";
|
return "Wall";
|
||||||
|
case DisplayMode.Tagger:
|
||||||
|
return "Tagger";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -53,6 +53,10 @@ const messages = defineMessages({
|
|||||||
id: "galleries",
|
id: "galleries",
|
||||||
defaultMessage: "Galleries",
|
defaultMessage: "Galleries",
|
||||||
},
|
},
|
||||||
|
sceneTagger: {
|
||||||
|
id: "sceneTagger",
|
||||||
|
defaultMessage: "Scene Tagger",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const menuItems: IMenuItem[] = [
|
const menuItems: IMenuItem[] = [
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||||||
const [twitter, setTwitter] = useState<string>();
|
const [twitter, setTwitter] = useState<string>();
|
||||||
const [instagram, setInstagram] = useState<string>();
|
const [instagram, setInstagram] = useState<string>();
|
||||||
const [gender, setGender] = useState<string | undefined>(undefined);
|
const [gender, setGender] = useState<string | undefined>(undefined);
|
||||||
|
const [stashIDs, setStashIDs] = useState<GQL.StashIdInput[]>([]);
|
||||||
|
|
||||||
// Network state
|
// Network state
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
@@ -121,6 +122,9 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||||||
setGender(
|
setGender(
|
||||||
genderToString((state as GQL.PerformerDataFragment).gender ?? undefined)
|
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) {
|
function translateScrapedGender(scrapedGender?: string) {
|
||||||
@@ -288,6 +292,10 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||||||
instagram,
|
instagram,
|
||||||
image,
|
image,
|
||||||
gender: stringToGender(gender),
|
gender: stringToGender(gender),
|
||||||
|
stash_ids: stashIDs.map((s) => ({
|
||||||
|
stash_id: s.stash_id,
|
||||||
|
endpoint: s.endpoint,
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!isNew) {
|
if (!isNew) {
|
||||||
@@ -623,6 +631,60 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<tr>
|
||||||
|
<td>StashIDs</td>
|
||||||
|
<td>
|
||||||
|
<ul className="pl-0">
|
||||||
|
{stashIDs.map((stashID) => {
|
||||||
|
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||||
|
const link = base ? (
|
||||||
|
<a
|
||||||
|
href={`${base}performers/${stashID.stash_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{stashID.stash_id}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
stashID.stash_id
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<li key={stashID.stash_id} className="row no-gutters">
|
||||||
|
{isEditing && (
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
className="mr-2 py-0"
|
||||||
|
title="Delete StashID"
|
||||||
|
onClick={() => removeStashID(stashID)}
|
||||||
|
>
|
||||||
|
<Icon icon="trash-alt" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{link}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const formatHeight = () => {
|
const formatHeight = () => {
|
||||||
if (isEditing) {
|
if (isEditing) {
|
||||||
return height;
|
return height;
|
||||||
@@ -720,6 +782,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||||||
isEditing: !!isEditing,
|
isEditing: !!isEditing,
|
||||||
onChange: setInstagram,
|
onChange: setInstagram,
|
||||||
})}
|
})}
|
||||||
|
{renderStashIDs()}
|
||||||
</tbody>
|
</tbody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||||||
>(new Map());
|
>(new Map());
|
||||||
const [tagIds, setTagIds] = useState<string[]>();
|
const [tagIds, setTagIds] = useState<string[]>();
|
||||||
const [coverImage, setCoverImage] = useState<string>();
|
const [coverImage, setCoverImage] = useState<string>();
|
||||||
|
const [stashIDs, setStashIDs] = useState<GQL.StashIdInput[]>([]);
|
||||||
|
|
||||||
const Scrapers = useListSceneScrapers();
|
const Scrapers = useListSceneScrapers();
|
||||||
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
|
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
|
||||||
@@ -174,6 +175,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||||||
setMovieSceneIndexes(movieSceneIdx);
|
setMovieSceneIndexes(movieSceneIdx);
|
||||||
setPerformerIds(perfIds);
|
setPerformerIds(perfIds);
|
||||||
setTagIds(tIds);
|
setTagIds(tIds);
|
||||||
|
setStashIDs(state?.stash_ids ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -198,6 +200,10 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||||||
movies: makeMovieInputs(),
|
movies: makeMovieInputs(),
|
||||||
tag_ids: tagIds,
|
tag_ids: tagIds,
|
||||||
cover_image: coverImage,
|
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<IProps> = (props: IProps) => {
|
|||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const removeStashID = (stashID: GQL.StashIdInput) => {
|
||||||
|
setStashIDs(
|
||||||
|
stashIDs.filter(
|
||||||
|
(s) =>
|
||||||
|
!(s.endpoint === stashID.endpoint && s.stash_id === stashID.stash_id)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
function renderTableMovies() {
|
function renderTableMovies() {
|
||||||
return (
|
return (
|
||||||
<SceneMovieTable
|
<SceneMovieTable
|
||||||
@@ -659,6 +674,40 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||||||
</Col>
|
</Col>
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="col-12 col-lg-6 col-xl-12">
|
||||||
|
<Form.Group controlId="details">
|
||||||
|
<Form.Label>StashIDs</Form.Label>
|
||||||
|
<ul className="pl-0">
|
||||||
|
{stashIDs.map((stashID) => {
|
||||||
|
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||||
|
const link = base ? (
|
||||||
|
<a
|
||||||
|
href={`${base}scenes/${stashID.stash_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{stashID.stash_id}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
stashID.stash_id
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<li key={stashID.stash_id} className="row no-gutters">
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
className="mr-2 py-0"
|
||||||
|
title="Delete StashID"
|
||||||
|
onClick={() => removeStashID(stashID)}
|
||||||
|
>
|
||||||
|
<Icon icon="trash-alt" />
|
||||||
|
</Button>
|
||||||
|
{link}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</Form.Group>
|
||||||
|
</div>
|
||||||
<div className="col-12 col-lg-6 col-xl-12">
|
<div className="col-12 col-lg-6 col-xl-12">
|
||||||
<Form.Group controlId="details">
|
<Form.Group controlId="details">
|
||||||
<Form.Label>Details</Form.Label>
|
<Form.Label>Details</Form.Label>
|
||||||
|
|||||||
@@ -189,6 +189,39 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderStashIDs() {
|
||||||
|
if (!props.scene.stash_ids.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row">
|
||||||
|
<span className="col-4">StashIDs</span>
|
||||||
|
<ul className="col-8">
|
||||||
|
{props.scene.stash_ids.map((stashID) => {
|
||||||
|
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||||
|
const link = base ? (
|
||||||
|
<a
|
||||||
|
href={`${base}scenes/${stashID.stash_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{stashID.stash_id}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
stashID.stash_id
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<li key={stashID.stash_id} className="row no-gutters">
|
||||||
|
{link}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container scene-file-info">
|
<div className="container scene-file-info">
|
||||||
{renderOSHash()}
|
{renderOSHash()}
|
||||||
@@ -203,6 +236,7 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
|||||||
{renderVideoCodec()}
|
{renderVideoCodec()}
|
||||||
{renderAudioCodec()}
|
{renderAudioCodec()}
|
||||||
{renderUrl()}
|
{renderUrl()}
|
||||||
|
{renderStashIDs()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useScenesList } from "src/hooks";
|
|||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { DisplayMode } from "src/models/list-filter/types";
|
import { DisplayMode } from "src/models/list-filter/types";
|
||||||
import { showWhenSelected } from "src/hooks/ListHook";
|
import { showWhenSelected } from "src/hooks/ListHook";
|
||||||
|
import Tagger from "src/components/Tagger";
|
||||||
import { WallPanel } from "../Wall/WallPanel";
|
import { WallPanel } from "../Wall/WallPanel";
|
||||||
import { SceneCard } from "./SceneCard";
|
import { SceneCard } from "./SceneCard";
|
||||||
import { SceneListTable } from "./SceneListTable";
|
import { SceneListTable } from "./SceneListTable";
|
||||||
@@ -214,6 +215,9 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||||||
if (filter.displayMode === DisplayMode.Wall) {
|
if (filter.displayMode === DisplayMode.Wall) {
|
||||||
return <WallPanel scenes={result.data.findScenes.scenes} />;
|
return <WallPanel scenes={result.data.findScenes.scenes} />;
|
||||||
}
|
}
|
||||||
|
if (filter.displayMode === DisplayMode.Tagger) {
|
||||||
|
return <Tagger scenes={result.data.findScenes.scenes} />;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderContent(
|
function renderContent(
|
||||||
|
|||||||
@@ -691,10 +691,12 @@ export const SettingsConfigurationPanel: React.FC = () => {
|
|||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
<Form.Group>
|
|
||||||
|
<Form.Group id="stashbox">
|
||||||
<h4>Stash-box integration</h4>
|
<h4>Stash-box integration</h4>
|
||||||
<StashBoxConfiguration boxes={stashBoxes} saveBoxes={setStashBoxes} />
|
<StashBoxConfiguration boxes={stashBoxes} saveBoxes={setStashBoxes} />
|
||||||
</Form.Group>
|
</Form.Group>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<Form.Group>
|
<Form.Group>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import cx from "classnames";
|
|||||||
interface ILoadingProps {
|
interface ILoadingProps {
|
||||||
message?: string;
|
message?: string;
|
||||||
inline?: boolean;
|
inline?: boolean;
|
||||||
|
small?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CLASSNAME = "LoadingIndicator";
|
const CLASSNAME = "LoadingIndicator";
|
||||||
@@ -13,12 +14,15 @@ const CLASSNAME_MESSAGE = `${CLASSNAME}-message`;
|
|||||||
const LoadingIndicator: React.FC<ILoadingProps> = ({
|
const LoadingIndicator: React.FC<ILoadingProps> = ({
|
||||||
message,
|
message,
|
||||||
inline = false,
|
inline = false,
|
||||||
|
small = false,
|
||||||
}) => (
|
}) => (
|
||||||
<div className={cx(CLASSNAME, { inline })}>
|
<div className={cx(CLASSNAME, { inline, small })}>
|
||||||
<Spinner animation="border" role="status">
|
<Spinner animation="border" role="status" size={small ? "sm" : undefined}>
|
||||||
<span className="sr-only">Loading...</span>
|
<span className="sr-only">Loading...</span>
|
||||||
</Spinner>
|
</Spinner>
|
||||||
|
{message !== "" && (
|
||||||
<h4 className={CLASSNAME_MESSAGE}>{message ?? "Loading..."}</h4>
|
<h4 className={CLASSNAME_MESSAGE}>{message ?? "Loading..."}</h4>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface IModal {
|
|||||||
isRunning?: boolean;
|
isRunning?: boolean;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
modalProps?: ModalProps;
|
modalProps?: ModalProps;
|
||||||
|
dialogClassName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ModalComponent: React.FC<IModal> = ({
|
const ModalComponent: React.FC<IModal> = ({
|
||||||
@@ -32,8 +33,15 @@ const ModalComponent: React.FC<IModal> = ({
|
|||||||
isRunning,
|
isRunning,
|
||||||
disabled,
|
disabled,
|
||||||
modalProps,
|
modalProps,
|
||||||
|
dialogClassName,
|
||||||
}) => (
|
}) => (
|
||||||
<Modal keyboard={false} onHide={onHide} show={show} {...modalProps}>
|
<Modal
|
||||||
|
keyboard={false}
|
||||||
|
onHide={onHide}
|
||||||
|
show={show}
|
||||||
|
dialogClassName={dialogClassName}
|
||||||
|
{...modalProps}
|
||||||
|
>
|
||||||
<Modal.Header>
|
<Modal.Header>
|
||||||
{icon ? <Icon icon={icon} /> : ""}
|
{icon ? <Icon icon={icon} /> : ""}
|
||||||
<span>{header ?? ""}</span>
|
<span>{header ?? ""}</span>
|
||||||
@@ -46,6 +54,7 @@ const ModalComponent: React.FC<IModal> = ({
|
|||||||
disabled={isRunning}
|
disabled={isRunning}
|
||||||
variant={cancel.variant ?? "primary"}
|
variant={cancel.variant ?? "primary"}
|
||||||
onClick={cancel.onClick}
|
onClick={cancel.onClick}
|
||||||
|
className="mr-2"
|
||||||
>
|
>
|
||||||
{cancel.text ?? "Cancel"}
|
{cancel.text ?? "Cancel"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ import { FilterSelect } from "./Select";
|
|||||||
type ValidTypes =
|
type ValidTypes =
|
||||||
| GQL.SlimPerformerDataFragment
|
| GQL.SlimPerformerDataFragment
|
||||||
| GQL.Tag
|
| GQL.Tag
|
||||||
| GQL.SlimStudioDataFragment;
|
| GQL.SlimStudioDataFragment
|
||||||
|
| GQL.SlimMovieDataFragment;
|
||||||
|
|
||||||
interface IMultiSetProps {
|
interface IMultiSetProps {
|
||||||
type: "performers" | "studios" | "tags";
|
type: "performers" | "studios" | "tags";
|
||||||
|
|||||||
@@ -20,10 +20,11 @@ import { useToast } from "src/hooks";
|
|||||||
import { ListFilterModel } from "src/models/list-filter/filter";
|
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||||
import { FilterMode } from "src/models/list-filter/types";
|
import { FilterMode } from "src/models/list-filter/types";
|
||||||
|
|
||||||
type ValidTypes =
|
export type ValidTypes =
|
||||||
| GQL.SlimPerformerDataFragment
|
| GQL.SlimPerformerDataFragment
|
||||||
| GQL.Tag
|
| GQL.Tag
|
||||||
| GQL.SlimStudioDataFragment;
|
| GQL.SlimStudioDataFragment
|
||||||
|
| GQL.SlimMovieDataFragment;
|
||||||
type Option = { value: string; label: string };
|
type Option = { value: string; label: string };
|
||||||
|
|
||||||
interface ITypeProps {
|
interface ITypeProps {
|
||||||
@@ -63,13 +64,9 @@ interface ISelectProps {
|
|||||||
groupHeader?: string;
|
groupHeader?: string;
|
||||||
closeMenuOnSelect?: boolean;
|
closeMenuOnSelect?: boolean;
|
||||||
}
|
}
|
||||||
interface IFilterItem {
|
|
||||||
id: string;
|
|
||||||
name?: string | null;
|
|
||||||
}
|
|
||||||
interface IFilterComponentProps extends IFilterProps {
|
interface IFilterComponentProps extends IFilterProps {
|
||||||
items: Array<IFilterItem>;
|
items: Array<ValidTypes>;
|
||||||
onCreate?: (name: string) => Promise<{ item: IFilterItem; message: string }>;
|
onCreate?: (name: string) => Promise<{ item: ValidTypes; message: string }>;
|
||||||
}
|
}
|
||||||
interface IFilterSelectProps
|
interface IFilterSelectProps
|
||||||
extends Omit<ISelectProps, "onChange" | "items" | "onCreateOption"> {}
|
extends Omit<ISelectProps, "onChange" | "items" | "onCreateOption"> {}
|
||||||
|
|||||||
12
ui/v2.5/src/components/Shared/SuccessIcon.tsx
Normal file
12
ui/v2.5/src/components/Shared/SuccessIcon.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import React from "react";
|
||||||
|
import { Icon } from "src/components/Shared";
|
||||||
|
|
||||||
|
interface ISuccessIconProps {
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SuccessIcon: React.FC<ISuccessIconProps> = ({ className }) => (
|
||||||
|
<Icon icon="check" className={className} color="#0f9960" />
|
||||||
|
);
|
||||||
|
|
||||||
|
export default SuccessIcon;
|
||||||
@@ -19,4 +19,5 @@ export { default as LoadingIndicator } from "./LoadingIndicator";
|
|||||||
export { ImageInput } from "./ImageInput";
|
export { ImageInput } from "./ImageInput";
|
||||||
export { SweatDrops } from "./SweatDrops";
|
export { SweatDrops } from "./SweatDrops";
|
||||||
export { default as CountryFlag } from "./CountryFlag";
|
export { default as CountryFlag } from "./CountryFlag";
|
||||||
|
export { default as SuccessIcon } from "./SuccessIcon";
|
||||||
export { default as ErrorMessage } from "./ErrorMessage";
|
export { default as ErrorMessage } from "./ErrorMessage";
|
||||||
|
|||||||
@@ -16,7 +16,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.inline {
|
&.inline {
|
||||||
height: inherit;
|
display: inline;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.small .spinner-border {
|
||||||
|
height: 1rem;
|
||||||
|
width: 1rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,41 @@ export const Studio: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderStashIDs() {
|
||||||
|
if (!studio.stash_ids?.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr>
|
||||||
|
<td>StashIDs</td>
|
||||||
|
<td>
|
||||||
|
<ul className="pl-0">
|
||||||
|
{studio.stash_ids.map((stashID) => {
|
||||||
|
const base = stashID.endpoint.match(/https?:\/\/.*?\//)?.[0];
|
||||||
|
const link = base ? (
|
||||||
|
<a
|
||||||
|
href={`${base}studios/${stashID.stash_id}`}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
{stashID.stash_id}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
stashID.stash_id
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<li key={stashID.stash_id} className="row no-gutters">
|
||||||
|
{link}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function onToggleEdit() {
|
function onToggleEdit() {
|
||||||
setIsEditing(!isEditing);
|
setIsEditing(!isEditing);
|
||||||
updateStudioData(studio);
|
updateStudioData(studio);
|
||||||
@@ -263,6 +298,7 @@ export const Studio: React.FC = () => {
|
|||||||
<td>Parent Studio</td>
|
<td>Parent Studio</td>
|
||||||
<td>{renderStudio()}</td>
|
<td>{renderStudio()}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
{!isEditing && renderStashIDs()}
|
||||||
</tbody>
|
</tbody>
|
||||||
</Table>
|
</Table>
|
||||||
<DetailsEditNavbar
|
<DetailsEditNavbar
|
||||||
|
|||||||
268
ui/v2.5/src/components/Tagger/Config.tsx
Normal file
268
ui/v2.5/src/components/Tagger/Config.tsx
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import React, { Dispatch, useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Collapse,
|
||||||
|
Form,
|
||||||
|
InputGroup,
|
||||||
|
} from "react-bootstrap";
|
||||||
|
import { Icon } from "src/components/Shared";
|
||||||
|
import localForage from "localforage";
|
||||||
|
|
||||||
|
import { useConfiguration } from "src/core/StashService";
|
||||||
|
|
||||||
|
const DEFAULT_BLACKLIST = [
|
||||||
|
"\\sXXX\\s",
|
||||||
|
"1080p",
|
||||||
|
"720p",
|
||||||
|
"2160p",
|
||||||
|
"KTR",
|
||||||
|
"RARBG",
|
||||||
|
"\\scom\\s",
|
||||||
|
"\\[",
|
||||||
|
"\\]",
|
||||||
|
];
|
||||||
|
|
||||||
|
export const initialConfig: ITaggerConfig = {
|
||||||
|
blacklist: DEFAULT_BLACKLIST,
|
||||||
|
showMales: false,
|
||||||
|
mode: "auto",
|
||||||
|
setCoverImage: true,
|
||||||
|
setTags: false,
|
||||||
|
tagOperation: "merge",
|
||||||
|
fingerprintQueue: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ParseMode = "auto" | "filename" | "dir" | "path" | "metadata";
|
||||||
|
const ModeDesc = {
|
||||||
|
auto: "Uses metadata if present, or filename",
|
||||||
|
metadata: "Only uses metadata",
|
||||||
|
filename: "Only uses filename",
|
||||||
|
dir: "Only uses parent directory of video file",
|
||||||
|
path: "Uses entire file path",
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ITaggerConfig {
|
||||||
|
blacklist: string[];
|
||||||
|
showMales: boolean;
|
||||||
|
mode: ParseMode;
|
||||||
|
setCoverImage: boolean;
|
||||||
|
setTags: boolean;
|
||||||
|
tagOperation: string;
|
||||||
|
selectedEndpoint?: string;
|
||||||
|
fingerprintQueue: Record<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IConfigProps {
|
||||||
|
show: boolean;
|
||||||
|
config: ITaggerConfig;
|
||||||
|
setConfig: Dispatch<ITaggerConfig>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Config: React.FC<IConfigProps> = ({ show, config, setConfig }) => {
|
||||||
|
const stashConfig = useConfiguration();
|
||||||
|
const [blacklistInput, setBlacklistInput] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
localForage.getItem<ITaggerConfig>("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<HTMLSelectElement>) => {
|
||||||
|
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 (
|
||||||
|
<Collapse in={show}>
|
||||||
|
<Card>
|
||||||
|
<div className="row">
|
||||||
|
<h4 className="col-12">Configuration</h4>
|
||||||
|
<hr className="w-100" />
|
||||||
|
<Form className="col-md-6">
|
||||||
|
<Form.Group controlId="tag-males" className="align-items-center">
|
||||||
|
<Form.Check
|
||||||
|
label="Show male performers"
|
||||||
|
checked={config.showMales}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setConfig({ ...config, showMales: e.currentTarget.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Text>
|
||||||
|
Toggle whether male performers will be available to tag.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group controlId="set-cover" className="align-items-center">
|
||||||
|
<Form.Check
|
||||||
|
label="Set scene cover image"
|
||||||
|
checked={config.setCoverImage}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setConfig({
|
||||||
|
...config,
|
||||||
|
setCoverImage: e.currentTarget.checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Text>Replace the scene cover if one is found.</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
<Form.Group className="align-items-center">
|
||||||
|
<div className="d-flex align-items-center">
|
||||||
|
<Form.Check
|
||||||
|
id="tag-mode"
|
||||||
|
label="Set tags"
|
||||||
|
className="mr-4"
|
||||||
|
checked={config.setTags}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setConfig({ ...config, setTags: e.currentTarget.checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Form.Control
|
||||||
|
id="tag-operation"
|
||||||
|
className="col-md-2 col-3 input-control"
|
||||||
|
as="select"
|
||||||
|
value={config.tagOperation}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||||
|
setConfig({
|
||||||
|
...config,
|
||||||
|
tagOperation: e.currentTarget.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={!config.setTags}
|
||||||
|
>
|
||||||
|
<option value="merge">Merge</option>
|
||||||
|
<option value="overwrite">Overwrite</option>
|
||||||
|
</Form.Control>
|
||||||
|
</div>
|
||||||
|
<Form.Text>
|
||||||
|
Attach tags to scene, either by overwriting or merging with
|
||||||
|
existing tags on scene.
|
||||||
|
</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
|
||||||
|
<Form.Group controlId="mode-select">
|
||||||
|
<div className="row no-gutters">
|
||||||
|
<Form.Label className="mr-4 mt-1">Query Mode:</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
as="select"
|
||||||
|
className="col-md-2 col-3 input-control"
|
||||||
|
value={config.mode}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLSelectElement>) =>
|
||||||
|
setConfig({
|
||||||
|
...config,
|
||||||
|
mode: e.currentTarget.value as ParseMode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="auto">Auto</option>
|
||||||
|
<option value="filename">Filename</option>
|
||||||
|
<option value="dir">Dir</option>
|
||||||
|
<option value="path">Path</option>
|
||||||
|
<option value="metadata">Metadata</option>
|
||||||
|
</Form.Control>
|
||||||
|
</div>
|
||||||
|
<Form.Text>{ModeDesc[config.mode]}</Form.Text>
|
||||||
|
</Form.Group>
|
||||||
|
</Form>
|
||||||
|
<div className="col-md-6">
|
||||||
|
<h5>Blacklist</h5>
|
||||||
|
<InputGroup>
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
value={blacklistInput}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setBlacklistInput(e.currentTarget.value)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputGroup.Append>
|
||||||
|
<Button onClick={handleBlacklistAddition}>Add</Button>
|
||||||
|
</InputGroup.Append>
|
||||||
|
</InputGroup>
|
||||||
|
<div>
|
||||||
|
Blacklist items are excluded from queries. Note that they are
|
||||||
|
regular expressions and also case-insensitive. Certain characters
|
||||||
|
must be escaped with a backslash: <code>[\^$.|?*+()</code>
|
||||||
|
</div>
|
||||||
|
{config.blacklist.map((item, index) => (
|
||||||
|
<Badge
|
||||||
|
className="tag-item d-inline-block"
|
||||||
|
variant="secondary"
|
||||||
|
key={item}
|
||||||
|
>
|
||||||
|
{item.toString()}
|
||||||
|
<Button
|
||||||
|
className="minimal ml-2"
|
||||||
|
onClick={() => removeBlacklist(index)}
|
||||||
|
>
|
||||||
|
<Icon icon="times" />
|
||||||
|
</Button>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Form.Group
|
||||||
|
controlId="stash-box-endpoint"
|
||||||
|
className="align-items-center row no-gutters mt-4"
|
||||||
|
>
|
||||||
|
<Form.Label className="mr-4">
|
||||||
|
Active stash-box instance:
|
||||||
|
</Form.Label>
|
||||||
|
<Form.Control
|
||||||
|
as="select"
|
||||||
|
value={config.selectedEndpoint}
|
||||||
|
className="col-md-4 col-6 input-control"
|
||||||
|
disabled={!stashBoxes.length}
|
||||||
|
onChange={handleInstanceSelect}
|
||||||
|
>
|
||||||
|
{!stashBoxes.length && <option>No instances found</option>}
|
||||||
|
{stashConfig.data?.configuration.general.stashBoxes.map((i) => (
|
||||||
|
<option value={i.endpoint} key={i.endpoint}>
|
||||||
|
{i.endpoint}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</Form.Control>
|
||||||
|
</Form.Group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Collapse>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Config;
|
||||||
177
ui/v2.5/src/components/Tagger/PerformerModal.tsx
Executable file
177
ui/v2.5/src/components/Tagger/PerformerModal.tsx
Executable file
@@ -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<IPerformerModalProps> = ({
|
||||||
|
modalVisible,
|
||||||
|
performer,
|
||||||
|
handlePerformerCreate,
|
||||||
|
showModal,
|
||||||
|
}) => {
|
||||||
|
const [imageIndex, setImageIndex] = useState(0);
|
||||||
|
const [imageState, setImageState] = useState<
|
||||||
|
"loading" | "error" | "loaded" | "empty"
|
||||||
|
>("empty");
|
||||||
|
const [loadDict, setLoadDict] = useState<Record<number, boolean>>({});
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<Modal
|
||||||
|
show={modalVisible}
|
||||||
|
accept={{
|
||||||
|
text: "Save",
|
||||||
|
onClick: () => handlePerformerCreate(imageIndex),
|
||||||
|
}}
|
||||||
|
cancel={{ onClick: () => showModal(false), variant: "secondary" }}
|
||||||
|
onHide={() => showModal(false)}
|
||||||
|
dialogClassName="performer-create-modal"
|
||||||
|
>
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-6">
|
||||||
|
<div className="row no-gutters mb-4">
|
||||||
|
<strong>Performer information</strong>
|
||||||
|
</div>
|
||||||
|
<div className="row no-gutters">
|
||||||
|
<strong className="col-6">Name:</strong>
|
||||||
|
<span className="col-6 text-truncate">{performer.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="row no-gutters">
|
||||||
|
<strong className="col-6">Gender:</strong>
|
||||||
|
<span className="col-6 text-truncate text-capitalize">
|
||||||
|
{performer.gender && genderToString(performer.gender)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="row no-gutters">
|
||||||
|
<strong className="col-6">Birthdate:</strong>
|
||||||
|
<span className="col-6 text-truncate">
|
||||||
|
{performer.birthdate ?? "Unknown"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="row no-gutters">
|
||||||
|
<strong className="col-6">Ethnicity:</strong>
|
||||||
|
<span className="col-6 text-truncate text-capitalize">
|
||||||
|
{performer.ethnicity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="row no-gutters">
|
||||||
|
<strong className="col-6">Country:</strong>
|
||||||
|
<span className="col-6 text-truncate">
|
||||||
|
{performer.country ?? ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="row no-gutters">
|
||||||
|
<strong className="col-6">Eye Color:</strong>
|
||||||
|
<span className="col-6 text-truncate text-capitalize">
|
||||||
|
{performer.eye_color}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="row no-gutters">
|
||||||
|
<strong className="col-6">Height:</strong>
|
||||||
|
<span className="col-6 text-truncate">{performer.height}</span>
|
||||||
|
</div>
|
||||||
|
<div className="row no-gutters">
|
||||||
|
<strong className="col-6">Measurements:</strong>
|
||||||
|
<span className="col-6 text-truncate">
|
||||||
|
{performer.measurements}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{performer?.gender !== GQL.GenderEnum.Male && (
|
||||||
|
<div className="row no-gutters">
|
||||||
|
<strong className="col-6">Fake Tits:</strong>
|
||||||
|
<span className="col-6 text-truncate">{performer.fake_tits}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="row no-gutters">
|
||||||
|
<strong className="col-6">Career Length:</strong>
|
||||||
|
<span className="col-6 text-truncate">
|
||||||
|
{performer.career_length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="row no-gutters">
|
||||||
|
<strong className="col-6">Tattoos:</strong>
|
||||||
|
<span className="col-6 text-truncate">{performer.tattoos}</span>
|
||||||
|
</div>
|
||||||
|
<div className="row no-gutters ">
|
||||||
|
<strong className="col-6">Piercings:</strong>
|
||||||
|
<span className="col-6 text-truncate">{performer.piercings}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{images.length > 0 && (
|
||||||
|
<div className="col-6 image-selection">
|
||||||
|
<div className="performer-image">
|
||||||
|
<img
|
||||||
|
src={images[imageIndex]}
|
||||||
|
className={cx({ "d-none": imageState !== "loaded" })}
|
||||||
|
alt=""
|
||||||
|
onLoad={() => handleLoad(imageIndex)}
|
||||||
|
onError={handleError}
|
||||||
|
/>
|
||||||
|
{imageState === "loading" && (
|
||||||
|
<LoadingIndicator message="Loading image..." />
|
||||||
|
)}
|
||||||
|
{imageState === "error" && (
|
||||||
|
<div className="h-100 d-flex justify-content-center align-items-center">
|
||||||
|
<b>Error loading image.</b>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="d-flex mt-2">
|
||||||
|
<Button
|
||||||
|
className="mr-auto"
|
||||||
|
onClick={setPrev}
|
||||||
|
disabled={images.length === 1}
|
||||||
|
>
|
||||||
|
<Icon icon="arrow-left" />
|
||||||
|
</Button>
|
||||||
|
<h5>
|
||||||
|
Select performer image
|
||||||
|
<br />
|
||||||
|
{imageIndex + 1} of {images.length}
|
||||||
|
</h5>
|
||||||
|
<Button
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={setNext}
|
||||||
|
disabled={images.length === 1}
|
||||||
|
>
|
||||||
|
<Icon icon="arrow-right" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PerformerModal;
|
||||||
155
ui/v2.5/src/components/Tagger/PerformerResult.tsx
Executable file
155
ui/v2.5/src/components/Tagger/PerformerResult.tsx
Executable file
@@ -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<IPerformerResultProps> = ({
|
||||||
|
performer,
|
||||||
|
setPerformer,
|
||||||
|
}) => {
|
||||||
|
const [selectedPerformer, setSelectedPerformer] = useState<string | null>();
|
||||||
|
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 <div>Loading performer</div>;
|
||||||
|
|
||||||
|
if (stashData?.findPerformers.performers?.[0]?.id) {
|
||||||
|
return (
|
||||||
|
<div className="row no-gutters my-2">
|
||||||
|
<div className="entity-name">
|
||||||
|
Performer:
|
||||||
|
<b className="ml-2">{performer.name}</b>
|
||||||
|
</div>
|
||||||
|
<span className="ml-auto">
|
||||||
|
<SuccessIcon />
|
||||||
|
Matched:
|
||||||
|
</span>
|
||||||
|
<b className="col-3 text-right">
|
||||||
|
{stashData.findPerformers.performers[0].name}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="row no-gutters align-items-center mt-2">
|
||||||
|
<PerformerModal
|
||||||
|
showModal={showModal}
|
||||||
|
modalVisible={modalVisible}
|
||||||
|
performer={performer}
|
||||||
|
handlePerformerCreate={handlePerformerCreate}
|
||||||
|
/>
|
||||||
|
<div className="entity-name">
|
||||||
|
Performer:
|
||||||
|
<b className="ml-2">{performer.name}</b>
|
||||||
|
</div>
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button
|
||||||
|
variant={selectedSource === "create" ? "primary" : "secondary"}
|
||||||
|
onClick={() => showModal(true)}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={selectedSource === "skip" ? "primary" : "secondary"}
|
||||||
|
onClick={() => handlePerformerSkip()}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</Button>
|
||||||
|
<PerformerSelect
|
||||||
|
ids={selectedPerformer ? [selectedPerformer] : []}
|
||||||
|
onSelect={handlePerformerSelect}
|
||||||
|
className={cx("performer-select", {
|
||||||
|
"performer-select-active": selectedSource === "existing",
|
||||||
|
})}
|
||||||
|
isClearable={false}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PerformerResult;
|
||||||
394
ui/v2.5/src/components/Tagger/StashSearchResult.tsx
Executable file
394
ui/v2.5/src/components/Tagger/StashSearchResult.tsx
Executable file
@@ -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 (
|
||||||
|
<div className="font-weight-bold">
|
||||||
|
<SuccessIcon className="mr-2" />
|
||||||
|
Duration is a match
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <div>Duration off by {Math.floor(diff)}s</div>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFingerprintStatus = (
|
||||||
|
scene: IStashBoxScene,
|
||||||
|
stashChecksum?: string
|
||||||
|
) => {
|
||||||
|
if (scene.fingerprints.some((f) => f.hash === stashChecksum))
|
||||||
|
return (
|
||||||
|
<div className="font-weight-bold">
|
||||||
|
<SuccessIcon className="mr-2" />
|
||||||
|
Checksum is a match
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
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<IStashSearchResultProps> = ({
|
||||||
|
scene,
|
||||||
|
stashScene,
|
||||||
|
isActive,
|
||||||
|
setActive,
|
||||||
|
showMales,
|
||||||
|
setScene,
|
||||||
|
setCoverImage,
|
||||||
|
tagOperation,
|
||||||
|
endpoint,
|
||||||
|
queueFingerprintSubmission,
|
||||||
|
}) => {
|
||||||
|
const [studio, setStudio] = useState<StudioOperation>();
|
||||||
|
const [performers, setPerformers] = useState<
|
||||||
|
Record<string, PerformerOperation>
|
||||||
|
>({});
|
||||||
|
const [saveState, setSaveState] = useState<string>("");
|
||||||
|
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<string, string> = (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 ? (
|
||||||
|
<a
|
||||||
|
href={scene.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="scene-link"
|
||||||
|
>
|
||||||
|
{scene?.title}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span>{scene?.title}</span>
|
||||||
|
);
|
||||||
|
|
||||||
|
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
|
||||||
|
<li
|
||||||
|
className={classname}
|
||||||
|
key={scene.stash_id}
|
||||||
|
onClick={() => !isActive && setActive()}
|
||||||
|
>
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<div className="row">
|
||||||
|
<img
|
||||||
|
src={scene.images[0]}
|
||||||
|
alt=""
|
||||||
|
className="align-self-center scene-image"
|
||||||
|
/>
|
||||||
|
<div className="d-flex flex-column justify-content-center scene-metadata">
|
||||||
|
<h4 className="text-truncate" title={scene?.title ?? ""}>
|
||||||
|
{sceneTitle}
|
||||||
|
</h4>
|
||||||
|
<h5>
|
||||||
|
{scene?.studio?.name} • {scene?.date}
|
||||||
|
</h5>
|
||||||
|
<div>
|
||||||
|
Performers: {scene?.performers?.map((p) => p.name).join(", ")}
|
||||||
|
</div>
|
||||||
|
{getDurationStatus(scene, stashScene.file?.duration)}
|
||||||
|
{getFingerprintStatus(
|
||||||
|
scene,
|
||||||
|
stashScene.checksum ?? stashScene.oshash ?? undefined
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isActive && (
|
||||||
|
<div className="col-lg-6">
|
||||||
|
<StudioResult studio={scene.studio} setStudio={setStudio} />
|
||||||
|
{scene.performers
|
||||||
|
.filter((p) => p.gender !== "MALE" || showMales)
|
||||||
|
.map((performer) => (
|
||||||
|
<PerformerResult
|
||||||
|
performer={performer}
|
||||||
|
setPerformer={(data: PerformerOperation) =>
|
||||||
|
setPerformer(data, performer.stash_id)
|
||||||
|
}
|
||||||
|
key={`${scene.stash_id}${performer.stash_id}`}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<div className="row no-gutters mt-2 align-items-center justify-content-end">
|
||||||
|
{error.message && (
|
||||||
|
<strong className="mt-1 mr-2 text-danger text-right">
|
||||||
|
<abbr title={error.details} className="mr-2">
|
||||||
|
Error:
|
||||||
|
</abbr>
|
||||||
|
{error.message}
|
||||||
|
</strong>
|
||||||
|
)}
|
||||||
|
{saveState && (
|
||||||
|
<strong className="col-4 mt-1 mr-2 text-right">
|
||||||
|
{saveState}
|
||||||
|
</strong>
|
||||||
|
)}
|
||||||
|
<Button onClick={handleSave} disabled={!saveEnabled}>
|
||||||
|
{saveState ? (
|
||||||
|
<LoadingIndicator inline small message="" />
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StashSearchResult;
|
||||||
161
ui/v2.5/src/components/Tagger/StudioResult.tsx
Executable file
161
ui/v2.5/src/components/Tagger/StudioResult.tsx
Executable file
@@ -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<SetStateAction<StudioOperation | undefined>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const StudioResult: React.FC<IStudioResultProps> = ({ studio, setStudio }) => {
|
||||||
|
const [selectedStudio, setSelectedStudio] = useState<string | null>();
|
||||||
|
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 <div>Loading studio</div>;
|
||||||
|
|
||||||
|
if (stashIDData?.findStudios.studios.length) {
|
||||||
|
return (
|
||||||
|
<div className="row no-gutters my-2">
|
||||||
|
<div className="entity-name">
|
||||||
|
Studio:
|
||||||
|
<b className="ml-2">{studio?.name}</b>
|
||||||
|
</div>
|
||||||
|
<span className="ml-auto">
|
||||||
|
<SuccessIcon className="mr-2" />
|
||||||
|
Matched:
|
||||||
|
</span>
|
||||||
|
<b className="col-3 text-right">
|
||||||
|
{stashIDData.findStudios.studios[0].name}
|
||||||
|
</b>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="row no-gutters align-items-center mt-2">
|
||||||
|
<Modal
|
||||||
|
show={modalVisible}
|
||||||
|
accept={{ text: "Save", onClick: handleStudioCreate }}
|
||||||
|
cancel={{ onClick: () => showModal(false), variant: "secondary" }}
|
||||||
|
>
|
||||||
|
<div className="row">
|
||||||
|
<strong className="col-2">Name:</strong>
|
||||||
|
<span className="col-10">{studio?.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<strong className="col-2">URL:</strong>
|
||||||
|
<span className="col-10">{studio?.url ?? ""}</span>
|
||||||
|
</div>
|
||||||
|
<div className="row">
|
||||||
|
<strong className="col-2">Logo:</strong>
|
||||||
|
<span className="col-10">
|
||||||
|
<img src={studio?.image ?? ""} alt="" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
<div className="entity-name">
|
||||||
|
Studio:
|
||||||
|
<b className="ml-2">{studio?.name}</b>
|
||||||
|
</div>
|
||||||
|
<ButtonGroup>
|
||||||
|
<Button
|
||||||
|
variant={selectedSource === "create" ? "primary" : "secondary"}
|
||||||
|
onClick={() => showModal(true)}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={selectedSource === "skip" ? "primary" : "secondary"}
|
||||||
|
onClick={() => handleStudioSkip()}
|
||||||
|
>
|
||||||
|
Skip
|
||||||
|
</Button>
|
||||||
|
<StudioSelect
|
||||||
|
ids={selectedStudio ? [selectedStudio] : []}
|
||||||
|
onSelect={handleStudioSelect}
|
||||||
|
className={cx("studio-select", {
|
||||||
|
"studio-select-active": selectedSource === "existing",
|
||||||
|
})}
|
||||||
|
isClearable={false}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default StudioResult;
|
||||||
445
ui/v2.5/src/components/Tagger/Tagger.tsx
Executable file
445
ui/v2.5/src/components/Tagger/Tagger.tsx
Executable file
@@ -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<GQL.SlimSceneDataFragment>,
|
||||||
|
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<ITaggerListProps> = ({
|
||||||
|
scenes,
|
||||||
|
selectedEndpoint,
|
||||||
|
config,
|
||||||
|
queueFingerprintSubmission,
|
||||||
|
clearSubmissionQueue,
|
||||||
|
}) => {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [queryString, setQueryString] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const [searchResults, setSearchResults] = useState<
|
||||||
|
Record<string, IStashBoxScene[]>
|
||||||
|
>({});
|
||||||
|
const [selectedResult, setSelectedResult] = useState<
|
||||||
|
Record<string, number>
|
||||||
|
>();
|
||||||
|
const [taggedScenes, setTaggedScenes] = useState<
|
||||||
|
Record<string, Partial<GQL.SlimSceneDataFragment>>
|
||||||
|
>({});
|
||||||
|
const [loadingFingerprints, setLoadingFingerprints] = useState(false);
|
||||||
|
const [fingerprints, setFingerprints] = useState<
|
||||||
|
Record<string, IStashBoxScene>
|
||||||
|
>({});
|
||||||
|
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<GQL.SlimSceneDataFragment>) => {
|
||||||
|
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 = (
|
||||||
|
<h5 className="text-right text-bold">Scene already tagged</h5>
|
||||||
|
);
|
||||||
|
} else if (!isTagged && !hasStashIDs) {
|
||||||
|
maincontent = (
|
||||||
|
<InputGroup>
|
||||||
|
<Form.Control
|
||||||
|
className="text-input"
|
||||||
|
value={modifiedQuery || defaultQueryString}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
setQueryString({
|
||||||
|
...queryString,
|
||||||
|
[scene.id]: e.currentTarget.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
onKeyPress={(e: React.KeyboardEvent<HTMLInputElement>) =>
|
||||||
|
e.key === "Enter" &&
|
||||||
|
doBoxSearch(
|
||||||
|
scene.id,
|
||||||
|
queryString[scene.id] || defaultQueryString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<InputGroup.Append>
|
||||||
|
<Button
|
||||||
|
disabled={loading}
|
||||||
|
onClick={() =>
|
||||||
|
doBoxSearch(
|
||||||
|
scene.id,
|
||||||
|
queryString[scene.id] || defaultQueryString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</Button>
|
||||||
|
</InputGroup.Append>
|
||||||
|
</InputGroup>
|
||||||
|
);
|
||||||
|
} else if (isTagged) {
|
||||||
|
maincontent = (
|
||||||
|
<h5 className="row no-gutters">
|
||||||
|
<b className="col-4">Scene successfully tagged:</b>
|
||||||
|
<Link
|
||||||
|
className="offset-1 col-7 text-right"
|
||||||
|
to={`/scenes/${scene.id}`}
|
||||||
|
>
|
||||||
|
{taggedScenes[scene.id].title}
|
||||||
|
</Link>
|
||||||
|
</h5>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let searchResult;
|
||||||
|
if (searchResults[scene.id]?.length === 0)
|
||||||
|
searchResult = (
|
||||||
|
<div className="text-danger font-weight-bold">No results found.</div>
|
||||||
|
);
|
||||||
|
else if (fingerprintMatch && !isTagged && !hasStashIDs) {
|
||||||
|
searchResult = (
|
||||||
|
<StashSearchResult
|
||||||
|
showMales={config.showMales}
|
||||||
|
stashScene={scene}
|
||||||
|
isActive
|
||||||
|
setActive={() => {}}
|
||||||
|
setScene={handleTaggedScene}
|
||||||
|
scene={fingerprintMatch}
|
||||||
|
setCoverImage={config.setCoverImage}
|
||||||
|
tagOperation={config.tagOperation}
|
||||||
|
endpoint={selectedEndpoint.endpoint}
|
||||||
|
queueFingerprintSubmission={queueFingerprintSubmission}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (searchResults[scene.id] && !isTagged && !fingerprintMatch) {
|
||||||
|
searchResult = (
|
||||||
|
<ul className="pl-0 mt-4">
|
||||||
|
{sortScenesByDuration(searchResults[scene.id]).map(
|
||||||
|
(sceneResult, i) =>
|
||||||
|
sceneResult && (
|
||||||
|
<StashSearchResult
|
||||||
|
key={sceneResult.stash_id}
|
||||||
|
showMales={config.showMales}
|
||||||
|
stashScene={scene}
|
||||||
|
scene={sceneResult}
|
||||||
|
isActive={(selectedResult?.[scene.id] ?? 0) === i}
|
||||||
|
setActive={() =>
|
||||||
|
setSelectedResult({
|
||||||
|
...selectedResult,
|
||||||
|
[scene.id]: i,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
setCoverImage={config.setCoverImage}
|
||||||
|
tagOperation={config.tagOperation}
|
||||||
|
setScene={handleTaggedScene}
|
||||||
|
endpoint={selectedEndpoint.endpoint}
|
||||||
|
queueFingerprintSubmission={queueFingerprintSubmission}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={scene.id} className="my-2 search-item">
|
||||||
|
<div className="row">
|
||||||
|
<div className="col-md-6 my-1 text-truncate align-self-center">
|
||||||
|
<Link
|
||||||
|
to={`/scenes/${scene.id}`}
|
||||||
|
className="scene-link"
|
||||||
|
title={scene.path}
|
||||||
|
>
|
||||||
|
{originalDir}
|
||||||
|
<wbr />
|
||||||
|
{`${file}.${ext}`}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-6 my-1">{maincontent}</div>
|
||||||
|
</div>
|
||||||
|
{searchResult}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="tagger-table">
|
||||||
|
<div className="tagger-table-header row mb-4">
|
||||||
|
<div className="col-md-6">
|
||||||
|
<b>Path</b>
|
||||||
|
</div>
|
||||||
|
<div className="col-md-2">
|
||||||
|
<b>Query</b>
|
||||||
|
</div>
|
||||||
|
<div className="ml-auto mr-2">
|
||||||
|
{fingerprintQueue.length > 0 && (
|
||||||
|
<Button
|
||||||
|
onClick={handleFingerprintSubmission}
|
||||||
|
disabled={submittingFingerprints}
|
||||||
|
>
|
||||||
|
{submittingFingerprints ? (
|
||||||
|
<LoadingIndicator message="" inline small />
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
Submit <b>{fingerprintQueue.length}</b> Fingerprints
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mr-2">
|
||||||
|
<Button
|
||||||
|
onClick={handleFingerprintSearch}
|
||||||
|
disabled={!canFingerprintSearch() && !loadingFingerprints}
|
||||||
|
>
|
||||||
|
{canFingerprintSearch() && <span>Match Fingerprints</span>}
|
||||||
|
{!canFingerprintSearch() && getFingerprintCount()}
|
||||||
|
{loadingFingerprints && (
|
||||||
|
<LoadingIndicator message="" inline small />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{renderScenes()}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
interface ITaggerProps {
|
||||||
|
scenes: GQL.SlimSceneDataFragment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Tagger: React.FC<ITaggerProps> = ({ scenes }) => {
|
||||||
|
const stashConfig = useConfiguration();
|
||||||
|
const [config, setConfig] = useState<ITaggerConfig>(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 (
|
||||||
|
<div className="tagger-container mx-auto">
|
||||||
|
{selectedEndpointIndex !== -1 && selectedEndpoint ? (
|
||||||
|
<>
|
||||||
|
<div className="row mb-2 no-gutters">
|
||||||
|
<Button onClick={() => setShowConfig(!showConfig)} variant="link">
|
||||||
|
{showConfig ? "Hide" : "Show"} Configuration
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Config config={config} setConfig={setConfig} show={showConfig} />
|
||||||
|
<TaggerList
|
||||||
|
scenes={scenes}
|
||||||
|
config={config}
|
||||||
|
selectedEndpoint={{
|
||||||
|
endpoint: selectedEndpoint.endpoint,
|
||||||
|
index: selectedEndpointIndex,
|
||||||
|
}}
|
||||||
|
queueFingerprintSubmission={queueFingerprintSubmission}
|
||||||
|
clearSubmissionQueue={clearSubmissionQueue}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="my-4">
|
||||||
|
<h3 className="text-center mt-4">
|
||||||
|
To use the scene tagger a stash-box instance needs to be configured.
|
||||||
|
</h3>
|
||||||
|
<h5 className="text-center">
|
||||||
|
Please see{" "}
|
||||||
|
<HashLink
|
||||||
|
to="/settings?tab=configuration#stashbox"
|
||||||
|
scroll={(el) =>
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "center" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Settings.
|
||||||
|
</HashLink>
|
||||||
|
</h5>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
1
ui/v2.5/src/components/Tagger/index.ts
Normal file
1
ui/v2.5/src/components/Tagger/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { Tagger as default } from "./Tagger";
|
||||||
239
ui/v2.5/src/components/Tagger/queries.ts
Normal file
239
ui/v2.5/src/components/Tagger/queries.ts
Normal file
@@ -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<GQL.FindStudiosQuery, GQL.FindStudiosQueryVariables>({
|
||||||
|
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<GQL.FindStudiosQuery, GQL.FindStudiosQueryVariables>({
|
||||||
|
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;
|
||||||
|
};
|
||||||
99
ui/v2.5/src/components/Tagger/styles.scss
Normal file
99
ui/v2.5/src/components/Tagger/styles.scss
Normal file
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
177
ui/v2.5/src/components/Tagger/utils.ts
Normal file
177
ui/v2.5/src/components/Tagger/utils.ts
Normal file
@@ -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;
|
||||||
|
});
|
||||||
@@ -866,3 +866,23 @@ export const stringToGender = (value?: string, caseInsensitive?: boolean) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const getGenderStrings = () => Array.from(stringGenderMap.keys());
|
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 },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|||||||
5
ui/v2.5/src/docs/en/Tagger.md
Normal file
5
ui/v2.5/src/docs/en/Tagger.md
Normal file
@@ -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.
|
||||||
@@ -17,6 +17,7 @@
|
|||||||
@import "src/components/Tags/styles.scss";
|
@import "src/components/Tags/styles.scss";
|
||||||
@import "src/components/Wall/styles.scss";
|
@import "src/components/Wall/styles.scss";
|
||||||
@import "../node_modules/flag-icon-css/css/flag-icon.min.css";
|
@import "../node_modules/flag-icon-css/css/flag-icon.min.css";
|
||||||
|
@import "src/components/Tagger/styles.scss";
|
||||||
|
|
||||||
/* stylelint-disable */
|
/* stylelint-disable */
|
||||||
#root {
|
#root {
|
||||||
@@ -67,10 +68,15 @@ code,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.input-control,
|
.input-control,
|
||||||
.input-control:focus {
|
.input-control:focus,
|
||||||
|
.input-control:disabled {
|
||||||
background-color: $secondary;
|
background-color: $secondary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.input-control:disabled {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
textarea.text-input {
|
textarea.text-input {
|
||||||
line-height: 2.5ex;
|
line-height: 2.5ex;
|
||||||
min-height: 12ex;
|
min-height: 12ex;
|
||||||
@@ -228,14 +234,6 @@ div.dropdown-menu {
|
|||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
||||||
& > :not(:last-child) {
|
|
||||||
margin-right: 7px;
|
|
||||||
}
|
|
||||||
|
|
||||||
& > :last-child {
|
|
||||||
margin-right: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,5 +10,6 @@
|
|||||||
"scenes": "Scenes",
|
"scenes": "Scenes",
|
||||||
"studios": "Studios",
|
"studios": "Studios",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"up-dir": "Up a directory"
|
"up-dir": "Up a directory",
|
||||||
|
"sceneTagger": "Scene Tagger"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ export class PerformerIsMissingCriterion extends IsMissingCriterion {
|
|||||||
"gender",
|
"gender",
|
||||||
"scenes",
|
"scenes",
|
||||||
"image",
|
"image",
|
||||||
|
"stash_id",
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,7 @@ export class ListFilterModel {
|
|||||||
DisplayMode.Grid,
|
DisplayMode.Grid,
|
||||||
DisplayMode.List,
|
DisplayMode.List,
|
||||||
DisplayMode.Wall,
|
DisplayMode.Wall,
|
||||||
|
DisplayMode.Tagger,
|
||||||
];
|
];
|
||||||
this.criterionOptions = [
|
this.criterionOptions = [
|
||||||
new NoneCriterionOption(),
|
new NoneCriterionOption(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export enum DisplayMode {
|
|||||||
Grid,
|
Grid,
|
||||||
List,
|
List,
|
||||||
Wall,
|
Wall,
|
||||||
|
Tagger,
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum FilterMode {
|
export enum FilterMode {
|
||||||
|
|||||||
@@ -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;
|
export default getISOCountry;
|
||||||
|
|||||||
@@ -3148,6 +3148,14 @@
|
|||||||
"@types/react" "*"
|
"@types/react" "*"
|
||||||
"@types/react-router" "*"
|
"@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@*":
|
"@types/react-router@*":
|
||||||
version "5.1.3"
|
version "5.1.3"
|
||||||
resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.3.tgz#7c7ca717399af64d8733d8cb338dd43641b96f2d"
|
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"
|
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
|
||||||
integrity sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==
|
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:
|
babel-code-frame@^6.22.0:
|
||||||
version "6.26.0"
|
version "6.26.0"
|
||||||
resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
|
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"
|
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
|
||||||
integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
|
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:
|
base64-js@^1.0.2:
|
||||||
version "1.3.1"
|
version "1.3.1"
|
||||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
|
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-invariant "^1.0.2"
|
||||||
tiny-warning "^1.0.0"
|
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:
|
react-router@5.2.0:
|
||||||
version "5.2.0"
|
version "5.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293"
|
resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293"
|
||||||
|
|||||||
Reference in New Issue
Block a user