Add Image Scraping (#5562)

Co-authored-by: keenbed <155155956+keenbed@users.noreply.github.com>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
WeedLordVegeta420 2025-02-24 00:38:14 -05:00 committed by GitHub
parent b6ace42973
commit e97f647a43
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1063 additions and 11 deletions

View file

@ -174,6 +174,12 @@ type Query {
input: ScrapeSingleGroupInput!
): [ScrapedGroup!]!
"Scrape for a single image"
scrapeSingleImage(
source: ScraperSourceInput!
input: ScrapeSingleImageInput!
): [ScrapedImage!]!
"Scrapes content based on a URL"
scrapeURL(url: String!, ty: ScrapeContentType!): ScrapedContent
@ -183,6 +189,8 @@ type Query {
scrapeSceneURL(url: String!): ScrapedScene
"Scrapes a complete gallery record based on a URL"
scrapeGalleryURL(url: String!): ScrapedGallery
"Scrapes a complete image record based on a URL"
scrapeImageURL(url: String!): ScrapedImage
"Scrapes a complete movie record based on a URL"
scrapeMovieURL(url: String!): ScrapedMovie
@deprecated(reason: "Use scrapeGroupURL instead")

View file

@ -10,6 +10,7 @@ enum ScrapeType {
"Type of the content a scraper generates"
enum ScrapeContentType {
GALLERY
IMAGE
MOVIE
GROUP
PERFORMER
@ -22,6 +23,7 @@ union ScrapedContent =
| ScrapedTag
| ScrapedScene
| ScrapedGallery
| ScrapedImage
| ScrapedMovie
| ScrapedGroup
| ScrapedPerformer
@ -41,6 +43,8 @@ type Scraper {
scene: ScraperSpec
"Details for gallery scraper"
gallery: ScraperSpec
"Details for image scraper"
image: ScraperSpec
"Details for movie scraper"
movie: ScraperSpec @deprecated(reason: "use group")
"Details for group scraper"
@ -128,6 +132,26 @@ input ScrapedGalleryInput {
# no studio, tags or performers
}
type ScrapedImage {
title: String
code: String
details: String
photographer: String
urls: [String!]
date: String
studio: ScrapedStudio
tags: [ScrapedTag!]
performers: [ScrapedPerformer!]
}
input ScrapedImageInput {
title: String
code: String
details: String
urls: [String!]
date: String
}
input ScraperSourceInput {
"Index of the configured stash-box instance to use. Should be unset if scraper_id is set"
stash_box_index: Int @deprecated(reason: "use stash_box_endpoint")
@ -190,6 +214,15 @@ input ScrapeSingleGalleryInput {
gallery_input: ScrapedGalleryInput
}
input ScrapeSingleImageInput {
"Instructs to query by string"
query: String
"Instructs to query by image id"
image_id: ID
"Instructs to query by image fragment"
image_input: ScrapedImageInput
}
input ScrapeSingleMovieInput {
"Instructs to query by string"
query: String

View file

@ -28,7 +28,7 @@ func (r *mutationResolver) getImage(ctx context.Context, id int) (ret *models.Im
return ret, nil
}
func (r *mutationResolver) ImageUpdate(ctx context.Context, input ImageUpdateInput) (ret *models.Image, err error) {
func (r *mutationResolver) ImageUpdate(ctx context.Context, input models.ImageUpdateInput) (ret *models.Image, err error) {
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
@ -46,7 +46,7 @@ func (r *mutationResolver) ImageUpdate(ctx context.Context, input ImageUpdateInp
return r.getImage(ctx, ret.ID)
}
func (r *mutationResolver) ImagesUpdate(ctx context.Context, input []*ImageUpdateInput) (ret []*models.Image, err error) {
func (r *mutationResolver) ImagesUpdate(ctx context.Context, input []*models.ImageUpdateInput) (ret []*models.Image, err error) {
inputMaps := getUpdateInputMaps(ctx)
// Start the transaction and save the image
@ -89,7 +89,7 @@ func (r *mutationResolver) ImagesUpdate(ctx context.Context, input []*ImageUpdat
return newRet, nil
}
func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInput, translator changesetTranslator) (*models.Image, error) {
func (r *mutationResolver) imageUpdate(ctx context.Context, input models.ImageUpdateInput, translator changesetTranslator) (*models.Image, error) {
imageID, err := strconv.Atoi(input.ID)
if err != nil {
return nil, fmt.Errorf("converting id: %w", err)

View file

@ -197,6 +197,15 @@ func (r *queryResolver) ScrapeGalleryURL(ctx context.Context, url string) (*scra
return ret, nil
}
func (r *queryResolver) ScrapeImageURL(ctx context.Context, url string) (*scraper.ScrapedImage, error) {
content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeImage)
if err != nil {
return nil, err
}
return marshalScrapedImage(content)
}
func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models.ScrapedMovie, error) {
content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeMovie)
if err != nil {
@ -491,6 +500,39 @@ func (r *queryResolver) ScrapeSingleGallery(ctx context.Context, source scraper.
return ret, nil
}
func (r *queryResolver) ScrapeSingleImage(ctx context.Context, source scraper.Source, input ScrapeSingleImageInput) ([]*scraper.ScrapedImage, error) {
if source.StashBoxIndex != nil {
return nil, ErrNotSupported
}
if source.ScraperID == nil {
return nil, fmt.Errorf("%w: scraper_id must be set", ErrInput)
}
var c scraper.ScrapedContent
switch {
case input.ImageID != nil:
imageID, err := strconv.Atoi(*input.ImageID)
if err != nil {
return nil, fmt.Errorf("%w: image id is not an integer: '%s'", ErrInput, *input.ImageID)
}
c, err = r.scraperCache().ScrapeID(ctx, *source.ScraperID, imageID, scraper.ScrapeContentTypeImage)
if err != nil {
return nil, err
}
return marshalScrapedImages([]scraper.ScrapedContent{c})
case input.ImageInput != nil:
c, err := r.scraperCache().ScrapeFragment(ctx, *source.ScraperID, scraper.Input{Image: input.ImageInput})
if err != nil {
return nil, err
}
return marshalScrapedImages([]scraper.ScrapedContent{c})
default:
return nil, ErrNotImplemented
}
}
func (r *queryResolver) ScrapeSingleMovie(ctx context.Context, source scraper.Source, input ScrapeSingleMovieInput) ([]*models.ScrapedMovie, error) {
return nil, ErrNotSupported
}

View file

@ -76,6 +76,27 @@ func marshalScrapedGalleries(content []scraper.ScrapedContent) ([]*scraper.Scrap
return ret, nil
}
func marshalScrapedImages(content []scraper.ScrapedContent) ([]*scraper.ScrapedImage, error) {
var ret []*scraper.ScrapedImage
for _, c := range content {
if c == nil {
// graphql schema requires images to be non-nil
continue
}
switch g := c.(type) {
case *scraper.ScrapedImage:
ret = append(ret, g)
case scraper.ScrapedImage:
ret = append(ret, &g)
default:
return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedImage", models.ErrConversion)
}
}
return ret, nil
}
// marshalScrapedMovies converts ScrapedContent into ScrapedMovie. If conversion
// fails, an error is returned.
func marshalScrapedMovies(content []scraper.ScrapedContent) ([]*models.ScrapedMovie, error) {
@ -129,6 +150,16 @@ func marshalScrapedGallery(content scraper.ScrapedContent) (*scraper.ScrapedGall
return g[0], nil
}
// marshalScrapedImage will marshal a single scraped image
func marshalScrapedImage(content scraper.ScrapedContent) (*scraper.ScrapedImage, error) {
g, err := marshalScrapedImages([]scraper.ScrapedContent{content})
if err != nil {
return nil, err
}
return g[0], nil
}
// marshalScrapedMovie will marshal a single scraped movie
func marshalScrapedMovie(content scraper.ScrapedContent) (*models.ScrapedMovie, error) {
m, err := marshalScrapedMovies([]scraper.ScrapedContent{content})

View file

@ -63,6 +63,28 @@ type ImageFilterType struct {
UpdatedAt *TimestampCriterionInput `json:"updated_at"`
}
type ImageUpdateInput struct {
ClientMutationID *string `json:"clientMutationId"`
ID string `json:"id"`
Title *string `json:"title"`
Code *string `json:"code"`
Urls []string `json:"urls"`
Date *string `json:"date"`
Details *string `json:"details"`
Photographer *string `json:"photographer"`
Rating100 *int `json:"rating100"`
Organized *bool `json:"organized"`
SceneIds []string `json:"scene_ids"`
StudioID *string `json:"studio_id"`
TagIds []string `json:"tag_ids"`
PerformerIds []string `json:"performer_ids"`
GalleryIds []string `json:"gallery_ids"`
PrimaryFileID *string `json:"primary_file_id"`
// deprecated
URL *string `json:"url"`
}
type ImageDestroyInput struct {
ID string `json:"id"`
DeleteFile *bool `json:"delete_file"`

View file

@ -31,6 +31,7 @@ type scraperActionImpl interface {
scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*ScrapedScene, error)
scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*ScrapedGallery, error)
scrapeImageByImage(ctx context.Context, image *models.Image) (*ScrapedImage, error)
}
func (c config) getScraper(scraper scraperTypeConfig, client *http.Client, globalConfig GlobalConfig) scraperActionImpl {

View file

@ -77,11 +77,18 @@ type GalleryFinder interface {
models.URLLoader
}
type ImageFinder interface {
models.ImageGetter
models.FileLoader
models.URLLoader
}
type Repository struct {
TxnManager models.TxnManager
SceneFinder SceneFinder
GalleryFinder GalleryFinder
ImageFinder ImageFinder
TagFinder TagFinder
PerformerFinder PerformerFinder
GroupFinder match.GroupNamesFinder
@ -93,6 +100,7 @@ func NewRepository(repo models.Repository) Repository {
TxnManager: repo.TxnManager,
SceneFinder: repo.Scene,
GalleryFinder: repo.Gallery,
ImageFinder: repo.Image,
TagFinder: repo.Tag,
PerformerFinder: repo.Performer,
GroupFinder: repo.Group,
@ -357,6 +365,28 @@ func (c Cache) ScrapeID(ctx context.Context, scraperID string, id int, ty Scrape
return nil, fmt.Errorf("scraper %s: %w", scraperID, err)
}
if scraped != nil {
ret = scraped
}
case ScrapeContentTypeImage:
is, ok := s.(imageScraper)
if !ok {
return nil, fmt.Errorf("%w: cannot use scraper %s as a image scraper", ErrNotSupported, scraperID)
}
scene, err := c.getImage(ctx, id)
if err != nil {
return nil, fmt.Errorf("scraper %s: unable to load image id %v: %w", scraperID, id, err)
}
// don't assign nil concrete pointer to ret interface, otherwise nil
// detection is harder
scraped, err := is.viaImage(ctx, c.client, scene)
if err != nil {
return nil, fmt.Errorf("scraper %s: %w", scraperID, err)
}
if scraped != nil {
ret = scraped
}
@ -426,3 +456,31 @@ func (c Cache) getGallery(ctx context.Context, galleryID int) (*models.Gallery,
}
return ret, nil
}
func (c Cache) getImage(ctx context.Context, imageID int) (*models.Image, error) {
var ret *models.Image
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
qb := r.ImageFinder
var err error
ret, err = qb.Find(ctx, imageID)
if err != nil {
return err
}
if ret == nil {
return fmt.Errorf("image with id %d not found", imageID)
}
err = ret.LoadFiles(ctx, qb)
if err != nil {
return err
}
return ret.LoadURLs(ctx, qb)
}); err != nil {
return nil, err
}
return ret, nil
}

View file

@ -45,6 +45,13 @@ type config struct {
// Configuration for querying a gallery by a URL
GalleryByURL []*scrapeByURLConfig `yaml:"galleryByURL"`
// Configuration for querying an image by a URL
ImageByURL []*scrapeByURLConfig `yaml:"imageByURL"`
// Configuration for querying image by an Image fragment
ImageByFragment *scraperTypeConfig `yaml:"imageByFragment"`
// Configuration for querying a movie by a URL
// Configuration for querying a movie by a URL - deprecated, use GroupByURL
MovieByURL []*scrapeByURLConfig `yaml:"movieByURL"`
GroupByURL []*scrapeByURLConfig `yaml:"groupByURL"`
@ -295,6 +302,21 @@ func (c config) spec() Scraper {
ret.Gallery = &gallery
}
image := ScraperSpec{}
if c.ImageByFragment != nil {
image.SupportedScrapes = append(image.SupportedScrapes, ScrapeTypeFragment)
}
if len(c.ImageByURL) > 0 {
image.SupportedScrapes = append(image.SupportedScrapes, ScrapeTypeURL)
for _, v := range c.ImageByURL {
image.Urls = append(image.Urls, v.URL...)
}
}
if len(image.SupportedScrapes) > 0 {
ret.Image = &image
}
group := ScraperSpec{}
if len(c.MovieByURL) > 0 || len(c.GroupByURL) > 0 {
group.SupportedScrapes = append(group.SupportedScrapes, ScrapeTypeURL)
@ -319,6 +341,8 @@ func (c config) supports(ty ScrapeContentType) bool {
return (c.SceneByName != nil && c.SceneByQueryFragment != nil) || c.SceneByFragment != nil || len(c.SceneByURL) > 0
case ScrapeContentTypeGallery:
return c.GalleryByFragment != nil || len(c.GalleryByURL) > 0
case ScrapeContentTypeImage:
return c.ImageByFragment != nil || len(c.ImageByURL) > 0
case ScrapeContentTypeMovie, ScrapeContentTypeGroup:
return len(c.MovieByURL) > 0 || len(c.GroupByURL) > 0
}
@ -346,6 +370,12 @@ func (c config) matchesURL(url string, ty ScrapeContentType) bool {
return true
}
}
case ScrapeContentTypeImage:
for _, scraper := range c.ImageByURL {
if scraper.matchesURL(url) {
return true
}
}
case ScrapeContentTypeMovie, ScrapeContentTypeGroup:
for _, scraper := range c.MovieByURL {
if scraper.matchesURL(url) {

View file

@ -33,6 +33,9 @@ func (g group) fragmentScraper(input Input) *scraperTypeConfig {
case input.Gallery != nil:
// TODO - this should be galleryByQueryFragment
return g.config.GalleryByFragment
case input.Image != nil:
// TODO - this should be imageByImageFragment
return g.config.ImageByFragment
case input.Scene != nil:
return g.config.SceneByQueryFragment
}
@ -75,6 +78,15 @@ func (g group) viaGallery(ctx context.Context, client *http.Client, gallery *mod
return s.scrapeGalleryByGallery(ctx, gallery)
}
func (g group) viaImage(ctx context.Context, client *http.Client, gallery *models.Image) (*ScrapedImage, error) {
if g.config.ImageByFragment == nil {
return nil, ErrNotSupported
}
s := g.config.getScraper(*g.config.ImageByFragment, client, g.globalConf)
return s.scrapeImageByImage(ctx, gallery)
}
func loadUrlCandidates(c config, ty ScrapeContentType) []*scrapeByURLConfig {
switch ty {
case ScrapeContentTypePerformer:
@ -85,6 +97,8 @@ func loadUrlCandidates(c config, ty ScrapeContentType) []*scrapeByURLConfig {
return append(c.MovieByURL, c.GroupByURL...)
case ScrapeContentTypeGallery:
return c.GalleryByURL
case ScrapeContentTypeImage:
return c.ImageByURL
}
panic("loadUrlCandidates: unreachable")

View file

@ -11,6 +11,28 @@ import (
"github.com/stashapp/stash/pkg/utils"
)
type ScrapedImage struct {
Title *string `json:"title"`
Code *string `json:"code"`
Details *string `json:"details"`
Photographer *string `json:"photographer"`
URLs []string `json:"urls"`
Date *string `json:"date"`
Studio *models.ScrapedStudio `json:"studio"`
Tags []*models.ScrapedTag `json:"tags"`
Performers []*models.ScrapedPerformer `json:"performers"`
}
func (ScrapedImage) IsScrapedContent() {}
type ScrapedImageInput struct {
Title *string `json:"title"`
Code *string `json:"code"`
Details *string `json:"details"`
URLs []string `json:"urls"`
Date *string `json:"date"`
}
func setPerformerImage(ctx context.Context, client *http.Client, p *models.ScrapedPerformer, globalConfig GlobalConfig) error {
// backwards compatibility: we fetch the image if it's a URL and set it to the first image
// Image is deprecated, so only do this if Images is unset

View file

@ -102,6 +102,12 @@ func (s *jsonScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeCont
return nil, err
}
return ret, nil
case ScrapeContentTypeImage:
ret, err := scraper.scrapeImage(ctx, q)
if err != nil || ret == nil {
return nil, err
}
return ret, nil
case ScrapeContentTypeMovie, ScrapeContentTypeGroup:
ret, err := scraper.scrapeGroup(ctx, q)
if err != nil || ret == nil {
@ -225,6 +231,30 @@ func (s *jsonScraper) scrapeByFragment(ctx context.Context, input Input) (Scrape
return scraper.scrapeScene(ctx, q)
}
func (s *jsonScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*ScrapedImage, error) {
// construct the URL
queryURL := queryURLParametersFromImage(image)
if s.scraper.QueryURLReplacements != nil {
queryURL.applyReplacements(s.scraper.QueryURLReplacements)
}
url := queryURL.constructURL(s.scraper.QueryURL)
scraper := s.getJsonScraper()
if scraper == nil {
return nil, errors.New("json scraper with name " + s.scraper.Scraper + " not found in config")
}
doc, err := s.loadURL(ctx, url)
if err != nil {
return nil, err
}
q := s.getJsonQuery(doc)
return scraper.scrapeImage(ctx, q)
}
func (s *jsonScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*ScrapedGallery, error) {
// construct the URL
queryURL := queryURLParametersFromGallery(gallery)

View file

@ -181,6 +181,7 @@ type mappedGalleryScraperConfig struct {
Performers mappedConfig `yaml:"Performers"`
Studio mappedConfig `yaml:"Studio"`
}
type _mappedGalleryScraperConfig mappedGalleryScraperConfig
func (s *mappedGalleryScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
@ -228,6 +229,60 @@ func (s *mappedGalleryScraperConfig) UnmarshalYAML(unmarshal func(interface{}) e
return nil
}
type mappedImageScraperConfig struct {
mappedConfig
Tags mappedConfig `yaml:"Tags"`
Performers mappedConfig `yaml:"Performers"`
Studio mappedConfig `yaml:"Studio"`
}
type _mappedImageScraperConfig mappedImageScraperConfig
func (s *mappedImageScraperConfig) UnmarshalYAML(unmarshal func(interface{}) error) error {
// HACK - unmarshal to map first, then remove known scene sub-fields, then
// remarshal to yaml and pass that down to the base map
parentMap := make(map[string]interface{})
if err := unmarshal(parentMap); err != nil {
return err
}
// move the known sub-fields to a separate map
thisMap := make(map[string]interface{})
thisMap[mappedScraperConfigSceneTags] = parentMap[mappedScraperConfigSceneTags]
thisMap[mappedScraperConfigScenePerformers] = parentMap[mappedScraperConfigScenePerformers]
thisMap[mappedScraperConfigSceneStudio] = parentMap[mappedScraperConfigSceneStudio]
delete(parentMap, mappedScraperConfigSceneTags)
delete(parentMap, mappedScraperConfigScenePerformers)
delete(parentMap, mappedScraperConfigSceneStudio)
// re-unmarshal the sub-fields
yml, err := yaml.Marshal(thisMap)
if err != nil {
return err
}
// needs to be a different type to prevent infinite recursion
c := _mappedImageScraperConfig{}
if err := yaml.Unmarshal(yml, &c); err != nil {
return err
}
*s = mappedImageScraperConfig(c)
yml, err = yaml.Marshal(parentMap)
if err != nil {
return err
}
if err := yaml.Unmarshal(yml, &s.mappedConfig); err != nil {
return err
}
return nil
}
type mappedPerformerScraperConfig struct {
mappedConfig
@ -785,6 +840,7 @@ type mappedScraper struct {
Common commonMappedConfig `yaml:"common"`
Scene *mappedSceneScraperConfig `yaml:"scene"`
Gallery *mappedGalleryScraperConfig `yaml:"gallery"`
Image *mappedImageScraperConfig `yaml:"image"`
Performer *mappedPerformerScraperConfig `yaml:"performer"`
Movie *mappedMovieScraperConfig `yaml:"movie"`
}
@ -1016,6 +1072,57 @@ func (s mappedScraper) scrapeScene(ctx context.Context, q mappedQuery) (*Scraped
return nil, nil
}
func (s mappedScraper) scrapeImage(ctx context.Context, q mappedQuery) (*ScrapedImage, error) {
var ret ScrapedImage
imageScraperConfig := s.Image
if imageScraperConfig == nil {
return nil, nil
}
imageMap := imageScraperConfig.mappedConfig
imagePerformersMap := imageScraperConfig.Performers
imageTagsMap := imageScraperConfig.Tags
imageStudioMap := imageScraperConfig.Studio
logger.Debug(`Processing image:`)
results := imageMap.process(ctx, q, s.Common)
// now apply the performers and tags
if imagePerformersMap != nil {
logger.Debug(`Processing image performers:`)
ret.Performers = processRelationships[models.ScrapedPerformer](ctx, s, imagePerformersMap, q)
}
if imageTagsMap != nil {
logger.Debug(`Processing image tags:`)
ret.Tags = processRelationships[models.ScrapedTag](ctx, s, imageTagsMap, q)
}
if imageStudioMap != nil {
logger.Debug(`Processing image studio:`)
studioResults := imageStudioMap.process(ctx, q, s.Common)
if len(studioResults) > 0 {
studio := &models.ScrapedStudio{}
studioResults[0].apply(studio)
ret.Studio = studio
}
}
// if no basic fields are populated, and no relationships, then return nil
if len(results) == 0 && len(ret.Performers) == 0 && len(ret.Tags) == 0 && ret.Studio == nil {
return nil, nil
}
if len(results) > 0 {
results[0].apply(&ret)
}
return &ret, nil
}
func (s mappedScraper) scrapeGallery(ctx context.Context, q mappedQuery) (*ScrapedGallery, error) {
var ret ScrapedGallery

View file

@ -33,6 +33,12 @@ func (c Cache) postScrape(ctx context.Context, content ScrapedContent) (ScrapedC
}
case ScrapedGallery:
return c.postScrapeGallery(ctx, v)
case *ScrapedImage:
if v != nil {
return c.postScrapeImage(ctx, *v)
}
case ScrapedImage:
return c.postScrapeImage(ctx, v)
case *models.ScrapedMovie:
if v != nil {
return c.postScrapeMovie(ctx, *v)
@ -315,6 +321,40 @@ func (c Cache) postScrapeGallery(ctx context.Context, g ScrapedGallery) (Scraped
return g, nil
}
func (c Cache) postScrapeImage(ctx context.Context, image ScrapedImage) (ScrapedContent, error) {
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
pqb := r.PerformerFinder
tqb := r.TagFinder
sqb := r.StudioFinder
for _, p := range image.Performers {
if err := match.ScrapedPerformer(ctx, pqb, p, nil); err != nil {
return err
}
}
tags, err := postProcessTags(ctx, tqb, image.Tags)
if err != nil {
return err
}
image.Tags = tags
if image.Studio != nil {
err := match.ScrapedStudio(ctx, sqb, image.Studio, nil)
if err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
}
return image, nil
}
func postProcessTags(ctx context.Context, tqb models.TagQueryer, scrapedTags []*models.ScrapedTag) ([]*models.ScrapedTag, error) {
var ret []*models.ScrapedTag

View file

@ -73,6 +73,24 @@ func queryURLParametersFromGallery(gallery *models.Gallery) queryURLParameters {
return ret
}
func queryURLParametersFromImage(image *models.Image) queryURLParameters {
ret := make(queryURLParameters)
ret["checksum"] = image.Checksum
if image.Path != "" {
ret["filename"] = filepath.Base(image.Path)
}
if image.Title != "" {
ret["title"] = image.Title
}
if len(image.URLs.List()) > 0 {
ret["url"] = image.URLs.List()[0]
}
return ret
}
func (p queryURLParameters) applyReplacements(r queryURLReplacements) {
for k, v := range p {
rpl, found := r[k]

View file

@ -36,6 +36,7 @@ const (
ScrapeContentTypeGroup ScrapeContentType = "GROUP"
ScrapeContentTypePerformer ScrapeContentType = "PERFORMER"
ScrapeContentTypeScene ScrapeContentType = "SCENE"
ScrapeContentTypeImage ScrapeContentType = "IMAGE"
)
var AllScrapeContentType = []ScrapeContentType{
@ -44,11 +45,12 @@ var AllScrapeContentType = []ScrapeContentType{
ScrapeContentTypeGroup,
ScrapeContentTypePerformer,
ScrapeContentTypeScene,
ScrapeContentTypeImage,
}
func (e ScrapeContentType) IsValid() bool {
switch e {
case ScrapeContentTypeGallery, ScrapeContentTypeMovie, ScrapeContentTypeGroup, ScrapeContentTypePerformer, ScrapeContentTypeScene:
case ScrapeContentTypeGallery, ScrapeContentTypeMovie, ScrapeContentTypeGroup, ScrapeContentTypePerformer, ScrapeContentTypeScene, ScrapeContentTypeImage:
return true
}
return false
@ -84,6 +86,8 @@ type Scraper struct {
Scene *ScraperSpec `json:"scene"`
// Details for gallery scraper
Gallery *ScraperSpec `json:"gallery"`
// Details for image scraper
Image *ScraperSpec `json:"image"`
// Details for movie scraper
Group *ScraperSpec `json:"group"`
// Details for movie scraper
@ -161,6 +165,7 @@ type Input struct {
Performer *ScrapedPerformerInput
Scene *ScrapedSceneInput
Gallery *ScrapedGalleryInput
Image *ScrapedImageInput
}
// populateURL populates the URL field of the input based on the
@ -225,6 +230,14 @@ type sceneScraper interface {
viaScene(ctx context.Context, client *http.Client, scene *models.Scene) (*ScrapedScene, error)
}
// imageScraper is a scraper which supports image scrapes with
// image data as the input.
type imageScraper interface {
scraper
viaImage(ctx context.Context, client *http.Client, image *models.Image) (*ScrapedImage, error)
}
// galleryScraper is a scraper which supports gallery scrapes with
// gallery data as the input.
type galleryScraper interface {

View file

@ -388,6 +388,10 @@ func (s *scriptScraper) scrape(ctx context.Context, input string, ty ScrapeConte
var movie *models.ScrapedMovie
err := s.runScraperScript(ctx, input, &movie)
return movie, err
case ScrapeContentTypeImage:
var image *ScrapedImage
err := s.runScraperScript(ctx, input, &image)
return image, err
}
return nil, ErrNotSupported
@ -421,6 +425,20 @@ func (s *scriptScraper) scrapeGalleryByGallery(ctx context.Context, gallery *mod
return ret, err
}
func (s *scriptScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*ScrapedImage, error) {
inString, err := json.Marshal(imageToUpdateInput(image))
if err != nil {
return nil, err
}
var ret *ScrapedImage
err = s.runScraperScript(ctx, string(inString), &ret)
return ret, err
}
func handleScraperStderr(name string, scraperOutputReader io.ReadCloser) {
const scraperPrefix = "[Scrape / %s] "

View file

@ -388,6 +388,33 @@ func (s *stashScraper) scrapeGalleryByGallery(ctx context.Context, gallery *mode
return &ret, nil
}
func (s *stashScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*ScrapedImage, error) {
return nil, ErrNotSupported
}
func (s *stashScraper) scrapeByURL(_ context.Context, _ string, _ ScrapeContentType) (ScrapedContent, error) {
return nil, ErrNotSupported
}
func imageToUpdateInput(gallery *models.Image) models.ImageUpdateInput {
dateToStringPtr := func(s *models.Date) *string {
if s != nil {
v := s.String()
return &v
}
return nil
}
// fallback to file basename if title is empty
title := gallery.GetTitle()
urls := gallery.URLs.List()
return models.ImageUpdateInput{
ID: strconv.Itoa(gallery.ID),
Title: &title,
Details: &gallery.Details,
Urls: urls,
Date: dateToStringPtr(gallery.Date),
}
}

View file

@ -83,6 +83,12 @@ func (s *xpathScraper) scrapeByURL(ctx context.Context, url string, ty ScrapeCon
return nil, err
}
return ret, nil
case ScrapeContentTypeImage:
ret, err := scraper.scrapeImage(ctx, q)
if err != nil || ret == nil {
return nil, err
}
return ret, nil
case ScrapeContentTypeMovie, ScrapeContentTypeGroup:
ret, err := scraper.scrapeGroup(ctx, q)
if err != nil || ret == nil {
@ -228,6 +234,30 @@ func (s *xpathScraper) scrapeGalleryByGallery(ctx context.Context, gallery *mode
return scraper.scrapeGallery(ctx, q)
}
func (s *xpathScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*ScrapedImage, error) {
// construct the URL
queryURL := queryURLParametersFromImage(image)
if s.scraper.QueryURLReplacements != nil {
queryURL.applyReplacements(s.scraper.QueryURLReplacements)
}
url := queryURL.constructURL(s.scraper.QueryURL)
scraper := s.getXpathScraper()
if scraper == nil {
return nil, errors.New("xpath scraper with name " + s.scraper.Scraper + " not found in config")
}
doc, err := s.loadURL(ctx, url)
if err != nil {
return nil, err
}
q := s.getXPathQuery(doc)
return scraper.scrapeImage(ctx, q)
}
func (s *xpathScraper) loadURL(ctx context.Context, url string) (*html.Node, error) {
r, err := loadURL(ctx, url, s.client, s.config, s.globalConfig)
if err != nil {

View file

@ -205,6 +205,27 @@ fragment ScrapedGalleryData on ScrapedGallery {
}
}
fragment ScrapedImageData on ScrapedImage {
title
code
details
photographer
urls
date
studio {
...ScrapedSceneStudioData
}
tags {
...ScrapedSceneTagData
}
performers {
...ScrapedScenePerformerData
}
}
fragment ScrapedStashBoxSceneData on ScrapedScene {
title
code

View file

@ -31,6 +31,17 @@ query ListGalleryScrapers {
}
}
query ListImageScrapers {
listScrapers(types: [IMAGE]) {
id
name
image {
urls
supported_scrapes
}
}
}
query ListGroupScrapers {
listScrapers(types: [GROUP]) {
id
@ -108,12 +119,27 @@ query ScrapeSingleGallery(
}
}
query ScrapeSingleImage(
$source: ScraperSourceInput!
$input: ScrapeSingleImageInput!
) {
scrapeSingleImage(source: $source, input: $input) {
...ScrapedImageData
}
}
query ScrapeGalleryURL($url: String!) {
scrapeGalleryURL(url: $url) {
...ScrapedGalleryData
}
}
query ScrapeImageURL($url: String!) {
scrapeImageURL(url: $url) {
...ScrapedImageData
}
}
query ScrapeGroupURL($url: String!) {
scrapeGroupURL(url: $url) {
...ScrapedGroupData

View file

@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { Button, Form, Col, Row } from "react-bootstrap";
import React, { useEffect, useMemo, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { Button, Form, Col, Row } from "react-bootstrap";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import * as yup from "yup";
@ -19,6 +19,13 @@ import {
PerformerSelect,
} from "src/components/Performers/PerformerSelect";
import { formikUtils } from "src/utils/form";
import {
queryScrapeImage,
queryScrapeImageURL,
useListImageScrapers,
mutateReloadScrapers,
} from "../../../core/StashService";
import { ImageScrapeDialog } from "./ImageScrapeDialog";
import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
import { galleryTitle } from "src/core/galleries";
import {
@ -27,6 +34,7 @@ import {
excludeFileBasedGalleries,
} from "src/components/Galleries/GallerySelect";
import { useTagsEdit } from "src/hooks/tagsEdit";
import { ScraperMenu } from "src/components/Shared/ScraperMenu";
interface IProps {
image: GQL.ImageDataFragment;
@ -51,6 +59,8 @@ export const ImageEditPanel: React.FC<IProps> = ({
const [performers, setPerformers] = useState<Performer[]>([]);
const [studio, setStudio] = useState<Studio | null>(null);
const isNew = image.id === undefined;
useEffect(() => {
setGalleries(
image.galleries?.map((g) => ({
@ -62,6 +72,9 @@ export const ImageEditPanel: React.FC<IProps> = ({
);
}, [image.galleries]);
const scrapers = useListImageScrapers();
const [scrapedImage, setScrapedImage] = useState<GQL.ScrapedImage | null>();
const schema = yup.object({
title: yup.string().ensure(),
code: yup.string().ensure(),
@ -97,8 +110,9 @@ export const ImageEditPanel: React.FC<IProps> = ({
onSubmit: (values) => onSave(schema.cast(values)),
});
const { tagsControl } = useTagsEdit(image.tags, (ids) =>
formik.setFieldValue("tag_ids", ids)
const { tags, updateTagsStateFromScraper, tagsControl } = useTagsEdit(
image.tags,
(ids) => formik.setFieldValue("tag_ids", ids)
);
function onSetGalleries(items: Gallery[]) {
@ -148,6 +162,12 @@ export const ImageEditPanel: React.FC<IProps> = ({
}
});
const fragmentScrapers = useMemo(() => {
return (scrapers?.data?.listScrapers ?? []).filter((s) =>
s.image?.supported_scrapes.includes(GQL.ScrapeType.Fragment)
);
}, [scrapers]);
async function onSave(input: InputValues) {
setIsLoading(true);
try {
@ -162,6 +182,122 @@ export const ImageEditPanel: React.FC<IProps> = ({
setIsLoading(false);
}
async function onScrapeClicked(s: GQL.ScraperSourceInput) {
if (!image || !image.id) return;
setIsLoading(true);
try {
const result = await queryScrapeImage(s.scraper_id!, image.id);
if (!result.data || !result.data.scrapeSingleImage?.length) {
Toast.success("No images found");
return;
}
setScrapedImage(result.data.scrapeSingleImage[0]);
} catch (e) {
Toast.error(e);
} finally {
setIsLoading(false);
}
}
function urlScrapable(scrapedUrl: string): boolean {
return (scrapers?.data?.listScrapers ?? []).some((s) =>
(s?.image?.urls ?? []).some((u) => scrapedUrl.includes(u))
);
}
function updateImageFromScrapedGallery(
imageData: GQL.ScrapedImageDataFragment
) {
if (imageData.title) {
formik.setFieldValue("title", imageData.title);
}
if (imageData.code) {
formik.setFieldValue("code", imageData.code);
}
if (imageData.details) {
formik.setFieldValue("details", imageData.details);
}
if (imageData.photographer) {
formik.setFieldValue("photographer", imageData.photographer);
}
if (imageData.date) {
formik.setFieldValue("date", imageData.date);
}
if (imageData.urls) {
formik.setFieldValue("urls", imageData.urls);
}
if (imageData.studio?.stored_id) {
onSetStudio({
id: imageData.studio.stored_id,
name: imageData.studio.name ?? "",
aliases: [],
});
}
if (imageData.performers?.length) {
const idPerfs = imageData.performers.filter((p) => {
return p.stored_id !== undefined && p.stored_id !== null;
});
if (idPerfs.length > 0) {
onSetPerformers(
idPerfs.map((p) => {
return {
id: p.stored_id!,
name: p.name ?? "",
alias_list: [],
};
})
);
}
}
updateTagsStateFromScraper(imageData.tags ?? undefined);
}
async function onReloadScrapers() {
setIsLoading(true);
try {
await mutateReloadScrapers();
} catch (e) {
Toast.error(e);
} finally {
setIsLoading(false);
}
}
async function onScrapeDialogClosed(data?: GQL.ScrapedImageDataFragment) {
if (data) {
updateImageFromScrapedGallery(data);
}
setScrapedImage(undefined);
}
async function onScrapeImageURL(url: string) {
if (!url) {
return;
}
setIsLoading(true);
try {
const result = await queryScrapeImageURL(url);
if (!result || !result.data || !result.data.scrapeImageURL) {
return;
}
setScrapedImage(result.data.scrapeImageURL);
} catch (e) {
Toast.error(e);
} finally {
setIsLoading(false);
}
}
if (isLoading) return <LoadingIndicator />;
const splitProps = {
@ -243,6 +379,30 @@ export const ImageEditPanel: React.FC<IProps> = ({
return renderInputField("details", "textarea", "details", props);
}
function maybeRenderScrapeDialog() {
if (!scrapedImage) {
return;
}
const currentImage = {
id: image.id!,
...formik.values,
};
return (
<ImageScrapeDialog
image={currentImage}
imageStudio={studio}
imageTags={tags}
imagePerformers={performers}
scraped={scrapedImage}
onClose={(data) => {
onScrapeDialogClosed(data);
}}
/>
);
}
return (
<div id="image-edit-details">
<Prompt
@ -250,13 +410,16 @@ export const ImageEditPanel: React.FC<IProps> = ({
message={intl.formatMessage({ id: "dialogs.unsaved_changes" })}
/>
{maybeRenderScrapeDialog()}
<Form noValidate onSubmit={formik.handleSubmit}>
<Row className="form-container edit-buttons-container px-3 pt-3">
<div className="edit-buttons mb-3 pl-0">
<Button
className="edit-button"
variant="primary"
disabled={!formik.dirty || !isEqual(formik.errors, {})}
disabled={
(!isNew && !formik.dirty) || !isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
@ -269,13 +432,23 @@ export const ImageEditPanel: React.FC<IProps> = ({
<FormattedMessage id="actions.delete" />
</Button>
</div>
<div className="ml-auto text-right d-flex">
{!isNew && (
<ScraperMenu
toggle={intl.formatMessage({ id: "actions.scrape_with" })}
scrapers={fragmentScrapers}
onScraperClicked={onScrapeClicked}
onReloadScrapers={onReloadScrapers}
/>
)}
</div>
</Row>
<Row className="form-container px-3">
<Col lg={7} xl={12}>
{renderInputField("title")}
{renderInputField("code", "text", "scene_code")}
{renderURLListField("urls")}
{renderURLListField("urls", onScrapeImageURL, urlScrapable)}
{renderDateField("date")}
{renderInputField("photographer")}

View file

@ -0,0 +1,227 @@
import React, { useState } from "react";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import {
ScrapeDialog,
ScrapedInputGroupRow,
ScrapedStringListRow,
ScrapedTextAreaRow,
} from "src/components/Shared/ScrapeDialog/ScrapeDialog";
import {
ObjectListScrapeResult,
ObjectScrapeResult,
ScrapeResult,
} from "src/components/Shared/ScrapeDialog/scrapeResult";
import {
ScrapedPerformersRow,
ScrapedStudioRow,
} from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow";
import { sortStoredIdObjects } from "src/utils/data";
import { Performer } from "src/components/Performers/PerformerSelect";
import {
useCreateScrapedPerformer,
useCreateScrapedStudio,
} from "src/components/Shared/ScrapeDialog/createObjects";
import { uniq } from "lodash-es";
import { Tag } from "src/components/Tags/TagSelect";
import { Studio } from "src/components/Studios/StudioSelect";
import { useScrapedTags } from "src/components/Shared/ScrapeDialog/scrapedTags";
interface IImageScrapeDialogProps {
image: Partial<GQL.ImageUpdateInput>;
imageStudio: Studio | null;
imageTags: Tag[];
imagePerformers: Performer[];
scraped: GQL.ScrapedImage;
onClose: (scrapedImage?: GQL.ScrapedImage) => void;
}
export const ImageScrapeDialog: React.FC<IImageScrapeDialogProps> = ({
image,
imageStudio,
imageTags,
imagePerformers,
scraped,
onClose,
}) => {
const intl = useIntl();
const [title, setTitle] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(image.title, scraped.title)
);
const [code, setCode] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(image.code, scraped.code)
);
const [urls, setURLs] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string[]>(
image.urls,
scraped.urls
? uniq((image.urls ?? []).concat(scraped.urls ?? []))
: undefined
)
);
const [date, setDate] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(image.date, scraped.date)
);
const [photographer, setPhotographer] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(image.photographer, scraped.photographer)
);
const [studio, setStudio] = useState<ObjectScrapeResult<GQL.ScrapedStudio>>(
new ObjectScrapeResult<GQL.ScrapedStudio>(
imageStudio
? {
stored_id: imageStudio.id,
name: imageStudio.name,
}
: undefined,
scraped.studio?.stored_id ? scraped.studio : undefined
)
);
const [newStudio, setNewStudio] = useState<GQL.ScrapedStudio | undefined>(
scraped.studio && !scraped.studio.stored_id ? scraped.studio : undefined
);
const [performers, setPerformers] = useState<
ObjectListScrapeResult<GQL.ScrapedPerformer>
>(
new ObjectListScrapeResult<GQL.ScrapedPerformer>(
sortStoredIdObjects(
imagePerformers.map((p) => ({
stored_id: p.id,
name: p.name,
}))
),
sortStoredIdObjects(scraped.performers ?? undefined)
)
);
const [newPerformers, setNewPerformers] = useState<GQL.ScrapedPerformer[]>(
scraped.performers?.filter((t) => !t.stored_id) ?? []
);
const { tags, newTags, scrapedTagsRow } = useScrapedTags(
imageTags,
scraped.tags
);
const [details, setDetails] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(image.details, scraped.details)
);
const createNewStudio = useCreateScrapedStudio({
scrapeResult: studio,
setScrapeResult: setStudio,
setNewObject: setNewStudio,
});
const createNewPerformer = useCreateScrapedPerformer({
scrapeResult: performers,
setScrapeResult: setPerformers,
newObjects: newPerformers,
setNewObjects: setNewPerformers,
});
// don't show the dialog if nothing was scraped
if (
[
title,
code,
urls,
date,
photographer,
studio,
performers,
tags,
details,
].every((r) => !r.scraped) &&
!newStudio &&
newPerformers.length === 0 &&
newTags.length === 0
) {
onClose();
return <></>;
}
function makeNewScrapedItem(): GQL.ScrapedImageDataFragment {
const newStudioValue = studio.getNewValue();
return {
title: title.getNewValue(),
code: code.getNewValue(),
urls: urls.getNewValue(),
date: date.getNewValue(),
photographer: photographer.getNewValue(),
studio: newStudioValue,
performers: performers.getNewValue(),
tags: tags.getNewValue(),
details: details.getNewValue(),
};
}
function renderScrapeRows() {
return (
<>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "title" })}
result={title}
onChange={(value) => setTitle(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "scene_code" })}
result={code}
onChange={(value) => setCode(value)}
/>
<ScrapedStringListRow
title={intl.formatMessage({ id: "urls" })}
result={urls}
onChange={(value) => setURLs(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "date" })}
placeholder="YYYY-MM-DD"
result={date}
onChange={(value) => setDate(value)}
/>
<ScrapedInputGroupRow
title={intl.formatMessage({ id: "photographer" })}
result={photographer}
onChange={(value) => setPhotographer(value)}
/>
<ScrapedStudioRow
title={intl.formatMessage({ id: "studios" })}
result={studio}
onChange={(value) => setStudio(value)}
newStudio={newStudio}
onCreateNew={createNewStudio}
/>
<ScrapedPerformersRow
title={intl.formatMessage({ id: "performers" })}
result={performers}
onChange={(value) => setPerformers(value)}
newObjects={newPerformers}
onCreateNew={createNewPerformer}
/>
{scrapedTagsRow}
<ScrapedTextAreaRow
title={intl.formatMessage({ id: "details" })}
result={details}
onChange={(value) => setDetails(value)}
/>
</>
);
}
return (
<ScrapeDialog
title={intl.formatMessage(
{ id: "dialogs.scrape_entity_title" },
{ entity_type: intl.formatMessage({ id: "image" }) }
)}
renderScrapeRows={renderScrapeRows}
onClose={(apply) => {
onClose(apply ? makeNewScrapedItem() : undefined);
}}
/>
);
};

View file

@ -7,6 +7,7 @@ import {
useListPerformerScrapers,
useListSceneScrapers,
useListGalleryScrapers,
useListImageScrapers,
} from "src/core/StashService";
import { useToast } from "src/hooks/Toast";
import TextUtils from "src/utils/text";
@ -178,6 +179,8 @@ const ScrapersSection: React.FC = () => {
useListSceneScrapers();
const { data: galleryScrapers, loading: loadingGalleries } =
useListGalleryScrapers();
const { data: imageScrapers, loading: loadingImages } =
useListImageScrapers();
const { data: groupScrapers, loading: loadingGroups } =
useListGroupScrapers();
@ -193,6 +196,9 @@ const ScrapersSection: React.FC = () => {
galleries: galleryScrapers?.listScrapers.filter((s) =>
filterFn(s.name, s.gallery?.urls)
),
images: imageScrapers?.listScrapers.filter((s) =>
filterFn(s.name, s.image?.urls)
),
groups: groupScrapers?.listScrapers.filter((s) =>
filterFn(s.name, s.group?.urls)
),
@ -201,6 +207,7 @@ const ScrapersSection: React.FC = () => {
performerScrapers,
sceneScrapers,
galleryScrapers,
imageScrapers,
groupScrapers,
filter,
]);
@ -213,7 +220,13 @@ const ScrapersSection: React.FC = () => {
}
}
if (loadingScenes || loadingGalleries || loadingPerformers || loadingGroups)
if (
loadingScenes ||
loadingGalleries ||
loadingPerformers ||
loadingGroups ||
loadingImages
)
return (
<SettingSection headingID="config.scraping.scrapers">
<LoadingIndicator />
@ -274,6 +287,23 @@ const ScrapersSection: React.FC = () => {
</ScraperTable>
)}
{!!filteredScrapers.images?.length && (
<ScraperTable
entityType="image"
count={filteredScrapers.images?.length}
>
{filteredScrapers.images?.map((scraper) => (
<ScraperTableRow
key={scraper.id}
name={scraper.name}
entityType="image"
supportedScrapes={scraper.image?.supported_scrapes ?? []}
urls={scraper.image?.urls ?? []}
/>
))}
</ScraperTable>
)}
{!!filteredScrapers.performers?.length && (
<ScraperTable
entityType="performer"

View file

@ -2291,6 +2291,8 @@ export const queryScrapeGroupURL = (url: string) =>
export const useListGalleryScrapers = () => GQL.useListGalleryScrapersQuery();
export const useListImageScrapers = () => GQL.useListImageScrapersQuery();
export const queryScrapeGallery = (scraperId: string, galleryId: string) =>
client.query<GQL.ScrapeSingleGalleryQuery>({
query: GQL.ScrapeSingleGalleryDocument,
@ -2312,6 +2314,27 @@ export const queryScrapeGalleryURL = (url: string) =>
fetchPolicy: "network-only",
});
export const queryScrapeImage = (scraperId: string, imageId: string) =>
client.query<GQL.ScrapeSingleImageQuery>({
query: GQL.ScrapeSingleImageDocument,
variables: {
source: {
scraper_id: scraperId,
},
input: {
image_id: imageId,
},
},
fetchPolicy: "network-only",
});
export const queryScrapeImageURL = (url: string) =>
client.query<GQL.ScrapeImageUrlQuery>({
query: GQL.ScrapeImageUrlDocument,
variables: { url },
fetchPolicy: "network-only",
});
export const mutateSubmitStashBoxSceneDraft = (
input: GQL.StashBoxDraftSubmissionInput
) =>
@ -2383,6 +2406,7 @@ export const scraperMutationImpactedQueries = [
GQL.ListGroupScrapersDocument,
GQL.ListPerformerScrapersDocument,
GQL.ListSceneScrapersDocument,
GQL.ListImageScrapersDocument,
GQL.InstalledScraperPackagesDocument,
GQL.InstalledScraperPackagesStatusDocument,
];

View file

@ -26,6 +26,10 @@ galleryByFragment:
<single scraper config>
galleryByURL:
<multiple scraper URL configs>
imageByFragment:
<single scraper config>
imageByURL:
<multiple scraper URL configs>
<other configurations>
```
@ -81,6 +85,8 @@ The script is sent input and expects output based on the scraping type, as detai
| `groupByURL` | `{"url": "<url>"}` | JSON-encoded group fragment |
| `galleryByFragment` | JSON-encoded gallery fragment | JSON-encoded gallery fragment |
| `galleryByURL` | `{"url": "<url>"}` | JSON-encoded gallery fragment |
| `imageByFragment` | JSON-encoded image fragment | JSON-encoded image fragment |
| `imageByURL` | `{"url": "<url>"}` | JSON-encoded image fragment |
For `performerByName`, only `name` is required in the returned performer fragments. One entire object is sent back to `performerByFragment` to scrape a specific performer, so the other fields may be included to assist in scraping a performer. For example, the `url` field may be filled in for the specific performer page, then `performerByFragment` can extract by using its value.

View file

@ -15,6 +15,7 @@ Stash supports scraping of metadata from various external sources.
| | Fragment | Search | URL |
|---|:---:|:---:|:---:|
| gallery | ✔️ | | ✔️ |
| image | ✔️ | | ✔️ |
| group | | | ✔️ |
| performer | | ✔️ | ✔️ |
| scene | ✔️ | ✔️ | ✔️ |