diff --git a/graphql/documents/data/config.graphql b/graphql/documents/data/config.graphql
index f102e217173..80c85d64f74 100644
--- a/graphql/documents/data/config.graphql
+++ b/graphql/documents/data/config.graphql
@@ -13,6 +13,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
calculateMD5
videoFileNamingAlgorithm
parallelTasks
+ concurrentGetImages
previewAudio
previewSegments
previewSegmentDuration
diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql
index 359229a4a19..67f5628dcf4 100644
--- a/graphql/schema/types/config.graphql
+++ b/graphql/schema/types/config.graphql
@@ -53,6 +53,8 @@ input ConfigGeneralInput {
videoFileNamingAlgorithm: HashAlgorithm
"""Number of parallel tasks to start during scan/generate"""
parallelTasks: Int
+ """Number of concurrent workers to download and convert images url to base64"""
+ concurrentGetImages: Int
"""Include audio stream in previews"""
previewAudio: Boolean
"""Number of segments in a preview file"""
@@ -156,6 +158,8 @@ type ConfigGeneralResult {
videoFileNamingAlgorithm: HashAlgorithm!
"""Number of parallel tasks to start during scan/generate"""
parallelTasks: Int!
+ """Number of concurrent workers to download and convert images url to base64"""
+ concurrentGetImages: Int!
"""Include audio stream in previews"""
previewAudio: Boolean!
"""Number of segments in a preview file"""
diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go
index 56c14867bd9..934f19f2a78 100644
--- a/internal/api/resolver_mutation_configure.go
+++ b/internal/api/resolver_mutation_configure.go
@@ -161,6 +161,10 @@ func (r *mutationResolver) ConfigureGeneral(ctx context.Context, input ConfigGen
c.Set(config.ParallelTasks, *input.ParallelTasks)
}
+ if input.ConcurrentGetImages != nil {
+ c.Set(config.ConcurrentGetImages, *input.ConcurrentGetImages)
+ }
+
if input.PreviewAudio != nil {
c.Set(config.PreviewAudio, *input.PreviewAudio)
}
diff --git a/internal/api/resolver_query_configuration.go b/internal/api/resolver_query_configuration.go
index b9aae65de0a..2ea34e35302 100644
--- a/internal/api/resolver_query_configuration.go
+++ b/internal/api/resolver_query_configuration.go
@@ -94,6 +94,7 @@ func makeConfigGeneralResult() *ConfigGeneralResult {
CalculateMd5: config.IsCalculateMD5(),
VideoFileNamingAlgorithm: config.GetVideoFileNamingAlgorithm(),
ParallelTasks: config.GetParallelTasks(),
+ ConcurrentGetImages: config.GetConcurrentGetImages(),
PreviewAudio: config.GetPreviewAudio(),
PreviewSegments: config.GetPreviewSegments(),
PreviewSegmentDuration: config.GetPreviewSegmentDuration(),
diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go
index bea1381e46c..1a6bd710507 100644
--- a/internal/manager/config/config.go
+++ b/internal/manager/config/config.go
@@ -72,6 +72,9 @@ const (
SequentialScanning = "sequential_scanning"
SequentialScanningDefault = false
+ ConcurrentGetImages = "concurrent_get_images"
+ concurrentGetImagesDefault = 10
+
PreviewPreset = "preview_preset"
PreviewAudio = "preview_audio"
@@ -762,6 +765,23 @@ func (i *Instance) GetParallelTasksWithAutoDetection() int {
return parallelTasks
}
+// GetConcurrentGetImages returns the number of Concurrent workers that should be started
+// to download and convert images url to base64.
+func (i *Instance) GetConcurrentGetImages() int {
+ return i.getInt(ConcurrentGetImages)
+}
+
+// GetConcurrentGetImagesOrDefault returns the number of Concurrent workers that should be started
+// to download and convert images url to base64. returns the default value if is below 1
+func (i *Instance) GetConcurrentGetImagesOrDefault() int {
+ result := i.GetConcurrentGetImages()
+ if result < 1 {
+ return concurrentGetImagesDefault
+ }
+
+ return result
+}
+
func (i *Instance) GetPreviewAudio() bool {
return i.getBool(PreviewAudio)
}
@@ -1472,6 +1492,7 @@ func (i *Instance) setDefaultValues(write bool) error {
i.main.SetDefault(ParallelTasks, parallelTasksDefault)
i.main.SetDefault(SequentialScanning, SequentialScanningDefault)
+ i.main.SetDefault(ConcurrentGetImages, concurrentGetImagesDefault)
i.main.SetDefault(PreviewSegmentDuration, previewSegmentDurationDefault)
i.main.SetDefault(PreviewSegments, previewSegmentsDefault)
i.main.SetDefault(PreviewExcludeStart, previewExcludeStartDefault)
diff --git a/internal/manager/config/config_concurrency_test.go b/internal/manager/config/config_concurrency_test.go
index 81bb7e81687..6bb625161a4 100644
--- a/internal/manager/config/config_concurrency_test.go
+++ b/internal/manager/config/config_concurrency_test.go
@@ -48,6 +48,7 @@ func TestConcurrentConfigAccess(t *testing.T) {
i.Set(VideoFileNamingAlgorithm, i.GetVideoFileNamingAlgorithm())
i.Set(ScrapersPath, i.GetScrapersPath())
i.Set(ScraperUserAgent, i.GetScraperUserAgent())
+ i.Set(ConcurrentGetImages, i.GetConcurrentGetImages())
i.Set(ScraperCDPPath, i.GetScraperCDPPath())
i.Set(ScraperCertCheck, i.GetScraperCertCheck())
i.Set(ScraperExcludeTagPatterns, i.GetScraperExcludeTagPatterns())
diff --git a/pkg/scraper/cache.go b/pkg/scraper/cache.go
index 3b53919947f..c6a50319f5d 100644
--- a/pkg/scraper/cache.go
+++ b/pkg/scraper/cache.go
@@ -9,6 +9,7 @@ import (
"path/filepath"
"sort"
"strings"
+ "sync"
"time"
"github.com/stashapp/stash/pkg/fsutil"
@@ -42,6 +43,7 @@ type GlobalConfig interface {
GetScraperCertCheck() bool
GetPythonPath() string
GetProxy() string
+ GetConcurrentGetImages() int
}
func isCDPPathHTTP(c GlobalConfig) bool {
@@ -227,6 +229,12 @@ func (c Cache) findScraper(scraperID string) scraper {
return nil
}
+type PostScrapedItem struct {
+ index int
+ item ScrapedContent
+ err error
+}
+
func (c Cache) ScrapeName(ctx context.Context, id, query string, ty ScrapeContentType) ([]ScrapedContent, error) {
// find scraper with the provided id
s := c.findScraper(id)
@@ -242,7 +250,62 @@ func (c Cache) ScrapeName(ctx context.Context, id, query string, ty ScrapeConten
return nil, fmt.Errorf("%w: cannot use scraper %s to scrape by name", ErrNotSupported, id)
}
- return ns.viaName(ctx, c.client, query, ty)
+ content, err := ns.viaName(ctx, c.client, query, ty)
+ if err != nil {
+ return nil, fmt.Errorf("error while name scraping with scraper %s: %w", id, err)
+ }
+
+ // prevent extra code executions and locks if there's no result.
+ if content == nil || len(content) < 1 {
+ return content, nil
+ }
+
+ // post-scraping items concurrently
+ // post scraping may download images then it would be slow if done single threaded on a big list.
+
+ concurrentGetImages := c.globalConfig.GetConcurrentGetImages()
+ threads := concurrentGetImages
+ // Check if the Threads is more than total results then set threads to the result's count.
+ if threads > len(content) {
+ threads = len(content)
+ }
+ channelQueue := make(chan PostScrapedItem, len(content))
+ channelResult := make(chan PostScrapedItem, len(content))
+ wg := sync.WaitGroup{}
+ wg.Add(threads)
+ for i := 0; i < threads; i++ {
+ go func(queue chan PostScrapedItem) {
+ for {
+ item, ok := <-queue // Get the queue item and remove it.
+ if !ok { // if there is nothing to do and the channel has been closed then end the goroutine
+ wg.Done()
+ return
+ }
+ postScrape, err := c.postScrape(ctx, item.item)
+ result := PostScrapedItem{index: item.index, item: postScrape, err: err}
+ channelResult <- result // Add the result to result channel
+ }
+ }(channelQueue)
+ }
+
+ // Now the jobs can be added to the channel, which is used as a queue
+ for i := 0; i < len(content); i++ {
+ channelQueue <- PostScrapedItem{index: i, item: content[i], err: nil} // add to queue
+ }
+
+ close(channelQueue) // This tells the goroutines there's nothing else to do
+ wg.Wait() // Wait for the threads to finish
+ close(channelResult)
+
+ // Set post-scraped results
+ for postScrapedItem := range channelResult {
+ if postScrapedItem.err != nil {
+ return nil, fmt.Errorf("error while post scraping with scraper %s: %w", id, err)
+ }
+ content[postScrapedItem.index] = postScrapedItem.item
+ }
+
+ return content, err
}
// ScrapeFragment uses the given fragment input to scrape
diff --git a/pkg/scraper/image.go b/pkg/scraper/image.go
index 5757bc9b383..1ea3ddc2c31 100644
--- a/pkg/scraper/image.go
+++ b/pkg/scraper/image.go
@@ -3,8 +3,10 @@ package scraper
import (
"context"
"fmt"
+ "github.com/stashapp/stash/pkg/logger"
"io"
"net/http"
+ pkg_neturl "net/url"
"strings"
"github.com/stashapp/stash/pkg/models"
@@ -12,19 +14,31 @@ import (
)
func setPerformerImage(ctx context.Context, client *http.Client, p *models.ScrapedPerformer, globalConfig GlobalConfig) error {
- if p.Image == nil || !strings.HasPrefix(*p.Image, "http") {
- // nothing to do
- return nil
+ if p.Images != nil && len(p.Images) > 0 {
+ for i := 0; i < len(p.Images); i++ {
+ if strings.HasPrefix(p.Images[i], "http") {
+ img, err := getImage(ctx, p.Images[i], client, globalConfig)
+ if err != nil {
+ logger.Warnf("Could not set image using URL %s: %s", p.Images[i], err.Error())
+ return err
+ }
+
+ p.Images[i] = *img
+ // Image is deprecated. Use images instead
+ }
+ }
}
- img, err := getImage(ctx, *p.Image, client, globalConfig)
- if err != nil {
- return err
- }
+ if p.Image != nil && strings.HasPrefix(*p.Image, "http") {
+ img, err := getImage(ctx, *p.Image, client, globalConfig)
+ if err != nil {
+ return err
+ }
- p.Image = img
- // Image is deprecated. Use images instead
- p.Images = []string{*img}
+ p.Image = img
+ // Image is deprecated. Use images instead
+ p.Images = append([]string{*img}, p.Images...)
+ }
return nil
}
@@ -81,6 +95,13 @@ func setMovieBackImage(ctx context.Context, client *http.Client, m *models.Scrap
}
func getImage(ctx context.Context, url string, client *http.Client, globalConfig GlobalConfig) (*string, error) {
+ if strings.HasPrefix(url, "https%3A") || strings.HasPrefix(url, "http%3A") {
+ urlDecoded, err := pkg_neturl.PathUnescape(url)
+ if err != nil {
+ return nil, err
+ }
+ url = urlDecoded
+ }
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
diff --git a/pkg/scraper/mapped.go b/pkg/scraper/mapped.go
index 77c4911ae92..4ea60e7dcee 100644
--- a/pkg/scraper/mapped.go
+++ b/pkg/scraper/mapped.go
@@ -47,9 +47,9 @@ func (s mappedConfig) process(ctx context.Context, q mappedQuery, common commonM
for k, attrConfig := range s {
if attrConfig.Fixed != "" {
- // TODO - not sure if this needs to set _all_ indexes for the key
- const i = 0
- ret = ret.setKey(i, k, attrConfig.Fixed)
+ for i := range ret {
+ ret = ret.setKey(i, k, attrConfig.Fixed)
+ }
} else {
selector := attrConfig.Selector
selector = s.applyCommon(common, selector)
@@ -747,12 +747,15 @@ func (r mappedResult) apply(dest interface{}) {
if field.IsValid() {
var reflectValue reflect.Value
- if field.Kind() == reflect.Ptr {
+ switch field.Kind() {
+ case reflect.Ptr:
// need to copy the value, otherwise everything is set to the
// same pointer
localValue := value
reflectValue = reflect.ValueOf(&localValue)
- } else {
+ case reflect.Slice:
+ reflectValue = reflect.ValueOf([]string{value})
+ default:
reflectValue = reflect.ValueOf(value)
}
diff --git a/pkg/scraper/postprocessing.go b/pkg/scraper/postprocessing.go
index cf8cac1eb34..e90fc538398 100644
--- a/pkg/scraper/postprocessing.go
+++ b/pkg/scraper/postprocessing.go
@@ -63,7 +63,9 @@ func (c Cache) postScrapePerformer(ctx context.Context, p models.ScrapedPerforme
// post-process - set the image if applicable
if err := setPerformerImage(ctx, c.client, &p, c.globalConfig); err != nil {
- logger.Warnf("Could not set image using URL %s: %s", *p.Image, err.Error())
+ if p.Image != nil {
+ logger.Warnf("Could not set image using URL %s: %s", *p.Image, err.Error())
+ }
}
p.Country = resolveCountryName(p.Country)
diff --git a/pkg/scraper/xpath_test.go b/pkg/scraper/xpath_test.go
index 06b6ad5b686..2f6182a1693 100644
--- a/pkg/scraper/xpath_test.go
+++ b/pkg/scraper/xpath_test.go
@@ -838,6 +838,10 @@ func (mockGlobalConfig) GetProxy() string {
return ""
}
+func (mockGlobalConfig) GetConcurrentGetImages() int {
+ return 4
+}
+
func TestSubScrape(t *testing.T) {
retHTML := `
diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx
index 357886ffb08..ea944665f42 100644
--- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx
+++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useState } from "react";
+import React, { useEffect, useMemo, useState, lazy } from "react";
import { Button, Tabs, Tab, Col, Row } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { useParams, useHistory } from "react-router-dom";
@@ -28,7 +28,6 @@ import { PerformerScenesPanel } from "./PerformerScenesPanel";
import { PerformerGalleriesPanel } from "./PerformerGalleriesPanel";
import { PerformerMoviesPanel } from "./PerformerMoviesPanel";
import { PerformerImagesPanel } from "./PerformerImagesPanel";
-import { PerformerEditPanel } from "./PerformerEditPanel";
import { PerformerSubmitButton } from "./PerformerSubmitButton";
import GenderIcon from "../GenderIcon";
import { faHeart, faLink } from "@fortawesome/free-solid-svg-icons";
@@ -36,9 +35,12 @@ import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons";
import { IUIConfig } from "src/core/config";
import { useRatingKeybinds } from "src/hooks/keybinds";
+const PerformerEditPanel = lazy(() => import("./PerformerEditPanel"));
+
interface IProps {
performer: GQL.PerformerDataFragment;
}
+
interface IPerformerParams {
tab?: string;
}
@@ -429,6 +431,19 @@ const PerformerPage: React.FC
= ({ performer }) => {
const PerformerLoader: React.FC = () => {
const { id } = useParams<{ id?: string }>();
const { data, loading, error } = useFindPerformer(id ?? "");
+ const { configuration } = React.useContext(ConfigurationContext);
+ const [showScrubber, setShowScrubber] = useState(
+ configuration?.interface.showScrubber ?? true
+ );
+
+ // set up hotkeys
+ useEffect(() => {
+ Mousetrap.bind(".", () => setShowScrubber(!showScrubber));
+
+ return () => {
+ Mousetrap.unbind(".");
+ };
+ });
if (loading) return ;
if (error) return ;
diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx
index cc77ccda322..20c1bcfd5ab 100644
--- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx
+++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx
@@ -1,4 +1,4 @@
-import React, { useEffect, useState } from "react";
+import React, { useEffect, useState, lazy } from "react";
import { Button, Form, Col, Row, Badge, Dropdown } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import Mousetrap from "mousetrap";
@@ -6,12 +6,12 @@ import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
import {
useListPerformerScrapers,
- queryScrapePerformer,
mutateReloadScrapers,
usePerformerUpdate,
usePerformerCreate,
useTagCreate,
queryScrapePerformerURL,
+ queryScrapePerformerQueryFragment,
} from "src/core/StashService";
import { Icon } from "src/components/Shared/Icon";
import { ImageInput } from "src/components/Shared/ImageInput";
@@ -33,8 +33,7 @@ import {
} from "src/utils/gender";
import { ConfigurationContext } from "src/hooks/Config";
import { PerformerScrapeDialog } from "./PerformerScrapeDialog";
-import PerformerScrapeModal from "./PerformerScrapeModal";
-import PerformerStashBoxModal, { IStashBox } from "./PerformerStashBoxModal";
+import { IStashBox } from "./PerformerStashBoxModal";
import cx from "classnames";
import {
faPlus,
@@ -43,11 +42,9 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import { StringListInput } from "src/components/Shared/StringListInput";
-const isScraper = (
- scraper: GQL.Scraper | GQL.StashBox
-): scraper is GQL.Scraper => (scraper as GQL.Scraper).id !== undefined;
+const PerformerScrapeModal = lazy(() => import("./PerformerScrapeModal"));
-interface IPerformerDetails {
+interface IProps {
performer: Partial;
isVisible: boolean;
onImageChange?: (image?: string | null) => void;
@@ -55,7 +52,7 @@ interface IPerformerDetails {
onCancelEditing?: () => void;
}
-export const PerformerEditPanel: React.FC = ({
+export const PerformerEditPanel: React.FC = ({
performer,
isVisible,
onImageChange,
@@ -67,8 +64,8 @@ export const PerformerEditPanel: React.FC = ({
const isNew = performer.id === undefined;
- // Editing stat
- const [scraper, setScraper] = useState();
+ // Editing state
+ const [scraper, setScraper] = useState();
const [newTags, setNewTags] = useState();
const [isScraperModalOpen, setIsScraperModalOpen] = useState(false);
@@ -490,6 +487,11 @@ export const PerformerEditPanel: React.FC = ({
formik.setFieldValue("image", url);
}
+ function onScrapeQueryClicked(s: GQL.ScraperSourceInput) {
+ setScraper(s);
+ setIsScraperModalOpen(true);
+ }
+
async function onReloadScrapers() {
setIsLoading(true);
try {
@@ -504,42 +506,6 @@ export const PerformerEditPanel: React.FC = ({
}
}
- async function onScrapePerformer(
- selectedPerformer: GQL.ScrapedPerformerDataFragment,
- selectedScraper: GQL.Scraper
- ) {
- setIsScraperModalOpen(false);
- try {
- if (!scraper) return;
- setIsLoading(true);
-
- const {
- __typename,
- images: _image,
- tags: _tags,
- ...ret
- } = selectedPerformer;
-
- const result = await queryScrapePerformer(selectedScraper.id, ret);
- if (!result?.data?.scrapeSinglePerformer?.length) return;
-
- // assume one result
- // if this is a new performer, just dump the data
- if (isNew) {
- updatePerformerEditStateFromScraper(
- result.data.scrapeSinglePerformer[0]
- );
- setScraper(undefined);
- } else {
- setScrapedPerformer(result.data.scrapeSinglePerformer[0]);
- }
- } catch (e) {
- Toast.error(e);
- } finally {
- setIsLoading(false);
- }
- }
-
async function onScrapePerformerURL() {
const { url } = formik.values;
if (!url) return;
@@ -563,29 +529,6 @@ export const PerformerEditPanel: React.FC = ({
}
}
- async function onScrapeStashBox(performerResult: GQL.ScrapedPerformer) {
- setIsScraperModalOpen(false);
-
- const result: GQL.ScrapedPerformerDataFragment = {
- ...performerResult,
- images: performerResult.images ?? undefined,
- __typename: "ScrapedPerformer",
- };
-
- // if this is a new performer, just dump the data
- if (isNew) {
- updatePerformerEditStateFromScraper(result);
- setScraper(undefined);
- } else {
- setScrapedPerformer(result);
- }
- }
-
- function onScraperSelected(s: GQL.Scraper | IStashBox | undefined) {
- setScraper(s);
- setIsScraperModalOpen(true);
- }
-
function renderScraperMenu() {
if (!performer) {
return;
@@ -599,7 +542,12 @@ export const PerformerEditPanel: React.FC = ({
as={Button}
key={s.endpoint}
className="minimal"
- onClick={() => onScraperSelected({ ...s, index })}
+ onClick={() =>
+ onScrapeQueryClicked({
+ stash_box_index: index,
+ stash_box_endpoint: s.endpoint,
+ })
+ }
>
{stashboxDisplayName(s.name, index)}
@@ -610,7 +558,7 @@ export const PerformerEditPanel: React.FC = ({
as={Button}
key={s.name}
className="minimal"
- onClick={() => onScraperSelected(s)}
+ onClick={() => onScrapeQueryClicked({ scraper_id: s.id })}
>
{s.name}
@@ -721,24 +669,61 @@ export const PerformerEditPanel: React.FC = ({
);
}
+ async function scrapeFromQuery(
+ s: GQL.ScraperSourceInput,
+ fragment: GQL.ScrapedPerformerDataFragment
+ ) {
+ setIsLoading(true);
+ try {
+ const input: GQL.ScrapedPerformerInput = {
+ name: fragment.name,
+ gender: fragment.gender,
+ birthdate: fragment.birthdate,
+ url: fragment.url,
+ };
+
+ const result = await queryScrapePerformerQueryFragment(s, input);
+ if (!result.data || !result.data?.scrapeSinglePerformer?.length) {
+ Toast.success({
+ content: "No performer found",
+ });
+ return;
+ }
+ // assume one returned scene
+ setScrapedPerformer(result.data?.scrapeSinglePerformer[0]);
+ } catch (e) {
+ Toast.error(e);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+
+ function onPerformerSelected(s: GQL.ScrapedPerformerDataFragment) {
+ if (!scraper) return;
+
+ if (scraper?.stash_box_index !== undefined) {
+ // must be stash-box - assume full scene
+ setScrapedPerformer(s);
+ } else {
+ // must be scraper
+ scrapeFromQuery(scraper, s);
+ }
+ }
+
const renderScrapeModal = () => {
- if (!isScraperModalOpen) return;
+ if (!isScraperModalOpen || !scraper) return;
- return scraper !== undefined && isScraper(scraper) ? (
+ return (
setScraper(undefined)}
- onSelectPerformer={onScrapePerformer}
- name={formik.values.name || ""}
- />
- ) : scraper !== undefined && !isScraper(scraper) ? (
- setScraper(undefined)}
- onSelectPerformer={onScrapeStashBox}
+ onSelectPerformer={(s) => {
+ setIsScraperModalOpen(false);
+ onPerformerSelected(s);
+ }}
name={formik.values.name || ""}
/>
- ) : undefined;
+ );
};
function renderTagsField() {
@@ -1037,3 +1022,5 @@ export const PerformerEditPanel: React.FC = ({
>
);
};
+
+export default PerformerEditPanel;
diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx
index 0f62285f5ab..13808942b5e 100644
--- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx
+++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerScrapeDialog.tsx
@@ -5,7 +5,7 @@ import {
ScrapeDialog,
ScrapeResult,
ScrapedInputGroupRow,
- ScrapedImageRow,
+ ScrapedImagesRow,
ScrapeDialogRow,
ScrapedTextAreaRow,
ScrapedCountryRow,
@@ -123,7 +123,7 @@ function renderScrapedTagsRow(
interface IPerformerScrapeDialogProps {
performer: Partial;
scraped: GQL.ScrapedPerformer;
- scraper?: GQL.Scraper | IStashBox;
+ scraper?: GQL.ScraperSourceInput;
onClose: (scrapedPerformer?: GQL.ScrapedPerformer) => void;
}
@@ -320,13 +320,22 @@ export const PerformerScrapeDialog: React.FC = (
const [image, setImage] = useState>(
new ScrapeResult(
- props.performer.image,
+ props.performer.image as string,
props.scraped.images && props.scraped.images.length > 0
? props.scraped.images[0]
- : undefined
+ : props.scraped.image
+ ? (props.scraped.image as string)
+ : ""
)
);
+ const images =
+ props.scraped.images && props.scraped.images.length > 0
+ ? props.scraped.images
+ : props.scraped.image
+ ? [props.scraped.image as string]
+ : [];
+
const allFields = [
name,
disambiguation,
@@ -545,10 +554,11 @@ export const PerformerScrapeDialog: React.FC = (
newTags,
createNewTag
)}
- setImage(value)}
/>
= ({ performer }) => {
+ function renderImage() {
+ if (performer.images && performer.images.length > 0) {
+ return (
+
+
+
+ );
+ }
+ }
+
+ function calculateAge() {
+ if (performer?.birthdate) {
+ // calculate the age from birthdate. In future, this should probably be
+ // provided by the server
+ return TextUtils.age(performer.birthdate, performer.death_date);
+ }
+ }
+
+ function renderTags() {
+ if (performer.tags) {
+ return (
+
+
+ {performer.tags?.map((tag) => (
+
+ {tag.name}
+
+ ))}
+
+
+ );
+ }
+ }
+
+ let calculated_age = calculateAge();
+
+ return (
+
+
+ {renderImage()}
+
+
+ {performer.name}
+ {performer.disambiguation && (
+
+ {` (${performer.disambiguation})`}
+
+ )}
+
+
+ {performer.gender && (
+
+ )}
+ {performer.gender && calculated_age && ` • `}
+ {calculated_age}
+ {calculated_age && " "}
+ {calculated_age && }
+
+
+
+
+
+
+
+
+ {renderTags()}
+
+ );
+};
+
+export interface IPerformerSearchResult {
+ performer: GQL.ScrapedPerformerDataFragment;
+}
+
+export const PerformerSearchResult: React.FC = ({
+ performer,
+}) => {
+ return (
+
+ );
+};
interface IProps {
- scraper: GQL.Scraper;
+ scraper: GQL.ScraperSourceInput;
onHide: () => void;
- onSelectPerformer: (
- performer: GQL.ScrapedPerformerDataFragment,
- scraper: GQL.Scraper
- ) => void;
+ onSelectPerformer: (performer: GQL.ScrapedPerformerDataFragment) => void;
name?: string;
}
+
const PerformerScrapeModal: React.FC = ({
scraper,
name,
onHide,
onSelectPerformer,
}) => {
+ const CLASSNAME = "PerformerScrapeModal";
+ const CLASSNAME_LIST = `${CLASSNAME}-list`;
+ const CLASSNAME_LIST_CONTAINER = `${CLASSNAME_LIST}-container`;
+
const intl = useIntl();
+ const Toast = useToast();
+
const inputRef = useRef(null);
- const [query, setQuery] = useState(name ?? "");
- const { data, loading } = useScrapePerformerList(scraper.id, query);
+ const [loading, setLoading] = useState(false);
+ const [performers, setPerformers] = useState<
+ GQL.ScrapedPerformer[] | undefined
+ >();
+ const [error, setError] = useState();
- const performers = data?.scrapeSinglePerformer ?? [];
+ const doQuery = useCallback(
+ async (input: string) => {
+ if (!input) return;
- const onInputChange = debounce((input: string) => {
- setQuery(input);
- }, 500);
+ setLoading(true);
+ try {
+ const r = await queryScrapePerformerQuery(scraper, input);
+ setPerformers(r.data?.scrapeSinglePerformer);
+ } catch (err) {
+ if (err instanceof Error) setError(err);
+ } finally {
+ setLoading(false);
+ }
+ },
+ [scraper]
+ );
useEffect(() => inputRef.current?.focus(), []);
+ useEffect(() => {
+ doQuery(name ?? "");
+ }, [doQuery, name]);
+ useEffect(() => {
+ if (error) {
+ Toast.error(error);
+ setError(undefined);
+ }
+ }, [error, Toast]);
+
+ function renderResults() {
+ if (!performers) {
+ return;
+ }
+
+ return (
+
+
+
+
+
+ {performers.map((p, i) => (
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions, react/no-array-index-key
+ onSelectPerformer(p)}>
+
+
+ ))}
+
+
+ );
+ }
return (
= ({
}}
>
-
onInputChange(e.currentTarget.value)}
- defaultValue={name ?? ""}
- placeholder="Performer name..."
- className="text-input mb-4"
- ref={inputRef}
- />
+
+ ) =>
+ e.key === "Enter" && doQuery(inputRef.current?.value ?? "")
+ }
+ />
+
+ {
+ doQuery(inputRef.current?.value ?? "");
+ }}
+ variant="primary"
+ title={intl.formatMessage({ id: "actions.search" })}
+ >
+
+
+
+
{loading ? (
) : (
-
- {performers.map((p) => (
-
- onSelectPerformer(p, scraper)}
- >
- {p.name}
- {p.disambiguation && ` (${p.disambiguation})`}
-
-
- ))}
-
+ renderResults()
)}
diff --git a/ui/v2.5/src/components/Performers/Performers.tsx b/ui/v2.5/src/components/Performers/Performers.tsx
index f919fdba51f..5ee01720af0 100644
--- a/ui/v2.5/src/components/Performers/Performers.tsx
+++ b/ui/v2.5/src/components/Performers/Performers.tsx
@@ -4,10 +4,11 @@ import { useIntl } from "react-intl";
import { Helmet } from "react-helmet";
import { TITLE_SUFFIX } from "src/components/Shared/constants";
import { PersistanceLevel } from "src/hooks/ListHook";
-import Performer from "./PerformerDetails/Performer";
import PerformerCreate from "./PerformerDetails/PerformerCreate";
import { PerformerList } from "./PerformerList";
+const Performer = React.lazy(() => import("./PerformerDetails/Performer"));
+
const Performers: React.FC = () => {
const intl = useIntl();
diff --git a/ui/v2.5/src/components/Performers/styles.scss b/ui/v2.5/src/components/Performers/styles.scss
index 2ec6433d485..d587dd139d6 100644
--- a/ui/v2.5/src/components/Performers/styles.scss
+++ b/ui/v2.5/src/components/Performers/styles.scss
@@ -210,3 +210,15 @@
font-size: 0.875em;
/* stylelint-enable */
}
+
+.PerformerScrapeModal-list {
+ list-style: none;
+ max-height: 50vh;
+ overflow-x: hidden;
+ overflow-y: auto;
+ padding-inline-start: 0;
+
+ li {
+ cursor: pointer;
+ }
+}
diff --git a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx
index 108f9652bbb..360ca61bd43 100644
--- a/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx
+++ b/ui/v2.5/src/components/Settings/SettingsSystemPanel.tsx
@@ -259,7 +259,7 @@ export const SettingsConfigurationPanel: React.FC = () => {
/>
-
+
{
value={general.parallelTasks ?? undefined}
onChange={(v) => saveGeneral({ parallelTasks: v })}
/>
+
+ saveGeneral({ concurrentGetImages: v })}
+ />
diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog.tsx
index 410534e4123..863625b92e6 100644
--- a/ui/v2.5/src/components/Shared/ScrapeDialog.tsx
+++ b/ui/v2.5/src/components/Shared/ScrapeDialog.tsx
@@ -1,4 +1,4 @@
-import React from "react";
+import React, { useState } from "react";
import {
Form,
Col,
@@ -15,6 +15,8 @@ import isEqual from "lodash-es/isEqual";
import clone from "lodash-es/clone";
import { FormattedMessage, useIntl } from "react-intl";
import {
+ faArrowLeft,
+ faArrowRight,
faCheck,
faPencilAlt,
faPlus,
@@ -354,6 +356,169 @@ export const ScrapedImageRow: React.FC = (props) => {
);
};
+interface IScrapedImageDialogRowProps<
+ T extends ScrapeResult,
+ V extends IHasName
+> extends IScrapedFieldProps {
+ title: string;
+ renderOriginalField: () => JSX.Element | undefined;
+ renderNewField: () => JSX.Element | undefined;
+ onChange: (value: T) => void;
+ newValues?: V[];
+ images: string[];
+ onCreateNew?: (index: number) => void;
+}
+
+export const ScrapeImageDialogRow = <
+ T extends ScrapeResult,
+ V extends IHasName
+>(
+ props: IScrapedImageDialogRowProps
+) => {
+ const [imageIndex, setImageIndex] = useState(0);
+
+ function hasNewValues() {
+ return props.newValues && props.newValues.length > 0 && props.onCreateNew;
+ }
+
+ function setPrev() {
+ let newIdx = imageIndex - 1;
+ if (newIdx < 0) {
+ newIdx = props.images.length - 1;
+ }
+ const ret = props.result.cloneWithValue(props.images[newIdx]);
+ props.onChange(ret as T);
+ setImageIndex(newIdx);
+ }
+
+ function setNext() {
+ let newIdx = imageIndex + 1;
+ if (newIdx >= props.images.length) {
+ newIdx = 0;
+ }
+ const ret = props.result.cloneWithValue(props.images[newIdx]);
+ props.onChange(ret as T);
+ setImageIndex(newIdx);
+ }
+
+ if (!props.result.scraped && !hasNewValues()) {
+ return <>>;
+ }
+
+ function renderSelector() {
+ return (
+ props.images.length > 0 && (
+
+
+
+
+
+ Select performer image
+
+ {imageIndex + 1} of {props.images.length}
+
+
+
+
+
+ )
+ );
+ }
+
+ function renderNewValues() {
+ if (!hasNewValues()) {
+ return;
+ }
+
+ const ret = (
+ <>
+ {props.newValues!.map((t, i) => (
+ props.onCreateNew!(i)}
+ >
+ {t.name}
+
+
+
+
+ ))}
+ >
+ );
+
+ const minCollapseLength = 10;
+
+ if (props.newValues!.length >= minCollapseLength) {
+ return (
+
+ {ret}
+
+ );
+ }
+
+ return ret;
+ }
+
+ return (
+
+
+ {props.title}
+
+
+
+
+
+ {props.renderOriginalField()}
+
+
+
+ {props.renderNewField()}
+ {renderSelector()}
+
+ {renderNewValues()}
+
+
+
+
+ );
+};
+
+interface IScrapedImagesRowProps {
+ title: string;
+ className?: string;
+ result: ScrapeResult;
+ images: string[];
+ onChange: (value: ScrapeResult) => void;
+}
+
+export const ScrapedImagesRow: React.FC = (props) => {
+ return (
+ (
+
+ )}
+ renderNewField={() => (
+
+ )}
+ onChange={props.onChange}
+ />
+ );
+};
+
interface IScrapeDialogProps {
title: string;
existingLabel?: string;
diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts
index 5578c8c33de..88265a13bf1 100644
--- a/ui/v2.5/src/core/StashService.ts
+++ b/ui/v2.5/src/core/StashService.ts
@@ -1025,6 +1025,37 @@ export const queryScrapePerformerURL = (url: string) =>
fetchPolicy: "network-only",
});
+export const queryScrapePerformerQuery = (
+ source: GQL.ScraperSourceInput,
+ q: string
+) =>
+ client.query({
+ query: GQL.ScrapeSinglePerformerDocument,
+ variables: {
+ source,
+ input: {
+ query: q,
+ },
+ },
+ // skip: q === "",
+ fetchPolicy: "network-only",
+ });
+
+export const queryScrapePerformerQueryFragment = (
+ source: GQL.ScraperSourceInput,
+ input: GQL.ScrapedPerformerInput
+) =>
+ client.query({
+ query: GQL.ScrapeSinglePerformerDocument,
+ variables: {
+ source,
+ input: {
+ performer_input: input,
+ },
+ },
+ fetchPolicy: "network-only",
+ });
+
export const queryScrapeSceneQuery = (
source: GQL.ScraperSourceInput,
q: string
diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json
index c4d766e749f..0105863b440 100644
--- a/ui/v2.5/src/locales/en-GB.json
+++ b/ui/v2.5/src/locales/en-GB.json
@@ -322,6 +322,9 @@
"number_of_parallel_task_for_scan_generation_desc": "Set to 0 for auto-detection. Warning running more tasks than is required to achieve 100% cpu utilisation will decrease performance and potentially cause other issues.",
"number_of_parallel_task_for_scan_generation_head": "Number of parallel task for scan/generation",
"parallel_scan_head": "Parallel Scan/Generation",
+ "parallel_head": "Parallel",
+ "number_of_parallel_task_for_image_download_head": "Number of concurrent workers for image download",
+ "number_of_parallel_task_for_image_download_desc": "Number of workers to download and convert performer/scene scraped images url to base64 concurrently",
"preview_generation": "Preview Generation",
"python_path": {
"description": "Location of python executable. Used for script scrapers and plugins. If blank, python will be resolved from the environment",
@@ -786,6 +789,7 @@
"video_previews_tooltip": "Video previews which play when hovering over a scene"
},
"scenes_found": "{count} scenes found",
+ "performers_found": "{count} performers found",
"scrape_entity_query": "{entity_type} Scrape Query",
"scrape_entity_title": "{entity_type} Scrape Results",
"scrape_results_existing": "Existing",