Stash-box tagger integration (#454)

This commit is contained in:
InfiniteTF 2020-10-24 05:31:39 +02:00 committed by GitHub
parent 70f73ecf4a
commit 3346f8dcca
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
75 changed files with 3007 additions and 79 deletions

View file

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

View file

@ -3,4 +3,8 @@ fragment SlimPerformerData on Performer {
name
gender
image_path
stash_ids {
endpoint
stash_id
}
}

View file

@ -20,4 +20,8 @@ fragment PerformerData on Performer {
favorite
image_path
scene_count
stash_ids {
stash_id
endpoint
}
}

View file

@ -68,4 +68,9 @@ fragment SlimSceneData on Scene {
favorite
image_path
}
stash_ids {
endpoint
stash_id
}
}

View file

@ -56,4 +56,9 @@ fragment SceneData on Scene {
performers {
...PerformerData
}
stash_ids {
endpoint
stash_id
}
}

View file

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

View file

@ -2,4 +2,8 @@ fragment SlimStudioData on Studio {
id
name
image_path
}
stash_ids {
endpoint
stash_id
}
}

View file

@ -21,4 +21,8 @@ fragment StudioData on Studio {
}
image_path
scene_count
stash_ids {
stash_id
endpoint
}
}

View file

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

View file

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

View file

@ -0,0 +1,3 @@
mutation SubmitStashBoxFingerprints($input: StashBoxFingerprintSubmissionInput!) {
submitStashBoxFingerprints(input: $input)
}

View file

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

View file

@ -11,4 +11,4 @@ query FindPerformer($id: ID!) {
findPerformer(id: $id) {
...PerformerData
}
}
}

View file

@ -92,6 +92,6 @@ query ScrapeMovieURL($url: String!) {
query QueryStashBoxScene($input: StashBoxQueryInput!) {
queryStashBoxScene(input: $input) {
...ScrapedSceneData
...ScrapedStashBoxSceneData
}
}
}

View file

@ -11,4 +11,4 @@ query FindStudio($id: ID!) {
findStudio(id: $id) {
...StudioData
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -53,6 +53,10 @@ const messages = defineMessages({
id: "galleries",
defaultMessage: "Galleries",
},
sceneTagger: {
id: "sceneTagger",
defaultMessage: "Scene Tagger",
},
});
const menuItems: IMenuItem[] = [

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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;

View file

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

View file

@ -16,7 +16,13 @@
}
&.inline {
height: inherit;
display: inline;
margin-left: 0.5rem;
}
&.small .spinner-border {
height: 1rem;
width: 1rem;
}
}

View file

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

View 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;

View 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;

View 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;

View 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;

View 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;

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

View file

@ -0,0 +1 @@
export { Tagger as default } from "./Tagger";

View 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;
};

View 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%;
}
}

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

View file

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

View file

@ -0,0 +1,5 @@
# Scene Tagger
The search works by matching the query against a scene&rsquo;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.

View file

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

View file

@ -10,5 +10,6 @@
"scenes": "Scenes",
"studios": "Studios",
"tags": "Tags",
"up-dir": "Up a directory"
"up-dir": "Up a directory",
"sceneTagger": "Scene Tagger"
}

View file

@ -63,6 +63,7 @@ export class PerformerIsMissingCriterion extends IsMissingCriterion {
"gender",
"scenes",
"image",
"stash_id",
];
}

View file

@ -127,6 +127,7 @@ export class ListFilterModel {
DisplayMode.Grid,
DisplayMode.List,
DisplayMode.Wall,
DisplayMode.Tagger,
];
this.criterionOptions = [
new NoneCriterionOption(),

View file

@ -4,6 +4,7 @@ export enum DisplayMode {
Grid,
List,
Wall,
Tagger,
}
export enum FilterMode {

View file

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

View file

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