mirror of
https://github.com/stashapp/stash.git
synced 2025-12-06 08:26:00 +01:00
Stash-box tagger integration (#454)
This commit is contained in:
parent
70f73ecf4a
commit
3346f8dcca
75 changed files with 3007 additions and 79 deletions
|
|
@ -52,3 +52,5 @@ models:
|
|||
model: github.com/stashapp/stash/pkg/models.ScrapedMovie
|
||||
ScrapedMovieStudio:
|
||||
model: github.com/stashapp/stash/pkg/models.ScrapedMovieStudio
|
||||
StashID:
|
||||
model: github.com/stashapp/stash/pkg/models.StashID
|
||||
|
|
|
|||
|
|
@ -3,4 +3,8 @@ fragment SlimPerformerData on Performer {
|
|||
name
|
||||
gender
|
||||
image_path
|
||||
stash_ids {
|
||||
endpoint
|
||||
stash_id
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,4 +20,8 @@ fragment PerformerData on Performer {
|
|||
favorite
|
||||
image_path
|
||||
scene_count
|
||||
stash_ids {
|
||||
stash_id
|
||||
endpoint
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -68,4 +68,9 @@ fragment SlimSceneData on Scene {
|
|||
favorite
|
||||
image_path
|
||||
}
|
||||
|
||||
stash_ids {
|
||||
endpoint
|
||||
stash_id
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,4 +56,9 @@ fragment SceneData on Scene {
|
|||
performers {
|
||||
...PerformerData
|
||||
}
|
||||
|
||||
stash_ids {
|
||||
endpoint
|
||||
stash_id
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,8 @@ fragment ScrapedScenePerformerData on ScrapedScenePerformer {
|
|||
tattoos
|
||||
piercings
|
||||
aliases
|
||||
remote_site_id
|
||||
images
|
||||
}
|
||||
|
||||
fragment ScrapedMovieStudioData on ScrapedMovieStudio {
|
||||
|
|
@ -77,6 +79,7 @@ fragment ScrapedSceneStudioData on ScrapedSceneStudio {
|
|||
stored_id
|
||||
name
|
||||
url
|
||||
remote_site_id
|
||||
}
|
||||
|
||||
fragment ScrapedSceneTagData on ScrapedSceneTag {
|
||||
|
|
@ -137,3 +140,46 @@ fragment ScrapedGalleryData on ScrapedGallery {
|
|||
...ScrapedScenePerformerData
|
||||
}
|
||||
}
|
||||
|
||||
fragment ScrapedStashBoxSceneData on ScrapedScene {
|
||||
title
|
||||
details
|
||||
url
|
||||
date
|
||||
image
|
||||
remote_site_id
|
||||
duration
|
||||
|
||||
file {
|
||||
size
|
||||
duration
|
||||
video_codec
|
||||
audio_codec
|
||||
width
|
||||
height
|
||||
framerate
|
||||
bitrate
|
||||
}
|
||||
|
||||
fingerprints {
|
||||
hash
|
||||
algorithm
|
||||
duration
|
||||
}
|
||||
|
||||
studio {
|
||||
...ScrapedSceneStudioData
|
||||
}
|
||||
|
||||
tags {
|
||||
...ScrapedSceneTagData
|
||||
}
|
||||
|
||||
performers {
|
||||
...ScrapedScenePerformerData
|
||||
}
|
||||
|
||||
movies {
|
||||
...ScrapedSceneMovieData
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,4 +2,8 @@ fragment SlimStudioData on Studio {
|
|||
id
|
||||
name
|
||||
image_path
|
||||
}
|
||||
stash_ids {
|
||||
endpoint
|
||||
stash_id
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,4 +21,8 @@ fragment StudioData on Studio {
|
|||
}
|
||||
image_path
|
||||
scene_count
|
||||
stash_ids {
|
||||
stash_id
|
||||
endpoint
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ mutation PerformerCreate(
|
|||
$twitter: String,
|
||||
$instagram: String,
|
||||
$favorite: Boolean,
|
||||
$stash_ids: [StashIDInput!],
|
||||
$image: String) {
|
||||
|
||||
performerCreate(input: {
|
||||
|
|
@ -36,6 +37,7 @@ mutation PerformerCreate(
|
|||
twitter: $twitter,
|
||||
instagram: $instagram,
|
||||
favorite: $favorite,
|
||||
stash_ids: $stash_ids,
|
||||
image: $image
|
||||
}) {
|
||||
...PerformerData
|
||||
|
|
@ -61,6 +63,7 @@ mutation PerformerUpdate(
|
|||
$twitter: String,
|
||||
$instagram: String,
|
||||
$favorite: Boolean,
|
||||
$stash_ids: [StashIDInput!],
|
||||
$image: String) {
|
||||
|
||||
performerUpdate(input: {
|
||||
|
|
@ -82,6 +85,7 @@ mutation PerformerUpdate(
|
|||
twitter: $twitter,
|
||||
instagram: $instagram,
|
||||
favorite: $favorite,
|
||||
stash_ids: $stash_ids,
|
||||
image: $image
|
||||
}) {
|
||||
...PerformerData
|
||||
|
|
@ -90,4 +94,4 @@ mutation PerformerUpdate(
|
|||
|
||||
mutation PerformerDestroy($id: ID!) {
|
||||
performerDestroy(input: { id: $id })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ mutation SceneUpdate(
|
|||
$performer_ids: [ID!] = [],
|
||||
$movies: [SceneMovieInput!] = [],
|
||||
$tag_ids: [ID!] = [],
|
||||
$stash_ids: [StashIDInput!],
|
||||
$cover_image: String) {
|
||||
|
||||
sceneUpdate(input: {
|
||||
|
|
@ -24,6 +25,7 @@ mutation SceneUpdate(
|
|||
performer_ids: $performer_ids,
|
||||
movies: $movies,
|
||||
tag_ids: $tag_ids,
|
||||
stash_ids: $stash_ids,
|
||||
cover_image: $cover_image
|
||||
}) {
|
||||
...SceneData
|
||||
|
|
|
|||
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(
|
||||
$name: String!,
|
||||
$url: String,
|
||||
$image: String
|
||||
$image: String,
|
||||
$stash_ids: [StashIDInput!],
|
||||
$parent_id: ID) {
|
||||
|
||||
studioCreate(input: { name: $name, url: $url, image: $image, parent_id: $parent_id }) {
|
||||
studioCreate(input: { name: $name, url: $url, image: $image, stash_ids: $stash_ids, parent_id: $parent_id }) {
|
||||
...StudioData
|
||||
}
|
||||
}
|
||||
|
|
@ -13,14 +14,15 @@ mutation StudioUpdate(
|
|||
$id: ID!
|
||||
$name: String,
|
||||
$url: String,
|
||||
$image: String
|
||||
$image: String,
|
||||
$stash_ids: [StashIDInput!],
|
||||
$parent_id: ID) {
|
||||
|
||||
studioUpdate(input: { id: $id, name: $name, url: $url, image: $image, parent_id: $parent_id }) {
|
||||
studioUpdate(input: { id: $id, name: $name, url: $url, image: $image, stash_ids: $stash_ids, parent_id: $parent_id }) {
|
||||
...StudioData
|
||||
}
|
||||
}
|
||||
|
||||
mutation StudioDestroy($id: ID!) {
|
||||
studioDestroy(input: { id: $id })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,4 +11,4 @@ query FindPerformer($id: ID!) {
|
|||
findPerformer(id: $id) {
|
||||
...PerformerData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,6 +92,6 @@ query ScrapeMovieURL($url: String!) {
|
|||
|
||||
query QueryStashBoxScene($input: StashBoxQueryInput!) {
|
||||
queryStashBoxScene(input: $input) {
|
||||
...ScrapedSceneData
|
||||
...ScrapedStashBoxSceneData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,4 +11,4 @@ query FindStudio($id: ID!) {
|
|||
findStudio(id: $id) {
|
||||
...StudioData
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,7 +98,6 @@ type Query {
|
|||
"""List available plugin operations"""
|
||||
pluginTasks: [PluginTask!]
|
||||
|
||||
|
||||
# Config
|
||||
"""Returns the current, complete configuration"""
|
||||
configuration: ConfigResult!
|
||||
|
|
@ -222,6 +221,9 @@ type Mutation {
|
|||
reloadPlugins: Boolean!
|
||||
|
||||
stopJob: Boolean!
|
||||
|
||||
""" Submit fingerprints to stash-box instance """
|
||||
submitStashBoxFingerprints(input: StashBoxFingerprintSubmissionInput!): Boolean!
|
||||
}
|
||||
|
||||
type Subscription {
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ input PerformerFilterType {
|
|||
gender: GenderCriterionInput
|
||||
"""Filter to only include performers missing this property"""
|
||||
is_missing: String
|
||||
"""Filter by StashID"""
|
||||
stash_id: String
|
||||
}
|
||||
|
||||
input SceneMarkerFilterType {
|
||||
|
|
@ -86,6 +88,8 @@ input SceneFilterType {
|
|||
tags: MultiCriterionInput
|
||||
"""Filter to only include scenes with these performers"""
|
||||
performers: MultiCriterionInput
|
||||
"""Filter by StashID"""
|
||||
stash_id: String
|
||||
}
|
||||
|
||||
input MovieFilterType {
|
||||
|
|
@ -98,6 +102,8 @@ input MovieFilterType {
|
|||
input StudioFilterType {
|
||||
"""Filter to only include studios with this parent studio"""
|
||||
parents: MultiCriterionInput
|
||||
"""Filter by StashID"""
|
||||
stash_id: String
|
||||
"""Filter to only include studios missing this property"""
|
||||
is_missing: String
|
||||
}
|
||||
|
|
@ -192,4 +198,4 @@ input MultiCriterionInput {
|
|||
input GenderCriterionInput {
|
||||
value: GenderEnum
|
||||
modifier: CriterionModifier!
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ type Performer {
|
|||
image_path: String # Resolver
|
||||
scene_count: Int # Resolver
|
||||
scenes: [Scene!]!
|
||||
stash_ids: [StashID!]!
|
||||
}
|
||||
|
||||
input PerformerCreateInput {
|
||||
|
|
@ -53,6 +54,7 @@ input PerformerCreateInput {
|
|||
favorite: Boolean
|
||||
"""This should be base64 encoded"""
|
||||
image: String
|
||||
stash_ids: [StashIDInput!]
|
||||
}
|
||||
|
||||
input PerformerUpdateInput {
|
||||
|
|
@ -76,6 +78,7 @@ input PerformerUpdateInput {
|
|||
favorite: Boolean
|
||||
"""This should be base64 encoded"""
|
||||
image: String
|
||||
stash_ids: [StashIDInput!]
|
||||
}
|
||||
|
||||
input PerformerDestroyInput {
|
||||
|
|
@ -85,4 +88,4 @@ input PerformerDestroyInput {
|
|||
type FindPerformersResultType {
|
||||
count: Int!
|
||||
performers: [Performer!]!
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -44,6 +44,7 @@ type Scene {
|
|||
movies: [SceneMovie!]!
|
||||
tags: [Tag!]!
|
||||
performers: [Performer!]!
|
||||
stash_ids: [StashID!]!
|
||||
}
|
||||
|
||||
input SceneMovieInput {
|
||||
|
|
@ -66,6 +67,7 @@ input SceneUpdateInput {
|
|||
tag_ids: [ID!]
|
||||
"""This should be base64 encoded"""
|
||||
cover_image: String
|
||||
stash_ids: [StashIDInput!]
|
||||
}
|
||||
|
||||
enum BulkUpdateIdMode {
|
||||
|
|
|
|||
|
|
@ -45,6 +45,9 @@ type ScrapedScenePerformer {
|
|||
tattoos: String
|
||||
piercings: String
|
||||
aliases: String
|
||||
|
||||
remote_site_id: String
|
||||
images: [String!]
|
||||
}
|
||||
|
||||
type ScrapedSceneMovie {
|
||||
|
|
@ -65,6 +68,8 @@ type ScrapedSceneStudio {
|
|||
stored_id: ID
|
||||
name: String!
|
||||
url: String
|
||||
|
||||
remote_site_id: String
|
||||
}
|
||||
|
||||
type ScrapedSceneTag {
|
||||
|
|
@ -88,6 +93,10 @@ type ScrapedScene {
|
|||
tags: [ScrapedSceneTag!]
|
||||
performers: [ScrapedScenePerformer!]
|
||||
movies: [ScrapedSceneMovie!]
|
||||
|
||||
remote_site_id: String
|
||||
duration: Int
|
||||
fingerprints: [StashBoxFingerprint!]
|
||||
}
|
||||
|
||||
type ScrapedGallery {
|
||||
|
|
@ -109,3 +118,9 @@ input StashBoxQueryInput {
|
|||
"""Query by query string"""
|
||||
q: String
|
||||
}
|
||||
|
||||
type StashBoxFingerprint {
|
||||
algorithm: String!
|
||||
hash: String!
|
||||
duration: Int!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,3 +9,18 @@ input StashBoxInput {
|
|||
api_key: String!
|
||||
name: String!
|
||||
}
|
||||
|
||||
type StashID {
|
||||
endpoint: String!
|
||||
stash_id: String!
|
||||
}
|
||||
|
||||
input StashIDInput {
|
||||
endpoint: String!
|
||||
stash_id: String!
|
||||
}
|
||||
|
||||
input StashBoxFingerprintSubmissionInput {
|
||||
scene_ids: [String!]!
|
||||
stash_box_index: Int!
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,10 @@ type Studio {
|
|||
url: String
|
||||
parent_studio: Studio
|
||||
child_studios: [Studio!]!
|
||||
|
||||
image_path: String # Resolver
|
||||
scene_count: Int # Resolver
|
||||
stash_ids: [StashID!]!
|
||||
}
|
||||
|
||||
input StudioCreateInput {
|
||||
|
|
@ -15,6 +17,7 @@ input StudioCreateInput {
|
|||
parent_id: ID
|
||||
"""This should be base64 encoded"""
|
||||
image: String
|
||||
stash_ids: [StashIDInput!]
|
||||
}
|
||||
|
||||
input StudioUpdateInput {
|
||||
|
|
@ -24,6 +27,7 @@ input StudioUpdateInput {
|
|||
parent_id: ID,
|
||||
"""This should be base64 encoded"""
|
||||
image: String
|
||||
stash_ids: [StashIDInput!]
|
||||
}
|
||||
|
||||
input StudioDestroyInput {
|
||||
|
|
@ -33,4 +37,4 @@ input StudioDestroyInput {
|
|||
type FindStudiosResultType {
|
||||
count: Int!
|
||||
studios: [Studio!]!
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,3 +148,8 @@ func (r *performerResolver) Scenes(ctx context.Context, obj *models.Performer) (
|
|||
qb := models.NewSceneQueryBuilder()
|
||||
return qb.FindByPerformerID(obj.ID)
|
||||
}
|
||||
|
||||
func (r *performerResolver) StashIds(ctx context.Context, obj *models.Performer) ([]*models.StashID, error) {
|
||||
qb := models.NewJoinsQueryBuilder()
|
||||
return qb.GetPerformerStashIDs(obj.ID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -150,3 +150,8 @@ func (r *sceneResolver) Performers(ctx context.Context, obj *models.Scene) ([]*m
|
|||
qb := models.NewPerformerQueryBuilder()
|
||||
return qb.FindBySceneID(obj.ID, nil)
|
||||
}
|
||||
|
||||
func (r *sceneResolver) StashIds(ctx context.Context, obj *models.Scene) ([]*models.StashID, error) {
|
||||
qb := models.NewJoinsQueryBuilder()
|
||||
return qb.GetSceneStashIDs(obj.ID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,3 +46,8 @@ func (r *studioResolver) ChildStudios(ctx context.Context, obj *models.Studio) (
|
|||
qb := models.NewStudioQueryBuilder()
|
||||
return qb.FindChildren(obj.ID, nil)
|
||||
}
|
||||
|
||||
func (r *studioResolver) StashIds(ctx context.Context, obj *models.Studio) ([]*models.StashID, error) {
|
||||
qb := models.NewJoinsQueryBuilder()
|
||||
return qb.GetStudioStashIDs(obj.ID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,8 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
|||
// Start the transaction and save the performer
|
||||
tx := database.DB.MustBeginTx(ctx, nil)
|
||||
qb := models.NewPerformerQueryBuilder()
|
||||
jqb := models.NewJoinsQueryBuilder()
|
||||
|
||||
performer, err := qb.Create(newPerformer, tx)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
|
|
@ -102,6 +104,21 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per
|
|||
}
|
||||
}
|
||||
|
||||
// Save the stash_ids
|
||||
if input.StashIds != nil {
|
||||
var stashIDJoins []models.StashID
|
||||
for _, stashID := range input.StashIds {
|
||||
newJoin := models.StashID{
|
||||
StashID: stashID.StashID,
|
||||
Endpoint: stashID.Endpoint,
|
||||
}
|
||||
stashIDJoins = append(stashIDJoins, newJoin)
|
||||
}
|
||||
if err := jqb.UpdatePerformerStashIDs(performer.ID, stashIDJoins, tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Commit
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -187,6 +204,8 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
|||
// Start the transaction and save the performer
|
||||
tx := database.DB.MustBeginTx(ctx, nil)
|
||||
qb := models.NewPerformerQueryBuilder()
|
||||
jqb := models.NewJoinsQueryBuilder()
|
||||
|
||||
performer, err := qb.Update(updatedPerformer, tx)
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
|
|
@ -207,6 +226,21 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per
|
|||
}
|
||||
}
|
||||
|
||||
// Save the stash_ids
|
||||
if input.StashIds != nil {
|
||||
var stashIDJoins []models.StashID
|
||||
for _, stashID := range input.StashIds {
|
||||
newJoin := models.StashID{
|
||||
StashID: stashID.StashID,
|
||||
Endpoint: stashID.Endpoint,
|
||||
}
|
||||
stashIDJoins = append(stashIDJoins, newJoin)
|
||||
}
|
||||
if err := jqb.UpdatePerformerStashIDs(performerID, stashIDJoins, tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Commit
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
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
|
||||
tx := database.DB.MustBeginTx(ctx, nil)
|
||||
qb := models.NewStudioQueryBuilder()
|
||||
jqb := models.NewJoinsQueryBuilder()
|
||||
|
||||
studio, err := qb.Create(newStudio, tx)
|
||||
if err != nil {
|
||||
_ = tx.Rollback()
|
||||
|
|
@ -62,6 +64,21 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
|||
}
|
||||
}
|
||||
|
||||
// Save the stash_ids
|
||||
if input.StashIds != nil {
|
||||
var stashIDJoins []models.StashID
|
||||
for _, stashID := range input.StashIds {
|
||||
newJoin := models.StashID{
|
||||
StashID: stashID.StashID,
|
||||
Endpoint: stashID.Endpoint,
|
||||
}
|
||||
stashIDJoins = append(stashIDJoins, newJoin)
|
||||
}
|
||||
if err := jqb.UpdateStudioStashIDs(studio.ID, stashIDJoins, tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Commit
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
|
|
@ -109,6 +126,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
|||
// Start the transaction and save the studio
|
||||
tx := database.DB.MustBeginTx(ctx, nil)
|
||||
qb := models.NewStudioQueryBuilder()
|
||||
jqb := models.NewJoinsQueryBuilder()
|
||||
|
||||
if err := manager.ValidateModifyStudio(updatedStudio, tx); err != nil {
|
||||
tx.Rollback()
|
||||
|
|
@ -135,6 +153,21 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio
|
|||
}
|
||||
}
|
||||
|
||||
// Save the stash_ids
|
||||
if input.StashIds != nil {
|
||||
var stashIDJoins []models.StashID
|
||||
for _, stashID := range input.StashIds {
|
||||
newJoin := models.StashID{
|
||||
StashID: stashID.StashID,
|
||||
Endpoint: stashID.Endpoint,
|
||||
}
|
||||
stashIDJoins = append(stashIDJoins, newJoin)
|
||||
}
|
||||
if err := jqb.UpdateStudioStashIDs(studioID, stashIDJoins, tx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// Commit
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import (
|
|||
|
||||
var DB *sqlx.DB
|
||||
var dbPath string
|
||||
var appSchemaVersion uint = 13
|
||||
var appSchemaVersion uint = 14
|
||||
var databaseSchemaVersion uint
|
||||
|
||||
const sqlite3Driver = "sqlite3ex"
|
||||
|
|
|
|||
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"`
|
||||
}
|
||||
|
||||
type StashID struct {
|
||||
StashID string `db:"stash_id" json:"stash_id"`
|
||||
Endpoint string `db:"endpoint" json:"endpoint"`
|
||||
}
|
||||
|
||||
type PerformersImages struct {
|
||||
PerformerID int `db:"performer_id" json:"performer_id"`
|
||||
ImageID int `db:"image_id" json:"image_id"`
|
||||
|
|
|
|||
|
|
@ -64,16 +64,19 @@ type ScrapedPerformerStash struct {
|
|||
}
|
||||
|
||||
type ScrapedScene struct {
|
||||
Title *string `graphql:"title" json:"title"`
|
||||
Details *string `graphql:"details" json:"details"`
|
||||
URL *string `graphql:"url" json:"url"`
|
||||
Date *string `graphql:"date" json:"date"`
|
||||
Image *string `graphql:"image" json:"image"`
|
||||
File *SceneFileType `graphql:"file" json:"file"`
|
||||
Studio *ScrapedSceneStudio `graphql:"studio" json:"studio"`
|
||||
Movies []*ScrapedSceneMovie `graphql:"movies" json:"movies"`
|
||||
Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"`
|
||||
Performers []*ScrapedScenePerformer `graphql:"performers" json:"performers"`
|
||||
Title *string `graphql:"title" json:"title"`
|
||||
Details *string `graphql:"details" json:"details"`
|
||||
URL *string `graphql:"url" json:"url"`
|
||||
Date *string `graphql:"date" json:"date"`
|
||||
Image *string `graphql:"image" json:"image"`
|
||||
RemoteSiteID *string `graphql:"remote_site_id" json:"remote_site_id"`
|
||||
Duration *int `graphql:"duration" json:"duration"`
|
||||
File *SceneFileType `graphql:"file" json:"file"`
|
||||
Fingerprints []*StashBoxFingerprint `graphql:"fingerprints" json:"fingerprints"`
|
||||
Studio *ScrapedSceneStudio `graphql:"studio" json:"studio"`
|
||||
Movies []*ScrapedSceneMovie `graphql:"movies" json:"movies"`
|
||||
Tags []*ScrapedSceneTag `graphql:"tags" json:"tags"`
|
||||
Performers []*ScrapedScenePerformer `graphql:"performers" json:"performers"`
|
||||
}
|
||||
|
||||
// stash doesn't return image, and we need id
|
||||
|
|
@ -103,30 +106,33 @@ type ScrapedGalleryStash struct {
|
|||
|
||||
type ScrapedScenePerformer struct {
|
||||
// Set if performer matched
|
||||
ID *string `graphql:"id" json:"id"`
|
||||
Name string `graphql:"name" json:"name"`
|
||||
Gender *string `graphql:"gender" json:"gender"`
|
||||
URL *string `graphql:"url" json:"url"`
|
||||
Twitter *string `graphql:"twitter" json:"twitter"`
|
||||
Instagram *string `graphql:"instagram" json:"instagram"`
|
||||
Birthdate *string `graphql:"birthdate" json:"birthdate"`
|
||||
Ethnicity *string `graphql:"ethnicity" json:"ethnicity"`
|
||||
Country *string `graphql:"country" json:"country"`
|
||||
EyeColor *string `graphql:"eye_color" json:"eye_color"`
|
||||
Height *string `graphql:"height" json:"height"`
|
||||
Measurements *string `graphql:"measurements" json:"measurements"`
|
||||
FakeTits *string `graphql:"fake_tits" json:"fake_tits"`
|
||||
CareerLength *string `graphql:"career_length" json:"career_length"`
|
||||
Tattoos *string `graphql:"tattoos" json:"tattoos"`
|
||||
Piercings *string `graphql:"piercings" json:"piercings"`
|
||||
Aliases *string `graphql:"aliases" json:"aliases"`
|
||||
ID *string `graphql:"id" json:"id"`
|
||||
Name string `graphql:"name" json:"name"`
|
||||
Gender *string `graphql:"gender" json:"gender"`
|
||||
URL *string `graphql:"url" json:"url"`
|
||||
Twitter *string `graphql:"twitter" json:"twitter"`
|
||||
Instagram *string `graphql:"instagram" json:"instagram"`
|
||||
Birthdate *string `graphql:"birthdate" json:"birthdate"`
|
||||
Ethnicity *string `graphql:"ethnicity" json:"ethnicity"`
|
||||
Country *string `graphql:"country" json:"country"`
|
||||
EyeColor *string `graphql:"eye_color" json:"eye_color"`
|
||||
Height *string `graphql:"height" json:"height"`
|
||||
Measurements *string `graphql:"measurements" json:"measurements"`
|
||||
FakeTits *string `graphql:"fake_tits" json:"fake_tits"`
|
||||
CareerLength *string `graphql:"career_length" json:"career_length"`
|
||||
Tattoos *string `graphql:"tattoos" json:"tattoos"`
|
||||
Piercings *string `graphql:"piercings" json:"piercings"`
|
||||
Aliases *string `graphql:"aliases" json:"aliases"`
|
||||
RemoteSiteID *string `graphql:"remote_site_id" json:"remote_site_id"`
|
||||
Images []string `graphql:"images" json:"images"`
|
||||
}
|
||||
|
||||
type ScrapedSceneStudio struct {
|
||||
// Set if studio matched
|
||||
ID *string `graphql:"id" json:"id"`
|
||||
Name string `graphql:"name" json:"name"`
|
||||
URL *string `graphql:"url" json:"url"`
|
||||
ID *string `graphql:"id" json:"id"`
|
||||
Name string `graphql:"name" json:"name"`
|
||||
URL *string `graphql:"url" json:"url"`
|
||||
RemoteSiteID *string `graphql:"remote_site_id" json:"remote_site_id"`
|
||||
}
|
||||
|
||||
type ScrapedSceneMovie struct {
|
||||
|
|
|
|||
|
|
@ -366,6 +366,18 @@ func (qb *JoinsQueryBuilder) DestroyScenesMarkers(sceneID int, tx *sqlx.Tx) erro
|
|||
return err
|
||||
}
|
||||
|
||||
func (qb *JoinsQueryBuilder) CreateStashIDs(entityName string, entityID int, newJoins []StashID, tx *sqlx.Tx) error {
|
||||
query := "INSERT INTO " + entityName + "_stash_ids (" + entityName + "_id, endpoint, stash_id) VALUES (?, ?, ?)"
|
||||
ensureTx(tx)
|
||||
for _, join := range newJoins {
|
||||
_, err := tx.Exec(query, entityID, join.Endpoint, join.StashID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (qb *JoinsQueryBuilder) GetImagePerformers(imageID int, tx *sqlx.Tx) ([]PerformersImages, error) {
|
||||
ensureTx(tx)
|
||||
|
||||
|
|
@ -885,3 +897,105 @@ func (qb *JoinsQueryBuilder) DestroyGalleriesTags(galleryID int, tx *sqlx.Tx) er
|
|||
|
||||
return err
|
||||
}
|
||||
|
||||
func (qb *JoinsQueryBuilder) GetSceneStashIDs(sceneID int) ([]*StashID, error) {
|
||||
rows, err := database.DB.Queryx(`SELECT stash_id, endpoint from scene_stash_ids WHERE scene_id = ?`, sceneID)
|
||||
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
stashIDs := []*StashID{}
|
||||
for rows.Next() {
|
||||
stashID := StashID{}
|
||||
if err := rows.StructScan(&stashID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stashIDs = append(stashIDs, &stashID)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stashIDs, nil
|
||||
}
|
||||
|
||||
func (qb *JoinsQueryBuilder) GetPerformerStashIDs(performerID int) ([]*StashID, error) {
|
||||
rows, err := database.DB.Queryx(`SELECT stash_id, endpoint from performer_stash_ids WHERE performer_id = ?`, performerID)
|
||||
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
stashIDs := []*StashID{}
|
||||
for rows.Next() {
|
||||
stashID := StashID{}
|
||||
if err := rows.StructScan(&stashID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stashIDs = append(stashIDs, &stashID)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stashIDs, nil
|
||||
}
|
||||
|
||||
func (qb *JoinsQueryBuilder) GetStudioStashIDs(studioID int) ([]*StashID, error) {
|
||||
rows, err := database.DB.Queryx(`SELECT stash_id, endpoint from studio_stash_ids WHERE studio_id = ?`, studioID)
|
||||
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
stashIDs := []*StashID{}
|
||||
for rows.Next() {
|
||||
stashID := StashID{}
|
||||
if err := rows.StructScan(&stashID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stashIDs = append(stashIDs, &stashID)
|
||||
}
|
||||
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return stashIDs, nil
|
||||
}
|
||||
|
||||
func (qb *JoinsQueryBuilder) UpdateSceneStashIDs(sceneID int, updatedJoins []StashID, tx *sqlx.Tx) error {
|
||||
ensureTx(tx)
|
||||
|
||||
_, err := tx.Exec("DELETE FROM scene_stash_ids WHERE scene_id = ?", sceneID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return qb.CreateStashIDs("scene", sceneID, updatedJoins, tx)
|
||||
}
|
||||
|
||||
func (qb *JoinsQueryBuilder) UpdatePerformerStashIDs(performerID int, updatedJoins []StashID, tx *sqlx.Tx) error {
|
||||
ensureTx(tx)
|
||||
|
||||
_, err := tx.Exec("DELETE FROM performer_stash_ids WHERE performer_id = ?", performerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return qb.CreateStashIDs("performer", performerID, updatedJoins, tx)
|
||||
}
|
||||
|
||||
func (qb *JoinsQueryBuilder) UpdateStudioStashIDs(studioID int, updatedJoins []StashID, tx *sqlx.Tx) error {
|
||||
ensureTx(tx)
|
||||
|
||||
_, err := tx.Exec("DELETE FROM studio_stash_ids WHERE studio_id = ?", studioID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return qb.CreateStashIDs("studio", studioID, updatedJoins, tx)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -224,6 +224,14 @@ func (qb *PerformerQueryBuilder) Query(performerFilter *PerformerFilterType, fin
|
|||
}
|
||||
}
|
||||
|
||||
if stashIDFilter := performerFilter.StashID; stashIDFilter != nil {
|
||||
query.body += `
|
||||
JOIN performer_stash_ids on performer_stash_ids.performer_id = performers.id
|
||||
`
|
||||
query.addWhere("performer_stash_ids.stash_id = ?")
|
||||
query.addArg(stashIDFilter)
|
||||
}
|
||||
|
||||
query.handleStringCriterionInput(performerFilter.Ethnicity, tableName+".ethnicity")
|
||||
query.handleStringCriterionInput(performerFilter.Country, tableName+".country")
|
||||
query.handleStringCriterionInput(performerFilter.EyeColor, tableName+".eye_color")
|
||||
|
|
|
|||
|
|
@ -404,6 +404,14 @@ func (qb *SceneQueryBuilder) Query(sceneFilter *SceneFilterType, findFilter *Fin
|
|||
query.addHaving(havingClause)
|
||||
}
|
||||
|
||||
if stashIDFilter := sceneFilter.StashID; stashIDFilter != nil {
|
||||
query.body += `
|
||||
JOIN scene_stash_ids on scene_stash_ids.scene_id = scenes.id
|
||||
`
|
||||
query.addWhere("scene_stash_ids.stash_id = ?")
|
||||
query.addArg(stashIDFilter)
|
||||
}
|
||||
|
||||
query.sortAndPagination = qb.getSceneSort(findFilter) + getPagination(findFilter)
|
||||
idsResult, countResult := query.executeFind()
|
||||
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ func (qb *StudioQueryBuilder) Create(newStudio Studio, tx *sqlx.Tx) (*Studio, er
|
|||
ensureTx(tx)
|
||||
result, err := tx.NamedExec(
|
||||
`INSERT INTO studios (checksum, name, url, parent_id, created_at, updated_at)
|
||||
VALUES (:checksum, :name, :url, :parent_id, :created_at, :updated_at)
|
||||
VALUES (:checksum, :name, :url, :parent_id, :created_at, :updated_at)
|
||||
`,
|
||||
newStudio,
|
||||
)
|
||||
|
|
@ -182,6 +182,14 @@ func (qb *StudioQueryBuilder) Query(studioFilter *StudioFilterType, findFilter *
|
|||
havingClauses = appendClause(havingClauses, havingClause)
|
||||
}
|
||||
|
||||
if stashIDFilter := studioFilter.StashID; stashIDFilter != nil {
|
||||
body += `
|
||||
JOIN studio_stash_ids on studio_stash_ids.studio_id = studios.id
|
||||
`
|
||||
whereClauses = append(whereClauses, "studio_stash_ids.stash_id = ?")
|
||||
args = append(args, stashIDFilter)
|
||||
}
|
||||
|
||||
if isMissingFilter := studioFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
|
||||
switch *isMissingFilter {
|
||||
case "image":
|
||||
|
|
|
|||
|
|
@ -113,6 +113,76 @@ func (c Client) findStashBoxScenesByFingerprints(fingerprints []string) ([]*mode
|
|||
return ret, nil
|
||||
}
|
||||
|
||||
func (c Client) SubmitStashBoxFingerprints(sceneIDs []string, endpoint string) (bool, error) {
|
||||
qb := models.NewSceneQueryBuilder()
|
||||
jqb := models.NewJoinsQueryBuilder()
|
||||
|
||||
var fingerprints []graphql.FingerprintSubmission
|
||||
|
||||
for _, sceneID := range sceneIDs {
|
||||
idInt, _ := strconv.Atoi(sceneID)
|
||||
scene, err := qb.Find(idInt)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if scene == nil {
|
||||
return false, fmt.Errorf("scene with id %d not found", idInt)
|
||||
}
|
||||
|
||||
stashIDs, err := jqb.GetSceneStashIDs(idInt)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
sceneStashID := ""
|
||||
for _, stashID := range stashIDs {
|
||||
if stashID.Endpoint == endpoint {
|
||||
sceneStashID = stashID.StashID
|
||||
}
|
||||
}
|
||||
|
||||
if sceneStashID != "" {
|
||||
if scene.Checksum.Valid && scene.Duration.Valid {
|
||||
fingerprint := graphql.FingerprintInput{
|
||||
Hash: scene.Checksum.String,
|
||||
Algorithm: graphql.FingerprintAlgorithmMd5,
|
||||
Duration: int(scene.Duration.Float64),
|
||||
}
|
||||
fingerprints = append(fingerprints, graphql.FingerprintSubmission{
|
||||
SceneID: sceneStashID,
|
||||
Fingerprint: &fingerprint,
|
||||
})
|
||||
}
|
||||
|
||||
if scene.OSHash.Valid && scene.Duration.Valid {
|
||||
fingerprint := graphql.FingerprintInput{
|
||||
Hash: scene.OSHash.String,
|
||||
Algorithm: graphql.FingerprintAlgorithmOshash,
|
||||
Duration: int(scene.Duration.Float64),
|
||||
}
|
||||
fingerprints = append(fingerprints, graphql.FingerprintSubmission{
|
||||
SceneID: sceneStashID,
|
||||
Fingerprint: &fingerprint,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.submitStashBoxFingerprints(fingerprints)
|
||||
}
|
||||
|
||||
func (c Client) submitStashBoxFingerprints(fingerprints []graphql.FingerprintSubmission) (bool, error) {
|
||||
for _, fingerprint := range fingerprints {
|
||||
_, err := c.client.SubmitFingerprint(context.TODO(), fingerprint)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func findURL(urls []*graphql.URLFragment, urlType string) *string {
|
||||
for _, u := range urls {
|
||||
if u.Type == urlType {
|
||||
|
|
@ -209,6 +279,11 @@ func fetchImage(url string) (*string, error) {
|
|||
}
|
||||
|
||||
func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *models.ScrapedScenePerformer {
|
||||
id := p.ID
|
||||
images := []string{}
|
||||
for _, image := range p.Images {
|
||||
images = append(images, image.URL)
|
||||
}
|
||||
sp := &models.ScrapedScenePerformer{
|
||||
Name: p.Name,
|
||||
Country: p.Country,
|
||||
|
|
@ -217,11 +292,13 @@ func performerFragmentToScrapedScenePerformer(p graphql.PerformerFragment) *mode
|
|||
Tattoos: formatBodyModifications(p.Tattoos),
|
||||
Piercings: formatBodyModifications(p.Piercings),
|
||||
Twitter: findURL(p.Urls, "TWITTER"),
|
||||
RemoteSiteID: &id,
|
||||
Images: images,
|
||||
// TODO - Image - should be returned as a set of URLs. Will need a
|
||||
// graphql schema change to accommodate this. Leave off for now.
|
||||
}
|
||||
|
||||
if p.Height != nil {
|
||||
if p.Height != nil && *p.Height > 0 {
|
||||
hs := strconv.Itoa(*p.Height)
|
||||
sp.Height = &hs
|
||||
}
|
||||
|
|
@ -259,12 +336,29 @@ func getFirstImage(images []*graphql.ImageFragment) *string {
|
|||
return ret
|
||||
}
|
||||
|
||||
func getFingerprints(scene *graphql.SceneFragment) []*models.StashBoxFingerprint {
|
||||
fingerprints := []*models.StashBoxFingerprint{}
|
||||
for _, fp := range scene.Fingerprints {
|
||||
fingerprint := models.StashBoxFingerprint{
|
||||
Algorithm: fp.Algorithm.String(),
|
||||
Hash: fp.Hash,
|
||||
Duration: fp.Duration,
|
||||
}
|
||||
fingerprints = append(fingerprints, &fingerprint)
|
||||
}
|
||||
return fingerprints
|
||||
}
|
||||
|
||||
func sceneFragmentToScrapedScene(s *graphql.SceneFragment) (*models.ScrapedScene, error) {
|
||||
stashID := s.ID
|
||||
ss := &models.ScrapedScene{
|
||||
Title: s.Title,
|
||||
Date: s.Date,
|
||||
Details: s.Details,
|
||||
URL: findURL(s.Urls, "STUDIO"),
|
||||
Title: s.Title,
|
||||
Date: s.Date,
|
||||
Details: s.Details,
|
||||
URL: findURL(s.Urls, "STUDIO"),
|
||||
Duration: s.Duration,
|
||||
RemoteSiteID: &stashID,
|
||||
Fingerprints: getFingerprints(s),
|
||||
// Image
|
||||
// stash_id
|
||||
}
|
||||
|
|
@ -276,9 +370,11 @@ func sceneFragmentToScrapedScene(s *graphql.SceneFragment) (*models.ScrapedScene
|
|||
}
|
||||
|
||||
if s.Studio != nil {
|
||||
studioID := s.Studio.ID
|
||||
ss.Studio = &models.ScrapedSceneStudio{
|
||||
Name: s.Studio.Name,
|
||||
URL: findURL(s.Studio.Urls, "HOME"),
|
||||
Name: s.Studio.Name,
|
||||
URL: findURL(s.Studio.Urls, "HOME"),
|
||||
RemoteSiteID: &studioID,
|
||||
}
|
||||
|
||||
err := models.MatchScrapedSceneStudio(ss.Studio)
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
"@types/mousetrap": "^1.6.3",
|
||||
"apollo-upload-client": "^14.1.2",
|
||||
"axios": "0.20.0",
|
||||
"base64-blob": "^1.4.1",
|
||||
"bootstrap": "^4.5.2",
|
||||
"classnames": "^2.2.6",
|
||||
"flag-icon-css": "^3.5.0",
|
||||
|
|
@ -59,6 +60,7 @@
|
|||
"react-photo-gallery": "^8.0.0",
|
||||
"react-router-bootstrap": "^0.25.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"react-router-hash-link": "^2.1.0",
|
||||
"react-select": "^3.1.0",
|
||||
"string.prototype.replaceall": "^1.0.3",
|
||||
"subscriptions-transport-ws": "^0.9.18",
|
||||
|
|
@ -80,6 +82,7 @@
|
|||
"@types/react-images": "^0.5.3",
|
||||
"@types/react-router-bootstrap": "^0.24.5",
|
||||
"@types/react-router-dom": "5.1.5",
|
||||
"@types/react-router-hash-link": "^1.2.1",
|
||||
"@types/react-select": "3.0.19",
|
||||
"@typescript-eslint/eslint-plugin": "^2.30.0",
|
||||
"@typescript-eslint/parser": "^2.30.0",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
### ✨ New Features
|
||||
* Add stash-box tagger to scenes page.
|
||||
* Add filters tab in scene page.
|
||||
* Add selectable streaming quality profiles in the scene player.
|
||||
* Add scrapers list setting page.
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import Interface from "src/docs/en/Interface.md";
|
|||
import Galleries from "src/docs/en/Galleries.md";
|
||||
import Scraping from "src/docs/en/Scraping.md";
|
||||
import Plugins from "src/docs/en/Plugins.md";
|
||||
import Tagger from "src/docs/en/Tagger.md";
|
||||
import Contributing from "src/docs/en/Contributing.md";
|
||||
import SceneFilenameParser from "src/docs/en/SceneFilenameParser.md";
|
||||
import KeyboardShortcuts from "src/docs/en/KeyboardShortcuts.md";
|
||||
|
|
@ -75,6 +76,11 @@ export const Manual: React.FC<IManualProps> = ({ show, onClose }) => {
|
|||
title: "Plugins",
|
||||
content: Plugins,
|
||||
},
|
||||
{
|
||||
key: "Tagger.md",
|
||||
title: "Scene Tagger",
|
||||
content: Tagger,
|
||||
},
|
||||
{
|
||||
key: "KeyboardShortcuts.md",
|
||||
title: "Keyboard Shortcuts",
|
||||
|
|
|
|||
|
|
@ -254,6 +254,8 @@ export const ListFilter: React.FC<IListFilterProps> = (
|
|||
return "list";
|
||||
case DisplayMode.Wall:
|
||||
return "square";
|
||||
case DisplayMode.Tagger:
|
||||
return "tags";
|
||||
}
|
||||
}
|
||||
function getLabel(option: DisplayMode) {
|
||||
|
|
@ -264,6 +266,8 @@ export const ListFilter: React.FC<IListFilterProps> = (
|
|||
return "List";
|
||||
case DisplayMode.Wall:
|
||||
return "Wall";
|
||||
case DisplayMode.Tagger:
|
||||
return "Tagger";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ const messages = defineMessages({
|
|||
id: "galleries",
|
||||
defaultMessage: "Galleries",
|
||||
},
|
||||
sceneTagger: {
|
||||
id: "sceneTagger",
|
||||
defaultMessage: "Scene Tagger",
|
||||
},
|
||||
});
|
||||
|
||||
const menuItems: IMenuItem[] = [
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||
const [twitter, setTwitter] = useState<string>();
|
||||
const [instagram, setInstagram] = useState<string>();
|
||||
const [gender, setGender] = useState<string | undefined>(undefined);
|
||||
const [stashIDs, setStashIDs] = useState<GQL.StashIdInput[]>([]);
|
||||
|
||||
// Network state
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
|
@ -121,6 +122,9 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||
setGender(
|
||||
genderToString((state as GQL.PerformerDataFragment).gender ?? undefined)
|
||||
);
|
||||
if ((state as GQL.PerformerDataFragment).stash_ids !== undefined) {
|
||||
setStashIDs((state as GQL.PerformerDataFragment).stash_ids);
|
||||
}
|
||||
}
|
||||
|
||||
function translateScrapedGender(scrapedGender?: string) {
|
||||
|
|
@ -288,6 +292,10 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||
instagram,
|
||||
image,
|
||||
gender: stringToGender(gender),
|
||||
stash_ids: stashIDs.map((s) => ({
|
||||
stash_id: s.stash_id,
|
||||
endpoint: s.endpoint,
|
||||
})),
|
||||
};
|
||||
|
||||
if (!isNew) {
|
||||
|
|
@ -623,6 +631,60 @@ export const PerformerDetailsPanel: React.FC<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 = () => {
|
||||
if (isEditing) {
|
||||
return height;
|
||||
|
|
@ -720,6 +782,7 @@ export const PerformerDetailsPanel: React.FC<IPerformerDetails> = ({
|
|||
isEditing: !!isEditing,
|
||||
onChange: setInstagram,
|
||||
})}
|
||||
{renderStashIDs()}
|
||||
</tbody>
|
||||
</Table>
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||
>(new Map());
|
||||
const [tagIds, setTagIds] = useState<string[]>();
|
||||
const [coverImage, setCoverImage] = useState<string>();
|
||||
const [stashIDs, setStashIDs] = useState<GQL.StashIdInput[]>([]);
|
||||
|
||||
const Scrapers = useListSceneScrapers();
|
||||
const [queryableScrapers, setQueryableScrapers] = useState<GQL.Scraper[]>([]);
|
||||
|
|
@ -174,6 +175,7 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||
setMovieSceneIndexes(movieSceneIdx);
|
||||
setPerformerIds(perfIds);
|
||||
setTagIds(tIds);
|
||||
setStashIDs(state?.stash_ids ?? []);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -198,6 +200,10 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||
movies: makeMovieInputs(),
|
||||
tag_ids: tagIds,
|
||||
cover_image: coverImage,
|
||||
stash_ids: stashIDs.map((s) => ({
|
||||
stash_id: s.stash_id,
|
||||
endpoint: s.endpoint,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -233,6 +239,15 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||
setIsLoading(false);
|
||||
}
|
||||
|
||||
const removeStashID = (stashID: GQL.StashIdInput) => {
|
||||
setStashIDs(
|
||||
stashIDs.filter(
|
||||
(s) =>
|
||||
!(s.endpoint === stashID.endpoint && s.stash_id === stashID.stash_id)
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
function renderTableMovies() {
|
||||
return (
|
||||
<SceneMovieTable
|
||||
|
|
@ -659,6 +674,40 @@ export const SceneEditPanel: React.FC<IProps> = (props: IProps) => {
|
|||
</Col>
|
||||
</Form.Group>
|
||||
</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">
|
||||
<Form.Group controlId="details">
|
||||
<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 (
|
||||
<div className="container scene-file-info">
|
||||
{renderOSHash()}
|
||||
|
|
@ -203,6 +236,7 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
|
|||
{renderVideoCodec()}
|
||||
{renderAudioCodec()}
|
||||
{renderUrl()}
|
||||
{renderStashIDs()}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import { useScenesList } from "src/hooks";
|
|||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { DisplayMode } from "src/models/list-filter/types";
|
||||
import { showWhenSelected } from "src/hooks/ListHook";
|
||||
import Tagger from "src/components/Tagger";
|
||||
import { WallPanel } from "../Wall/WallPanel";
|
||||
import { SceneCard } from "./SceneCard";
|
||||
import { SceneListTable } from "./SceneListTable";
|
||||
|
|
@ -214,6 +215,9 @@ export const SceneList: React.FC<ISceneList> = ({
|
|||
if (filter.displayMode === DisplayMode.Wall) {
|
||||
return <WallPanel scenes={result.data.findScenes.scenes} />;
|
||||
}
|
||||
if (filter.displayMode === DisplayMode.Tagger) {
|
||||
return <Tagger scenes={result.data.findScenes.scenes} />;
|
||||
}
|
||||
}
|
||||
|
||||
function renderContent(
|
||||
|
|
|
|||
|
|
@ -691,10 +691,12 @@ export const SettingsConfigurationPanel: React.FC = () => {
|
|||
</Form.Group>
|
||||
|
||||
<hr />
|
||||
<Form.Group>
|
||||
|
||||
<Form.Group id="stashbox">
|
||||
<h4>Stash-box integration</h4>
|
||||
<StashBoxConfiguration boxes={stashBoxes} saveBoxes={setStashBoxes} />
|
||||
</Form.Group>
|
||||
|
||||
<hr />
|
||||
|
||||
<Form.Group>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import cx from "classnames";
|
|||
interface ILoadingProps {
|
||||
message?: string;
|
||||
inline?: boolean;
|
||||
small?: boolean;
|
||||
}
|
||||
|
||||
const CLASSNAME = "LoadingIndicator";
|
||||
|
|
@ -13,12 +14,15 @@ const CLASSNAME_MESSAGE = `${CLASSNAME}-message`;
|
|||
const LoadingIndicator: React.FC<ILoadingProps> = ({
|
||||
message,
|
||||
inline = false,
|
||||
small = false,
|
||||
}) => (
|
||||
<div className={cx(CLASSNAME, { inline })}>
|
||||
<Spinner animation="border" role="status">
|
||||
<div className={cx(CLASSNAME, { inline, small })}>
|
||||
<Spinner animation="border" role="status" size={small ? "sm" : undefined}>
|
||||
<span className="sr-only">Loading...</span>
|
||||
</Spinner>
|
||||
<h4 className={CLASSNAME_MESSAGE}>{message ?? "Loading..."}</h4>
|
||||
{message !== "" && (
|
||||
<h4 className={CLASSNAME_MESSAGE}>{message ?? "Loading..."}</h4>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ interface IModal {
|
|||
isRunning?: boolean;
|
||||
disabled?: boolean;
|
||||
modalProps?: ModalProps;
|
||||
dialogClassName?: string;
|
||||
}
|
||||
|
||||
const ModalComponent: React.FC<IModal> = ({
|
||||
|
|
@ -32,8 +33,15 @@ const ModalComponent: React.FC<IModal> = ({
|
|||
isRunning,
|
||||
disabled,
|
||||
modalProps,
|
||||
dialogClassName,
|
||||
}) => (
|
||||
<Modal keyboard={false} onHide={onHide} show={show} {...modalProps}>
|
||||
<Modal
|
||||
keyboard={false}
|
||||
onHide={onHide}
|
||||
show={show}
|
||||
dialogClassName={dialogClassName}
|
||||
{...modalProps}
|
||||
>
|
||||
<Modal.Header>
|
||||
{icon ? <Icon icon={icon} /> : ""}
|
||||
<span>{header ?? ""}</span>
|
||||
|
|
@ -46,6 +54,7 @@ const ModalComponent: React.FC<IModal> = ({
|
|||
disabled={isRunning}
|
||||
variant={cancel.variant ?? "primary"}
|
||||
onClick={cancel.onClick}
|
||||
className="mr-2"
|
||||
>
|
||||
{cancel.text ?? "Cancel"}
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ import { FilterSelect } from "./Select";
|
|||
type ValidTypes =
|
||||
| GQL.SlimPerformerDataFragment
|
||||
| GQL.Tag
|
||||
| GQL.SlimStudioDataFragment;
|
||||
| GQL.SlimStudioDataFragment
|
||||
| GQL.SlimMovieDataFragment;
|
||||
|
||||
interface IMultiSetProps {
|
||||
type: "performers" | "studios" | "tags";
|
||||
|
|
|
|||
|
|
@ -20,10 +20,11 @@ import { useToast } from "src/hooks";
|
|||
import { ListFilterModel } from "src/models/list-filter/filter";
|
||||
import { FilterMode } from "src/models/list-filter/types";
|
||||
|
||||
type ValidTypes =
|
||||
export type ValidTypes =
|
||||
| GQL.SlimPerformerDataFragment
|
||||
| GQL.Tag
|
||||
| GQL.SlimStudioDataFragment;
|
||||
| GQL.SlimStudioDataFragment
|
||||
| GQL.SlimMovieDataFragment;
|
||||
type Option = { value: string; label: string };
|
||||
|
||||
interface ITypeProps {
|
||||
|
|
@ -63,13 +64,9 @@ interface ISelectProps {
|
|||
groupHeader?: string;
|
||||
closeMenuOnSelect?: boolean;
|
||||
}
|
||||
interface IFilterItem {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
}
|
||||
interface IFilterComponentProps extends IFilterProps {
|
||||
items: Array<IFilterItem>;
|
||||
onCreate?: (name: string) => Promise<{ item: IFilterItem; message: string }>;
|
||||
items: Array<ValidTypes>;
|
||||
onCreate?: (name: string) => Promise<{ item: ValidTypes; message: string }>;
|
||||
}
|
||||
interface IFilterSelectProps
|
||||
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 { SweatDrops } from "./SweatDrops";
|
||||
export { default as CountryFlag } from "./CountryFlag";
|
||||
export { default as SuccessIcon } from "./SuccessIcon";
|
||||
export { default as ErrorMessage } from "./ErrorMessage";
|
||||
|
|
|
|||
|
|
@ -16,7 +16,13 @@
|
|||
}
|
||||
|
||||
&.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() {
|
||||
setIsEditing(!isEditing);
|
||||
updateStudioData(studio);
|
||||
|
|
@ -263,6 +298,7 @@ export const Studio: React.FC = () => {
|
|||
<td>Parent Studio</td>
|
||||
<td>{renderStudio()}</td>
|
||||
</tr>
|
||||
{!isEditing && renderStashIDs()}
|
||||
</tbody>
|
||||
</Table>
|
||||
<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 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/Wall/styles.scss";
|
||||
@import "../node_modules/flag-icon-css/css/flag-icon.min.css";
|
||||
@import "src/components/Tagger/styles.scss";
|
||||
|
||||
/* stylelint-disable */
|
||||
#root {
|
||||
|
|
@ -67,10 +68,15 @@ code,
|
|||
}
|
||||
|
||||
.input-control,
|
||||
.input-control:focus {
|
||||
.input-control:focus,
|
||||
.input-control:disabled {
|
||||
background-color: $secondary;
|
||||
}
|
||||
|
||||
.input-control:disabled {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
textarea.text-input {
|
||||
line-height: 2.5ex;
|
||||
min-height: 12ex;
|
||||
|
|
@ -228,14 +234,6 @@ div.dropdown-menu {
|
|||
|
||||
.dropdown-item {
|
||||
display: flex;
|
||||
|
||||
& > :not(:last-child) {
|
||||
margin-right: 7px;
|
||||
}
|
||||
|
||||
& > :last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,5 +10,6 @@
|
|||
"scenes": "Scenes",
|
||||
"studios": "Studios",
|
||||
"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",
|
||||
"scenes",
|
||||
"image",
|
||||
"stash_id",
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -127,6 +127,7 @@ export class ListFilterModel {
|
|||
DisplayMode.Grid,
|
||||
DisplayMode.List,
|
||||
DisplayMode.Wall,
|
||||
DisplayMode.Tagger,
|
||||
];
|
||||
this.criterionOptions = [
|
||||
new NoneCriterionOption(),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export enum DisplayMode {
|
|||
Grid,
|
||||
List,
|
||||
Wall,
|
||||
Tagger,
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -3148,6 +3148,14 @@
|
|||
"@types/react" "*"
|
||||
"@types/react-router" "*"
|
||||
|
||||
"@types/react-router-hash-link@^1.2.1":
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-router-hash-link/-/react-router-hash-link-1.2.1.tgz#fba7dc351cef2985791023018b7a5dbd0653c843"
|
||||
integrity sha512-jdzPGE8jFGq7fHUpPaKrJvLW1Yhoe5MQCrmgeesC+eSLseMj3cGCTYMDA4BNWG8JQmwO8NTYt/oT3uBZ77pmBA==
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
"@types/react-router-dom" "*"
|
||||
|
||||
"@types/react-router@*":
|
||||
version "5.1.3"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-router/-/react-router-5.1.3.tgz#7c7ca717399af64d8733d8cb338dd43641b96f2d"
|
||||
|
|
@ -4060,6 +4068,11 @@ axobject-query@^2.1.2:
|
|||
resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be"
|
||||
integrity sha512-Td525n+iPOOyUQIeBfcASuG6uJsDOITl7Mds5gFyerkWiX7qhUTdYUBlSgNMyVqtSJqwpt1kXGLdUt6SykLMRA==
|
||||
|
||||
b64-to-blob@^1.2.19:
|
||||
version "1.2.19"
|
||||
resolved "https://registry.yarnpkg.com/b64-to-blob/-/b64-to-blob-1.2.19.tgz#157d85fdc8811665b9a35d29ffbc6a522ba28fbe"
|
||||
integrity sha512-L3nSu8GgF4iEyNYakCQSfL2F5GI5aCXcot9mNTf+4N0/BMhpxqqHyOb6jIR24iq2xLjQZLG8FOt3gnUcV+9NVg==
|
||||
|
||||
babel-code-frame@^6.22.0:
|
||||
version "6.26.0"
|
||||
resolved "https://registry.yarnpkg.com/babel-code-frame/-/babel-code-frame-6.26.0.tgz#63fd43f7dc1e3bb7ce35947db8fe369a3f58c74b"
|
||||
|
|
@ -4306,6 +4319,13 @@ balanced-match@^1.0.0:
|
|||
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
|
||||
integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
|
||||
|
||||
base64-blob@^1.4.1:
|
||||
version "1.4.1"
|
||||
resolved "https://registry.yarnpkg.com/base64-blob/-/base64-blob-1.4.1.tgz#f8dfc16c22b24ee499e2782719bcce800132c18a"
|
||||
integrity sha512-n5Ov4cPTbLBTX1PiFbaB5AmK7LMigO9HWh5Lzx+Kcx/yx1MppeeLYtAH8aLv1m++WNoHQnr+xbGSqcZinopwlw==
|
||||
dependencies:
|
||||
b64-to-blob "^1.2.19"
|
||||
|
||||
base64-js@^1.0.2:
|
||||
version "1.3.1"
|
||||
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
|
||||
|
|
@ -12733,6 +12753,13 @@ react-router-dom@^5.2.0:
|
|||
tiny-invariant "^1.0.2"
|
||||
tiny-warning "^1.0.0"
|
||||
|
||||
react-router-hash-link@^2.1.0:
|
||||
version "2.1.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router-hash-link/-/react-router-hash-link-2.1.0.tgz#69cc93df0945480adff14e9e501aea5f356896a8"
|
||||
integrity sha512-U/WizkZwV2IoxLScRJX5CHJWreXjv/kCmjT/LpfYiFdXGnrKgPd0KqcA4KfmQbkwO411OwDmUKKz+bOKoMkzKg==
|
||||
dependencies:
|
||||
prop-types "^15.6.0"
|
||||
|
||||
react-router@5.2.0:
|
||||
version "5.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-router/-/react-router-5.2.0.tgz#424e75641ca8747fbf76e5ecca69781aa37ea293"
|
||||
|
|
|
|||
Loading…
Reference in a new issue