mirror of
https://github.com/stashapp/stash.git
synced 2026-06-11 07:41:08 -05:00
Compare commits
14 Commits
v0.28.1
...
docs-updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0df4077ace | ||
|
|
b14b2796f9 | ||
|
|
4bfc93b7ae | ||
|
|
c0d5d1e5a7 | ||
|
|
bac0b0a379 | ||
|
|
d9b4e62420 | ||
|
|
c8d74f0bcf | ||
|
|
18381664aa | ||
|
|
e9a67eb51f | ||
|
|
2ec264ed62 | ||
|
|
e5446a2336 | ||
|
|
db7d45792e | ||
|
|
5d3d02e1e7 | ||
|
|
2541e9d1eb |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -21,6 +21,9 @@ vendor
|
||||
# GraphQL generated output
|
||||
internal/api/generated_*.go
|
||||
|
||||
# Generated locale files
|
||||
ui/login/locales/*
|
||||
|
||||
####
|
||||
# Visual Studio
|
||||
####
|
||||
|
||||
9
Makefile
9
Makefile
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
3
go.mod
@@ -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
6
go.sum
@@ -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=
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
66
pkg/scene/find.go
Normal 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
40
pkg/scene/fingerprints.go
Normal 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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -586,6 +586,7 @@ var studioSortOptions = sortOptions{
|
||||
"scenes_count",
|
||||
"random",
|
||||
"rating",
|
||||
"tag_count",
|
||||
"updated_at",
|
||||
}
|
||||
|
||||
|
||||
101
pkg/stashbox/client.go
Normal file
101
pkg/stashbox/client.go
Normal 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)
|
||||
}
|
||||
@@ -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
468
pkg/stashbox/scene.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
90
scripts/generateLoginLocales.go
Normal file
90
scripts/generateLoginLocales.go
Normal 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
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
1
ui/ui.go
1
ui/ui.go
@@ -1,3 +1,4 @@
|
||||
//go:generate go run -tags=dev ../scripts/generateLoginLocales.go
|
||||
package ui
|
||||
|
||||
import (
|
||||
|
||||
@@ -49,6 +49,7 @@ fragment ConfigGeneralData on ConfigGeneralResult {
|
||||
name
|
||||
endpoint
|
||||
api_key
|
||||
max_requests_per_minute
|
||||
}
|
||||
pythonPath
|
||||
transcodeInputArgs
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user