Compare commits

...

14 Commits

Author SHA1 Message Date
DogmaDragon
0df4077ace Fix typo 2025-04-01 13:21:24 +03:00
DogmaDragon
b14b2796f9 Update scraper objects 2025-03-31 14:49:45 +03:00
bob123491234
4bfc93b7ae Add marker end seconds import/export (#5777)
* skip importing markers if scene is skipped
2025-03-28 16:50:26 +11:00
bob123491234
c0d5d1e5a7 Add tag count to studio sort whitelist (#5776) 2025-03-28 12:45:40 +11:00
WithoutPants
bac0b0a379 Refactor scene list to not use ItemList component (#5767)
* Add fields to useListSelect
* Add more utility hooks
* Remove context from FilteredListToolbar
* Refactor SceneList to not use ItemList
* Move common logic into useFilteredListHook
2025-03-28 11:59:05 +11:00
WithoutPants
d9b4e62420 Login page internationalisation (#5765)
* Load locale strings in login page
* Generate and use login locale strings
* Add makefile target
* Update workflow
* Update build dockerfiles
* Add missing default string
2025-03-27 11:56:43 +11:00
WithoutPants
c8d74f0bcf Add rate limit to stashbox connection (#5764)
* Add max requests per minute stashbox option
* Implement rate limiting
* Add requests per minute to stashbox config
* Add UI setting
2025-03-27 11:54:00 +11:00
DogmaDragon
18381664aa Update Configuration.md (#5770) 2025-03-27 11:50:14 +11:00
blaspheme-ship-it
e9a67eb51f Add IP address to login errors (#5760) 2025-03-25 13:15:10 +11:00
WithoutPants
2ec264ed62 Fix merge error 2025-03-25 11:19:14 +11:00
dependabot[bot]
e5446a2336 Bump github.com/golang-jwt/jwt/v4 from 4.5.1 to 4.5.2 (#5754)
Bumps [github.com/golang-jwt/jwt/v4](https://github.com/golang-jwt/jwt) from 4.5.1 to 4.5.2.
- [Release notes](https://github.com/golang-jwt/jwt/releases)
- [Changelog](https://github.com/golang-jwt/jwt/blob/main/VERSION_HISTORY.md)
- [Commits](https://github.com/golang-jwt/jwt/compare/v4.5.1...v4.5.2)

---
updated-dependencies:
- dependency-name: github.com/golang-jwt/jwt/v4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-25 10:32:43 +11:00
WithoutPants
db7d45792e Refactor stashbox package (#5699)
* Move stashbox package under pkg
* Remove StashBox from method names
* Add fingerprint conversion methods to Fingerprint

Refactor Fingerprints methods

* Make FindSceneByFingerprints accept fingerprints not scene ids
* Refactor SubmitSceneDraft to not require readers
* Have SubmitFingerprints accept scenes

Remove SceneReader dependency

* Move ScrapedScene to models package
* Move ScrapedImage into models package
* Move ScrapedGallery into models package
* Move Scene relationship matching out of stashbox package

This is now expected to be done in the client code

* Remove TagFinder dependency from stashbox.Client
* Make stashbox scene find full hierarchy of studios
* Move studio resolution into separate method
* Move studio matching out of stashbox package

This is now client code responsibility

* Move performer matching out of FindPerformerByID and FindPerformerByName
* Refactor performer querying logic and remove unused stashbox models

Renames FindStashBoxPerformersByPerformerNames to QueryPerformers and accepts names instead of performer ids

* Refactor SubmitPerformerDraft to not load relationships

This will be the responsibility of the calling code

* Remove repository references
2025-03-25 10:30:51 +11:00
WithoutPants
5d3d02e1e7 Optimise card width calculation (#5713)
* Add hook for grid card width calculation
* Move card width calculation into grid instead of card

Now calculates once instead of per card

* Debounce resize observer
2025-03-25 10:28:57 +11:00
WithoutPants
2541e9d1eb Refactor login page to not include in history (#5747) 2025-03-25 10:26:31 +11:00
95 changed files with 2537 additions and 1901 deletions

3
.gitignore vendored
View File

@@ -21,6 +21,9 @@ vendor
# GraphQL generated output
internal/api/generated_*.go
# Generated locale files
ui/login/locales/*
####
# Visual Studio
####

View File

@@ -281,6 +281,10 @@ generate-ui:
generate-backend: touch-ui
go generate ./cmd/stash
.PHONY: generate-login-locale
generate-login-locale:
go generate ./ui
.PHONY: generate-dataloaders
generate-dataloaders:
go generate ./internal/api/loaders
@@ -351,7 +355,10 @@ ifdef STASH_SOURCEMAPS
endif
.PHONY: ui
ui: ui-env
ui: ui-only generate-login-locale
.PHONY: ui-only
ui-only: ui-env
cd ui/v2.5 && yarn build
.PHONY: zip-ui

View File

@@ -1,7 +1,7 @@
# This dockerfile should be built with `make docker-build` from the stash root.
# Build Frontend
FROM node:alpine as frontend
FROM node:20-alpine AS frontend
RUN apk add --no-cache make git
## cache node_modules separately
COPY ./ui/v2.5/package.json ./ui/v2.5/yarn.lock /stash/ui/v2.5/
@@ -13,19 +13,22 @@ RUN make pre-ui
RUN make generate-ui
ARG GITHASH
ARG STASH_VERSION
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
# Build Backend
FROM golang:1.22-alpine as backend
FROM golang:1.22.8-alpine AS backend
RUN apk add --no-cache make alpine-sdk
WORKDIR /stash
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/
COPY ./graphql /stash/graphql/
COPY ./scripts /stash/scripts/
COPY ./pkg /stash/pkg/
COPY ./cmd /stash/cmd
COPY ./internal /stash/internal
COPY ./cmd /stash/cmd/
COPY ./internal /stash/internal/
# needed for generate-login-locale
COPY ./ui /stash/ui/
RUN make generate-backend generate-login-locale
COPY --from=frontend /stash /stash/
RUN make generate-backend
ARG GITHASH
ARG STASH_VERSION
RUN make flags-release flags-pie stash

View File

@@ -1,7 +1,7 @@
# This dockerfile should be built with `make docker-cuda-build` from the stash root.
# Build Frontend
FROM node:alpine as frontend
FROM node:20-alpine AS frontend
RUN apk add --no-cache make git
## cache node_modules separately
COPY ./ui/v2.5/package.json ./ui/v2.5/yarn.lock /stash/ui/v2.5/
@@ -13,19 +13,22 @@ RUN make pre-ui
RUN make generate-ui
ARG GITHASH
ARG STASH_VERSION
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui
RUN BUILD_DATE=$(date +"%Y-%m-%d %H:%M:%S") make ui-only
# Build Backend
FROM golang:1.22-bullseye as backend
FROM golang:1.22.8-bullseye AS backend
RUN apt update && apt install -y build-essential golang
WORKDIR /stash
COPY ./go* ./*.go Makefile gqlgen.yml .gqlgenc.yml /stash/
COPY ./graphql /stash/graphql/
COPY ./scripts /stash/scripts/
COPY ./pkg /stash/pkg/
COPY ./cmd /stash/cmd
COPY ./internal /stash/internal
# needed for generate-login-locale
COPY ./ui /stash/ui/
RUN make generate-backend generate-login-locale
COPY --from=frontend /stash /stash/
RUN make generate-backend
ARG GITHASH
ARG STASH_VERSION
RUN make flags-release flags-pie stash

View File

@@ -4,7 +4,7 @@ This dockerfile is used to build a stash docker container using the current sour
# Building the docker container
From the top-level directory (should contain `main.go` file):
From the top-level directory (should contain `tools.go` file):
```
make docker-build

3
go.mod
View File

@@ -19,7 +19,7 @@ require (
github.com/go-chi/cors v1.2.1
github.com/go-chi/httplog v0.3.1
github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4
github.com/golang-jwt/jwt/v4 v4.5.1
github.com/golang-jwt/jwt/v4 v4.5.2
github.com/golang-migrate/migrate/v4 v4.16.2
github.com/google/uuid v1.6.0
github.com/gorilla/securecookie v1.1.1
@@ -57,6 +57,7 @@ require (
golang.org/x/sys v0.28.0
golang.org/x/term v0.27.0
golang.org/x/text v0.21.0
golang.org/x/time v0.10.0
gopkg.in/guregu/null.v4 v4.0.0
gopkg.in/yaml.v2 v2.4.0
)

6
go.sum
View File

@@ -246,8 +246,8 @@ github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/K
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v4 v4.5.1 h1:JdqV9zKUdtaa9gdPlywC3aeoEsR681PlKC+4F5gQgeo=
github.com/golang-jwt/jwt/v4 v4.5.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-migrate/migrate/v4 v4.16.2 h1:8coYbMKUyInrFk1lfGfRovTLAW7PhWp8qQDT2iKfuoA=
github.com/golang-migrate/migrate/v4 v4.16.2/go.mod h1:pfcJX4nPHaVdc5nmdCikFBWtm+UBpiZjRNNsyBbp0/o=
github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
@@ -954,6 +954,8 @@ golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.10.0 h1:3usCWA8tQn0L8+hFJQNgzpWbd89begxN66o1Ojdn5L4=
golang.org/x/time v0.10.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=

View File

@@ -17,7 +17,7 @@ autobind:
- github.com/stashapp/stash/pkg/scraper
- github.com/stashapp/stash/internal/identify
- github.com/stashapp/stash/internal/dlna
- github.com/stashapp/stash/pkg/scraper/stashbox
- github.com/stashapp/stash/pkg/stashbox
models:
# Scalars

View File

@@ -2,12 +2,15 @@ type StashBox {
endpoint: String!
api_key: String!
name: String!
max_requests_per_minute: Int!
}
input StashBoxInput {
endpoint: String!
api_key: String!
name: String!
# defaults to 240
max_requests_per_minute: Int
}
type StashID {

View File

@@ -13,7 +13,6 @@ import (
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin/hook"
"github.com/stashapp/stash/pkg/scraper"
"github.com/stashapp/stash/pkg/scraper/stashbox"
)
var (
@@ -138,10 +137,6 @@ func (r *Resolver) withReadTxn(ctx context.Context, fn func(ctx context.Context)
return r.repository.WithReadTxn(ctx, fn)
}
func (r *Resolver) stashboxRepository() stashbox.Repository {
return stashbox.NewRepository(r.repository)
}
func (r *queryResolver) MarkerWall(ctx context.Context, q *string) (ret []*models.SceneMarker, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.SceneMarker.Wall(ctx, q)

View File

@@ -7,6 +7,10 @@ import (
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/stashbox"
)
func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input StashBoxFingerprintSubmissionInput) (bool, error) {
@@ -15,8 +19,23 @@ func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input
return false, err
}
ids, err := stringslice.StringSliceToIntSlice(input.SceneIds)
if err != nil {
return false, err
}
client := r.newStashBoxClient(*b)
return client.SubmitStashBoxFingerprints(ctx, input.SceneIds)
var scenes []*models.Scene
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
scenes, err = r.sceneService.FindMany(ctx, ids, scene.LoadStashIDs, scene.LoadFiles)
return err
}); err != nil {
return false, err
}
return client.SubmitFingerprints(ctx, scenes)
}
func (r *mutationResolver) StashBoxBatchPerformerTag(ctx context.Context, input manager.StashBoxBatchTagInput) (string, error) {
@@ -69,17 +88,76 @@ func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input S
logger.Errorf("Error getting scene cover: %v", err)
}
if err := scene.LoadURLs(ctx, r.repository.Scene); err != nil {
return fmt.Errorf("loading scene URLs: %w", err)
draft, err := r.makeSceneDraft(ctx, scene, cover)
if err != nil {
return err
}
res, err = client.SubmitSceneDraft(ctx, scene, cover)
res, err = client.SubmitSceneDraft(ctx, *draft)
return err
})
return res, err
}
func (r *mutationResolver) makeSceneDraft(ctx context.Context, s *models.Scene, cover []byte) (*stashbox.SceneDraft, error) {
if err := s.LoadURLs(ctx, r.repository.Scene); err != nil {
return nil, fmt.Errorf("loading scene URLs: %w", err)
}
if err := s.LoadStashIDs(ctx, r.repository.Scene); err != nil {
return nil, err
}
draft := &stashbox.SceneDraft{
Scene: s,
}
pqb := r.repository.Performer
sqb := r.repository.Studio
if s.StudioID != nil {
var err error
draft.Studio, err = sqb.Find(ctx, *s.StudioID)
if err != nil {
return nil, err
}
if draft.Studio == nil {
return nil, fmt.Errorf("studio with id %d not found", *s.StudioID)
}
if err := draft.Studio.LoadStashIDs(ctx, r.repository.Studio); err != nil {
return nil, err
}
}
// submit all file fingerprints
if err := s.LoadFiles(ctx, r.repository.Scene); err != nil {
return nil, err
}
scenePerformers, err := pqb.FindBySceneID(ctx, s.ID)
if err != nil {
return nil, err
}
for _, p := range scenePerformers {
if err := p.LoadStashIDs(ctx, pqb); err != nil {
return nil, err
}
}
draft.Performers = scenePerformers
draft.Tags, err = r.repository.Tag.FindBySceneID(ctx, s.ID)
if err != nil {
return nil, err
}
draft.Cover = cover
return draft, nil
}
func (r *mutationResolver) SubmitStashBoxPerformerDraft(ctx context.Context, input StashBoxDraftSubmissionInput) (*string, error) {
b, err := resolveStashBox(input.StashBoxIndex, input.StashBoxEndpoint)
if err != nil {
@@ -105,7 +183,22 @@ func (r *mutationResolver) SubmitStashBoxPerformerDraft(ctx context.Context, inp
return fmt.Errorf("performer with id %d not found", id)
}
res, err = client.SubmitPerformerDraft(ctx, performer)
pqb := r.repository.Performer
if err := performer.LoadAliases(ctx, pqb); err != nil {
return err
}
if err := performer.LoadURLs(ctx, pqb); err != nil {
return err
}
if err := performer.LoadStashIDs(ctx, pqb); err != nil {
return err
}
img, _ := pqb.GetImage(ctx, performer.ID)
res, err = client.SubmitPerformerDraft(ctx, performer, img)
return err
})

View File

@@ -6,9 +6,10 @@ import (
"fmt"
"strconv"
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper"
"github.com/stashapp/stash/pkg/scraper/stashbox"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
@@ -29,7 +30,7 @@ func (r *queryResolver) ScrapePerformerURL(ctx context.Context, url string) (*mo
return marshalScrapedPerformer(content)
}
func (r *queryResolver) ScrapeSceneQuery(ctx context.Context, scraperID string, query string) ([]*scraper.ScrapedScene, error) {
func (r *queryResolver) ScrapeSceneQuery(ctx context.Context, scraperID string, query string) ([]*models.ScrapedScene, error) {
if query == "" {
return nil, nil
}
@@ -47,7 +48,7 @@ func (r *queryResolver) ScrapeSceneQuery(ctx context.Context, scraperID string,
return ret, nil
}
func (r *queryResolver) ScrapeSceneURL(ctx context.Context, url string) (*scraper.ScrapedScene, error) {
func (r *queryResolver) ScrapeSceneURL(ctx context.Context, url string) (*models.ScrapedScene, error) {
content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeScene)
if err != nil {
return nil, err
@@ -61,7 +62,7 @@ func (r *queryResolver) ScrapeSceneURL(ctx context.Context, url string) (*scrape
return ret, nil
}
func (r *queryResolver) ScrapeGalleryURL(ctx context.Context, url string) (*scraper.ScrapedGallery, error) {
func (r *queryResolver) ScrapeGalleryURL(ctx context.Context, url string) (*models.ScrapedGallery, error) {
content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeGallery)
if err != nil {
return nil, err
@@ -75,7 +76,7 @@ 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) {
func (r *queryResolver) ScrapeImageURL(ctx context.Context, url string) (*models.ScrapedImage, error) {
content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeImage)
if err != nil {
return nil, err
@@ -129,8 +130,8 @@ func (r *queryResolver) ScrapeGroupURL(ctx context.Context, url string) (*models
return group, nil
}
func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.Source, input ScrapeSingleSceneInput) ([]*scraper.ScrapedScene, error) {
var ret []*scraper.ScrapedScene
func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.Source, input ScrapeSingleSceneInput) ([]*models.ScrapedScene, error) {
var ret []*models.ScrapedScene
var sceneID int
if input.SceneID != nil {
@@ -182,9 +183,14 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.So
switch {
case input.SceneID != nil:
ret, err = client.FindStashBoxSceneByFingerprints(ctx, sceneID)
var fps []models.Fingerprints
fps, err = r.getScenesFingerprints(ctx, []int{sceneID})
if err != nil {
return nil, err
}
ret, err = client.FindSceneByFingerprints(ctx, fps[0])
case input.Query != nil:
ret, err = client.QueryStashBoxScene(ctx, *input.Query)
ret, err = client.QueryScene(ctx, *input.Query)
default:
return nil, fmt.Errorf("%w: scene_id or query must be set", ErrInput)
}
@@ -192,6 +198,11 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.So
if err != nil {
return nil, err
}
// TODO - this should happen after any scene is scraped
if err := r.matchScenesRelationships(ctx, ret, *source.StashBoxEndpoint); err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("%w: scraper_id or stash_box_index must be set", ErrInput)
}
@@ -199,7 +210,7 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.So
return ret, nil
}
func (r *queryResolver) ScrapeMultiScenes(ctx context.Context, source scraper.Source, input ScrapeMultiScenesInput) ([][]*scraper.ScrapedScene, error) {
func (r *queryResolver) ScrapeMultiScenes(ctx context.Context, source scraper.Source, input ScrapeMultiScenesInput) ([][]*models.ScrapedScene, error) {
if source.ScraperID != nil {
return nil, ErrNotImplemented
} else if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil {
@@ -215,12 +226,89 @@ func (r *queryResolver) ScrapeMultiScenes(ctx context.Context, source scraper.So
return nil, err
}
return client.FindStashBoxScenesByFingerprints(ctx, sceneIDs)
fps, err := r.getScenesFingerprints(ctx, sceneIDs)
if err != nil {
return nil, err
}
ret, err := client.FindScenesByFingerprints(ctx, fps)
if err != nil {
return nil, err
}
// match relationships - this mutates the existing scenes so we can
// just flatten the slice and pass it in
flat := sliceutil.Flatten(ret)
if err := r.matchScenesRelationships(ctx, flat, *source.StashBoxEndpoint); err != nil {
return nil, err
}
return ret, nil
}
return nil, errors.New("scraper_id or stash_box_index must be set")
}
func (r *queryResolver) getScenesFingerprints(ctx context.Context, ids []int) ([]models.Fingerprints, error) {
fingerprints := make([]models.Fingerprints, len(ids))
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Scene
for i, sceneID := range ids {
scene, err := qb.Find(ctx, sceneID)
if err != nil {
return err
}
if scene == nil {
return fmt.Errorf("scene with id %d not found", sceneID)
}
if err := scene.LoadFiles(ctx, qb); err != nil {
return err
}
var sceneFPs models.Fingerprints
for _, f := range scene.Files.List() {
sceneFPs = append(sceneFPs, f.Fingerprints...)
}
fingerprints[i] = sceneFPs
}
return nil
}); err != nil {
return nil, err
}
return fingerprints, nil
}
// matchSceneRelationships accepts scraped scenes and attempts to match its relationships to existing stash models.
func (r *queryResolver) matchScenesRelationships(ctx context.Context, ss []*models.ScrapedScene, endpoint string) error {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
matcher := match.SceneRelationships{
PerformerFinder: r.repository.Performer,
TagFinder: r.repository.Tag,
StudioFinder: r.repository.Studio,
}
for _, s := range ss {
if err := matcher.MatchRelationships(ctx, s, endpoint); err != nil {
return err
}
}
return nil
}); err != nil {
return err
}
return nil
}
func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.Source, input ScrapeSingleStudioInput) ([]*models.ScrapedStudio, error) {
if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil {
b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint)
@@ -231,7 +319,7 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S
client := r.newStashBoxClient(*b)
var ret []*models.ScrapedStudio
out, err := client.FindStashBoxStudio(ctx, *input.Query)
out, err := client.FindStudio(ctx, *input.Query)
if err != nil {
return nil, err
@@ -240,6 +328,17 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S
}
if len(ret) > 0 {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
for _, studio := range ret {
if err := match.ScrapedStudioHierarchy(ctx, r.repository.Studio, studio, *source.StashBoxEndpoint); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
}
return ret, nil
}
@@ -285,23 +384,29 @@ func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scrape
client := r.newStashBoxClient(*b)
var res []*stashbox.StashBoxPerformerQueryResult
var query string
switch {
case input.PerformerID != nil:
res, err = client.FindStashBoxPerformersByNames(ctx, []string{*input.PerformerID})
names, err := r.findPerformerNames(ctx, []string{*input.PerformerID})
if err != nil {
return nil, err
}
query = names[0]
case input.Query != nil:
res, err = client.QueryStashBoxPerformer(ctx, *input.Query)
query = *input.Query
default:
return nil, ErrNotImplemented
}
if query == "" {
return nil, nil
}
ret, err = client.QueryPerformer(ctx, query)
if err != nil {
return nil, err
}
if len(res) > 0 {
ret = res[0].Results
}
default:
return nil, errors.New("scraper_id or stash_box_index must be set")
}
@@ -313,6 +418,11 @@ func (r *queryResolver) ScrapeMultiPerformers(ctx context.Context, source scrape
if source.ScraperID != nil {
return nil, ErrNotImplemented
} else if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil {
names, err := r.findPerformerNames(ctx, input.PerformerIds)
if err != nil {
return nil, err
}
b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint)
if err != nil {
return nil, err
@@ -320,14 +430,40 @@ func (r *queryResolver) ScrapeMultiPerformers(ctx context.Context, source scrape
client := r.newStashBoxClient(*b)
return client.FindStashBoxPerformersByPerformerNames(ctx, input.PerformerIds)
return client.QueryPerformers(ctx, names)
}
return nil, errors.New("scraper_id or stash_box_index must be set")
}
func (r *queryResolver) ScrapeSingleGallery(ctx context.Context, source scraper.Source, input ScrapeSingleGalleryInput) ([]*scraper.ScrapedGallery, error) {
var ret []*scraper.ScrapedGallery
func (r *queryResolver) findPerformerNames(ctx context.Context, performerIDs []string) ([]string, error) {
ids, err := stringslice.StringSliceToIntSlice(performerIDs)
if err != nil {
return nil, err
}
names := make([]string, len(ids))
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
p, err := r.repository.Performer.FindMany(ctx, ids)
if err != nil {
return err
}
for i, pp := range p {
names[i] = pp.Name
}
return nil
}); err != nil {
return nil, err
}
return names, nil
}
func (r *queryResolver) ScrapeSingleGallery(ctx context.Context, source scraper.Source, input ScrapeSingleGalleryInput) ([]*models.ScrapedGallery, error) {
var ret []*models.ScrapedGallery
if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil {
return nil, ErrNotSupported
@@ -369,7 +505,7 @@ 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) {
func (r *queryResolver) ScrapeSingleImage(ctx context.Context, source scraper.Source, input ScrapeSingleImageInput) ([]*models.ScrapedImage, error) {
if source.StashBoxIndex != nil {
return nil, ErrNotSupported
}

View File

@@ -9,8 +9,8 @@ import (
// marshalScrapedScenes converts ScrapedContent into ScrapedScene. If conversion fails, an
// error is returned to the caller.
func marshalScrapedScenes(content []scraper.ScrapedContent) ([]*scraper.ScrapedScene, error) {
var ret []*scraper.ScrapedScene
func marshalScrapedScenes(content []scraper.ScrapedContent) ([]*models.ScrapedScene, error) {
var ret []*models.ScrapedScene
for _, c := range content {
if c == nil {
// graphql schema requires scenes to be non-nil
@@ -18,9 +18,9 @@ func marshalScrapedScenes(content []scraper.ScrapedContent) ([]*scraper.ScrapedS
}
switch s := c.(type) {
case *scraper.ScrapedScene:
case *models.ScrapedScene:
ret = append(ret, s)
case scraper.ScrapedScene:
case models.ScrapedScene:
ret = append(ret, &s)
default:
return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedScene", models.ErrConversion)
@@ -55,8 +55,8 @@ func marshalScrapedPerformers(content []scraper.ScrapedContent) ([]*models.Scrap
// marshalScrapedGalleries converts ScrapedContent into ScrapedGallery. If
// conversion fails, an error is returned.
func marshalScrapedGalleries(content []scraper.ScrapedContent) ([]*scraper.ScrapedGallery, error) {
var ret []*scraper.ScrapedGallery
func marshalScrapedGalleries(content []scraper.ScrapedContent) ([]*models.ScrapedGallery, error) {
var ret []*models.ScrapedGallery
for _, c := range content {
if c == nil {
// graphql schema requires galleries to be non-nil
@@ -64,9 +64,9 @@ func marshalScrapedGalleries(content []scraper.ScrapedContent) ([]*scraper.Scrap
}
switch g := c.(type) {
case *scraper.ScrapedGallery:
case *models.ScrapedGallery:
ret = append(ret, g)
case scraper.ScrapedGallery:
case models.ScrapedGallery:
ret = append(ret, &g)
default:
return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedGallery", models.ErrConversion)
@@ -76,8 +76,8 @@ func marshalScrapedGalleries(content []scraper.ScrapedContent) ([]*scraper.Scrap
return ret, nil
}
func marshalScrapedImages(content []scraper.ScrapedContent) ([]*scraper.ScrapedImage, error) {
var ret []*scraper.ScrapedImage
func marshalScrapedImages(content []scraper.ScrapedContent) ([]*models.ScrapedImage, error) {
var ret []*models.ScrapedImage
for _, c := range content {
if c == nil {
// graphql schema requires images to be non-nil
@@ -85,9 +85,9 @@ func marshalScrapedImages(content []scraper.ScrapedContent) ([]*scraper.ScrapedI
}
switch g := c.(type) {
case *scraper.ScrapedImage:
case *models.ScrapedImage:
ret = append(ret, g)
case scraper.ScrapedImage:
case models.ScrapedImage:
ret = append(ret, &g)
default:
return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedImage", models.ErrConversion)
@@ -131,7 +131,7 @@ func marshalScrapedPerformer(content scraper.ScrapedContent) (*models.ScrapedPer
}
// marshalScrapedScene will marshal a single scraped scene
func marshalScrapedScene(content scraper.ScrapedContent) (*scraper.ScrapedScene, error) {
func marshalScrapedScene(content scraper.ScrapedContent) (*models.ScrapedScene, error) {
s, err := marshalScrapedScenes([]scraper.ScrapedContent{content})
if err != nil {
return nil, err
@@ -141,7 +141,7 @@ func marshalScrapedScene(content scraper.ScrapedContent) (*scraper.ScrapedScene,
}
// marshalScrapedGallery will marshal a single scraped gallery
func marshalScrapedGallery(content scraper.ScrapedContent) (*scraper.ScrapedGallery, error) {
func marshalScrapedGallery(content scraper.ScrapedContent) (*models.ScrapedGallery, error) {
g, err := marshalScrapedGalleries([]scraper.ScrapedContent{content})
if err != nil {
return nil, err
@@ -151,7 +151,7 @@ func marshalScrapedGallery(content scraper.ScrapedContent) (*scraper.ScrapedGall
}
// marshalScrapedImage will marshal a single scraped image
func marshalScrapedImage(content scraper.ScrapedContent) (*scraper.ScrapedImage, error) {
func marshalScrapedImage(content scraper.ScrapedContent) (*models.ScrapedImage, error) {
g, err := marshalScrapedImages([]scraper.ScrapedContent{content})
if err != nil {
return nil, err

View File

@@ -41,10 +41,11 @@ import (
)
const (
loginEndpoint = "/login"
logoutEndpoint = "/logout"
gqlEndpoint = "/graphql"
playgroundEndpoint = "/playground"
loginEndpoint = "/login"
loginLocaleEndpoint = loginEndpoint + "/locale"
logoutEndpoint = "/logout"
gqlEndpoint = "/graphql"
playgroundEndpoint = "/playground"
)
type Server struct {
@@ -228,6 +229,7 @@ func Initialize() (*Server, error) {
r.Get(loginEndpoint, handleLogin())
r.Post(loginEndpoint, handleLoginPost())
r.Get(logoutEndpoint, handleLogout())
r.Get(loginLocaleEndpoint, handleLoginLocale(cfg))
r.HandleFunc(loginEndpoint+"/*", func(w http.ResponseWriter, r *http.Request) {
r.URL.Path = strings.TrimPrefix(r.URL.Path, loginEndpoint)
w.Header().Set("Cache-Control", "no-cache")

View File

@@ -17,7 +17,11 @@ import (
"github.com/stashapp/stash/ui"
)
const returnURLParam = "returnURL"
const (
returnURLParam = "returnURL"
defaultLocale = "en-GB"
)
func getLoginPage() []byte {
data, err := fs.ReadFile(ui.LoginUIBox, "login.html")
@@ -58,6 +62,47 @@ func serveLoginPage(w http.ResponseWriter, r *http.Request, returnURL string, lo
utils.ServeStaticContent(w, r, buffer.Bytes())
}
func handleLoginLocale(cfg *config.Config) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// get the locale from the config
lang := cfg.GetLanguage()
if lang == "" {
lang = defaultLocale
}
data, err := getLoginLocale(lang)
if err != nil {
logger.Debugf("Failed to load login locale file for language %s: %v", lang, err)
// try again with the default language
if lang != defaultLocale {
data, err = getLoginLocale(defaultLocale)
if err != nil {
logger.Errorf("Failed to load login locale file for default language %s: %v", defaultLocale, err)
}
}
// if there's still an error, response with an internal server error
if err != nil {
http.Error(w, "Failed to load login locale file", http.StatusInternalServerError)
return
}
}
// write a script to set the locale string map as a global variable
localeScript := fmt.Sprintf("var localeStrings = %s;", data)
w.Header().Set("Content-Type", "application/javascript")
_, _ = w.Write([]byte(localeScript))
}
}
func getLoginLocale(lang string) ([]byte, error) {
data, err := fs.ReadFile(ui.LoginUIBox, "locales/"+lang+".json")
if err != nil {
return nil, err
}
return data, nil
}
func handleLogin() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
returnURL := r.URL.Query().Get(returnURLParam)
@@ -78,31 +123,26 @@ func handleLogin() http.HandlerFunc {
func handleLoginPost() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
url := r.FormValue(returnURLParam)
if url == "" {
url = getProxyPrefix(r) + "/"
}
err := manager.GetInstance().SessionStore.Login(w, r)
if err != nil {
// always log the error
logger.Errorf("Error logging in: %v", err)
logger.Errorf("Error logging in: %v from IP: %s", err, r.RemoteAddr)
}
var invalidCredentialsError *session.InvalidCredentialsError
if errors.As(err, &invalidCredentialsError) {
// serve login page with an error
serveLoginPage(w, r, url, "Username or password is invalid")
http.Error(w, "Username or password is invalid", http.StatusUnauthorized)
return
}
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
// don't expose the error to the user
http.Error(w, "An unexpected error occurred. See logs", http.StatusInternalServerError)
return
}
http.Redirect(w, r, url, http.StatusFound)
w.WriteHeader(http.StatusOK)
}
}

View File

@@ -7,11 +7,11 @@ import (
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper/stashbox"
"github.com/stashapp/stash/pkg/stashbox"
)
func (r *Resolver) newStashBoxClient(box models.StashBox) *stashbox.Client {
return stashbox.NewClient(box, r.stashboxRepository(), manager.GetInstance().Config.GetScraperExcludeTagPatterns())
return stashbox.NewClient(box, stashbox.ExcludeTagPatterns(manager.GetInstance().Config.GetScraperExcludeTagPatterns()))
}
func resolveStashBoxFn(indexField, endpointField string) func(index *int, endpoint *string) (*models.StashBox, error) {

View File

@@ -13,7 +13,6 @@ import (
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/scraper"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/txn"
"github.com/stashapp/stash/pkg/utils"
@@ -32,7 +31,7 @@ func (e *MultipleMatchesFoundError) Error() string {
}
type SceneScraper interface {
ScrapeScenes(ctx context.Context, sceneID int) ([]*scraper.ScrapedScene, error)
ScrapeScenes(ctx context.Context, sceneID int) ([]*models.ScrapedScene, error)
}
type SceneUpdatePostHookExecutor interface {
@@ -96,7 +95,7 @@ func (t *SceneIdentifier) Identify(ctx context.Context, scene *models.Scene) err
}
type scrapeResult struct {
result *scraper.ScrapedScene
result *models.ScrapedScene
source ScraperSource
}
@@ -374,7 +373,7 @@ func getFieldOptions(options []MetadataOptions) map[string]*FieldOptions {
return ret
}
func getScenePartial(scene *models.Scene, scraped *scraper.ScrapedScene, fieldOptions map[string]*FieldOptions, setOrganized bool) models.ScenePartial {
func getScenePartial(scene *models.Scene, scraped *models.ScrapedScene, fieldOptions map[string]*FieldOptions, setOrganized bool) models.ScenePartial {
partial := models.ScenePartial{}
if scraped.Title != nil && (scene.Title != *scraped.Title) {

View File

@@ -10,7 +10,6 @@ import (
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stashapp/stash/pkg/scraper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
@@ -19,10 +18,10 @@ var testCtx = context.Background()
type mockSceneScraper struct {
errIDs []int
results map[int][]*scraper.ScrapedScene
results map[int][]*models.ScrapedScene
}
func (s mockSceneScraper) ScrapeScenes(ctx context.Context, sceneID int) ([]*scraper.ScrapedScene, error) {
func (s mockSceneScraper) ScrapeScenes(ctx context.Context, sceneID int) ([]*models.ScrapedScene, error) {
if slices.Contains(s.errIDs, sceneID) {
return nil, errors.New("scrape scene error")
}
@@ -70,7 +69,7 @@ func TestSceneIdentifier_Identify(t *testing.T) {
{
Scraper: mockSceneScraper{
errIDs: []int{errID1},
results: map[int][]*scraper.ScrapedScene{
results: map[int][]*models.ScrapedScene{
found1ID: {{
Title: &scrapedTitle,
}},
@@ -80,7 +79,7 @@ func TestSceneIdentifier_Identify(t *testing.T) {
{
Scraper: mockSceneScraper{
errIDs: []int{errID2},
results: map[int][]*scraper.ScrapedScene{
results: map[int][]*models.ScrapedScene{
found2ID: {{
Title: &scrapedTitle,
}},
@@ -250,7 +249,7 @@ func TestSceneIdentifier_modifyScene(t *testing.T) {
StashIDs: models.NewRelatedStashIDs([]models.StashID{}),
},
&scrapeResult{
result: &scraper.ScrapedScene{},
result: &models.ScrapedScene{},
source: ScraperSource{
Options: defaultOptions,
},
@@ -386,14 +385,14 @@ func Test_getScenePartial(t *testing.T) {
Mode: models.RelationshipUpdateModeSet,
}
scrapedScene := &scraper.ScrapedScene{
scrapedScene := &models.ScrapedScene{
Title: &scrapedTitle,
Date: &scrapedDate,
Details: &scrapedDetails,
URLs: []string{scrapedURL},
}
scrapedUnchangedScene := &scraper.ScrapedScene{
scrapedUnchangedScene := &models.ScrapedScene{
Title: &originalTitle,
Date: &originalDate,
Details: &originalDetails,
@@ -423,7 +422,7 @@ func Test_getScenePartial(t *testing.T) {
type args struct {
scene *models.Scene
scraped *scraper.ScrapedScene
scraped *models.ScrapedScene
fieldOptions map[string]*FieldOptions
setOrganized bool
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/mocks"
"github.com/stashapp/stash/pkg/scraper"
"github.com/stashapp/stash/pkg/utils"
"github.com/stretchr/testify/mock"
)
@@ -125,7 +124,7 @@ func Test_sceneRelationships_studio(t *testing.T) {
source: ScraperSource{
RemoteSite: "endpoint",
},
result: &scraper.ScrapedScene{
result: &models.ScrapedScene{
Studio: tt.result,
},
}
@@ -315,7 +314,7 @@ func Test_sceneRelationships_performers(t *testing.T) {
tr.scene = tt.scene
tr.fieldOptions["performers"] = tt.fieldOptions
tr.result = &scrapeResult{
result: &scraper.ScrapedScene{
result: &models.ScrapedScene{
Performers: tt.scraped,
},
}
@@ -507,7 +506,7 @@ func Test_sceneRelationships_tags(t *testing.T) {
tr.scene = tt.scene
tr.fieldOptions["tags"] = tt.fieldOptions
tr.result = &scrapeResult{
result: &scraper.ScrapedScene{
result: &models.ScrapedScene{
Tags: tt.scraped,
},
}
@@ -727,7 +726,7 @@ func Test_sceneRelationships_stashIDs(t *testing.T) {
source: ScraperSource{
RemoteSite: tt.endpoint,
},
result: &scraper.ScrapedScene{
result: &models.ScrapedScene{
RemoteSiteID: tt.remoteSiteID,
},
}
@@ -827,7 +826,7 @@ func Test_sceneRelationships_cover(t *testing.T) {
ID: tt.sceneID,
}
tr.result = &scrapeResult{
result: &scraper.ScrapedScene{
result: &models.ScrapedScene{
Image: tt.image,
},
}

View File

@@ -1105,9 +1105,10 @@ func stashBoxValidate(str string) bool {
}
type StashBoxInput struct {
Endpoint string `json:"endpoint"`
APIKey string `json:"api_key"`
Name string `json:"name"`
Endpoint string `json:"endpoint"`
APIKey string `json:"api_key"`
Name string `json:"name"`
MaxRequestsPerMinute int `json:"max_requests_per_minute"`
}
func (i *Config) ValidateStashBoxes(boxes []*StashBoxInput) error {

View File

@@ -14,6 +14,9 @@ type SceneService interface {
AssignFile(ctx context.Context, sceneID int, fileID models.FileID) error
Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *scene.FileDeleter, options scene.MergeOptions) error
Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile bool) error
FindMany(ctx context.Context, ids []int, load ...scene.LoadRelationshipOption) ([]*models.Scene, error)
sceneFingerprintGetter
}
type ImageService interface {

View File

@@ -9,11 +9,13 @@ import (
"github.com/stashapp/stash/internal/identify"
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/scraper"
"github.com/stashapp/stash/pkg/scraper/stashbox"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/stashbox"
"github.com/stashapp/stash/pkg/txn"
)
var ErrInput = errors.New("invalid request input")
@@ -169,12 +171,20 @@ func (j *IdentifyJob) getSources() ([]identify.ScraperSource, error) {
var src identify.ScraperSource
if stashBox != nil {
stashboxRepository := stashbox.NewRepository(instance.Repository)
matcher := match.SceneRelationships{
PerformerFinder: instance.Repository.Performer,
TagFinder: instance.Repository.Tag,
StudioFinder: instance.Repository.Studio,
}
src = identify.ScraperSource{
Name: "stash-box: " + stashBox.Endpoint,
Scraper: stashboxSource{
stashbox.NewClient(*stashBox, stashboxRepository, instance.Config.GetScraperExcludeTagPatterns()),
stashBox.Endpoint,
Client: stashbox.NewClient(*stashBox, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns())),
endpoint: stashBox.Endpoint,
txnManager: instance.Repository.TxnManager,
sceneFingerprintGetter: instance.SceneService,
matcher: matcher,
},
RemoteSite: stashBox.Endpoint,
}
@@ -247,14 +257,42 @@ func resolveStashBox(sb []*models.StashBox, source scraper.Source) (*models.Stas
type stashboxSource struct {
*stashbox.Client
endpoint string
txnManager models.TxnManager
sceneFingerprintGetter sceneFingerprintGetter
matcher match.SceneRelationships
}
func (s stashboxSource) ScrapeScenes(ctx context.Context, sceneID int) ([]*scraper.ScrapedScene, error) {
results, err := s.FindStashBoxSceneByFingerprints(ctx, sceneID)
type sceneFingerprintGetter interface {
GetScenesFingerprints(ctx context.Context, ids []int) ([]models.Fingerprints, error)
}
func (s stashboxSource) ScrapeScenes(ctx context.Context, sceneID int) ([]*models.ScrapedScene, error) {
var fps []models.Fingerprints
if err := txn.WithReadTxn(ctx, s.txnManager, func(ctx context.Context) error {
var err error
fps, err = s.sceneFingerprintGetter.GetScenesFingerprints(ctx, []int{sceneID})
return err
}); err != nil {
return nil, fmt.Errorf("error getting scene fingerprints: %w", err)
}
results, err := s.FindSceneByFingerprints(ctx, fps[0])
if err != nil {
return nil, fmt.Errorf("error querying stash-box using scene ID %d: %w", sceneID, err)
}
if err := txn.WithReadTxn(ctx, s.txnManager, func(ctx context.Context) error {
for _, ret := range results {
if err := s.matcher.MatchRelationships(ctx, ret, s.endpoint); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, fmt.Errorf("error matching scene relationships: %w", err)
}
if len(results) > 0 {
return results, nil
}
@@ -271,7 +309,7 @@ type scraperSource struct {
scraperID string
}
func (s scraperSource) ScrapeScenes(ctx context.Context, sceneID int) ([]*scraper.ScrapedScene, error) {
func (s scraperSource) ScrapeScenes(ctx context.Context, sceneID int) ([]*models.ScrapedScene, error) {
content, err := s.cache.ScrapeID(ctx, s.scraperID, sceneID, scraper.ScrapeContentTypeScene)
if err != nil {
return nil, err
@@ -282,8 +320,8 @@ func (s scraperSource) ScrapeScenes(ctx context.Context, sceneID int) ([]*scrape
return nil, nil
}
if scene, ok := content.(scraper.ScrapedScene); ok {
return []*scraper.ScrapedScene{&scene}, nil
if scene, ok := content.(models.ScrapedScene); ok {
return []*models.ScrapedScene{&scene}, nil
}
return nil, errors.New("could not convert content to scene")

View File

@@ -709,6 +709,11 @@ func (t *ImportTask) ImportScenes(ctx context.Context) {
return err
}
// skip importing markers if the scene was not created
if sceneImporter.ID == 0 {
return nil
}
// import the scene markers
for _, m := range sceneJSON.Markers {
markerImporter := &scene.MarkerImporter{

View File

@@ -6,10 +6,11 @@ import (
"strconv"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/performer"
"github.com/stashapp/stash/pkg/scraper/stashbox"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/stashbox"
"github.com/stashapp/stash/pkg/studio"
)
@@ -95,8 +96,7 @@ func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*mode
r := instance.Repository
stashboxRepository := stashbox.NewRepository(r)
client := stashbox.NewClient(*t.box, stashboxRepository, instance.Config.GetScraperExcludeTagPatterns())
client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))
if t.refresh {
var remoteID string
@@ -119,7 +119,7 @@ func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*mode
return nil, err
}
if remoteID != "" {
performer, err = client.FindStashBoxPerformerByID(ctx, remoteID)
performer, err = client.FindPerformerByID(ctx, remoteID)
if performer != nil && performer.RemoteMergedIntoId != nil {
mergedPerformer, err := t.handleMergedPerformer(ctx, performer, client)
@@ -140,14 +140,22 @@ func (t *StashBoxBatchTagTask) findStashBoxPerformer(ctx context.Context) (*mode
} else {
name = t.performer.Name
}
performer, err = client.FindStashBoxPerformerByName(ctx, name)
performer, err = client.FindPerformerByName(ctx, name)
}
if performer != nil {
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
return match.ScrapedPerformer(ctx, r.Performer, performer, t.box.Endpoint)
}); err != nil {
return nil, err
}
}
return performer, err
}
func (t *StashBoxBatchTagTask) handleMergedPerformer(ctx context.Context, performer *models.ScrapedPerformer, client *stashbox.Client) (mergedPerformer *models.ScrapedPerformer, err error) {
mergedPerformer, err = client.FindStashBoxPerformerByID(ctx, *performer.RemoteMergedIntoId)
mergedPerformer, err = client.FindPerformerByID(ctx, *performer.RemoteMergedIntoId)
if err != nil {
return nil, fmt.Errorf("loading merged performer %s from stashbox", *performer.RemoteMergedIntoId)
}
@@ -287,8 +295,7 @@ func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.
r := instance.Repository
stashboxRepository := stashbox.NewRepository(r)
client := stashbox.NewClient(*t.box, stashboxRepository, instance.Config.GetScraperExcludeTagPatterns())
client := stashbox.NewClient(*t.box, stashbox.ExcludeTagPatterns(instance.Config.GetScraperExcludeTagPatterns()))
if t.refresh {
var remoteID string
@@ -309,7 +316,7 @@ func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.
return nil, err
}
if remoteID != "" {
studio, err = client.FindStashBoxStudio(ctx, remoteID)
studio, err = client.FindStudio(ctx, remoteID)
}
} else {
var name string
@@ -318,7 +325,19 @@ func (t *StashBoxBatchTagTask) findStashBoxStudio(ctx context.Context) (*models.
} else {
name = t.studio.Name
}
studio, err = client.FindStashBoxStudio(ctx, name)
studio, err = client.FindStudio(ctx, name)
}
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
if studio != nil {
if err := match.ScrapedStudioHierarchy(ctx, r.Studio, studio, t.box.Endpoint); err != nil {
return err
}
}
return nil
}); err != nil {
return nil, err
}
return studio, err

View File

@@ -20,18 +20,52 @@ type GroupNamesFinder interface {
FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Group, error)
}
type SceneRelationships struct {
PerformerFinder PerformerFinder
TagFinder models.TagQueryer
StudioFinder StudioFinder
}
// MatchRelationships accepts a scraped scene and attempts to match its relationships to existing stash models.
func (r SceneRelationships) MatchRelationships(ctx context.Context, s *models.ScrapedScene, endpoint string) error {
thisStudio := s.Studio
for thisStudio != nil {
if err := ScrapedStudio(ctx, r.StudioFinder, s.Studio, endpoint); err != nil {
return err
}
thisStudio = thisStudio.Parent
}
for _, p := range s.Performers {
err := ScrapedPerformer(ctx, r.PerformerFinder, p, endpoint)
if err != nil {
return err
}
}
for _, t := range s.Tags {
err := ScrapedTag(ctx, r.TagFinder, t)
if err != nil {
return err
}
}
return nil
}
// ScrapedPerformer matches the provided performer with the
// performers in the database and sets the ID field if one is found.
func ScrapedPerformer(ctx context.Context, qb PerformerFinder, p *models.ScrapedPerformer, stashBoxEndpoint *string) error {
func ScrapedPerformer(ctx context.Context, qb PerformerFinder, p *models.ScrapedPerformer, stashBoxEndpoint string) error {
if p.StoredID != nil || p.Name == nil {
return nil
}
// Check if a performer with the StashID already exists
if stashBoxEndpoint != nil && p.RemoteSiteID != nil {
if stashBoxEndpoint != "" && p.RemoteSiteID != nil {
performers, err := qb.FindByStashID(ctx, models.StashID{
StashID: *p.RemoteSiteID,
Endpoint: *stashBoxEndpoint,
Endpoint: stashBoxEndpoint,
})
if err != nil {
return err
@@ -73,16 +107,16 @@ type StudioFinder interface {
// ScrapedStudio matches the provided studio with the studios
// in the database and sets the ID field if one is found.
func ScrapedStudio(ctx context.Context, qb StudioFinder, s *models.ScrapedStudio, stashBoxEndpoint *string) error {
func ScrapedStudio(ctx context.Context, qb StudioFinder, s *models.ScrapedStudio, stashBoxEndpoint string) error {
if s.StoredID != nil {
return nil
}
// Check if a studio with the StashID already exists
if stashBoxEndpoint != nil && s.RemoteSiteID != nil {
if stashBoxEndpoint != "" && s.RemoteSiteID != nil {
studios, err := qb.FindByStashID(ctx, models.StashID{
StashID: *s.RemoteSiteID,
Endpoint: *stashBoxEndpoint,
Endpoint: stashBoxEndpoint,
})
if err != nil {
return err
@@ -118,6 +152,19 @@ func ScrapedStudio(ctx context.Context, qb StudioFinder, s *models.ScrapedStudio
return nil
}
// ScrapedStudioHierarchy executes ScrapedStudio for the provided studio and its parents recursively.
func ScrapedStudioHierarchy(ctx context.Context, qb StudioFinder, s *models.ScrapedStudio, stashBoxEndpoint string) error {
if err := ScrapedStudio(ctx, qb, s, stashBoxEndpoint); err != nil {
return err
}
if s.Parent == nil {
return nil
}
return ScrapedStudioHierarchy(ctx, qb, s.Parent, stashBoxEndpoint)
}
// ScrapedGroup matches the provided movie with the movies
// in the database and returns the ID field if one is found.
func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, name *string) (matchedID *string, err error) {

View File

@@ -26,6 +26,20 @@ func (f *Fingerprint) Value() string {
}
}
// String returns the string representation of the Fingerprint.
// It will return an empty string if the Fingerprint is not a string.
func (f Fingerprint) String() string {
s, _ := f.Fingerprint.(string)
return s
}
// Int64 returns the int64 representation of the Fingerprint.
// It will return 0 if the Fingerprint is not an int64.
func (f Fingerprint) Int64() int64 {
v, _ := f.Fingerprint.(int64)
return v
}
type Fingerprints []Fingerprint
func (f Fingerprints) Remove(type_ string) Fingerprints {
@@ -102,33 +116,27 @@ func (f Fingerprints) For(type_ string) *Fingerprint {
}
func (f Fingerprints) Get(type_ string) interface{} {
for _, fp := range f {
if fp.Type == type_ {
return fp.Fingerprint
}
fp := f.For(type_)
if fp == nil {
return nil
}
return nil
return fp.Fingerprint
}
func (f Fingerprints) GetString(type_ string) string {
fp := f.Get(type_)
if fp != nil {
s, _ := fp.(string)
return s
fp := f.For(type_)
if fp == nil {
return ""
}
return ""
return fp.String()
}
func (f Fingerprints) GetInt64(type_ string) int64 {
fp := f.Get(type_)
fp := f.For(type_)
if fp != nil {
v, _ := fp.(int64)
return v
return 0
}
return 0
return fp.Int64()
}
// AppendUnique appends a fingerprint to the list if a Fingerprint of the same type does not already exist in the list. If one does, then it is updated with o's Fingerprint value.

View File

@@ -14,6 +14,7 @@ import (
type SceneMarker struct {
Title string `json:"title,omitempty"`
Seconds string `json:"seconds,omitempty"`
EndSeconds string `json:"end_seconds,omitempty"`
PrimaryTag string `json:"primary_tag,omitempty"`
Tags []string `json:"tags,omitempty"`
CreatedAt json.JSONTime `json:"created_at,omitempty"`

View File

@@ -492,3 +492,88 @@ func (g ScrapedGroup) ScrapedMovie() ScrapedMovie {
return ret
}
type ScrapedScene struct {
Title *string `json:"title"`
Code *string `json:"code"`
Details *string `json:"details"`
Director *string `json:"director"`
URL *string `json:"url"`
URLs []string `json:"urls"`
Date *string `json:"date"`
// This should be a base64 encoded data URL
Image *string `json:"image"`
File *SceneFileType `json:"file"`
Studio *ScrapedStudio `json:"studio"`
Tags []*ScrapedTag `json:"tags"`
Performers []*ScrapedPerformer `json:"performers"`
Groups []*ScrapedGroup `json:"groups"`
Movies []*ScrapedMovie `json:"movies"`
RemoteSiteID *string `json:"remote_site_id"`
Duration *int `json:"duration"`
Fingerprints []*StashBoxFingerprint `json:"fingerprints"`
}
func (ScrapedScene) IsScrapedContent() {}
type ScrapedSceneInput struct {
Title *string `json:"title"`
Code *string `json:"code"`
Details *string `json:"details"`
Director *string `json:"director"`
URL *string `json:"url"`
URLs []string `json:"urls"`
Date *string `json:"date"`
RemoteSiteID *string `json:"remote_site_id"`
}
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 *ScrapedStudio `json:"studio"`
Tags []*ScrapedTag `json:"tags"`
Performers []*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"`
}
type ScrapedGallery 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 *ScrapedStudio `json:"studio"`
Tags []*ScrapedTag `json:"tags"`
Performers []*ScrapedPerformer `json:"performers"`
// deprecated
URL *string `json:"url"`
}
func (ScrapedGallery) IsScrapedContent() {}
type ScrapedGalleryInput 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"`
// deprecated
URL *string `json:"url"`
}

View File

@@ -7,7 +7,8 @@ type StashBoxFingerprint struct {
}
type StashBox struct {
Endpoint string `json:"endpoint"`
APIKey string `json:"api_key"`
Name string `json:"name"`
Endpoint string `json:"endpoint"`
APIKey string `json:"api_key"`
Name string `json:"name"`
MaxRequestsPerMinute int `json:"max_requests_per_minute" koanf:"max_requests_per_minute"`
}

View File

@@ -235,6 +235,10 @@ func GetSceneMarkersJSON(ctx context.Context, markerReader models.SceneMarkerFin
UpdatedAt: json.JSONTime{Time: sceneMarker.UpdatedAt},
}
if sceneMarker.EndSeconds != nil {
sceneMarkerJSON.EndSeconds = getDecimalString(*sceneMarker.EndSeconds)
}
results = append(results, sceneMarkerJSON)
}

66
pkg/scene/find.go Normal file
View File

@@ -0,0 +1,66 @@
package scene
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
)
type LoadRelationshipOption func(context.Context, *models.Scene, models.SceneReader) error
func LoadURLs(ctx context.Context, scene *models.Scene, r models.SceneReader) error {
if err := scene.LoadURLs(ctx, r); err != nil {
return fmt.Errorf("loading scene URLs: %w", err)
}
return nil
}
func LoadStashIDs(ctx context.Context, scene *models.Scene, r models.SceneReader) error {
if err := scene.LoadStashIDs(ctx, r); err != nil {
return fmt.Errorf("failed to load stash IDs for scene %d: %w", scene.ID, err)
}
return nil
}
func LoadFiles(ctx context.Context, scene *models.Scene, r models.SceneReader) error {
if err := scene.LoadFiles(ctx, r); err != nil {
return fmt.Errorf("failed to load files for scene %d: %w", scene.ID, err)
}
return nil
}
// FindMany retrieves multiple scenes by their IDs.
// This method will load the specified relationships for each scene.
func (s *Service) FindMany(ctx context.Context, ids []int, load ...LoadRelationshipOption) ([]*models.Scene, error) {
var scenes []*models.Scene
qb := s.Repository
var err error
scenes, err = qb.FindMany(ctx, ids)
if err != nil {
return nil, err
}
// TODO - we should bulk load these relationships
for _, scene := range scenes {
if err := s.LoadRelationships(ctx, scene, load...); err != nil {
return nil, err
}
}
return scenes, nil
}
func (s *Service) LoadRelationships(ctx context.Context, scene *models.Scene, load ...LoadRelationshipOption) error {
for _, l := range load {
if err := l(ctx, scene, s.Repository); err != nil {
return err
}
}
return nil
}

40
pkg/scene/fingerprints.go Normal file
View File

@@ -0,0 +1,40 @@
package scene
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
)
// GetFingerprints returns the fingerprints for the given scene ids.
func (s *Service) GetScenesFingerprints(ctx context.Context, ids []int) ([]models.Fingerprints, error) {
fingerprints := make([]models.Fingerprints, len(ids))
qb := s.Repository
for i, sceneID := range ids {
scene, err := qb.Find(ctx, sceneID)
if err != nil {
return nil, err
}
if scene == nil {
return nil, fmt.Errorf("scene with id %d not found", sceneID)
}
if err := scene.LoadFiles(ctx, qb); err != nil {
return nil, err
}
var sceneFPs models.Fingerprints
for _, f := range scene.Files.List() {
sceneFPs = append(sceneFPs, f.Fingerprints...)
}
fingerprints[i] = sceneFPs
}
return fingerprints, nil
}

View File

@@ -27,12 +27,20 @@ type MarkerImporter struct {
func (i *MarkerImporter) PreImport(ctx context.Context) error {
seconds, _ := strconv.ParseFloat(i.Input.Seconds, 64)
var endSeconds *float64
if i.Input.EndSeconds != "" {
parsedEndSeconds, _ := strconv.ParseFloat(i.Input.EndSeconds, 64)
endSeconds = &parsedEndSeconds
}
i.marker = models.SceneMarker{
Title: i.Input.Title,
Seconds: seconds,
SceneID: i.SceneID,
CreatedAt: i.Input.CreatedAt.GetTime(),
UpdatedAt: i.Input.UpdatedAt.GetTime(),
Title: i.Input.Title,
Seconds: seconds,
EndSeconds: endSeconds,
SceneID: i.SceneID,
CreatedAt: i.Input.CreatedAt.GetTime(),
UpdatedAt: i.Input.UpdatedAt.GetTime(),
}
if err := i.populateTags(ctx); err != nil {

View File

@@ -29,9 +29,9 @@ type scraperActionImpl interface {
scrapeByName(ctx context.Context, name string, ty ScrapeContentType) ([]ScrapedContent, error)
scrapeByFragment(ctx context.Context, input Input) (ScrapedContent, error)
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)
scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error)
scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error)
scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error)
}
func (c config) getScraper(scraper scraperTypeConfig, client *http.Client, globalConfig GlobalConfig) scraperActionImpl {

View File

@@ -89,8 +89,8 @@ func autotagMatchTags(ctx context.Context, path string, tagReader models.TagAuto
return ret, nil
}
func (s autotagScraper) viaScene(ctx context.Context, _client *http.Client, scene *models.Scene) (*ScrapedScene, error) {
var ret *ScrapedScene
func (s autotagScraper) viaScene(ctx context.Context, _client *http.Client, scene *models.Scene) (*models.ScrapedScene, error) {
var ret *models.ScrapedScene
const trimExt = false
// populate performers, studio and tags based on scene path
@@ -115,7 +115,7 @@ func (s autotagScraper) viaScene(ctx context.Context, _client *http.Client, scen
}
if len(performers) > 0 || studio != nil || len(tags) > 0 {
ret = &ScrapedScene{
ret = &models.ScrapedScene{
Performers: performers,
Studio: studio,
Tags: tags,
@@ -130,7 +130,7 @@ func (s autotagScraper) viaScene(ctx context.Context, _client *http.Client, scen
return ret, nil
}
func (s autotagScraper) viaGallery(ctx context.Context, _client *http.Client, gallery *models.Gallery) (*ScrapedGallery, error) {
func (s autotagScraper) viaGallery(ctx context.Context, _client *http.Client, gallery *models.Gallery) (*models.ScrapedGallery, error) {
path := gallery.Path
if path == "" {
// not valid for non-path-based galleries
@@ -140,7 +140,7 @@ func (s autotagScraper) viaGallery(ctx context.Context, _client *http.Client, ga
// only trim extension if gallery is file-based
trimExt := gallery.PrimaryFileID != nil
var ret *ScrapedGallery
var ret *models.ScrapedGallery
// populate performers, studio and tags based on scene path
if err := txn.WithReadTxn(ctx, s.txnManager, func(ctx context.Context) error {
@@ -160,7 +160,7 @@ func (s autotagScraper) viaGallery(ctx context.Context, _client *http.Client, ga
}
if len(performers) > 0 || studio != nil || len(tags) > 0 {
ret = &ScrapedGallery{
ret = &models.ScrapedGallery{
Performers: performers,
Studio: studio,
Tags: tags,

View File

@@ -1,32 +0,0 @@
package scraper
import "github.com/stashapp/stash/pkg/models"
type ScrapedGallery 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"`
// deprecated
URL *string `json:"url"`
}
func (ScrapedGallery) IsScrapedContent() {}
type ScrapedGalleryInput 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"`
// deprecated
URL *string `json:"url"`
}

View File

@@ -60,7 +60,7 @@ func (g group) viaFragment(ctx context.Context, client *http.Client, input Input
return s.scrapeByFragment(ctx, input)
}
func (g group) viaScene(ctx context.Context, client *http.Client, scene *models.Scene) (*ScrapedScene, error) {
func (g group) viaScene(ctx context.Context, client *http.Client, scene *models.Scene) (*models.ScrapedScene, error) {
if g.config.SceneByFragment == nil {
return nil, ErrNotSupported
}
@@ -69,7 +69,7 @@ func (g group) viaScene(ctx context.Context, client *http.Client, scene *models.
return s.scrapeSceneByScene(ctx, scene)
}
func (g group) viaGallery(ctx context.Context, client *http.Client, gallery *models.Gallery) (*ScrapedGallery, error) {
func (g group) viaGallery(ctx context.Context, client *http.Client, gallery *models.Gallery) (*models.ScrapedGallery, error) {
if g.config.GalleryByFragment == nil {
return nil, ErrNotSupported
}
@@ -78,7 +78,7 @@ 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) {
func (g group) viaImage(ctx context.Context, client *http.Client, gallery *models.Image) (*models.ScrapedImage, error) {
if g.config.ImageByFragment == nil {
return nil, ErrNotSupported
}

View File

@@ -11,28 +11,6 @@ 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
@@ -59,7 +37,7 @@ func setPerformerImage(ctx context.Context, client *http.Client, p *models.Scrap
return nil
}
func setSceneImage(ctx context.Context, client *http.Client, s *ScrapedScene, globalConfig GlobalConfig) error {
func setSceneImage(ctx context.Context, client *http.Client, s *models.ScrapedScene, globalConfig GlobalConfig) error {
// don't try to get the image if it doesn't appear to be a URL
if s.Image == nil || !strings.HasPrefix(*s.Image, "http") {
// nothing to do

View File

@@ -172,7 +172,7 @@ func (s *jsonScraper) scrapeByName(ctx context.Context, name string, ty ScrapeCo
return nil, ErrNotSupported
}
func (s *jsonScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*ScrapedScene, error) {
func (s *jsonScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) {
// construct the URL
queryURL := queryURLParametersFromScene(scene)
if s.scraper.QueryURLReplacements != nil {
@@ -231,7 +231,7 @@ 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) {
func (s *jsonScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) {
// construct the URL
queryURL := queryURLParametersFromImage(image)
if s.scraper.QueryURLReplacements != nil {
@@ -255,7 +255,7 @@ func (s *jsonScraper) scrapeImageByImage(ctx context.Context, image *models.Imag
return scraper.scrapeImage(ctx, q)
}
func (s *jsonScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*ScrapedGallery, error) {
func (s *jsonScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) {
// construct the URL
queryURL := queryURLParametersFromGallery(gallery)
if s.scraper.QueryURLReplacements != nil {

View File

@@ -997,8 +997,8 @@ func (s mappedScraper) scrapePerformers(ctx context.Context, q mappedQuery) ([]*
return ret, nil
}
// processSceneRelationships sets the relationships on the ScrapedScene. It returns true if any relationships were set.
func (s mappedScraper) processSceneRelationships(ctx context.Context, q mappedQuery, resultIndex int, ret *ScrapedScene) bool {
// processSceneRelationships sets the relationships on the models.ScrapedScene. It returns true if any relationships were set.
func (s mappedScraper) processSceneRelationships(ctx context.Context, q mappedQuery, resultIndex int, ret *models.ScrapedScene) bool {
sceneScraperConfig := s.Scene
scenePerformersMap := sceneScraperConfig.Performers
@@ -1082,8 +1082,8 @@ func processRelationships[T any](ctx context.Context, s mappedScraper, relations
return ret
}
func (s mappedScraper) scrapeScenes(ctx context.Context, q mappedQuery) ([]*ScrapedScene, error) {
var ret []*ScrapedScene
func (s mappedScraper) scrapeScenes(ctx context.Context, q mappedQuery) ([]*models.ScrapedScene, error) {
var ret []*models.ScrapedScene
sceneScraperConfig := s.Scene
sceneMap := sceneScraperConfig.mappedConfig
@@ -1097,7 +1097,7 @@ func (s mappedScraper) scrapeScenes(ctx context.Context, q mappedQuery) ([]*Scra
for i, r := range results {
logger.Debug(`Processing scene:`)
var thisScene ScrapedScene
var thisScene models.ScrapedScene
r.apply(&thisScene)
s.processSceneRelationships(ctx, q, i, &thisScene)
ret = append(ret, &thisScene)
@@ -1106,7 +1106,7 @@ func (s mappedScraper) scrapeScenes(ctx context.Context, q mappedQuery) ([]*Scra
return ret, nil
}
func (s mappedScraper) scrapeScene(ctx context.Context, q mappedQuery) (*ScrapedScene, error) {
func (s mappedScraper) scrapeScene(ctx context.Context, q mappedQuery) (*models.ScrapedScene, error) {
sceneScraperConfig := s.Scene
if sceneScraperConfig == nil {
return nil, nil
@@ -1117,7 +1117,7 @@ func (s mappedScraper) scrapeScene(ctx context.Context, q mappedQuery) (*Scraped
logger.Debug(`Processing scene:`)
results := sceneMap.process(ctx, q, s.Common, urlsIsMulti)
var ret ScrapedScene
var ret models.ScrapedScene
if len(results) > 0 {
results[0].apply(&ret)
}
@@ -1133,8 +1133,8 @@ 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
func (s mappedScraper) scrapeImage(ctx context.Context, q mappedQuery) (*models.ScrapedImage, error) {
var ret models.ScrapedImage
imageScraperConfig := s.Image
if imageScraperConfig == nil {
@@ -1184,8 +1184,8 @@ func (s mappedScraper) scrapeImage(ctx context.Context, q mappedQuery) (*Scraped
return &ret, nil
}
func (s mappedScraper) scrapeGallery(ctx context.Context, q mappedQuery) (*ScrapedGallery, error) {
var ret ScrapedGallery
func (s mappedScraper) scrapeGallery(ctx context.Context, q mappedQuery) (*models.ScrapedGallery, error) {
var ret models.ScrapedGallery
galleryScraperConfig := s.Gallery
if galleryScraperConfig == nil {

View File

@@ -23,23 +23,23 @@ func (c Cache) postScrape(ctx context.Context, content ScrapedContent, excludeTa
}
case models.ScrapedPerformer:
return c.postScrapePerformer(ctx, v, excludeTagRE)
case *ScrapedScene:
case *models.ScrapedScene:
if v != nil {
return c.postScrapeScene(ctx, *v, excludeTagRE)
}
case ScrapedScene:
case models.ScrapedScene:
return c.postScrapeScene(ctx, v, excludeTagRE)
case *ScrapedGallery:
case *models.ScrapedGallery:
if v != nil {
return c.postScrapeGallery(ctx, *v, excludeTagRE)
}
case ScrapedGallery:
case models.ScrapedGallery:
return c.postScrapeGallery(ctx, v, excludeTagRE)
case *ScrapedImage:
case *models.ScrapedImage:
if v != nil {
return c.postScrapeImage(ctx, *v, excludeTagRE)
}
case ScrapedImage:
case models.ScrapedImage:
return c.postScrapeImage(ctx, v, excludeTagRE)
case *models.ScrapedMovie:
if v != nil {
@@ -133,7 +133,7 @@ func (c Cache) postScrapeMovie(ctx context.Context, m models.ScrapedMovie, exclu
m.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
if m.Studio != nil {
if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, nil); err != nil {
if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, ""); err != nil {
return err
}
}
@@ -165,7 +165,7 @@ func (c Cache) postScrapeGroup(ctx context.Context, m models.ScrapedGroup, exclu
m.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
if m.Studio != nil {
if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, nil); err != nil {
if err := match.ScrapedStudio(ctx, r.StudioFinder, m.Studio, ""); err != nil {
return err
}
}
@@ -201,7 +201,7 @@ func (c Cache) postScrapeScenePerformer(ctx context.Context, p models.ScrapedPer
return ignoredTags, nil
}
func (c Cache) postScrapeScene(ctx context.Context, scene ScrapedScene, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
func (c Cache) postScrapeScene(ctx context.Context, scene models.ScrapedScene, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
// set the URL/URLs field
if scene.URL == nil && len(scene.URLs) > 0 {
scene.URL = &scene.URLs[0]
@@ -227,7 +227,7 @@ func (c Cache) postScrapeScene(ctx context.Context, scene ScrapedScene, excludeT
return err
}
if err := match.ScrapedPerformer(ctx, pqb, p, nil); err != nil {
if err := match.ScrapedPerformer(ctx, pqb, p, ""); err != nil {
return err
}
@@ -277,7 +277,7 @@ func (c Cache) postScrapeScene(ctx context.Context, scene ScrapedScene, excludeT
scene.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
if scene.Studio != nil {
err := match.ScrapedStudio(ctx, sqb, scene.Studio, nil)
err := match.ScrapedStudio(ctx, sqb, scene.Studio, "")
if err != nil {
return err
}
@@ -296,7 +296,7 @@ func (c Cache) postScrapeScene(ctx context.Context, scene ScrapedScene, excludeT
return scene, ignoredTags, nil
}
func (c Cache) postScrapeGallery(ctx context.Context, g ScrapedGallery, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
func (c Cache) postScrapeGallery(ctx context.Context, g models.ScrapedGallery, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
// set the URL/URLs field
if g.URL == nil && len(g.URLs) > 0 {
g.URL = &g.URLs[0]
@@ -312,7 +312,7 @@ func (c Cache) postScrapeGallery(ctx context.Context, g ScrapedGallery, excludeT
sqb := r.StudioFinder
for _, p := range g.Performers {
err := match.ScrapedPerformer(ctx, pqb, p, nil)
err := match.ScrapedPerformer(ctx, pqb, p, "")
if err != nil {
return err
}
@@ -325,7 +325,7 @@ func (c Cache) postScrapeGallery(ctx context.Context, g ScrapedGallery, excludeT
g.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
if g.Studio != nil {
err := match.ScrapedStudio(ctx, sqb, g.Studio, nil)
err := match.ScrapedStudio(ctx, sqb, g.Studio, "")
if err != nil {
return err
}
@@ -339,7 +339,7 @@ func (c Cache) postScrapeGallery(ctx context.Context, g ScrapedGallery, excludeT
return g, ignoredTags, nil
}
func (c Cache) postScrapeImage(ctx context.Context, image ScrapedImage, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
func (c Cache) postScrapeImage(ctx context.Context, image models.ScrapedImage, excludeTagRE []*regexp.Regexp) (_ ScrapedContent, ignoredTags []string, err error) {
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
pqb := r.PerformerFinder
@@ -347,7 +347,7 @@ func (c Cache) postScrapeImage(ctx context.Context, image ScrapedImage, excludeT
sqb := r.StudioFinder
for _, p := range image.Performers {
if err := match.ScrapedPerformer(ctx, pqb, p, nil); err != nil {
if err := match.ScrapedPerformer(ctx, pqb, p, ""); err != nil {
return err
}
}
@@ -360,7 +360,7 @@ func (c Cache) postScrapeImage(ctx context.Context, image ScrapedImage, excludeT
image.Tags, ignoredTags = FilterTags(excludeTagRE, tags)
if image.Studio != nil {
err := match.ScrapedStudio(ctx, sqb, image.Studio, nil)
err := match.ScrapedStudio(ctx, sqb, image.Studio, "")
if err != nil {
return err
}

View File

@@ -26,7 +26,7 @@ func queryURLParametersFromScene(scene *models.Scene) queryURLParameters {
return ret
}
func queryURLParametersFromScrapedScene(scene ScrapedSceneInput) queryURLParameters {
func queryURLParametersFromScrapedScene(scene models.ScrapedSceneInput) queryURLParameters {
ret := make(queryURLParameters)
setField := func(field string, value *string) {

View File

@@ -1,39 +0,0 @@
package scraper
import (
"github.com/stashapp/stash/pkg/models"
)
type ScrapedScene struct {
Title *string `json:"title"`
Code *string `json:"code"`
Details *string `json:"details"`
Director *string `json:"director"`
URL *string `json:"url"`
URLs []string `json:"urls"`
Date *string `json:"date"`
// This should be a base64 encoded data URL
Image *string `json:"image"`
File *models.SceneFileType `json:"file"`
Studio *models.ScrapedStudio `json:"studio"`
Tags []*models.ScrapedTag `json:"tags"`
Performers []*models.ScrapedPerformer `json:"performers"`
Groups []*models.ScrapedGroup `json:"groups"`
Movies []*models.ScrapedMovie `json:"movies"`
RemoteSiteID *string `json:"remote_site_id"`
Duration *int `json:"duration"`
Fingerprints []*models.StashBoxFingerprint `json:"fingerprints"`
}
func (ScrapedScene) IsScrapedContent() {}
type ScrapedSceneInput struct {
Title *string `json:"title"`
Code *string `json:"code"`
Details *string `json:"details"`
Director *string `json:"director"`
URL *string `json:"url"`
URLs []string `json:"urls"`
Date *string `json:"date"`
RemoteSiteID *string `json:"remote_site_id"`
}

View File

@@ -163,9 +163,9 @@ var (
// set to nil.
type Input struct {
Performer *ScrapedPerformerInput
Scene *ScrapedSceneInput
Gallery *ScrapedGalleryInput
Image *ScrapedImageInput
Scene *models.ScrapedSceneInput
Gallery *models.ScrapedGalleryInput
Image *models.ScrapedImageInput
}
// populateURL populates the URL field of the input based on the
@@ -227,7 +227,7 @@ type fragmentScraper interface {
type sceneScraper interface {
scraper
viaScene(ctx context.Context, client *http.Client, scene *models.Scene) (*ScrapedScene, error)
viaScene(ctx context.Context, client *http.Client, scene *models.Scene) (*models.ScrapedScene, error)
}
// imageScraper is a scraper which supports image scrapes with
@@ -235,7 +235,7 @@ type sceneScraper interface {
type imageScraper interface {
scraper
viaImage(ctx context.Context, client *http.Client, image *models.Image) (*ScrapedImage, error)
viaImage(ctx context.Context, client *http.Client, image *models.Image) (*models.ScrapedImage, error)
}
// galleryScraper is a scraper which supports gallery scrapes with
@@ -243,5 +243,5 @@ type imageScraper interface {
type galleryScraper interface {
scraper
viaGallery(ctx context.Context, client *http.Client, gallery *models.Gallery) (*ScrapedGallery, error)
viaGallery(ctx context.Context, client *http.Client, gallery *models.Gallery) (*models.ScrapedGallery, error)
}

View File

@@ -328,7 +328,7 @@ func (s *scriptScraper) scrapeByName(ctx context.Context, name string, ty Scrape
}
}
case ScrapeContentTypeScene:
var scenes []ScrapedScene
var scenes []models.ScrapedScene
err = s.runScraperScript(ctx, input, &scenes)
if err == nil {
for _, s := range scenes {
@@ -377,11 +377,11 @@ func (s *scriptScraper) scrape(ctx context.Context, input string, ty ScrapeConte
err := s.runScraperScript(ctx, input, &performer)
return performer, err
case ScrapeContentTypeGallery:
var gallery *ScrapedGallery
var gallery *models.ScrapedGallery
err := s.runScraperScript(ctx, input, &gallery)
return gallery, err
case ScrapeContentTypeScene:
var scene *ScrapedScene
var scene *models.ScrapedScene
err := s.runScraperScript(ctx, input, &scene)
return scene, err
case ScrapeContentTypeMovie, ScrapeContentTypeGroup:
@@ -389,7 +389,7 @@ func (s *scriptScraper) scrape(ctx context.Context, input string, ty ScrapeConte
err := s.runScraperScript(ctx, input, &movie)
return movie, err
case ScrapeContentTypeImage:
var image *ScrapedImage
var image *models.ScrapedImage
err := s.runScraperScript(ctx, input, &image)
return image, err
}
@@ -397,42 +397,42 @@ func (s *scriptScraper) scrape(ctx context.Context, input string, ty ScrapeConte
return nil, ErrNotSupported
}
func (s *scriptScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*ScrapedScene, error) {
func (s *scriptScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) {
inString, err := json.Marshal(sceneInputFromScene(scene))
if err != nil {
return nil, err
}
var ret *ScrapedScene
var ret *models.ScrapedScene
err = s.runScraperScript(ctx, string(inString), &ret)
return ret, err
}
func (s *scriptScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*ScrapedGallery, error) {
func (s *scriptScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) {
inString, err := json.Marshal(galleryInputFromGallery(gallery))
if err != nil {
return nil, err
}
var ret *ScrapedGallery
var ret *models.ScrapedGallery
err = s.runScraperScript(ctx, string(inString), &ret)
return ret, err
}
func (s *scriptScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*ScrapedImage, error) {
func (s *scriptScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) {
inString, err := json.Marshal(imageToUpdateInput(image))
if err != nil {
return nil, err
}
var ret *ScrapedImage
var ret *models.ScrapedImage
err = s.runScraperScript(ctx, string(inString), &ret)

View File

@@ -170,7 +170,7 @@ func (s *stashScraper) scrapeByPerformerFragment(ctx context.Context, scrapedPer
return &ret, nil
}
func (s *stashScraper) scrapeBySceneFragment(ctx context.Context, scrapedScene ScrapedSceneInput) (ScrapedContent, error) {
func (s *stashScraper) scrapeBySceneFragment(ctx context.Context, scrapedScene models.ScrapedSceneInput) (ScrapedContent, error) {
client := s.getStashClient()
var q struct {
@@ -219,8 +219,8 @@ type stashFindSceneNamesResultType struct {
Scenes []*scrapedSceneStash `graphql:"scenes"`
}
func (s *stashScraper) scrapedStashSceneToScrapedScene(ctx context.Context, scene *scrapedSceneStash) (*ScrapedScene, error) {
ret := ScrapedScene{}
func (s *stashScraper) scrapedStashSceneToScrapedScene(ctx context.Context, scene *scrapedSceneStash) (*models.ScrapedScene, error) {
ret := models.ScrapedScene{}
err := copier.Copy(&ret, scene)
if err != nil {
return nil, err
@@ -341,7 +341,7 @@ type scrapedSceneStash struct {
Performers []*scrapedPerformerStash `graphql:"performers" json:"performers"`
}
func (s *stashScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*ScrapedScene, error) {
func (s *stashScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) {
// query by MD5
var q struct {
FindScene *scrapedSceneStash `graphql:"findSceneByHash(input: $c)"`
@@ -401,7 +401,7 @@ type scrapedGalleryStash struct {
Performers []*scrapedPerformerStash `graphql:"performers" json:"performers"`
}
func (s *stashScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*ScrapedGallery, error) {
func (s *stashScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) {
var q struct {
FindGallery *scrapedGalleryStash `graphql:"findGalleryByHash(input: $c)"`
}
@@ -425,7 +425,7 @@ func (s *stashScraper) scrapeGalleryByGallery(ctx context.Context, gallery *mode
}
// need to copy back to a scraped scene
ret := ScrapedGallery{}
ret := models.ScrapedGallery{}
if err := copier.Copy(&ret, q.FindGallery); err != nil {
return nil, err
}
@@ -433,7 +433,7 @@ func (s *stashScraper) scrapeGalleryByGallery(ctx context.Context, gallery *mode
return &ret, nil
}
func (s *stashScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*ScrapedImage, error) {
func (s *stashScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) {
return nil, ErrNotSupported
}

View File

@@ -1,102 +0,0 @@
// Package stashbox provides a client interface to a stash-box server instance.
package stashbox
import (
"context"
"net/http"
"regexp"
"github.com/Yamashou/gqlgenc/clientv2"
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper"
"github.com/stashapp/stash/pkg/scraper/stashbox/graphql"
"github.com/stashapp/stash/pkg/txn"
)
type SceneReader interface {
models.SceneGetter
models.StashIDLoader
models.VideoFileLoader
}
type PerformerReader interface {
models.PerformerGetter
match.PerformerFinder
models.AliasLoader
models.StashIDLoader
models.URLLoader
FindBySceneID(ctx context.Context, sceneID int) ([]*models.Performer, error)
GetImage(ctx context.Context, performerID int) ([]byte, error)
}
type StudioReader interface {
models.StudioGetter
match.StudioFinder
models.StashIDLoader
}
type TagFinder interface {
models.TagQueryer
FindBySceneID(ctx context.Context, sceneID int) ([]*models.Tag, error)
}
type Repository struct {
TxnManager models.TxnManager
Scene SceneReader
Performer PerformerReader
Tag TagFinder
Studio StudioReader
}
func NewRepository(repo models.Repository) Repository {
return Repository{
TxnManager: repo.TxnManager,
Scene: repo.Scene,
Performer: repo.Performer,
Tag: repo.Tag,
Studio: repo.Studio,
}
}
func (r *Repository) WithReadTxn(ctx context.Context, fn txn.TxnFunc) error {
return txn.WithReadTxn(ctx, r.TxnManager, fn)
}
// Client represents the client interface to a stash-box server instance.
type Client struct {
client *graphql.Client
repository Repository
box models.StashBox
// tag patterns to be excluded
excludeTagRE []*regexp.Regexp
}
// NewClient returns a new instance of a stash-box client.
func NewClient(box models.StashBox, repo Repository, excludeTagPatterns []string) *Client {
authHeader := func(ctx context.Context, req *http.Request, gqlInfo *clientv2.GQLRequestInfo, res interface{}, next clientv2.RequestInterceptorFunc) error {
req.Header.Set("ApiKey", box.APIKey)
return next(ctx, req, gqlInfo, res)
}
client := &graphql.Client{
Client: clientv2.NewClient(http.DefaultClient, box.Endpoint, nil, authHeader),
}
return &Client{
client: client,
repository: repo,
box: box,
excludeTagRE: scraper.CompileExclusionRegexps(excludeTagPatterns),
}
}
func (c Client) getHTTPClient() *http.Client {
return c.client.Client.Client
}
func (c Client) GetUser(ctx context.Context) (*graphql.Me, error) {
return c.client.Me(ctx)
}

View File

@@ -1,13 +0,0 @@
package stashbox
import "github.com/stashapp/stash/pkg/models"
type StashBoxStudioQueryResult struct {
Query string `json:"query"`
Results []*models.ScrapedStudio `json:"results"`
}
type StashBoxPerformerQueryResult struct {
Query string `json:"query"`
Results []*models.ScrapedPerformer `json:"results"`
}

View File

@@ -1,589 +0,0 @@
package stashbox
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper"
"github.com/stashapp/stash/pkg/scraper/stashbox/graphql"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/utils"
)
// QueryStashBoxScene queries stash-box for scenes using a query string.
func (c Client) QueryStashBoxScene(ctx context.Context, queryStr string) ([]*scraper.ScrapedScene, error) {
scenes, err := c.client.SearchScene(ctx, queryStr)
if err != nil {
return nil, err
}
sceneFragments := scenes.SearchScene
var ret []*scraper.ScrapedScene
var ignoredTags []string
for _, s := range sceneFragments {
ss, err := c.sceneFragmentToScrapedScene(ctx, s)
if err != nil {
return nil, err
}
var thisIgnoredTags []string
ss.Tags, thisIgnoredTags = scraper.FilterTags(c.excludeTagRE, ss.Tags)
ignoredTags = sliceutil.AppendUniques(ignoredTags, thisIgnoredTags)
ret = append(ret, ss)
}
scraper.LogIgnoredTags(ignoredTags)
return ret, nil
}
// FindStashBoxScenesByFingerprints queries stash-box for a scene using the
// scene's MD5/OSHASH checksum, or PHash.
func (c Client) FindStashBoxSceneByFingerprints(ctx context.Context, sceneID int) ([]*scraper.ScrapedScene, error) {
res, err := c.FindStashBoxScenesByFingerprints(ctx, []int{sceneID})
if len(res) > 0 {
return res[0], err
}
return nil, err
}
// FindStashBoxScenesByFingerprints queries stash-box for scenes using every
// scene's MD5/OSHASH checksum, or PHash, and returns results in the same order
// as the input slice.
func (c Client) FindStashBoxScenesByFingerprints(ctx context.Context, ids []int) ([][]*scraper.ScrapedScene, error) {
var fingerprints [][]*graphql.FingerprintQueryInput
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
qb := r.Scene
for _, sceneID := range ids {
scene, err := qb.Find(ctx, sceneID)
if err != nil {
return err
}
if scene == nil {
return fmt.Errorf("scene with id %d not found", sceneID)
}
if err := scene.LoadFiles(ctx, r.Scene); err != nil {
return err
}
var sceneFPs []*graphql.FingerprintQueryInput
for _, f := range scene.Files.List() {
checksum := f.Fingerprints.GetString(models.FingerprintTypeMD5)
if checksum != "" {
sceneFPs = append(sceneFPs, &graphql.FingerprintQueryInput{
Hash: checksum,
Algorithm: graphql.FingerprintAlgorithmMd5,
})
}
oshash := f.Fingerprints.GetString(models.FingerprintTypeOshash)
if oshash != "" {
sceneFPs = append(sceneFPs, &graphql.FingerprintQueryInput{
Hash: oshash,
Algorithm: graphql.FingerprintAlgorithmOshash,
})
}
phash := f.Fingerprints.GetInt64(models.FingerprintTypePhash)
if phash != 0 {
phashStr := utils.PhashToString(phash)
sceneFPs = append(sceneFPs, &graphql.FingerprintQueryInput{
Hash: phashStr,
Algorithm: graphql.FingerprintAlgorithmPhash,
})
}
}
fingerprints = append(fingerprints, sceneFPs)
}
return nil
}); err != nil {
return nil, err
}
return c.findStashBoxScenesByFingerprints(ctx, fingerprints)
}
func (c Client) findStashBoxScenesByFingerprints(ctx context.Context, scenes [][]*graphql.FingerprintQueryInput) ([][]*scraper.ScrapedScene, error) {
var results [][]*scraper.ScrapedScene
// filter out nils
var validScenes [][]*graphql.FingerprintQueryInput
for _, s := range scenes {
if len(s) > 0 {
validScenes = append(validScenes, s)
}
}
var ignoredTags []string
for i := 0; i < len(validScenes); i += 40 {
end := i + 40
if end > len(validScenes) {
end = len(validScenes)
}
scenes, err := c.client.FindScenesBySceneFingerprints(ctx, validScenes[i:end])
if err != nil {
return nil, err
}
for _, sceneFragments := range scenes.FindScenesBySceneFingerprints {
var sceneResults []*scraper.ScrapedScene
for _, scene := range sceneFragments {
ss, err := c.sceneFragmentToScrapedScene(ctx, scene)
if err != nil {
return nil, err
}
var thisIgnoredTags []string
ss.Tags, thisIgnoredTags = scraper.FilterTags(c.excludeTagRE, ss.Tags)
ignoredTags = sliceutil.AppendUniques(ignoredTags, thisIgnoredTags)
sceneResults = append(sceneResults, ss)
}
results = append(results, sceneResults)
}
}
scraper.LogIgnoredTags(ignoredTags)
// repopulate the results to be the same order as the input
ret := make([][]*scraper.ScrapedScene, len(scenes))
upTo := 0
for i, v := range scenes {
if len(v) > 0 {
ret[i] = results[upTo]
upTo++
}
}
return ret, nil
}
func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.SceneFragment) (*scraper.ScrapedScene, error) {
stashID := s.ID
ss := &scraper.ScrapedScene{
Title: s.Title,
Code: s.Code,
Date: s.Date,
Details: s.Details,
Director: s.Director,
URL: findURL(s.Urls, "STUDIO"),
Duration: s.Duration,
RemoteSiteID: &stashID,
Fingerprints: getFingerprints(s),
// Image
// stash_id
}
for _, u := range s.Urls {
ss.URLs = append(ss.URLs, u.URL)
}
if len(ss.URLs) > 0 {
ss.URL = &ss.URLs[0]
}
if len(s.Images) > 0 {
// TODO - #454 code sorts images by aspect ratio according to a wanted
// orientation. I'm just grabbing the first for now
ss.Image = getFirstImage(ctx, c.getHTTPClient(), s.Images)
}
if ss.URL == nil && len(s.Urls) > 0 {
// The scene in Stash-box may not have a Studio URL but it does have another URL.
// For example it has a www.manyvids.com URL, which is auto set as type ManyVids.
// This should be re-visited once Stashapp can support more than one URL.
ss.URL = &s.Urls[0].URL
}
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
pqb := r.Performer
tqb := r.Tag
if s.Studio != nil {
ss.Studio = studioFragmentToScrapedStudio(*s.Studio)
err := match.ScrapedStudio(ctx, r.Studio, ss.Studio, &c.box.Endpoint)
if err != nil {
return err
}
var parentStudio *graphql.FindStudio
if s.Studio.Parent != nil {
parentStudio, err = c.client.FindStudio(ctx, &s.Studio.Parent.ID, nil)
if err != nil {
return err
}
if parentStudio.FindStudio != nil {
ss.Studio.Parent = studioFragmentToScrapedStudio(*parentStudio.FindStudio)
err = match.ScrapedStudio(ctx, r.Studio, ss.Studio.Parent, &c.box.Endpoint)
if err != nil {
return err
}
}
}
}
for _, p := range s.Performers {
sp := performerFragmentToScrapedPerformer(*p.Performer)
err := match.ScrapedPerformer(ctx, pqb, sp, &c.box.Endpoint)
if err != nil {
return err
}
ss.Performers = append(ss.Performers, sp)
}
for _, t := range s.Tags {
st := &models.ScrapedTag{
Name: t.Name,
}
err := match.ScrapedTag(ctx, tqb, st)
if err != nil {
return err
}
ss.Tags = append(ss.Tags, st)
}
return nil
}); err != nil {
return nil, err
}
return ss, nil
}
func getFirstImage(ctx context.Context, client *http.Client, images []*graphql.ImageFragment) *string {
ret, err := fetchImage(ctx, client, images[0].URL)
if err != nil && !errors.Is(err, context.Canceled) {
logger.Warnf("Error fetching image %s: %s", images[0].URL, err.Error())
}
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 (c Client) SubmitSceneDraft(ctx context.Context, scene *models.Scene, cover []byte) (*string, error) {
draft := graphql.SceneDraftInput{}
var image io.Reader
r := c.repository
pqb := r.Performer
sqb := r.Studio
endpoint := c.box.Endpoint
if scene.Title != "" {
draft.Title = &scene.Title
}
if scene.Code != "" {
draft.Code = &scene.Code
}
if scene.Details != "" {
draft.Details = &scene.Details
}
if scene.Director != "" {
draft.Director = &scene.Director
}
// TODO - draft does not accept multiple URLs. Use single URL for now.
if len(scene.URLs.List()) > 0 {
url := strings.TrimSpace(scene.URLs.List()[0])
draft.URL = &url
}
if scene.Date != nil {
v := scene.Date.String()
draft.Date = &v
}
if scene.StudioID != nil {
studio, err := sqb.Find(ctx, *scene.StudioID)
if err != nil {
return nil, err
}
if studio == nil {
return nil, fmt.Errorf("studio with id %d not found", *scene.StudioID)
}
studioDraft := graphql.DraftEntityInput{
Name: studio.Name,
}
stashIDs, err := sqb.GetStashIDs(ctx, studio.ID)
if err != nil {
return nil, err
}
for _, stashID := range stashIDs {
c := stashID
if stashID.Endpoint == endpoint {
studioDraft.ID = &c.StashID
break
}
}
draft.Studio = &studioDraft
}
fingerprints := []*graphql.FingerprintInput{}
// submit all file fingerprints
if err := scene.LoadFiles(ctx, r.Scene); err != nil {
return nil, err
}
for _, f := range scene.Files.List() {
duration := f.Duration
if duration != 0 {
if oshash := f.Fingerprints.GetString(models.FingerprintTypeOshash); oshash != "" {
fingerprint := graphql.FingerprintInput{
Hash: oshash,
Algorithm: graphql.FingerprintAlgorithmOshash,
Duration: int(duration),
}
fingerprints = appendFingerprintUnique(fingerprints, &fingerprint)
}
if checksum := f.Fingerprints.GetString(models.FingerprintTypeMD5); checksum != "" {
fingerprint := graphql.FingerprintInput{
Hash: checksum,
Algorithm: graphql.FingerprintAlgorithmMd5,
Duration: int(duration),
}
fingerprints = appendFingerprintUnique(fingerprints, &fingerprint)
}
if phash := f.Fingerprints.GetInt64(models.FingerprintTypePhash); phash != 0 {
fingerprint := graphql.FingerprintInput{
Hash: utils.PhashToString(phash),
Algorithm: graphql.FingerprintAlgorithmPhash,
Duration: int(duration),
}
fingerprints = appendFingerprintUnique(fingerprints, &fingerprint)
}
}
}
draft.Fingerprints = fingerprints
scenePerformers, err := pqb.FindBySceneID(ctx, scene.ID)
if err != nil {
return nil, err
}
performers := []*graphql.DraftEntityInput{}
for _, p := range scenePerformers {
performerDraft := graphql.DraftEntityInput{
Name: p.Name,
}
stashIDs, err := pqb.GetStashIDs(ctx, p.ID)
if err != nil {
return nil, err
}
for _, stashID := range stashIDs {
c := stashID
if stashID.Endpoint == endpoint {
performerDraft.ID = &c.StashID
break
}
}
performers = append(performers, &performerDraft)
}
draft.Performers = performers
var tags []*graphql.DraftEntityInput
sceneTags, err := r.Tag.FindBySceneID(ctx, scene.ID)
if err != nil {
return nil, err
}
for _, tag := range sceneTags {
tags = append(tags, &graphql.DraftEntityInput{Name: tag.Name})
}
draft.Tags = tags
if len(cover) > 0 {
image = bytes.NewReader(cover)
}
if err := scene.LoadStashIDs(ctx, r.Scene); err != nil {
return nil, err
}
stashIDs := scene.StashIDs.List()
var stashID *string
for _, v := range stashIDs {
if v.Endpoint == endpoint {
vv := v.StashID
stashID = &vv
break
}
}
draft.ID = stashID
var id *string
var ret graphql.SubmitSceneDraft
err = c.submitDraft(ctx, graphql.SubmitSceneDraftDocument, draft, image, &ret)
id = ret.SubmitSceneDraft.ID
return id, err
// ret, err := c.client.SubmitSceneDraft(ctx, draft, uploadImage(image))
// if err != nil {
// return nil, err
// }
// id := ret.SubmitSceneDraft.ID
// return id, nil
}
func (c Client) SubmitStashBoxFingerprints(ctx context.Context, sceneIDs []string) (bool, error) {
ids, err := stringslice.StringSliceToIntSlice(sceneIDs)
if err != nil {
return false, err
}
endpoint := c.box.Endpoint
var fingerprints []graphql.FingerprintSubmission
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
qb := r.Scene
for _, sceneID := range ids {
scene, err := qb.Find(ctx, sceneID)
if err != nil {
return err
}
if scene == nil {
continue
}
if err := scene.LoadStashIDs(ctx, qb); err != nil {
return err
}
if err := scene.LoadFiles(ctx, qb); err != nil {
return err
}
stashIDs := scene.StashIDs.List()
sceneStashID := ""
for _, stashID := range stashIDs {
if stashID.Endpoint == endpoint {
sceneStashID = stashID.StashID
}
}
if sceneStashID != "" {
for _, f := range scene.Files.List() {
duration := f.Duration
if duration != 0 {
if checksum := f.Fingerprints.GetString(models.FingerprintTypeMD5); checksum != "" {
fingerprint := graphql.FingerprintInput{
Hash: checksum,
Algorithm: graphql.FingerprintAlgorithmMd5,
Duration: int(duration),
}
fingerprints = append(fingerprints, graphql.FingerprintSubmission{
SceneID: sceneStashID,
Fingerprint: &fingerprint,
})
}
if oshash := f.Fingerprints.GetString(models.FingerprintTypeOshash); oshash != "" {
fingerprint := graphql.FingerprintInput{
Hash: oshash,
Algorithm: graphql.FingerprintAlgorithmOshash,
Duration: int(duration),
}
fingerprints = append(fingerprints, graphql.FingerprintSubmission{
SceneID: sceneStashID,
Fingerprint: &fingerprint,
})
}
if phash := f.Fingerprints.GetInt64(models.FingerprintTypePhash); phash != 0 {
fingerprint := graphql.FingerprintInput{
Hash: utils.PhashToString(phash),
Algorithm: graphql.FingerprintAlgorithmPhash,
Duration: int(duration),
}
fingerprints = append(fingerprints, graphql.FingerprintSubmission{
SceneID: sceneStashID,
Fingerprint: &fingerprint,
})
}
}
}
}
}
return nil
}); err != nil {
return false, err
}
return c.submitStashBoxFingerprints(ctx, fingerprints)
}
func (c Client) submitStashBoxFingerprints(ctx context.Context, fingerprints []graphql.FingerprintSubmission) (bool, error) {
for _, fingerprint := range fingerprints {
_, err := c.client.SubmitFingerprint(ctx, fingerprint)
if err != nil {
return false, err
}
}
return true, nil
}
func appendFingerprintUnique(v []*graphql.FingerprintInput, toAdd *graphql.FingerprintInput) []*graphql.FingerprintInput {
for _, vv := range v {
if vv.Algorithm == toAdd.Algorithm && vv.Hash == toAdd.Hash {
return v
}
}
return append(v, toAdd)
}

View File

@@ -151,7 +151,7 @@ func (s *xpathScraper) scrapeByName(ctx context.Context, name string, ty ScrapeC
return nil, ErrNotSupported
}
func (s *xpathScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*ScrapedScene, error) {
func (s *xpathScraper) scrapeSceneByScene(ctx context.Context, scene *models.Scene) (*models.ScrapedScene, error) {
// construct the URL
queryURL := queryURLParametersFromScene(scene)
if s.scraper.QueryURLReplacements != nil {
@@ -210,7 +210,7 @@ func (s *xpathScraper) scrapeByFragment(ctx context.Context, input Input) (Scrap
return scraper.scrapeScene(ctx, q)
}
func (s *xpathScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*ScrapedGallery, error) {
func (s *xpathScraper) scrapeGalleryByGallery(ctx context.Context, gallery *models.Gallery) (*models.ScrapedGallery, error) {
// construct the URL
queryURL := queryURLParametersFromGallery(gallery)
if s.scraper.QueryURLReplacements != nil {
@@ -234,7 +234,7 @@ 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) {
func (s *xpathScraper) scrapeImageByImage(ctx context.Context, image *models.Image) (*models.ScrapedImage, error) {
// construct the URL
queryURL := queryURLParametersFromImage(image)
if s.scraper.QueryURLReplacements != nil {

View File

@@ -167,3 +167,13 @@ func ValuesToPtrs[T any](vs []T) []*T {
}
return ret
}
// Flatten returns a single slice containing all elements of the provided
// slice of slices.
func Flatten[T any](vs [][]T) []T {
var ret []T
for _, v := range vs {
ret = append(ret, v...)
}
return ret
}

View File

@@ -586,6 +586,7 @@ var studioSortOptions = sortOptions{
"scenes_count",
"random",
"rating",
"tag_count",
"updated_at",
}

101
pkg/stashbox/client.go Normal file
View File

@@ -0,0 +1,101 @@
// Package stashbox provides a client interface to a stash-box server instance.
package stashbox
import (
"context"
"net/http"
"regexp"
"github.com/Yamashou/gqlgenc/clientv2"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper"
"github.com/stashapp/stash/pkg/stashbox/graphql"
"golang.org/x/time/rate"
)
// DefaultMaxRequestsPerMinute is the default maximum number of requests per minute.
const DefaultMaxRequestsPerMinute = 240
// Client represents the client interface to a stash-box server instance.
type Client struct {
client *graphql.Client
box models.StashBox
maxRequestsPerMinute int
// tag patterns to be excluded
excludeTagRE []*regexp.Regexp
}
type ClientOption func(*Client)
func ExcludeTagPatterns(patterns []string) ClientOption {
return func(c *Client) {
c.excludeTagRE = scraper.CompileExclusionRegexps(patterns)
}
}
func MaxRequestsPerMinute(n int) ClientOption {
return func(c *Client) {
if n > 0 {
c.maxRequestsPerMinute = n
}
}
}
func setApiKeyHeader(apiKey string) clientv2.RequestInterceptor {
return func(ctx context.Context, req *http.Request, gqlInfo *clientv2.GQLRequestInfo, res interface{}, next clientv2.RequestInterceptorFunc) error {
req.Header.Set("ApiKey", apiKey)
return next(ctx, req, gqlInfo, res)
}
}
func rateLimit(n int) clientv2.RequestInterceptor {
perSec := float64(n) / 60
limiter := rate.NewLimiter(rate.Limit(perSec), 1)
return func(ctx context.Context, req *http.Request, gqlInfo *clientv2.GQLRequestInfo, res interface{}, next clientv2.RequestInterceptorFunc) error {
if err := limiter.Wait(ctx); err != nil {
// should only happen if the context is canceled
return err
}
return next(ctx, req, gqlInfo, res)
}
}
// NewClient returns a new instance of a stash-box client.
func NewClient(box models.StashBox, options ...ClientOption) *Client {
ret := &Client{
box: box,
maxRequestsPerMinute: DefaultMaxRequestsPerMinute,
}
if box.MaxRequestsPerMinute > 0 {
ret.maxRequestsPerMinute = box.MaxRequestsPerMinute
}
for _, option := range options {
option(ret)
}
authHeader := setApiKeyHeader(box.APIKey)
limitRequests := rateLimit(ret.maxRequestsPerMinute)
client := &graphql.Client{
Client: clientv2.NewClient(http.DefaultClient, box.Endpoint, nil, authHeader, limitRequests),
}
ret.client = client
return ret
}
func (c Client) getHTTPClient() *http.Client {
return c.client.Client.Client
}
func (c Client) GetUser(ctx context.Context) (*graphql.Me, error) {
return c.client.Me(ctx)
}

View File

@@ -9,39 +9,31 @@ import (
"strconv"
"strings"
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper"
"github.com/stashapp/stash/pkg/scraper/stashbox/graphql"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/stashbox/graphql"
"github.com/stashapp/stash/pkg/utils"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
// QueryStashBoxPerformer queries stash-box for performers using a query string.
func (c Client) QueryStashBoxPerformer(ctx context.Context, queryStr string) ([]*StashBoxPerformerQueryResult, error) {
performers, err := c.queryStashBoxPerformer(ctx, queryStr)
res := []*StashBoxPerformerQueryResult{
{
Query: queryStr,
Results: performers,
},
}
// QueryPerformer queries stash-box for performers using a query string.
func (c Client) QueryPerformer(ctx context.Context, queryStr string) ([]*models.ScrapedPerformer, error) {
performers, err := c.queryPerformer(ctx, queryStr)
// set the deprecated image field
for _, p := range res[0].Results {
for _, p := range performers {
if len(p.Images) > 0 {
p.Image = &p.Images[0]
}
}
return res, err
return performers, err
}
func (c Client) queryStashBoxPerformer(ctx context.Context, queryStr string) ([]*models.ScrapedPerformer, error) {
func (c Client) queryPerformer(ctx context.Context, queryStr string) ([]*models.ScrapedPerformer, error) {
performers, err := c.client.SearchPerformer(ctx, queryStr)
if err != nil {
return nil, err
@@ -67,101 +59,18 @@ func (c Client) queryStashBoxPerformer(ctx context.Context, queryStr string) ([]
return ret, nil
}
// FindStashBoxPerformersByNames queries stash-box for performers by name
func (c Client) FindStashBoxPerformersByNames(ctx context.Context, performerIDs []string) ([]*StashBoxPerformerQueryResult, error) {
ids, err := stringslice.StringSliceToIntSlice(performerIDs)
if err != nil {
return nil, err
}
var performers []*models.Performer
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
qb := r.Performer
for _, performerID := range ids {
performer, err := qb.Find(ctx, performerID)
if err != nil {
return err
}
if performer == nil {
return fmt.Errorf("performer with id %d not found", performerID)
}
if performer.Name != "" {
performers = append(performers, performer)
}
// QueryPerformers queries stash-box for performers using a list of names.
func (c Client) QueryPerformers(ctx context.Context, names []string) ([][]*models.ScrapedPerformer, error) {
ret := make([][]*models.ScrapedPerformer, len(names))
for i, name := range names {
if name != "" {
continue
}
return nil
}); err != nil {
return nil, err
}
return c.findStashBoxPerformersByNames(ctx, performers)
}
func (c Client) FindStashBoxPerformersByPerformerNames(ctx context.Context, performerIDs []string) ([][]*models.ScrapedPerformer, error) {
ids, err := stringslice.StringSliceToIntSlice(performerIDs)
if err != nil {
return nil, err
}
var performers []*models.Performer
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
qb := r.Performer
for _, performerID := range ids {
performer, err := qb.Find(ctx, performerID)
if err != nil {
return err
}
if performer == nil {
return fmt.Errorf("performer with id %d not found", performerID)
}
if performer.Name != "" {
performers = append(performers, performer)
}
}
return nil
}); err != nil {
return nil, err
}
results, err := c.findStashBoxPerformersByNames(ctx, performers)
if err != nil {
return nil, err
}
var ret [][]*models.ScrapedPerformer
for _, r := range results {
ret = append(ret, r.Results)
}
return ret, nil
}
func (c Client) findStashBoxPerformersByNames(ctx context.Context, performers []*models.Performer) ([]*StashBoxPerformerQueryResult, error) {
var ret []*StashBoxPerformerQueryResult
for _, performer := range performers {
if performer.Name != "" {
performerResults, err := c.queryStashBoxPerformer(ctx, performer.Name)
if err != nil {
return nil, err
}
result := StashBoxPerformerQueryResult{
Query: strconv.Itoa(performer.ID),
Results: performerResults,
}
ret = append(ret, &result)
var err error
ret[i], err = c.queryPerformer(ctx, name)
if err != nil {
return nil, err
}
}
@@ -388,7 +297,8 @@ func padFuzzyDate(date *string) *string {
return &paddedDate
}
func (c Client) FindStashBoxPerformerByID(ctx context.Context, id string) (*models.ScrapedPerformer, error) {
// FindPerformerByID queries stash-box for a performer by ID.
func (c Client) FindPerformerByID(ctx context.Context, id string) (*models.ScrapedPerformer, error) {
performer, err := c.client.FindPerformerByID(ctx, id)
if err != nil {
return nil, err
@@ -400,18 +310,12 @@ func (c Client) FindStashBoxPerformerByID(ctx context.Context, id string) (*mode
ret := performerFragmentToScrapedPerformer(*performer.FindPerformer)
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
err := match.ScrapedPerformer(ctx, r.Performer, ret, &c.box.Endpoint)
return err
}); err != nil {
return nil, err
}
return ret, nil
}
func (c Client) FindStashBoxPerformerByName(ctx context.Context, name string) (*models.ScrapedPerformer, error) {
// FindPerformerByName queries stash-box for a performer by name.
// Unlike QueryPerformer, this function will only return a performer if the name matches exactly.
func (c Client) FindPerformerByName(ctx context.Context, name string) (*models.ScrapedPerformer, error) {
performers, err := c.client.SearchPerformer(ctx, name)
if err != nil {
return nil, err
@@ -424,41 +328,17 @@ func (c Client) FindStashBoxPerformerByName(ctx context.Context, name string) (*
}
}
if ret == nil {
return nil, nil
}
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
err := match.ScrapedPerformer(ctx, r.Performer, ret, &c.box.Endpoint)
return err
}); err != nil {
return nil, err
}
return ret, nil
}
func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Performer) (*string, error) {
// SubmitPerformerDraft submits a performer draft to stash-box.
// The performer parameter must have aliases, URLs and stash IDs loaded.
func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Performer, img []byte) (*string, error) {
draft := graphql.PerformerDraftInput{}
var image io.Reader
pqb := c.repository.Performer
endpoint := c.box.Endpoint
if err := performer.LoadAliases(ctx, pqb); err != nil {
return nil, err
}
if err := performer.LoadURLs(ctx, pqb); err != nil {
return nil, err
}
if err := performer.LoadStashIDs(ctx, pqb); err != nil {
return nil, err
}
img, _ := pqb.GetImage(ctx, performer.ID)
if img != nil {
if len(img) > 0 {
image = bytes.NewReader(img)
}
@@ -524,12 +404,8 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf
draft.Urls = performer.URLs.List()
}
stashIDs, err := pqb.GetStashIDs(ctx, performer.ID)
if err != nil {
return nil, err
}
var stashID *string
for _, v := range stashIDs {
for _, v := range performer.StashIDs.List() {
c := v
if v.Endpoint == endpoint {
stashID = &c.StashID
@@ -540,7 +416,7 @@ func (c Client) SubmitPerformerDraft(ctx context.Context, performer *models.Perf
var id *string
var ret graphql.SubmitPerformerDraft
err = c.submitDraft(ctx, graphql.SubmitPerformerDraftDocument, draft, image, &ret)
err := c.submitDraft(ctx, graphql.SubmitPerformerDraftDocument, draft, image, &ret)
id = ret.SubmitPerformerDraft.ID
return id, err

468
pkg/stashbox/scene.go Normal file
View File

@@ -0,0 +1,468 @@
package stashbox
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"strings"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper"
"github.com/stashapp/stash/pkg/sliceutil"
"github.com/stashapp/stash/pkg/stashbox/graphql"
"github.com/stashapp/stash/pkg/utils"
)
// QueryScene queries stash-box for scenes using a query string.
func (c Client) QueryScene(ctx context.Context, queryStr string) ([]*models.ScrapedScene, error) {
scenes, err := c.client.SearchScene(ctx, queryStr)
if err != nil {
return nil, err
}
sceneFragments := scenes.SearchScene
var ret []*models.ScrapedScene
var ignoredTags []string
for _, s := range sceneFragments {
ss, err := c.sceneFragmentToScrapedScene(ctx, s)
if err != nil {
return nil, err
}
var thisIgnoredTags []string
ss.Tags, thisIgnoredTags = scraper.FilterTags(c.excludeTagRE, ss.Tags)
ignoredTags = sliceutil.AppendUniques(ignoredTags, thisIgnoredTags)
ret = append(ret, ss)
}
scraper.LogIgnoredTags(ignoredTags)
return ret, nil
}
// FindStashBoxScenesByFingerprints queries stash-box for a scene using the
// scene's MD5/OSHASH checksum, or PHash.
func (c Client) FindSceneByFingerprints(ctx context.Context, fps models.Fingerprints) ([]*models.ScrapedScene, error) {
res, err := c.FindScenesByFingerprints(ctx, []models.Fingerprints{fps})
if len(res) > 0 {
return res[0], err
}
return nil, err
}
// FindScenesByFingerprints queries stash-box for scenes using every
// scene's MD5/OSHASH checksum, or PHash, and returns results in the same order
// as the input slice.
func (c Client) FindScenesByFingerprints(ctx context.Context, fps []models.Fingerprints) ([][]*models.ScrapedScene, error) {
var fingerprints [][]*graphql.FingerprintQueryInput
for _, fp := range fps {
fingerprints = append(fingerprints, convertFingerprints(fp))
}
return c.findScenesByFingerprints(ctx, fingerprints)
}
func convertFingerprints(fps models.Fingerprints) []*graphql.FingerprintQueryInput {
var ret []*graphql.FingerprintQueryInput
for _, f := range fps {
var i = &graphql.FingerprintQueryInput{}
switch f.Type {
case models.FingerprintTypeMD5:
i.Algorithm = graphql.FingerprintAlgorithmMd5
i.Hash = f.String()
case models.FingerprintTypeOshash:
i.Algorithm = graphql.FingerprintAlgorithmOshash
i.Hash = f.String()
case models.FingerprintTypePhash:
i.Algorithm = graphql.FingerprintAlgorithmPhash
i.Hash = utils.PhashToString(f.Int64())
default:
continue
}
if !i.Algorithm.IsValid() {
continue
}
ret = append(ret, i)
}
return ret
}
func (c Client) findScenesByFingerprints(ctx context.Context, scenes [][]*graphql.FingerprintQueryInput) ([][]*models.ScrapedScene, error) {
var results [][]*models.ScrapedScene
// filter out nils
var validScenes [][]*graphql.FingerprintQueryInput
for _, s := range scenes {
if len(s) > 0 {
validScenes = append(validScenes, s)
}
}
var ignoredTags []string
for i := 0; i < len(validScenes); i += 40 {
end := i + 40
if end > len(validScenes) {
end = len(validScenes)
}
scenes, err := c.client.FindScenesBySceneFingerprints(ctx, validScenes[i:end])
if err != nil {
return nil, err
}
for _, sceneFragments := range scenes.FindScenesBySceneFingerprints {
var sceneResults []*models.ScrapedScene
for _, scene := range sceneFragments {
ss, err := c.sceneFragmentToScrapedScene(ctx, scene)
if err != nil {
return nil, err
}
var thisIgnoredTags []string
ss.Tags, thisIgnoredTags = scraper.FilterTags(c.excludeTagRE, ss.Tags)
ignoredTags = sliceutil.AppendUniques(ignoredTags, thisIgnoredTags)
sceneResults = append(sceneResults, ss)
}
results = append(results, sceneResults)
}
}
scraper.LogIgnoredTags(ignoredTags)
// repopulate the results to be the same order as the input
ret := make([][]*models.ScrapedScene, len(scenes))
upTo := 0
for i, v := range scenes {
if len(v) > 0 {
ret[i] = results[upTo]
upTo++
}
}
return ret, nil
}
func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.SceneFragment) (*models.ScrapedScene, error) {
stashID := s.ID
ss := &models.ScrapedScene{
Title: s.Title,
Code: s.Code,
Date: s.Date,
Details: s.Details,
Director: s.Director,
URL: findURL(s.Urls, "STUDIO"),
Duration: s.Duration,
RemoteSiteID: &stashID,
Fingerprints: getFingerprints(s),
// Image
// stash_id
}
for _, u := range s.Urls {
ss.URLs = append(ss.URLs, u.URL)
}
if len(ss.URLs) > 0 {
ss.URL = &ss.URLs[0]
}
if len(s.Images) > 0 {
// TODO - #454 code sorts images by aspect ratio according to a wanted
// orientation. I'm just grabbing the first for now
ss.Image = getFirstImage(ctx, c.getHTTPClient(), s.Images)
}
ss.URLs = make([]string, len(s.Urls))
for i, u := range s.Urls {
ss.URLs[i] = u.URL
}
if s.Studio != nil {
var err error
ss.Studio, err = c.resolveStudio(ctx, s.Studio)
if err != nil {
return nil, err
}
}
for _, p := range s.Performers {
sp := performerFragmentToScrapedPerformer(*p.Performer)
ss.Performers = append(ss.Performers, sp)
}
for _, t := range s.Tags {
st := &models.ScrapedTag{
Name: t.Name,
}
ss.Tags = append(ss.Tags, st)
}
return ss, nil
}
func getFirstImage(ctx context.Context, client *http.Client, images []*graphql.ImageFragment) *string {
ret, err := fetchImage(ctx, client, images[0].URL)
if err != nil && !errors.Is(err, context.Canceled) {
logger.Warnf("Error fetching image %s: %s", images[0].URL, err.Error())
}
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
}
type SceneDraft struct {
// Files, URLs, StashIDs must be loaded
Scene *models.Scene
// StashIDs must be loaded
Performers []*models.Performer
// StashIDs must be loaded
Studio *models.Studio
Tags []*models.Tag
Cover []byte
}
func (c Client) SubmitSceneDraft(ctx context.Context, d SceneDraft) (*string, error) {
draft := newSceneDraftInput(d, c.box.Endpoint)
var image io.Reader
if len(d.Cover) > 0 {
image = bytes.NewReader(d.Cover)
}
var id *string
var ret graphql.SubmitSceneDraft
err := c.submitDraft(ctx, graphql.SubmitSceneDraftDocument, draft, image, &ret)
id = ret.SubmitSceneDraft.ID
return id, err
// ret, err := c.client.SubmitSceneDraft(ctx, draft, uploadImage(image))
// if err != nil {
// return nil, err
// }
// id := ret.SubmitSceneDraft.ID
// return id, nil
}
func newSceneDraftInput(d SceneDraft, endpoint string) graphql.SceneDraftInput {
scene := d.Scene
draft := graphql.SceneDraftInput{}
if scene.Title != "" {
draft.Title = &scene.Title
}
if scene.Code != "" {
draft.Code = &scene.Code
}
if scene.Details != "" {
draft.Details = &scene.Details
}
if scene.Director != "" {
draft.Director = &scene.Director
}
// TODO - draft does not accept multiple URLs. Use single URL for now.
if len(scene.URLs.List()) > 0 {
url := strings.TrimSpace(scene.URLs.List()[0])
draft.URL = &url
}
if scene.Date != nil {
v := scene.Date.String()
draft.Date = &v
}
if d.Studio != nil {
studio := d.Studio
studioDraft := graphql.DraftEntityInput{
Name: studio.Name,
}
stashIDs := studio.StashIDs.List()
for _, stashID := range stashIDs {
c := stashID
if stashID.Endpoint == endpoint {
studioDraft.ID = &c.StashID
break
}
}
draft.Studio = &studioDraft
}
fingerprints := []*graphql.FingerprintInput{}
for _, f := range scene.Files.List() {
duration := f.Duration
if duration != 0 {
fingerprints = appendFingerprintsUnique(fingerprints, fileFingerprintsToInputGraphQL(f.Fingerprints, int(duration))...)
}
}
draft.Fingerprints = fingerprints
scenePerformers := d.Performers
inputPerformers := []*graphql.DraftEntityInput{}
for _, p := range scenePerformers {
performerDraft := graphql.DraftEntityInput{
Name: p.Name,
}
stashIDs := p.StashIDs.List()
for _, stashID := range stashIDs {
c := stashID
if stashID.Endpoint == endpoint {
performerDraft.ID = &c.StashID
break
}
}
inputPerformers = append(inputPerformers, &performerDraft)
}
draft.Performers = inputPerformers
var tags []*graphql.DraftEntityInput
sceneTags := d.Tags
for _, tag := range sceneTags {
tags = append(tags, &graphql.DraftEntityInput{Name: tag.Name})
}
draft.Tags = tags
stashIDs := scene.StashIDs.List()
var stashID *string
for _, v := range stashIDs {
if v.Endpoint == endpoint {
vv := v.StashID
stashID = &vv
break
}
}
draft.ID = stashID
return draft
}
func fileFingerprintsToInputGraphQL(fps models.Fingerprints, duration int) []*graphql.FingerprintInput {
var ret []*graphql.FingerprintInput
for _, f := range fps {
var i = &graphql.FingerprintInput{
Duration: duration,
}
switch f.Type {
case models.FingerprintTypeMD5:
i.Algorithm = graphql.FingerprintAlgorithmMd5
i.Hash = f.String()
case models.FingerprintTypeOshash:
i.Algorithm = graphql.FingerprintAlgorithmOshash
i.Hash = f.String()
case models.FingerprintTypePhash:
i.Algorithm = graphql.FingerprintAlgorithmPhash
i.Hash = utils.PhashToString(f.Int64())
default:
continue
}
if !i.Algorithm.IsValid() {
continue
}
ret = appendFingerprintUnique(ret, i)
}
return ret
}
func (c Client) SubmitFingerprints(ctx context.Context, scenes []*models.Scene) (bool, error) {
endpoint := c.box.Endpoint
var fingerprints []graphql.FingerprintSubmission
for _, scene := range scenes {
stashIDs := scene.StashIDs.List()
sceneStashID := ""
for _, stashID := range stashIDs {
if stashID.Endpoint == endpoint {
sceneStashID = stashID.StashID
}
}
if sceneStashID == "" {
continue
}
for _, f := range scene.Files.List() {
duration := f.Duration
if duration == 0 {
continue
}
fps := fileFingerprintsToInputGraphQL(f.Fingerprints, int(duration))
for _, fp := range fps {
fingerprints = append(fingerprints, graphql.FingerprintSubmission{
SceneID: sceneStashID,
Fingerprint: fp,
})
}
}
}
return c.submitFingerprints(ctx, fingerprints)
}
func (c Client) submitFingerprints(ctx context.Context, fingerprints []graphql.FingerprintSubmission) (bool, error) {
for _, fingerprint := range fingerprints {
_, err := c.client.SubmitFingerprint(ctx, fingerprint)
if err != nil {
return false, err
}
}
return true, nil
}
func appendFingerprintUnique(v []*graphql.FingerprintInput, toAdd *graphql.FingerprintInput) []*graphql.FingerprintInput {
for _, vv := range v {
if vv.Algorithm == toAdd.Algorithm && vv.Hash == toAdd.Hash {
return v
}
}
return append(v, toAdd)
}
func appendFingerprintsUnique(v []*graphql.FingerprintInput, toAdd ...*graphql.FingerprintInput) []*graphql.FingerprintInput {
for _, a := range toAdd {
v = appendFingerprintUnique(v, a)
}
return v
}

View File

@@ -4,12 +4,33 @@ import (
"context"
"github.com/google/uuid"
"github.com/stashapp/stash/pkg/match"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scraper/stashbox/graphql"
"github.com/stashapp/stash/pkg/stashbox/graphql"
)
func (c Client) FindStashBoxStudio(ctx context.Context, query string) (*models.ScrapedStudio, error) {
func (c Client) resolveStudio(ctx context.Context, s *graphql.StudioFragment) (*models.ScrapedStudio, error) {
scraped := studioFragmentToScrapedStudio(*s)
if s.Parent != nil {
parentStudio, err := c.client.FindStudio(ctx, &s.Parent.ID, nil)
if err != nil {
return nil, err
}
if parentStudio.FindStudio == nil {
return scraped, nil
}
scraped.Parent, err = c.resolveStudio(ctx, parentStudio.FindStudio)
if err != nil {
return nil, err
}
}
return scraped, nil
}
func (c Client) FindStudio(ctx context.Context, query string) (*models.ScrapedStudio, error) {
var studio *graphql.FindStudio
_, err := uuid.Parse(query)
@@ -27,32 +48,8 @@ func (c Client) FindStashBoxStudio(ctx context.Context, query string) (*models.S
var ret *models.ScrapedStudio
if studio.FindStudio != nil {
r := c.repository
if err := r.WithReadTxn(ctx, func(ctx context.Context) error {
ret = studioFragmentToScrapedStudio(*studio.FindStudio)
err = match.ScrapedStudio(ctx, r.Studio, ret, &c.box.Endpoint)
if err != nil {
return err
}
if studio.FindStudio.Parent != nil {
parentStudio, err := c.client.FindStudio(ctx, &studio.FindStudio.Parent.ID, nil)
if err != nil {
return err
}
if parentStudio.FindStudio != nil {
ret.Parent = studioFragmentToScrapedStudio(*parentStudio.FindStudio)
err = match.ScrapedStudio(ctx, r.Studio, ret.Parent, &c.box.Endpoint)
if err != nil {
return err
}
}
}
return nil
}); err != nil {
ret, err = c.resolveStudio(ctx, studio.FindStudio)
if err != nil {
return nil, err
}
}

View File

@@ -0,0 +1,90 @@
//go:build ignore
// +build ignore
package main
import (
"encoding/json"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/utils"
)
func main() {
verbose := len(os.Args) > 1 && os.Args[1] == "-v"
fmt.Printf("Generating login locales\n")
// read all json files in the locales directory
// and extract only the login part
// assume running from ui directory
dirFS := os.DirFS(filepath.Join("v2.5", "src", "locales"))
// ensure the login/locales directory exists
if err := fsutil.EnsureDir(filepath.Join("login", "locales")); err != nil {
panic(err)
}
fs.WalkDir(dirFS, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
panic(err)
}
if d.IsDir() {
return nil
}
if filepath.Ext(path) != ".json" {
return nil
}
// extract the login part
// from the json file
src, err := dirFS.Open(path)
if err != nil {
panic(err)
}
defer src.Close()
data, err := io.ReadAll(src)
if err != nil {
panic(err)
}
m := make(utils.NestedMap)
if err := json.Unmarshal(data, &m); err != nil {
panic(err)
}
l, found := m.Get("login")
if !found {
// nothing to do
return nil
}
// create new json file
// with only the login part
if verbose {
fmt.Printf("Writing %s\n", d.Name())
}
f, err := os.Create(filepath.Join("login", "locales", d.Name()))
if err != nil {
panic(err)
}
defer f.Close()
e := json.NewEncoder(f)
if err := e.Encode(l); err != nil {
panic(err)
}
return nil
})
}

View File

@@ -9,32 +9,77 @@
<link rel="shortcut icon" href="data:,">
<link rel="stylesheet" href="login/login.css">
<link rel="stylesheet" href="css">
</head>
<body class="login">
<!-- load locale -->
<script>
var localeStrings = {
username: "Username",
password: "Password",
login: "Login",
invalid_credentials: "Invalid credentials",
internal_error: "Unexpected internal error. See logs for more details"
};
</script>
<script src="login/locale"></script>
</head>
<script>
function login() {
var username = document.getElementById("username").value;
var password = document.getElementById("password").value;
var returnURL = document.getElementById("returnURL").value;
var xhr = new XMLHttpRequest();
xhr.open("POST", "login", true);
xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
xhr.onreadystatechange = function() {
if (xhr.readyState == 4) {
if (xhr.status == 200) {
window.location.replace(returnURL);
} else {
document.getElementsByClassName("login-error")[0].innerHTML = localeStrings.invalid_credentials;
}
}
};
xhr.onerror = function() {
document.getElementsByClassName("login-error")[0].innerHTML = localeStrings.internal_error;
};
xhr.send("username=" + username + "&password=" + password + "&returnURL=" + returnURL);
}
</script>
<body class="login">
<div class="dialog">
<div class="card">
<form action="login" method="POST">
<form action="login" method="POST" onsubmit="event.preventDefault(); login();">
<div class="form-group">
<label for="username"><h6>Username</h6></label>
<label for="username"><h6 id="username-heading">Username</h6></label>
<input class="text-input form-control" id="username" name="username" type="text" placeholder="Username" />
</div>
<div class="form-group">
<label for="password"><h6>Password</h6></label>
<label for="password"><h6 id="password-heading">Password</h6></label>
<input class="text-input form-control" id="password" name="password" type="password" placeholder="Password" />
</div>
<div class="login-error">
{{.Error}}
</div>
<input type="hidden" name="returnURL" value="{{.URL}}" />
<input type="hidden" id="returnURL" name="returnURL" value="{{.URL}}" />
<div>
<input class="btn btn-primary" type="submit" value="Login">
<input id="login-button" class="btn btn-primary" type="submit" value="Login">
</div>
</form>
</div>
</div>
</body>
<script>
document.getElementById("username-heading").innerText = localeStrings.username;
document.getElementById("password-heading").innerText = localeStrings.password;
document.getElementById("username").placeholder = localeStrings.username;
document.getElementById("password").placeholder = localeStrings.password;
document.getElementById("login-button").value = localeStrings.login;
</script>
</html>

View File

@@ -1,3 +1,4 @@
//go:generate go run -tags=dev ../scripts/generateLoginLocales.go
package ui
import (

View File

@@ -49,6 +49,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
name
endpoint
api_key
max_requests_per_minute
}
pythonPath
transcodeInputArgs

View File

@@ -1,7 +1,7 @@
import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap";
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import * as GQL from "src/core/generated-graphql";
import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard";
import { GridCard } from "../Shared/GridCard/GridCard";
import { HoverPopover } from "../Shared/HoverPopover";
import { Icon } from "../Shared/Icon";
import { SceneLink, TagLink } from "../Shared/TagLink";
@@ -12,7 +12,6 @@ import NavUtils from "src/utils/navigation";
import { RatingBanner } from "../Shared/RatingBanner";
import { faBox, faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons";
import { galleryTitle } from "src/core/galleries";
import ScreenUtils from "src/utils/screen";
import { StudioOverlay } from "../Shared/GridCard/StudioOverlay";
import { GalleryPreviewScrubber } from "./GalleryPreviewScrubber";
import cx from "classnames";
@@ -56,7 +55,7 @@ export const GalleryPreview: React.FC<IGalleryPreviewProps> = ({
interface IProps {
gallery: GQL.SlimGalleryDataFragment;
containerWidth?: number;
cardWidth?: number;
selecting?: boolean;
selected?: boolean | undefined;
zoomIndex?: number;
@@ -65,37 +64,6 @@ interface IProps {
export const GalleryCard: React.FC<IProps> = (props) => {
const history = useHistory();
const [cardWidth, setCardWidth] = useState<number>();
useEffect(() => {
if (
!props.containerWidth ||
props.zoomIndex === undefined ||
ScreenUtils.isMobile()
)
return;
let zoomValue = props.zoomIndex;
let preferredCardWidth: number;
switch (zoomValue) {
case 0:
preferredCardWidth = 280;
break;
case 1:
preferredCardWidth = 340;
break;
case 2:
preferredCardWidth = 480;
break;
case 3:
preferredCardWidth = 640;
}
let fittedCardWidth = calculateCardWidth(
props.containerWidth,
preferredCardWidth!
);
setCardWidth(fittedCardWidth);
}, [props.containerWidth, props.zoomIndex]);
function maybeRenderScenePopoverButton() {
if (props.gallery.scenes.length === 0) return;
@@ -207,7 +175,7 @@ export const GalleryCard: React.FC<IProps> = (props) => {
<GridCard
className={`gallery-card zoom-${props.zoomIndex}`}
url={`/galleries/${props.gallery.id}`}
width={cardWidth}
width={props.cardWidth}
title={galleryTitle(props.gallery)}
linkClassName="gallery-card-header"
image={

View File

@@ -1,7 +1,10 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { GalleryCard } from "./GalleryCard";
import { useContainerDimensions } from "../Shared/GridCard/GridCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
interface IGalleryCardGrid {
galleries: GQL.SlimGalleryDataFragment[];
@@ -10,19 +13,23 @@ interface IGalleryCardGrid {
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
const zoomWidths = [280, 340, 480, 640];
export const GalleryCardGrid: React.FC<IGalleryCardGrid> = ({
galleries,
selectedIds,
zoomIndex,
onSelectChange,
}) => {
const [componentRef, { width }] = useContainerDimensions();
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{galleries.map((gallery) => (
<GalleryCard
key={gallery.id}
containerWidth={width}
cardWidth={cardWidth}
gallery={gallery}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useMemo } from "react";
import { Button, ButtonGroup } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard";
import { GridCard } from "../Shared/GridCard/GridCard";
import { HoverPopover } from "../Shared/HoverPopover";
import { Icon } from "../Shared/Icon";
import { SceneLink, TagLink } from "../Shared/TagLink";
@@ -9,7 +9,6 @@ import { TruncatedText } from "../Shared/TruncatedText";
import { FormattedMessage } from "react-intl";
import { RatingBanner } from "../Shared/RatingBanner";
import { faPlayCircle, faTag } from "@fortawesome/free-solid-svg-icons";
import ScreenUtils from "src/utils/screen";
import { RelatedGroupPopoverButton } from "./RelatedGroupPopover";
const Description: React.FC<{
@@ -37,7 +36,7 @@ const Description: React.FC<{
interface IProps {
group: GQL.GroupDataFragment;
containerWidth?: number;
cardWidth?: number;
sceneNumber?: number;
selecting?: boolean;
selected?: boolean;
@@ -50,7 +49,7 @@ interface IProps {
export const GroupCard: React.FC<IProps> = ({
group,
sceneNumber,
containerWidth,
cardWidth,
selecting,
selected,
zoomIndex,
@@ -58,8 +57,6 @@ export const GroupCard: React.FC<IProps> = ({
fromGroupId,
onMove,
}) => {
const [cardWidth, setCardWidth] = useState<number>();
const groupDescription = useMemo(() => {
if (!fromGroupId) {
return undefined;
@@ -72,32 +69,6 @@ export const GroupCard: React.FC<IProps> = ({
return containingGroup?.description ?? undefined;
}, [fromGroupId, group.containing_groups]);
useEffect(() => {
if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile())
return;
let zoomValue = zoomIndex;
let preferredCardWidth: number;
switch (zoomValue) {
case 0:
preferredCardWidth = 210;
break;
case 1:
preferredCardWidth = 250;
break;
case 2:
preferredCardWidth = 300;
break;
case 3:
preferredCardWidth = 375;
}
let fittedCardWidth = calculateCardWidth(
containerWidth,
preferredCardWidth!
);
setCardWidth(fittedCardWidth);
}, [containerWidth, zoomIndex]);
function maybeRenderScenesPopoverButton() {
if (group.scenes.length === 0) return;

View File

@@ -1,7 +1,10 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { GroupCard } from "./GroupCard";
import { useContainerDimensions } from "../Shared/GridCard/GridCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
interface IGroupCardGrid {
groups: GQL.GroupDataFragment[];
@@ -12,6 +15,8 @@ interface IGroupCardGrid {
onMove?: (srcIds: string[], targetId: string, after: boolean) => void;
}
const zoomWidths = [210, 250, 300, 375];
export const GroupCardGrid: React.FC<IGroupCardGrid> = ({
groups,
selectedIds,
@@ -20,13 +25,15 @@ export const GroupCardGrid: React.FC<IGroupCardGrid> = ({
fromGroupId,
onMove,
}) => {
const [componentRef, { width }] = useContainerDimensions();
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{groups.map((p) => (
<GroupCard
key={p.id}
containerWidth={width}
cardWidth={cardWidth}
group={p}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}

View File

@@ -5,7 +5,7 @@ import {
GroupsCriterionOption,
} from "src/models/list-filter/criteria/groups";
import { ListFilterModel } from "src/models/list-filter/filter";
import { SceneList } from "src/components/Scenes/SceneList";
import { FilteredSceneList } from "src/components/Scenes/SceneList";
import { View } from "src/components/List/views";
interface IGroupScenesPanel {
@@ -64,7 +64,7 @@ export const GroupScenesPanel: React.FC<IGroupScenesPanel> = ({
if (group && group.id) {
return (
<SceneList
<FilteredSceneList
filterHook={filterHook}
defaultSort="group_scene_number"
alterQuery={active}

View File

@@ -1,4 +1,4 @@
import React, { MouseEvent, useEffect, useMemo, useState } from "react";
import React, { MouseEvent, useMemo } from "react";
import { Button, ButtonGroup } from "react-bootstrap";
import cx from "classnames";
import * as GQL from "src/core/generated-graphql";
@@ -7,10 +7,7 @@ import { GalleryLink, TagLink } from "src/components/Shared/TagLink";
import { HoverPopover } from "src/components/Shared/HoverPopover";
import { SweatDrops } from "src/components/Shared/SweatDrops";
import { PerformerPopoverButton } from "src/components/Shared/PerformerPopoverButton";
import {
GridCard,
calculateCardWidth,
} from "src/components/Shared/GridCard/GridCard";
import { GridCard } from "src/components/Shared/GridCard/GridCard";
import { RatingBanner } from "src/components/Shared/RatingBanner";
import {
faBox,
@@ -20,12 +17,11 @@ import {
} from "@fortawesome/free-solid-svg-icons";
import { imageTitle } from "src/core/files";
import { TruncatedText } from "../Shared/TruncatedText";
import ScreenUtils from "src/utils/screen";
import { StudioOverlay } from "../Shared/GridCard/StudioOverlay";
interface IImageCardProps {
image: GQL.SlimImageDataFragment;
containerWidth?: number;
cardWidth?: number;
selecting?: boolean;
selected?: boolean | undefined;
zoomIndex: number;
@@ -36,38 +32,6 @@ interface IImageCardProps {
export const ImageCard: React.FC<IImageCardProps> = (
props: IImageCardProps
) => {
const [cardWidth, setCardWidth] = useState<number>();
useEffect(() => {
if (
!props.containerWidth ||
props.zoomIndex === undefined ||
ScreenUtils.isMobile()
)
return;
let zoomValue = props.zoomIndex;
let preferredCardWidth: number;
switch (zoomValue) {
case 0:
preferredCardWidth = 280;
break;
case 1:
preferredCardWidth = 340;
break;
case 2:
preferredCardWidth = 480;
break;
case 3:
preferredCardWidth = 640;
}
let fittedCardWidth = calculateCardWidth(
props.containerWidth,
preferredCardWidth!
);
setCardWidth(fittedCardWidth);
}, [props.containerWidth, props.zoomIndex]);
const file = useMemo(
() =>
props.image.visual_files.length > 0
@@ -196,7 +160,7 @@ export const ImageCard: React.FC<IImageCardProps> = (
<GridCard
className={`image-card zoom-${props.zoomIndex}`}
url={`/images/${props.image.id}`}
width={cardWidth}
width={props.cardWidth}
title={imageTitle(props.image)}
linkClassName="image-card-link"
image={

View File

@@ -1,7 +1,10 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { ImageCard } from "./ImageCard";
import { useContainerDimensions } from "../Shared/GridCard/GridCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
interface IImageCardGrid {
images: GQL.SlimImageDataFragment[];
@@ -11,6 +14,8 @@ interface IImageCardGrid {
onPreview: (index: number, ev: React.MouseEvent<Element, MouseEvent>) => void;
}
const zoomWidths = [280, 340, 480, 640];
export const ImageGridCard: React.FC<IImageCardGrid> = ({
images,
selectedIds,
@@ -18,13 +23,15 @@ export const ImageGridCard: React.FC<IImageCardGrid> = ({
onSelectChange,
onPreview,
}) => {
const [componentRef, { width }] = useContainerDimensions();
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{images.map((image, index) => (
<ImageCard
key={image.id}
containerWidth={width}
cardWidth={cardWidth}
image={image}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}

View File

@@ -488,3 +488,33 @@ export const EditFilterDialog: React.FC<IEditFilterProps> = ({
</>
);
};
export function useShowEditFilter(props: {
filter: ListFilterModel;
setFilter: (f: ListFilterModel) => void;
showModal: (content: React.ReactNode) => void;
closeModal: () => void;
}) {
const { filter, setFilter, showModal, closeModal } = props;
const showEditFilter = useCallback(
(editingCriterion?: string) => {
function onApplyEditFilter(f: ListFilterModel) {
closeModal();
setFilter(f);
}
showModal(
<EditFilterDialog
filter={filter}
onApply={onApplyEditFilter}
onCancel={() => closeModal()}
editingCriterion={editingCriterion}
/>
);
},
[filter, setFilter, showModal, closeModal]
);
return showEditFilter;
}

View File

@@ -8,11 +8,9 @@ import {
IListFilterOperation,
ListOperationButtons,
} from "./ListOperationButtons";
import { DisplayMode } from "src/models/list-filter/types";
import { ButtonToolbar } from "react-bootstrap";
import { View } from "./views";
import { useListContext } from "./ListProvider";
import { useFilter } from "./FilterProvider";
import { IListSelect, useFilterOperations } from "./util";
export interface IItemListOperation<T extends QueryResult> {
text: string;
@@ -32,8 +30,13 @@ export interface IItemListOperation<T extends QueryResult> {
}
export interface IFilteredListToolbar {
showEditFilter?: (editingCriterion?: string) => void;
filter: ListFilterModel;
setFilter: (
value: ListFilterModel | ((prevState: ListFilterModel) => ListFilterModel)
) => void;
showEditFilter: () => void;
view?: View;
listSelect: IListSelect;
onEdit?: () => void;
onDelete?: () => void;
operations?: IListFilterOperation[];
@@ -41,25 +44,22 @@ export interface IFilteredListToolbar {
}
export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
filter,
setFilter,
showEditFilter,
view,
listSelect,
onEdit,
onDelete,
operations,
zoomable = false,
}) => {
const { getSelected, onSelectAll, onSelectNone } = useListContext();
const { filter, setFilter } = useFilter();
const filterOptions = filter.options;
function onChangeDisplayMode(displayMode: DisplayMode) {
setFilter(filter.setDisplayMode(displayMode));
}
function onChangeZoom(newZoomIndex: number) {
setFilter(filter.setZoom(newZoomIndex));
}
const { setDisplayMode, setZoom } = useFilterOperations({
filter,
setFilter,
});
const { selectedIds, onSelectAll, onSelectNone } = listSelect;
return (
<ButtonToolbar className="filtered-list-toolbar">
@@ -75,16 +75,16 @@ export const FilteredListToolbar: React.FC<IFilteredListToolbar> = ({
onSelectAll={onSelectAll}
onSelectNone={onSelectNone}
otherOperations={operations}
itemsSelected={getSelected().length > 0}
itemsSelected={selectedIds.size > 0}
onEdit={onEdit}
onDelete={onDelete}
/>
<ListViewOptions
displayMode={filter.displayMode}
displayModeOptions={filterOptions.displayModeOptions}
onSetDisplayMode={onChangeDisplayMode}
onSetDisplayMode={setDisplayMode}
zoomIndex={zoomable ? filter.zoomIndex : undefined}
onSetZoom={zoomable ? onChangeZoom : undefined}
onSetZoom={zoomable ? setZoom : undefined}
/>
</ButtonToolbar>
);

View File

@@ -10,7 +10,10 @@ import * as GQL from "src/core/generated-graphql";
import { QueryResult } from "@apollo/client";
import { Criterion } from "src/models/list-filter/criteria/criterion";
import { ListFilterModel } from "src/models/list-filter/filter";
import { EditFilterDialog } from "src/components/List/EditFilterDialog";
import {
EditFilterDialog,
useShowEditFilter,
} from "src/components/List/EditFilterDialog";
import { FilterTags } from "./FilterTags";
import { View } from "./views";
import { IHasID } from "src/utils/data";
@@ -23,9 +26,15 @@ import {
import { FilterContext, SetFilterURL, useFilter } from "./FilterProvider";
import { useModal } from "src/hooks/modal";
import {
IFilterStateHook,
IQueryResultHook,
useDefaultFilter,
useEnsureValidPage,
useFilterOperations,
useFilterState,
useListKeyboardShortcuts,
useListSelect,
useQueryResult,
useScrollToTopOnPageChange,
} from "./util";
import {
@@ -36,6 +45,72 @@ import {
import { PagedList } from "./PagedList";
import { ConfigurationContext } from "src/hooks/Config";
interface IFilteredItemList<T extends QueryResult, E extends IHasID = IHasID> {
filterStateProps: IFilterStateHook;
queryResultProps: IQueryResultHook<T, E>;
}
// Provides the common state and behaviour for filtered item list components
export function useFilteredItemList<
T extends QueryResult,
E extends IHasID = IHasID
>(props: IFilteredItemList<T, E>) {
const { configuration: config } = useContext(ConfigurationContext);
// States
const filterState = useFilterState({
config,
...props.filterStateProps,
});
const { filter, setFilter } = filterState;
const queryResult = useQueryResult({
filter,
...props.queryResultProps,
});
const { result, items, totalCount, pages } = queryResult;
const listSelect = useListSelect(items);
const { onSelectAll, onSelectNone } = listSelect;
const modalState = useModal();
const { showModal, closeModal } = modalState;
// Utility hooks
const { setPage } = useFilterOperations({ filter, setFilter });
// scroll to the top of the page when the page changes
useScrollToTopOnPageChange(filter.currentPage, result.loading);
// ensure that the current page is valid
useEnsureValidPage(filter, totalCount, setFilter);
const showEditFilter = useShowEditFilter({
showModal,
closeModal,
filter,
setFilter,
});
useListKeyboardShortcuts({
currentPage: filter.currentPage,
onChangePage: setPage,
onSelectAll,
onSelectNone,
pages,
showEditFilter,
});
return {
filterState,
queryResult,
listSelect,
modalState,
showEditFilter,
};
}
interface IItemListProps<T extends QueryResult, E extends IHasID> {
view?: View;
zoomable?: boolean;
@@ -83,13 +158,14 @@ export const ItemList = <T extends QueryResult, E extends IHasID>(
const { filter, setFilter: updateFilter } = useFilter();
const { effectiveFilter, result, cachedResult, totalCount } =
useQueryResultContext<T, E>();
const listSelect = useListContext<E>();
const {
selectedIds,
getSelected,
onSelectChange,
onSelectAll,
onSelectNone,
} = useListContext<E>();
} = listSelect;
// scroll to the top of the page when the page changes
useScrollToTopOnPageChange(filter.currentPage, result.loading);
@@ -236,7 +312,10 @@ export const ItemList = <T extends QueryResult, E extends IHasID>(
updateFilter(filter.clearCriteria());
}
const filterListToolbarProps = {
const filterListToolbarProps: IFilteredListToolbar = {
filter,
setFilter: updateFilter,
listSelect,
showEditFilter,
view: view,
operations: operations,

View File

@@ -29,22 +29,12 @@ export const ListContext = <T extends IHasID = IHasID>(
) => {
const { selectable = false, items, children } = props;
const {
selectedIds,
getSelected,
onSelectChange,
onSelectAll,
onSelectNone,
} = useListSelect(items);
const listSelect = useListSelect(items);
const state: IListContextState<T> = {
selectable,
selectedIds,
getSelected,
onSelectChange,
onSelectAll,
onSelectNone,
items,
...listSelect,
};
return (
@@ -74,6 +64,8 @@ const emptyState: IListContextState = {
onSelectAll: () => {},
onSelectNone: () => {},
items: [],
hasSelection: false,
selectedItems: [],
};
export function useListContextOptional<T extends IHasID = IHasID>() {

View File

@@ -8,6 +8,9 @@ import { IHasID } from "src/utils/data";
import { ConfigurationContext } from "src/hooks/Config";
import { View } from "./views";
import { usePrevious } from "src/hooks/state";
import * as GQL from "src/core/generated-graphql";
import { DisplayMode } from "src/models/list-filter/types";
import { Criterion } from "src/models/list-filter/criteria/criterion";
export function useFilterURL(
filter: ListFilterModel,
@@ -106,6 +109,106 @@ export function useDefaultFilter(emptyFilter: ListFilterModel, view?: View) {
return { defaultFilter: retFilter, loading };
}
function useEmptyFilter(props: {
filterMode: GQL.FilterMode;
defaultSort?: string;
config?: GQL.ConfigDataFragment;
}) {
const { filterMode, defaultSort, config } = props;
const emptyFilter = useMemo(
() =>
new ListFilterModel(filterMode, config, {
defaultSortBy: defaultSort,
}),
[config, filterMode, defaultSort]
);
return emptyFilter;
}
export interface IFilterStateHook {
filterMode: GQL.FilterMode;
defaultSort?: string;
view?: View;
useURL?: boolean;
}
export function useFilterState(
props: IFilterStateHook & {
config?: GQL.ConfigDataFragment;
}
) {
const { filterMode, defaultSort, config, view, useURL } = props;
const [filter, setFilterState] = useState<ListFilterModel>(
() =>
new ListFilterModel(filterMode, config, { defaultSortBy: defaultSort })
);
const emptyFilter = useEmptyFilter({ filterMode, defaultSort, config });
const { defaultFilter, loading } = useDefaultFilter(emptyFilter, view);
const { setFilter } = useFilterURL(filter, setFilterState, {
defaultFilter,
active: useURL,
});
return { loading, filter, setFilter };
}
export function useFilterOperations(props: {
filter: ListFilterModel;
setFilter: (
value: ListFilterModel | ((prevState: ListFilterModel) => ListFilterModel)
) => void;
}) {
const { setFilter } = props;
const setPage = useCallback(
(p: number) => {
setFilter((cv) => cv.changePage(p));
},
[setFilter]
);
const setDisplayMode = useCallback(
(displayMode: DisplayMode) => {
setFilter((cv) => cv.setDisplayMode(displayMode));
},
[setFilter]
);
const setZoom = useCallback(
(newZoomIndex: number) => {
setFilter((cv) => cv.setZoom(newZoomIndex));
},
[setFilter]
);
const removeCriterion = useCallback(
(removedCriterion: Criterion) => {
setFilter((cv) =>
cv.removeCriterion(removedCriterion.criterionOption.type)
);
},
[setFilter]
);
const clearAllCriteria = useCallback(() => {
setFilter((cv) => cv.clearCriteria());
}, [setFilter]);
return {
setPage,
setDisplayMode,
setZoom,
removeCriterion,
clearAllCriteria,
};
}
export function useListKeyboardShortcuts(props: {
currentPage?: number;
onChangePage?: (page: number) => void;
@@ -190,10 +293,11 @@ export function useListKeyboardShortcuts(props: {
}, [onSelectAll, onSelectNone]);
}
export function useListSelect<T extends { id: string }>(items: T[]) {
export function useListSelect<T extends IHasID = IHasID>(items: T[]) {
const [itemsSelected, setItemsSelected] = useState<T[]>([]);
const [lastClickedId, setLastClickedId] = useState<string>();
// TODO - this doesn't get updated when items changes
const selectedIds = useMemo(() => {
const newSelectedIds = new Set<string>();
itemsSelected.forEach((item) => {
@@ -303,18 +407,26 @@ export function useListSelect<T extends { id: string }>(items: T[]) {
setLastClickedId(undefined);
}
// TODO - this is for backwards compatibility
const getSelected = useCallback(() => itemsSelected, [itemsSelected]);
// convenience state
const hasSelection = itemsSelected.length > 0;
return {
selectedItems: itemsSelected,
selectedIds,
getSelected,
onSelectChange,
onSelectAll,
onSelectNone,
hasSelection,
};
}
export type IListSelect<T extends IHasID> = ReturnType<typeof useListSelect<T>>;
export type IListSelect<T extends IHasID = IHasID> = ReturnType<
typeof useListSelect<T>
>;
// returns true if the filter has changed in a way that impacts the total count
function totalCountImpacted(
@@ -358,6 +470,81 @@ export function useCachedQueryResult<T extends QueryResult>(
return cachedResult;
}
export interface IQueryResultHook<
T extends QueryResult,
E extends IHasID = IHasID
> {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
useResult: (filter: ListFilterModel) => T;
getCount: (data: T) => number;
getItems: (data: T) => E[];
}
export function useQueryResult<
T extends QueryResult,
E extends IHasID = IHasID
>(
props: IQueryResultHook<T, E> & {
filter: ListFilterModel;
}
) {
const { filter, filterHook, useResult, getItems, getCount } = props;
const effectiveFilter = useMemo(() => {
if (filterHook) {
return filterHook(filter.clone());
}
return filter;
}, [filter, filterHook]);
const result = useResult(effectiveFilter);
// use cached query result for pagination and metadata rendering
const cachedResult = useCachedQueryResult(effectiveFilter, result);
const items = useMemo(() => getItems(result), [getItems, result]);
const totalCount = useMemo(
() => getCount(cachedResult),
[getCount, cachedResult]
);
const pages = Math.ceil(totalCount / filter.itemsPerPage);
return {
effectiveFilter,
result,
cachedResult,
items,
totalCount,
pages,
};
}
// this hook collects the common logic when closing the edit/delete dialog
// if applied is true, then the list should be refetched and selection cleared
export function useCloseEditDelete(props: {
onSelectNone: () => void;
closeModal: () => void;
result: QueryResult;
}) {
const { onSelectNone, closeModal, result } = props;
const onCloseEditDelete = useCallback(
(applied?: boolean) => {
closeModal();
if (applied) {
onSelectNone();
// refetch
result.refetch();
}
},
[onSelectNone, closeModal, result]
);
return onCloseEditDelete;
}
export function useScrollToTopOnPageChange(
currentPage: number,
loading: boolean

View File

@@ -1,10 +1,10 @@
import React, { useEffect, useState } from "react";
import React from "react";
import { Link } from "react-router-dom";
import { useIntl } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import NavUtils from "src/utils/navigation";
import TextUtils from "src/utils/text";
import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard";
import { GridCard } from "../Shared/GridCard/GridCard";
import { CountryFlag } from "../Shared/CountryFlag";
import { SweatDrops } from "../Shared/SweatDrops";
import { HoverPopover } from "../Shared/HoverPopover";
@@ -21,7 +21,6 @@ import { faTag } from "@fortawesome/free-solid-svg-icons";
import { RatingBanner } from "../Shared/RatingBanner";
import { usePerformerUpdate } from "src/core/StashService";
import { ILabeledId } from "src/models/list-filter/types";
import ScreenUtils from "src/utils/screen";
import { FavoriteIcon } from "../Shared/FavoriteIcon";
import { PatchComponent } from "src/patch";
@@ -35,7 +34,7 @@ export interface IPerformerCardExtraCriteria {
interface IPerformerCardProps {
performer: GQL.PerformerDataFragment;
containerWidth?: number;
cardWidth?: number;
ageFromDate?: string;
selecting?: boolean;
selected?: boolean;
@@ -300,41 +299,13 @@ export const PerformerCard: React.FC<IPerformerCardProps> = PatchComponent(
(props) => {
const {
performer,
containerWidth,
cardWidth,
selecting,
selected,
onSelectedChanged,
zoomIndex,
} = props;
const [cardWidth, setCardWidth] = useState<number>();
useEffect(() => {
if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile())
return;
let zoomValue = zoomIndex;
let preferredCardWidth: number;
switch (zoomValue) {
case 0:
preferredCardWidth = 240;
break;
case 1:
preferredCardWidth = 300;
break;
case 2:
preferredCardWidth = 375;
break;
case 3:
preferredCardWidth = 470;
}
let fittedCardWidth = calculateCardWidth(
containerWidth,
preferredCardWidth!
);
setCardWidth(fittedCardWidth);
}, [containerWidth, zoomIndex]);
return (
<GridCard
className={`performer-card zoom-${zoomIndex}`}

View File

@@ -1,7 +1,10 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { IPerformerCardExtraCriteria, PerformerCard } from "./PerformerCard";
import { useContainerDimensions } from "../Shared/GridCard/GridCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
interface IPerformerCardGrid {
performers: GQL.PerformerDataFragment[];
@@ -11,6 +14,8 @@ interface IPerformerCardGrid {
extraCriteria?: IPerformerCardExtraCriteria;
}
const zoomWidths = [240, 300, 375, 470];
export const PerformerCardGrid: React.FC<IPerformerCardGrid> = ({
performers,
selectedIds,
@@ -18,13 +23,15 @@ export const PerformerCardGrid: React.FC<IPerformerCardGrid> = ({
onSelectChange,
extraCriteria,
}) => {
const [componentRef, { width }] = useContainerDimensions();
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{performers.map((p) => (
<PerformerCard
key={p.id}
containerWidth={width}
cardWidth={cardWidth}
performer={p}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}

View File

@@ -1,6 +1,6 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { SceneList } from "src/components/Scenes/SceneList";
import { FilteredSceneList } from "src/components/Scenes/SceneList";
import { usePerformerFilterHook } from "src/core/performers";
import { View } from "src/components/List/views";
import { PatchComponent } from "src/patch";
@@ -14,7 +14,7 @@ export const PerformerScenesPanel: React.FC<IPerformerDetailsProps> =
PatchComponent("PerformerScenesPanel", ({ active, performer }) => {
const filterHook = usePerformerFilterHook(performer);
return (
<SceneList
<FilteredSceneList
filterHook={filterHook}
alterQuery={active}
view={View.PerformerScenes}

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import React, { useEffect, useMemo, useRef } from "react";
import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap";
import { useHistory } from "react-router-dom";
import cx from "classnames";
@@ -13,7 +13,7 @@ import TextUtils from "src/utils/text";
import { SceneQueue } from "src/models/sceneQueue";
import { ConfigurationContext } from "src/hooks/Config";
import { PerformerPopoverButton } from "../Shared/PerformerPopoverButton";
import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard";
import { GridCard } from "../Shared/GridCard/GridCard";
import { RatingBanner } from "../Shared/RatingBanner";
import { FormattedMessage } from "react-intl";
import {
@@ -27,7 +27,6 @@ import {
import { objectPath, objectTitle } from "src/core/files";
import { PreviewScrubber } from "./PreviewScrubber";
import { PatchComponent } from "src/patch";
import ScreenUtils from "src/utils/screen";
import { StudioOverlay } from "../Shared/GridCard/StudioOverlay";
import { GroupTag } from "../Groups/GroupTag";
import { FileSize } from "../Shared/FileSize";
@@ -94,7 +93,7 @@ export const ScenePreview: React.FC<IScenePreviewProps> = ({
interface ISceneCardProps {
scene: GQL.SlimSceneDataFragment;
containerWidth?: number;
width?: number;
previewHeight?: number;
index?: number;
queue?: SceneQueue;
@@ -439,7 +438,6 @@ export const SceneCard = PatchComponent(
"SceneCard",
(props: ISceneCardProps) => {
const { configuration } = React.useContext(ConfigurationContext);
const [cardWidth, setCardWidth] = useState<number>();
const file = useMemo(
() => (props.scene.files.length > 0 ? props.scene.files[0] : undefined),
@@ -462,36 +460,6 @@ export const SceneCard = PatchComponent(
return "";
}
useEffect(() => {
if (
!props.containerWidth ||
props.zoomIndex === undefined ||
ScreenUtils.isMobile()
)
return;
let zoomValue = props.zoomIndex;
let preferredCardWidth: number;
switch (zoomValue) {
case 0:
preferredCardWidth = 280;
break;
case 1:
preferredCardWidth = 340; // this value is intentionally higher than 320
break;
case 2:
preferredCardWidth = 480;
break;
case 3:
preferredCardWidth = 640;
}
let fittedCardWidth = calculateCardWidth(
props.containerWidth,
preferredCardWidth!
);
setCardWidth(fittedCardWidth);
}, [props.containerWidth, props.zoomIndex]);
const cont = configuration?.interface.continuePlaylistDefault ?? false;
const sceneLink = props.queue
@@ -506,7 +474,7 @@ export const SceneCard = PatchComponent(
className={`scene-card ${zoomIndex()} ${filelessClass()}`}
url={sceneLink}
title={objectTitle(props.scene)}
width={cardWidth}
width={props.width}
linkClassName="scene-card-link"
thumbnailSectionClassName="video-section"
resumeTime={props.scene.resume_time ?? undefined}

View File

@@ -2,7 +2,10 @@ import React from "react";
import * as GQL from "src/core/generated-graphql";
import { SceneQueue } from "src/models/sceneQueue";
import { SceneCard } from "./SceneCard";
import { useContainerDimensions } from "../Shared/GridCard/GridCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
interface ISceneCardsGrid {
scenes: GQL.SlimSceneDataFragment[];
@@ -13,6 +16,8 @@ interface ISceneCardsGrid {
fromGroupId?: string;
}
const zoomWidths = [280, 340, 480, 640];
export const SceneCardsGrid: React.FC<ISceneCardsGrid> = ({
scenes,
queue,
@@ -21,13 +26,16 @@ export const SceneCardsGrid: React.FC<ISceneCardsGrid> = ({
onSelectChange,
fromGroupId,
}) => {
const [componentRef, { width }] = useContainerDimensions();
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{scenes.map((scene, index) => (
<SceneCard
key={scene.id}
containerWidth={width}
width={cardWidth}
scene={scene}
queue={queue}
index={index}

View File

@@ -1,11 +1,10 @@
import React, { useState } from "react";
import React, { useCallback, useContext, useEffect, useMemo } from "react";
import cloneDeep from "lodash-es/cloneDeep";
import { useIntl } from "react-intl";
import { useHistory } from "react-router-dom";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import { queryFindScenes, useFindScenes } from "src/core/StashService";
import { ItemList, ItemListContext, showWhenSelected } from "../List/ItemList";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { Tagger } from "../Tagger/scenes/SceneTagger";
@@ -26,14 +25,12 @@ import { objectTitle } from "src/core/files";
import TextUtils from "src/utils/text";
import { View } from "../List/views";
import { FileSize } from "../Shared/FileSize";
function getItems(result: GQL.FindScenesQueryResult) {
return result?.data?.findScenes?.scenes ?? [];
}
function getCount(result: GQL.FindScenesQueryResult) {
return result?.data?.findScenes?.count ?? 0;
}
import { PagedList } from "../List/PagedList";
import { useCloseEditDelete, useFilterOperations } from "../List/util";
import { IListFilterOperation } from "../List/ListOperationButtons";
import { FilteredListToolbar } from "../List/FilteredListToolbar";
import { useFilteredItemList } from "../List/ItemList";
import { FilterTags } from "../List/FilterTags";
function renderMetadataByline(result: GQL.FindScenesQueryResult) {
const duration = result?.data?.findScenes?.duration;
@@ -64,7 +61,130 @@ function renderMetadataByline(result: GQL.FindScenesQueryResult) {
);
}
interface ISceneList {
function usePlayScene() {
const history = useHistory();
const playScene = useCallback(
(queue: SceneQueue, sceneID: string, options: IPlaySceneOptions) => {
history.push(queue.makeLink(sceneID, options));
},
[history]
);
return playScene;
}
function usePlaySelected(selectedIds: Set<string>) {
const { configuration: config } = useContext(ConfigurationContext);
const playScene = usePlayScene();
const playSelected = useCallback(() => {
// populate queue and go to first scene
const sceneIDs = Array.from(selectedIds.values());
const queue = SceneQueue.fromSceneIDList(sceneIDs);
const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;
playScene(queue, sceneIDs[0], { autoPlay });
}, [selectedIds, config?.interface.autostartVideoOnPlaySelected, playScene]);
return playSelected;
}
function usePlayRandom(filter: ListFilterModel, count: number) {
const { configuration: config } = useContext(ConfigurationContext);
const playScene = usePlayScene();
const playRandom = useCallback(async () => {
// query for a random scene
if (count === 0) {
return;
}
const pages = Math.ceil(count / filter.itemsPerPage);
const page = Math.floor(Math.random() * pages) + 1;
const indexMax = Math.min(filter.itemsPerPage, count);
const index = Math.floor(Math.random() * indexMax);
const filterCopy = cloneDeep(filter);
filterCopy.currentPage = page;
filterCopy.sortBy = "random";
const queryResults = await queryFindScenes(filterCopy);
const scene = queryResults.data.findScenes.scenes[index];
if (scene) {
// navigate to the image player page
const queue = SceneQueue.fromListFilterModel(filterCopy);
const autoPlay = config?.interface.autostartVideoOnPlaySelected ?? false;
playScene(queue, scene.id, { sceneIndex: index, autoPlay });
}
}, [
filter,
count,
config?.interface.autostartVideoOnPlaySelected,
playScene,
]);
return playRandom;
}
function useAddKeybinds(filter: ListFilterModel, count: number) {
const playRandom = usePlayRandom(filter, count);
useEffect(() => {
Mousetrap.bind("p r", () => {
playRandom();
});
return () => {
Mousetrap.unbind("p r");
};
}, [playRandom]);
}
const SceneList: React.FC<{
scenes: GQL.SlimSceneDataFragment[];
filter: ListFilterModel;
selectedIds: Set<string>;
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
fromGroupId?: string;
}> = ({ scenes, filter, selectedIds, onSelectChange, fromGroupId }) => {
const queue = useMemo(() => SceneQueue.fromListFilterModel(filter), [filter]);
if (scenes.length === 0) {
return null;
}
if (filter.displayMode === DisplayMode.Grid) {
return (
<SceneCardsGrid
scenes={scenes}
queue={queue}
zoomIndex={filter.zoomIndex}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
fromGroupId={fromGroupId}
/>
);
}
if (filter.displayMode === DisplayMode.List) {
return (
<SceneListTable
scenes={scenes}
queue={queue}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return <SceneWallPanel scenes={scenes} sceneQueue={queue} />;
}
if (filter.displayMode === DisplayMode.Tagger) {
return <Tagger scenes={scenes} queue={queue} />;
}
return null;
};
interface IFilteredScenes {
filterHook?: (filter: ListFilterModel) => ListFilterModel;
defaultSort?: string;
view?: View;
@@ -72,30 +192,108 @@ interface ISceneList {
fromGroupId?: string;
}
export const SceneList: React.FC<ISceneList> = ({
filterHook,
defaultSort,
view,
alterQuery,
fromGroupId,
}) => {
export const FilteredSceneList = (props: IFilteredScenes) => {
const intl = useIntl();
const history = useHistory();
const config = React.useContext(ConfigurationContext);
const [isGenerateDialogOpen, setIsGenerateDialogOpen] = useState(false);
const [mergeScenes, setMergeScenes] =
useState<{ id: string; title: string }[]>();
const [isIdentifyDialogOpen, setIsIdentifyDialogOpen] = useState(false);
const [isExportDialogOpen, setIsExportDialogOpen] = useState(false);
const [isExportAll, setIsExportAll] = useState(false);
const filterMode = GQL.FilterMode.Scenes;
const { filterHook, defaultSort, view, alterQuery, fromGroupId } = props;
const otherOperations = [
// States
const { filterState, queryResult, modalState, listSelect, showEditFilter } =
useFilteredItemList({
filterStateProps: {
filterMode: GQL.FilterMode.Scenes,
defaultSort,
view,
useURL: alterQuery,
},
queryResultProps: {
useResult: useFindScenes,
getCount: (r) => r.data?.findScenes.count ?? 0,
getItems: (r) => r.data?.findScenes.scenes ?? [],
filterHook,
},
});
const { filter, setFilter, loading: filterLoading } = filterState;
const { effectiveFilter, result, cachedResult, items, totalCount } =
queryResult;
const {
selectedIds,
selectedItems,
onSelectChange,
onSelectNone,
hasSelection,
} = listSelect;
const { modal, showModal, closeModal } = modalState;
// Utility hooks
const { setPage, removeCriterion, clearAllCriteria } = useFilterOperations({
filter,
setFilter,
});
useAddKeybinds(filter, totalCount);
const onCloseEditDelete = useCloseEditDelete({
closeModal,
onSelectNone,
result,
});
const metadataByline = useMemo(() => {
if (cachedResult.loading) return "";
return renderMetadataByline(cachedResult) ?? "";
}, [cachedResult]);
const playSelected = usePlaySelected(selectedIds);
const playRandom = usePlayRandom(filter, totalCount);
function onExport(all: boolean) {
showModal(
<ExportDialog
exportInput={{
scenes: {
ids: Array.from(selectedIds.values()),
all: all,
},
}}
onClose={() => closeModal()}
/>
);
}
function onMerge() {
const selected =
selectedItems.map((s) => {
return {
id: s.id,
title: objectTitle(s),
};
}) ?? [];
showModal(
<SceneMergeModal
scenes={selected}
onClose={(mergedID?: string) => {
closeModal();
if (mergedID) {
history.push(`/scenes/${mergedID}`);
}
}}
show
/>
);
}
const otherOperations: IListFilterOperation[] = [
{
text: intl.formatMessage({ id: "actions.play_selected" }),
onClick: playSelected,
isDisplayed: showWhenSelected,
isDisplayed: () => hasSelection,
icon: faPlay,
},
{
@@ -104,273 +302,103 @@ export const SceneList: React.FC<ISceneList> = ({
},
{
text: `${intl.formatMessage({ id: "actions.generate" })}…`,
onClick: async () => setIsGenerateDialogOpen(true),
isDisplayed: showWhenSelected,
},
{
text: `${intl.formatMessage({ id: "actions.identify" })}…`,
onClick: async () => setIsIdentifyDialogOpen(true),
isDisplayed: showWhenSelected,
},
{
text: `${intl.formatMessage({ id: "actions.merge" })}…`,
onClick: onMerge,
isDisplayed: showWhenSelected,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: onExport,
isDisplayed: showWhenSelected,
},
{
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: onExportAll,
},
];
function addKeybinds(
result: GQL.FindScenesQueryResult,
filter: ListFilterModel
) {
Mousetrap.bind("p r", () => {
playRandom(result, filter);
});
return () => {
Mousetrap.unbind("p r");
};
}
function playScene(
queue: SceneQueue,
sceneID: string,
options: IPlaySceneOptions
) {
history.push(queue.makeLink(sceneID, options));
}
async function playSelected(
result: GQL.FindScenesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
// populate queue and go to first scene
const sceneIDs = Array.from(selectedIds.values());
const queue = SceneQueue.fromSceneIDList(sceneIDs);
const autoPlay =
config.configuration?.interface.autostartVideoOnPlaySelected ?? false;
playScene(queue, sceneIDs[0], { autoPlay });
}
async function playRandom(
result: GQL.FindScenesQueryResult,
filter: ListFilterModel
) {
// query for a random scene
if (result.data?.findScenes) {
const { count } = result.data.findScenes;
const pages = Math.ceil(count / filter.itemsPerPage);
const page = Math.floor(Math.random() * pages) + 1;
const indexMax = Math.min(filter.itemsPerPage, count);
const index = Math.floor(Math.random() * indexMax);
const filterCopy = cloneDeep(filter);
filterCopy.currentPage = page;
filterCopy.sortBy = "random";
const queryResults = await queryFindScenes(filterCopy);
const scene = queryResults.data.findScenes.scenes[index];
if (scene) {
// navigate to the image player page
const queue = SceneQueue.fromListFilterModel(filterCopy);
const autoPlay =
config.configuration?.interface.autostartVideoOnPlaySelected ?? false;
playScene(queue, scene.id, { sceneIndex: index, autoPlay });
}
}
}
async function onMerge(
result: GQL.FindScenesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
const selected =
result.data?.findScenes.scenes
.filter((s) => selectedIds.has(s.id))
.map((s) => {
return {
id: s.id,
title: objectTitle(s),
};
}) ?? [];
setMergeScenes(selected);
}
async function onExport() {
setIsExportAll(false);
setIsExportDialogOpen(true);
}
async function onExportAll() {
setIsExportAll(true);
setIsExportDialogOpen(true);
}
function renderContent(
result: GQL.FindScenesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>,
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void
) {
function maybeRenderSceneGenerateDialog() {
if (isGenerateDialogOpen) {
return (
onClick: () =>
showModal(
<GenerateDialog
type="scene"
selectedIds={Array.from(selectedIds.values())}
onClose={() => setIsGenerateDialogOpen(false)}
onClose={() => closeModal()}
/>
);
}
}
function maybeRenderSceneIdentifyDialog() {
if (isIdentifyDialogOpen) {
return (
),
isDisplayed: () => hasSelection,
},
{
text: `${intl.formatMessage({ id: "actions.identify" })}…`,
onClick: () =>
showModal(
<IdentifyDialog
selectedIds={Array.from(selectedIds.values())}
onClose={() => setIsIdentifyDialogOpen(false)}
onClose={() => closeModal()}
/>
);
}
}
),
isDisplayed: () => hasSelection,
},
{
text: `${intl.formatMessage({ id: "actions.merge" })}…`,
onClick: () => onMerge(),
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.export" }),
onClick: () => onExport(false),
isDisplayed: () => hasSelection,
},
{
text: intl.formatMessage({ id: "actions.export_all" }),
onClick: () => onExport(true),
},
];
function maybeRenderSceneExportDialog() {
if (isExportDialogOpen) {
return (
<ExportDialog
exportInput={{
scenes: {
ids: Array.from(selectedIds.values()),
all: isExportAll,
},
}}
onClose={() => setIsExportDialogOpen(false)}
/>
);
}
}
// render
if (filterLoading) return null;
function renderMergeDialog() {
if (mergeScenes) {
return (
<SceneMergeModal
scenes={mergeScenes}
onClose={(mergedID?: string) => {
setMergeScenes(undefined);
if (mergedID) {
history.push(`/scenes/${mergedID}`);
}
}}
show
/>
);
}
}
return (
<TaggerContext>
<div className="item-list-container">
{modal}
function renderScenes() {
if (!result.data?.findScenes) return;
<FilteredListToolbar
filter={filter}
setFilter={setFilter}
showEditFilter={showEditFilter}
view={view}
listSelect={listSelect}
onEdit={() =>
showModal(
<EditScenesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
)
}
onDelete={() => {
showModal(
<DeleteScenesDialog
selected={selectedItems}
onClose={onCloseEditDelete}
/>
);
}}
operations={otherOperations}
zoomable
/>
const queue = SceneQueue.fromListFilterModel(filter);
<FilterTags
criteria={filter.criteria}
onEditCriterion={(c) => showEditFilter(c.criterionOption.type)}
onRemoveCriterion={removeCriterion}
onRemoveAll={() => clearAllCriteria()}
/>
if (filter.displayMode === DisplayMode.Grid) {
return (
<SceneCardsGrid
scenes={result.data.findScenes.scenes}
queue={queue}
zoomIndex={filter.zoomIndex}
<PagedList
result={result}
cachedResult={cachedResult}
filter={filter}
totalCount={totalCount}
onChangePage={setPage}
metadataByline={metadataByline}
>
<SceneList
filter={effectiveFilter}
scenes={items}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
fromGroupId={fromGroupId}
/>
);
}
if (filter.displayMode === DisplayMode.List) {
return (
<SceneListTable
scenes={result.data.findScenes.scenes}
queue={queue}
selectedIds={selectedIds}
onSelectChange={onSelectChange}
/>
);
}
if (filter.displayMode === DisplayMode.Wall) {
return (
<SceneWallPanel
scenes={result.data.findScenes.scenes}
sceneQueue={queue}
/>
);
}
if (filter.displayMode === DisplayMode.Tagger) {
return <Tagger scenes={result.data.findScenes.scenes} queue={queue} />;
}
}
return (
<>
{maybeRenderSceneGenerateDialog()}
{maybeRenderSceneIdentifyDialog()}
{maybeRenderSceneExportDialog()}
{renderMergeDialog()}
{renderScenes()}
</>
);
}
function renderEditDialog(
selectedScenes: GQL.SlimSceneDataFragment[],
onClose: (applied: boolean) => void
) {
return <EditScenesDialog selected={selectedScenes} onClose={onClose} />;
}
function renderDeleteDialog(
selectedScenes: GQL.SlimSceneDataFragment[],
onClose: (confirmed: boolean) => void
) {
return <DeleteScenesDialog selected={selectedScenes} onClose={onClose} />;
}
return (
<TaggerContext>
<ItemListContext
filterMode={filterMode}
defaultSort={defaultSort}
useResult={useFindScenes}
getItems={getItems}
getCount={getCount}
alterQuery={alterQuery}
filterHook={filterHook}
view={view}
selectable
>
<ItemList
zoomable
view={view}
otherOperations={otherOperations}
addKeybinds={addKeybinds}
renderContent={renderContent}
renderEditDialog={renderEditDialog}
renderDeleteDialog={renderDeleteDialog}
renderMetadataByline={renderMetadataByline}
/>
</ItemListContext>
</PagedList>
</div>
</TaggerContext>
);
};
export default SceneList;
export default FilteredSceneList;

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useMemo, useState } from "react";
import React, { useMemo } from "react";
import { Button, ButtonGroup } from "react-bootstrap";
import * as GQL from "src/core/generated-graphql";
import { Icon } from "../Shared/Icon";
@@ -7,9 +7,8 @@ import { HoverPopover } from "../Shared/HoverPopover";
import NavUtils from "src/utils/navigation";
import TextUtils from "src/utils/text";
import { ConfigurationContext } from "src/hooks/Config";
import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard";
import { GridCard } from "../Shared/GridCard/GridCard";
import { faTag } from "@fortawesome/free-solid-svg-icons";
import ScreenUtils from "src/utils/screen";
import { markerTitle } from "src/core/markers";
import { Link } from "react-router-dom";
import { objectTitle } from "src/core/files";
@@ -19,7 +18,7 @@ import { TruncatedText } from "../Shared/TruncatedText";
interface ISceneMarkerCardProps {
marker: GQL.SceneMarkerDataFragment;
containerWidth?: number;
cardWidth?: number;
previewHeight?: number;
index?: number;
compact?: boolean;
@@ -154,8 +153,6 @@ const SceneMarkerCardImage = (props: ISceneMarkerCardProps) => {
};
export const SceneMarkerCard = (props: ISceneMarkerCardProps) => {
const [cardWidth, setCardWidth] = useState<number>();
function zoomIndex() {
if (!props.compact && props.zoomIndex !== undefined) {
return `zoom-${props.zoomIndex}`;
@@ -164,42 +161,12 @@ export const SceneMarkerCard = (props: ISceneMarkerCardProps) => {
return "";
}
useEffect(() => {
if (
!props.containerWidth ||
props.zoomIndex === undefined ||
ScreenUtils.isMobile()
)
return;
let zoomValue = props.zoomIndex;
let preferredCardWidth: number;
switch (zoomValue) {
case 0:
preferredCardWidth = 240;
break;
case 1:
preferredCardWidth = 340; // this value is intentionally higher than 320
break;
case 2:
preferredCardWidth = 480;
break;
case 3:
preferredCardWidth = 640;
}
let fittedCardWidth = calculateCardWidth(
props.containerWidth,
preferredCardWidth!
);
setCardWidth(fittedCardWidth);
}, [props, props.containerWidth, props.zoomIndex]);
return (
<GridCard
className={`scene-marker-card ${zoomIndex()}`}
url={NavUtils.makeSceneMarkerUrl(props.marker)}
title={markerTitle(props.marker)}
width={cardWidth}
width={props.cardWidth}
linkClassName="scene-marker-card-link"
thumbnailSectionClassName="video-section"
resumeTime={props.marker.seconds}

View File

@@ -1,7 +1,10 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { SceneMarkerCard } from "./SceneMarkerCard";
import { useContainerDimensions } from "../Shared/GridCard/GridCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
interface ISceneMarkerCardsGrid {
markers: GQL.SceneMarkerDataFragment[];
@@ -10,19 +13,23 @@ interface ISceneMarkerCardsGrid {
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
const zoomWidths = [240, 340, 480, 640];
export const SceneMarkerCardsGrid: React.FC<ISceneMarkerCardsGrid> = ({
markers,
selectedIds,
zoomIndex,
onSelectChange,
}) => {
const [componentRef, { width }] = useContainerDimensions();
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{markers.map((marker, index) => (
<SceneMarkerCard
key={marker.id}
containerWidth={width}
cardWidth={cardWidth}
marker={marker}
index={index}
zoomIndex={zoomIndex}

View File

@@ -10,6 +10,8 @@ export interface IStashBoxModal {
close: (v?: GQL.StashBoxInput) => void;
}
const defaultMaxRequestsPerMinute = 240;
export const StashBoxModal: React.FC<IStashBoxModal> = ({ value, close }) => {
const intl = useIntl();
const endpoint = useRef<HTMLInputElement | null>(null);
@@ -114,6 +116,38 @@ export const StashBoxModal: React.FC<IStashBoxModal> = ({ value, close }) => {
</b>
)}
</Form.Group>
<Form.Group id="stashbox-max-requests-per-minute">
<h6>
{intl.formatMessage({
id: "config.stashbox.max_requests_per_minute",
})}
</h6>
<Form.Control
placeholder={intl.formatMessage({
id: "config.stashbox.max_requests_per_minute",
})}
className="text-input"
value={v?.max_requests_per_minute ?? defaultMaxRequestsPerMinute}
isValid={
(v?.max_requests_per_minute ?? defaultMaxRequestsPerMinute) >= 0
}
type="number"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
setValue({
...v!,
max_requests_per_minute: parseInt(e.currentTarget.value),
})
}
ref={apiKey}
/>
<div className="sub-heading">
<FormattedMessage
id="config.stashbox.max_requests_per_minute_description"
values={{ defaultValue: defaultMaxRequestsPerMinute }}
/>
</div>
</Form.Group>
</>
)}
close={close}

View File

@@ -1,6 +1,7 @@
import React, {
MutableRefObject,
PropsWithChildren,
useMemo,
useRef,
useState,
} from "react";
@@ -13,6 +14,7 @@ import useResizeObserver from "@react-hook/resize-observer";
import { Icon } from "../Icon";
import { faGripLines } from "@fortawesome/free-solid-svg-icons";
import { DragSide, useDragMoveSelect } from "./dragMoveSelect";
import { useDebounce } from "src/hooks/debounce";
interface ICardProps {
className?: string;
@@ -63,7 +65,7 @@ export const useContainerDimensions = <T extends HTMLElement = HTMLDivElement>(
height: 0,
});
useResizeObserver(target, (entry) => {
const debouncedSetDimension = useDebounce((entry: ResizeObserverEntry) => {
const { inlineSize: width, blockSize: height } = entry.contentBoxSize[0];
let difference = Math.abs(dimension.width - width);
// Only adjust when width changed by a significant margin. This addresses the cornercase that sees
@@ -73,11 +75,38 @@ export const useContainerDimensions = <T extends HTMLElement = HTMLDivElement>(
if (difference > sensitivityThreshold) {
setDimension({ width, height });
}
});
}, 50);
useResizeObserver(target, debouncedSetDimension);
return [target, dimension];
};
export function useCardWidth(
containerWidth: number,
zoomIndex: number,
zoomWidths: number[]
) {
return useMemo(() => {
if (
!containerWidth ||
zoomIndex === undefined ||
zoomIndex < 0 ||
zoomIndex >= zoomWidths.length ||
ScreenUtils.isMobile()
)
return;
let zoomValue = zoomIndex;
const preferredCardWidth = zoomWidths[zoomValue];
let fittedCardWidth = calculateCardWidth(
containerWidth,
preferredCardWidth!
);
return fittedCardWidth;
}, [containerWidth, zoomIndex, zoomWidths]);
}
const Checkbox: React.FC<{
selected?: boolean;
onSelectedChanged?: (selected: boolean, shiftKey: boolean) => void;

View File

@@ -1,11 +1,8 @@
import React, { useEffect, useState } from "react";
import React from "react";
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import NavUtils from "src/utils/navigation";
import {
GridCard,
calculateCardWidth,
} from "src/components/Shared/GridCard/GridCard";
import { GridCard } from "src/components/Shared/GridCard/GridCard";
import { HoverPopover } from "../Shared/HoverPopover";
import { Icon } from "../Shared/Icon";
import { TagLink } from "../Shared/TagLink";
@@ -13,14 +10,13 @@ import { Button, ButtonGroup } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import { PopoverCountButton } from "../Shared/PopoverCountButton";
import { RatingBanner } from "../Shared/RatingBanner";
import ScreenUtils from "src/utils/screen";
import { FavoriteIcon } from "../Shared/FavoriteIcon";
import { useStudioUpdate } from "src/core/StashService";
import { faTag } from "@fortawesome/free-solid-svg-icons";
interface IProps {
studio: GQL.StudioDataFragment;
containerWidth?: number;
cardWidth?: number;
hideParent?: boolean;
selecting?: boolean;
selected?: boolean;
@@ -75,7 +71,7 @@ function maybeRenderChildren(studio: GQL.StudioDataFragment) {
export const StudioCard: React.FC<IProps> = ({
studio,
containerWidth,
cardWidth,
hideParent,
selecting,
selected,
@@ -83,34 +79,6 @@ export const StudioCard: React.FC<IProps> = ({
onSelectedChanged,
}) => {
const [updateStudio] = useStudioUpdate();
const [cardWidth, setCardWidth] = useState<number>();
useEffect(() => {
if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile())
return;
let zoomValue = zoomIndex;
console.log(zoomValue);
let preferredCardWidth: number;
switch (zoomValue) {
case 0:
preferredCardWidth = 280;
break;
case 1:
preferredCardWidth = 340;
break;
case 2:
preferredCardWidth = 420;
break;
case 3:
preferredCardWidth = 560;
}
let fittedCardWidth = calculateCardWidth(
containerWidth,
preferredCardWidth!
);
setCardWidth(fittedCardWidth);
}, [containerWidth, zoomIndex]);
function onToggleFavorite(v: boolean) {
if (studio.id) {

View File

@@ -1,6 +1,9 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { useContainerDimensions } from "../Shared/GridCard/GridCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
import { StudioCard } from "./StudioCard";
interface IStudioCardGrid {
@@ -11,6 +14,8 @@ interface IStudioCardGrid {
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
const zoomWidths = [280, 340, 420, 560];
export const StudioCardGrid: React.FC<IStudioCardGrid> = ({
studios,
fromParent,
@@ -18,13 +23,15 @@ export const StudioCardGrid: React.FC<IStudioCardGrid> = ({
zoomIndex,
onSelectChange,
}) => {
const [componentRef, { width }] = useContainerDimensions();
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{studios.map((studio) => (
<StudioCard
key={studio.id}
containerWidth={width}
cardWidth={cardWidth}
studio={studio}
zoomIndex={zoomIndex}
hideParent={fromParent}

View File

@@ -1,6 +1,6 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { SceneList } from "src/components/Scenes/SceneList";
import { FilteredSceneList } from "src/components/Scenes/SceneList";
import { useStudioFilterHook } from "src/core/studios";
import { View } from "src/components/List/views";
@@ -17,7 +17,7 @@ export const StudioScenesPanel: React.FC<IStudioScenesPanel> = ({
}) => {
const filterHook = useStudioFilterHook(studio, showChildStudioContent);
return (
<SceneList
<FilteredSceneList
filterHook={filterHook}
alterQuery={active}
view={View.StudioScenes}

View File

@@ -1,14 +1,13 @@
import { PatchComponent } from "src/patch";
import { Button, ButtonGroup } from "react-bootstrap";
import React, { useEffect, useState } from "react";
import React from "react";
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import NavUtils from "src/utils/navigation";
import { FormattedMessage } from "react-intl";
import { TruncatedText } from "../Shared/TruncatedText";
import { GridCard, calculateCardWidth } from "../Shared/GridCard/GridCard";
import { GridCard } from "../Shared/GridCard/GridCard";
import { PopoverCountButton } from "../Shared/PopoverCountButton";
import ScreenUtils from "src/utils/screen";
import { Icon } from "../Shared/Icon";
import { faHeart } from "@fortawesome/free-solid-svg-icons";
import cx from "classnames";
@@ -16,7 +15,7 @@ import { useTagUpdate } from "src/core/StashService";
interface IProps {
tag: GQL.TagDataFragment;
containerWidth?: number;
cardWidth?: number;
zoomIndex: number;
selecting?: boolean;
selected?: boolean;
@@ -234,40 +233,8 @@ const TagCardTitle: React.FC<IProps> = PatchComponent(
);
export const TagCard: React.FC<IProps> = PatchComponent("TagCard", (props) => {
const {
tag,
containerWidth,
zoomIndex,
selecting,
selected,
onSelectedChanged,
} = props;
const [cardWidth, setCardWidth] = useState<number>();
useEffect(() => {
if (!containerWidth || zoomIndex === undefined || ScreenUtils.isMobile())
return;
let zoomValue = zoomIndex;
let preferredCardWidth: number;
switch (zoomValue) {
case 0:
preferredCardWidth = 280;
break;
case 1:
preferredCardWidth = 340;
break;
case 2:
preferredCardWidth = 480;
break;
case 3:
preferredCardWidth = 640;
}
let fittedCardWidth = calculateCardWidth(
containerWidth,
preferredCardWidth!
);
setCardWidth(fittedCardWidth);
}, [containerWidth, zoomIndex]);
const { tag, cardWidth, zoomIndex, selecting, selected, onSelectedChanged } =
props;
return (
<GridCard

View File

@@ -1,6 +1,9 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { useContainerDimensions } from "../Shared/GridCard/GridCard";
import {
useCardWidth,
useContainerDimensions,
} from "../Shared/GridCard/GridCard";
import { TagCard } from "./TagCard";
interface ITagCardGrid {
@@ -10,19 +13,23 @@ interface ITagCardGrid {
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
}
const zoomWidths = [280, 340, 480, 640];
export const TagCardGrid: React.FC<ITagCardGrid> = ({
tags,
selectedIds,
zoomIndex,
onSelectChange,
}) => {
const [componentRef, { width }] = useContainerDimensions();
const [componentRef, { width: containerWidth }] = useContainerDimensions();
const cardWidth = useCardWidth(containerWidth, zoomIndex, zoomWidths);
return (
<div className="row justify-content-center" ref={componentRef}>
{tags.map((tag) => (
<TagCard
key={tag.id}
containerWidth={width}
cardWidth={cardWidth}
tag={tag}
zoomIndex={zoomIndex}
selecting={selectedIds.size > 0}

View File

@@ -1,6 +1,6 @@
import React from "react";
import * as GQL from "src/core/generated-graphql";
import { SceneList } from "src/components/Scenes/SceneList";
import { FilteredSceneList } from "src/components/Scenes/SceneList";
import { useTagFilterHook } from "src/core/tags";
import { View } from "src/components/List/views";
@@ -17,7 +17,7 @@ export const TagScenesPanel: React.FC<ITagScenesPanel> = ({
}) => {
const filterHook = useTagFilterHook(tag, showSubTagContent);
return (
<SceneList
<FilteredSceneList
filterHook={filterHook}
alterQuery={active}
view={View.TagScenes}

View File

@@ -128,9 +128,11 @@ The logout button is situated in the upper-right part of the screen when you are
### Recovering from a forgotten username or password
Stash saves login credentials in the config.yml file. You must reset both login and password if you have forgotten your password by doing the following:
* Close your Stash process
* Open the `config.yml` file found in your Stash directory with a text editor
* Delete the `login` and `password` lines from the file and save
* Delete the `username` and `password` lines from the file and save
Stash authentication should now be reset with no authentication credentials.
## Advanced configuration options
@@ -167,4 +169,4 @@ custom_served_folders:
With the above configuration, a request for `/custom/foo/bar.png` would serve `D:\bar\bar.png`.
The `/` entry matches anything that is not otherwise mapped by the other entries. For example, `/custom/baz/xyz.png` would serve `D:\stash\static\baz\xyz.png`.
The `/` entry matches anything that is not otherwise mapped by the other entries. For example, `/custom/baz/xyz.png` would serve `D:\stash\static\baz\xyz.png`.

View File

@@ -2,6 +2,10 @@
Scrapers can be contributed to the community by creating a PR in [this repository](https://github.com/stashapp/CommunityScrapers/pulls).
## XPath scraper templates
The most basic XPath scraper templates are available on [CommunityScrapers repository](https://github.com/stashapp/CommunityScrapers/tree/master/templates).
## Scraper configuration file format
```yaml
@@ -792,83 +796,120 @@ driver:
```
## Object fields
### Gallery
```
Code
Date
Details
Performers (see Performer fields)
Photographer
Rating
Studio (see Studio Fields)
Tags (see Tag fields)
Title
URLs
```
> **Important**: `Title` field is required.
### Group
```
Aliases
BackImage
Date
Director
Duration
FrontImage
Name
Rating
Studio (see Studio Fields)
Synopsis
Tags (see Tag fields)
URLs
```
> **Important**: `Name` field is required.
### Image
```
Code
Date
Details
Performers (see Performer fields)
Photographer
Rating
Studio (see Studio Fields)
Tags (see Tag fields)
Title
URLs
```
### Performer
```
Name
Gender
URL
Twitter
Instagram
Birthdate
DeathDate
Ethnicity
Country
HairColor
EyeColor
Height
Weight
Measurements
FakeTits
CareerLength
Tattoos
Piercings
Aliases
Tags (see Tag fields)
Image
Birthdate
CareerLength
Circumcised
Country
DeathDate
Details
Disambiguation
Ethnicity
EyeColor
FakeTits
Gender
HairColor
Height
Measurements
Name
PenisLength
Piercings
Tags (see Tag fields)
Tattoos
URLs
Weight
```
*Note:* - `Gender` must be one of `male`, `female`, `transgender_male`, `transgender_female`, `intersex`, `non_binary` (case insensitive).
> **Important**: `Name` field is required.
> **Note:** - `Gender` must be one of `male`, `female`, `transgender_male`, `transgender_female`, `intersex`, `non_binary` (case insensitive).
### Scene
```
Title
Details
Code
Director
URL
Date
Image
Studio (see Studio Fields)
Details
Director
Groups (see Group Fields)
Image
Performers (see Performer fields)
Studio (see Studio Fields)
Tags (see Tag fields)
Performers (list of Performer fields)
Title
URLs
```
> **Important**: `Title` field is required only if fileless.
### Studio
```
Name
URL
```
> **Important**: `Name` field is required.
### Tag
```
Name
```
### Group
```
Name
Aliases
Duration
Date
Rating
Director
Studio
Synopsis
URL
FrontImage
BackImage
```
### Gallery
```
Title
Details
URL
Date
Rating
Studio (see Studio Fields)
Tags (see Tag fields)
Performers (list of Performer fields)
```
> **Important**: `Name` field is required.

View File

@@ -448,6 +448,8 @@
"description": "Stash-box facilitates automated tagging of scenes and performers based on fingerprints and filenames.\nEndpoint and API key can be found on your account page on the stash-box instance. Names are required when more than one instance is added.",
"endpoint": "Endpoint",
"graphql_endpoint": "GraphQL endpoint",
"max_requests_per_minute": "Max requests per minute",
"max_requests_per_minute_description": "Uses default value of {defaultValue} if set to 0",
"name": "Name",
"title": "Stash-box Endpoints"
},
@@ -1140,6 +1142,13 @@
"generic": "Loading…",
"plugins": "Loading plugins…"
},
"login": {
"login": "Login",
"username": "Username",
"password": "Password",
"invalid_credentials": "Invalid username or password",
"internal_error": "Unexpected internal error. See logs for more details"
},
"marker_count": "Marker Count",
"markers": "Markers",
"measurements": "Measurements",