mirror of
https://github.com/stashapp/stash.git
synced 2026-06-11 18:35:26 -05:00
Compare commits
17 Commits
docs-updat
...
update-bui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05f2343421 | ||
|
|
d0ece86bb8 | ||
|
|
62d7076ff3 | ||
|
|
f9fb33e8cc | ||
|
|
2375bc6cac | ||
|
|
87d01e86fd | ||
|
|
e774706f43 | ||
|
|
8efae13afb | ||
|
|
6ed66f3275 | ||
|
|
2eb7bde95a | ||
|
|
edbd9b69eb | ||
|
|
db06eae7cb | ||
|
|
0f2bc3e01d | ||
|
|
ffee4df8b7 | ||
|
|
2d5160c576 | ||
|
|
3489dca83a | ||
|
|
1d3bc40a6b |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -16,7 +16,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-20.04
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
|
||||
@@ -152,6 +152,9 @@ func recoverPanic() {
|
||||
func exitError(err error) {
|
||||
exitCode = 1
|
||||
logger.Error(err)
|
||||
// #5784 - log to stdout as well as the logger
|
||||
// this does mean that it will log twice if the logger is set to stdout
|
||||
fmt.Println(err)
|
||||
if desktop.IsDesktop() {
|
||||
desktop.FatalError(err)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
# This dockerfile should be built with `make docker-cuda-build` from the stash root.
|
||||
ARG CUDA_VERSION=12.8.0
|
||||
|
||||
# Build Frontend
|
||||
FROM node:20-alpine AS frontend
|
||||
@@ -34,19 +35,26 @@ ARG STASH_VERSION
|
||||
RUN make flags-release flags-pie stash
|
||||
|
||||
# Final Runnable Image
|
||||
FROM nvidia/cuda:12.0.1-base-ubuntu22.04
|
||||
RUN apt update && apt upgrade -y && apt install -y ca-certificates libvips-tools ffmpeg wget intel-media-va-driver-non-free vainfo
|
||||
RUN rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=backend /stash/stash /usr/bin/
|
||||
FROM nvidia/cuda:${CUDA_VERSION}-base-ubuntu24.04
|
||||
RUN apt update && apt upgrade -y && apt install -y \
|
||||
# stash dependencies
|
||||
ca-certificates libvips-tools ffmpeg \
|
||||
# intel dependencies
|
||||
intel-media-va-driver-non-free vainfo \
|
||||
# python tools
|
||||
python3 python3-pip && \
|
||||
# cleanup
|
||||
apt autoremove -y && apt clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=backend --chmod=555 /stash/stash /usr/bin/
|
||||
|
||||
# NVENC Patch
|
||||
RUN mkdir -p /usr/local/bin /patched-lib
|
||||
RUN wget https://raw.githubusercontent.com/keylase/nvidia-patch/master/patch.sh -O /usr/local/bin/patch.sh
|
||||
RUN wget https://raw.githubusercontent.com/keylase/nvidia-patch/master/docker-entrypoint.sh -O /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/patch.sh /usr/local/bin/docker-entrypoint.sh /usr/bin/stash
|
||||
ADD --chmod=555 https://raw.githubusercontent.com/keylase/nvidia-patch/master/patch.sh /usr/local/bin/patch.sh
|
||||
ADD --chmod=555 https://raw.githubusercontent.com/keylase/nvidia-patch/master/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
ENV LANG C.UTF-8
|
||||
ENV NVIDIA_VISIBLE_DEVICES all
|
||||
ENV LANG=C.UTF-8
|
||||
ENV NVIDIA_VISIBLE_DEVICES=all
|
||||
ENV NVIDIA_DRIVER_CAPABILITIES=video,utility
|
||||
ENV STASH_CONFIG_FILE=/root/.stash/config.yml
|
||||
EXPOSE 9999
|
||||
|
||||
@@ -694,6 +694,13 @@ func validateSceneMarkerEndSeconds(seconds, endSeconds float64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func float64OrZero(f *float64) float64 {
|
||||
if f == nil {
|
||||
return 0
|
||||
}
|
||||
return *f
|
||||
}
|
||||
|
||||
func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMarkerUpdateInput) (*models.SceneMarker, error) {
|
||||
markerID, err := strconv.Atoi(input.ID)
|
||||
if err != nil {
|
||||
@@ -784,7 +791,7 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar
|
||||
}
|
||||
|
||||
// remove the marker preview if the scene changed or if the timestamp was changed
|
||||
if existingMarker.SceneID != newMarker.SceneID || existingMarker.Seconds != newMarker.Seconds || existingMarker.EndSeconds != newMarker.EndSeconds {
|
||||
if existingMarker.SceneID != newMarker.SceneID || existingMarker.Seconds != newMarker.Seconds || float64OrZero(existingMarker.EndSeconds) != float64OrZero(newMarker.EndSeconds) {
|
||||
seconds := int(existingMarker.Seconds)
|
||||
if err := fileDeleter.MarkMarkerFiles(existingScene, seconds); err != nil {
|
||||
return err
|
||||
|
||||
@@ -29,7 +29,7 @@ func (r *mutationResolver) SubmitStashBoxFingerprints(ctx context.Context, input
|
||||
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)
|
||||
scenes, err = r.sceneService.FindByIDs(ctx, ids, scene.LoadStashIDs, scene.LoadFiles)
|
||||
return err
|
||||
}); err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -62,7 +62,11 @@ func (r *queryResolver) FindTags(ctx context.Context, tagFilter *models.TagFilte
|
||||
func (r *queryResolver) AllTags(ctx context.Context) (ret []*models.Tag, err error) {
|
||||
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
|
||||
ret, err = r.repository.Tag.All(ctx)
|
||||
return err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/match"
|
||||
@@ -100,12 +101,12 @@ func (r *queryResolver) ScrapeMovieURL(ctx context.Context, url string) (*models
|
||||
}
|
||||
|
||||
func (r *queryResolver) ScrapeGroupURL(ctx context.Context, url string) (*models.ScrapedGroup, error) {
|
||||
content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeMovie)
|
||||
content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeGroup)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret, err := marshalScrapedMovie(content)
|
||||
ret, err := marshalScrapedGroup(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -207,6 +208,10 @@ func (r *queryResolver) ScrapeSingleScene(ctx context.Context, source scraper.So
|
||||
return nil, fmt.Errorf("%w: scraper_id or stash_box_index must be set", ErrInput)
|
||||
}
|
||||
|
||||
for i := range ret {
|
||||
slices.SortFunc(ret[i].Tags, models.ScrapedTagSortFunction)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,30 @@ func marshalScrapedMovies(content []scraper.ScrapedContent) ([]*models.ScrapedMo
|
||||
case models.ScrapedMovie:
|
||||
ret = append(ret, &m)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: cannot turn ScrapedConetnt into ScrapedMovie", models.ErrConversion)
|
||||
return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedMovie", models.ErrConversion)
|
||||
}
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// marshalScrapedMovies converts ScrapedContent into ScrapedMovie. If conversion
|
||||
// fails, an error is returned.
|
||||
func marshalScrapedGroups(content []scraper.ScrapedContent) ([]*models.ScrapedGroup, error) {
|
||||
var ret []*models.ScrapedGroup
|
||||
for _, c := range content {
|
||||
if c == nil {
|
||||
// graphql schema requires groups to be non-nil
|
||||
continue
|
||||
}
|
||||
|
||||
switch m := c.(type) {
|
||||
case *models.ScrapedGroup:
|
||||
ret = append(ret, m)
|
||||
case models.ScrapedGroup:
|
||||
ret = append(ret, &m)
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: cannot turn ScrapedContent into ScrapedGroup", models.ErrConversion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,3 +192,13 @@ func marshalScrapedMovie(content scraper.ScrapedContent) (*models.ScrapedMovie,
|
||||
|
||||
return m[0], nil
|
||||
}
|
||||
|
||||
// marshalScrapedMovie will marshal a single scraped movie
|
||||
func marshalScrapedGroup(content scraper.ScrapedContent) (*models.ScrapedGroup, error) {
|
||||
m, err := marshalScrapedGroups([]scraper.ScrapedContent{content})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return m[0], nil
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ type SceneService interface {
|
||||
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)
|
||||
FindByIDs(ctx context.Context, ids []int, load ...scene.LoadRelationshipOption) ([]*models.Scene, error)
|
||||
sceneFingerprintGetter
|
||||
}
|
||||
|
||||
|
||||
@@ -1042,23 +1042,43 @@ func (t *ExportTask) ExportTags(ctx context.Context, workers int) {
|
||||
logger.Info("[tags] exporting")
|
||||
startTime := time.Now()
|
||||
|
||||
jobCh := make(chan *models.Tag, workers*2) // make a buffered channel to feed workers
|
||||
|
||||
for w := 0; w < workers; w++ { // create export Tag workers
|
||||
tagsWg.Add(1)
|
||||
go t.exportTag(ctx, &tagsWg, jobCh)
|
||||
tagIdx := 0
|
||||
if t.tags != nil {
|
||||
tagIdx = len(t.tags.IDs)
|
||||
}
|
||||
|
||||
for i, tag := range tags {
|
||||
index := i + 1
|
||||
logger.Progressf("[tags] %d of %d", index, len(tags))
|
||||
for {
|
||||
jobCh := make(chan *models.Tag, workers*2) // make a buffered channel to feed workers
|
||||
|
||||
jobCh <- tag // feed workers
|
||||
for w := 0; w < workers; w++ { // create export Tag workers
|
||||
tagsWg.Add(1)
|
||||
go t.exportTag(ctx, &tagsWg, jobCh)
|
||||
}
|
||||
|
||||
for i, tag := range tags {
|
||||
index := i + 1 + tagIdx
|
||||
logger.Progressf("[tags] %d of %d", index, len(tags)+tagIdx)
|
||||
|
||||
jobCh <- tag // feed workers
|
||||
}
|
||||
|
||||
close(jobCh)
|
||||
tagsWg.Wait()
|
||||
|
||||
// if more tags were added, we need to export those too
|
||||
if t.tags == nil || len(t.tags.IDs) == tagIdx {
|
||||
break
|
||||
}
|
||||
|
||||
newTags, err := reader.FindMany(ctx, t.tags.IDs[tagIdx:])
|
||||
if err != nil {
|
||||
logger.Errorf("[tags] failed to fetch tags: %v", err)
|
||||
}
|
||||
|
||||
tags = newTags
|
||||
tagIdx = len(t.tags.IDs)
|
||||
}
|
||||
|
||||
close(jobCh)
|
||||
tagsWg.Wait()
|
||||
|
||||
logger.Infof("[tags] export complete in %s. %d workers used.", time.Since(startTime), workers)
|
||||
}
|
||||
|
||||
@@ -1075,6 +1095,15 @@ func (t *ExportTask) exportTag(ctx context.Context, wg *sync.WaitGroup, jobChan
|
||||
continue
|
||||
}
|
||||
|
||||
if t.includeDependencies {
|
||||
tagIDs, err := tag.GetDependentTagIDs(ctx, tagReader, thisTag)
|
||||
if err != nil {
|
||||
logger.Errorf("[tags] <%s> error getting dependent tags: %v", thisTag.Name, err)
|
||||
continue
|
||||
}
|
||||
t.tags.IDs = sliceutil.AppendUniques(t.tags.IDs, tagIDs)
|
||||
}
|
||||
|
||||
fn := newTagJSON.Filename()
|
||||
|
||||
if err := t.json.saveTag(fn, newTagJSON); err != nil {
|
||||
|
||||
@@ -426,9 +426,11 @@ func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request,
|
||||
return
|
||||
}
|
||||
|
||||
prefix := r.Header.Get("X-Forwarded-Prefix")
|
||||
|
||||
baseUrl := *r.URL
|
||||
baseUrl.RawQuery = ""
|
||||
baseURL := baseUrl.String()
|
||||
baseURL := prefix + baseUrl.String()
|
||||
|
||||
urlQuery := url.Values{}
|
||||
apikey := r.URL.Query().Get(apiKeyParamKey)
|
||||
@@ -559,9 +561,11 @@ func serveDASHManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request
|
||||
mediaDuration := mpd.Duration(time.Duration(probeResult.FileDuration * float64(time.Second)))
|
||||
m := mpd.NewMPD(mpd.DASH_PROFILE_LIVE, mediaDuration.String(), "PT4.0S")
|
||||
|
||||
prefix := r.Header.Get("X-Forwarded-Prefix")
|
||||
|
||||
baseUrl := r.URL.JoinPath("/")
|
||||
baseUrl.RawQuery = ""
|
||||
m.BaseURL = baseUrl.String()
|
||||
m.BaseURL = prefix + baseUrl.String()
|
||||
|
||||
video, _ := m.AddNewAdaptationSetVideo(MimeWebmVideo, "progressive", true, 1)
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package fsutil
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -151,7 +153,12 @@ var (
|
||||
)
|
||||
|
||||
// SanitiseBasename returns a file basename removing any characters that are illegal or problematic to use in the filesystem.
|
||||
// It appends a short hash of the original string to ensure uniqueness.
|
||||
func SanitiseBasename(v string) string {
|
||||
// Generate a short hash for uniqueness
|
||||
hash := sha1.Sum([]byte(v))
|
||||
shortHash := hex.EncodeToString(hash[:4]) // Use the first 4 bytes of the hash
|
||||
|
||||
v = strings.TrimSpace(v)
|
||||
|
||||
// replace illegal filename characters with -
|
||||
@@ -163,7 +170,7 @@ func SanitiseBasename(v string) string {
|
||||
// remove multiple hyphens
|
||||
v = multiHyphenRE.ReplaceAllString(v, "-")
|
||||
|
||||
return strings.TrimSpace(v)
|
||||
return strings.TrimSpace(v) + "-" + shortHash
|
||||
}
|
||||
|
||||
// GetExeName returns the name of the given executable for the current platform.
|
||||
|
||||
@@ -8,13 +8,13 @@ func TestSanitiseBasename(t *testing.T) {
|
||||
v string
|
||||
want string
|
||||
}{
|
||||
{"basic", "basic", "basic"},
|
||||
{"spaces", `spaced name`, "spaced-name"},
|
||||
{"leading/trailing spaces", ` spaced name `, "spaced-name"},
|
||||
{"hyphen name", `hyphened-name`, "hyphened-name"},
|
||||
{"multi-hyphen", `hyphened--name`, "hyphened-name"},
|
||||
{"replaced characters", `a&b=c\d/:e*"f?_ g`, "a-b-c-d-e-f-g"},
|
||||
{"removed characters", `foo!!bar@@and, more`, "foobarand-more"},
|
||||
{"basic", "basic", "basic-61a7508e"},
|
||||
{"spaces", `spaced name`, "spaced-name-b297cf60"},
|
||||
{"leading/trailing spaces", ` spaced name `, "spaced-name-175433e9"},
|
||||
{"hyphen name", `hyphened-name`, "hyphened-name-789c55f2"},
|
||||
{"multi-hyphen", `hyphened--name`, "hyphened-name-2da2a58f"},
|
||||
{"replaced characters", `a&b=c\d/:e*"f?_ g`, "a-b-c-d-e-f-g-ffca6fb0"},
|
||||
{"removed characters", `foo!!bar@@and, more`, "foobarand-more-7cee02ab"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
|
||||
@@ -30,7 +30,7 @@ type SceneRelationships struct {
|
||||
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 {
|
||||
if err := ScrapedStudio(ctx, r.StudioFinder, thisStudio, endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -549,6 +549,29 @@ func (_m *SceneReaderWriter) FindByGroupID(ctx context.Context, groupID int) ([]
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByIDs provides a mock function with given fields: ctx, ids
|
||||
func (_m *SceneReaderWriter) FindByIDs(ctx context.Context, ids []int) ([]*models.Scene, error) {
|
||||
ret := _m.Called(ctx, ids)
|
||||
|
||||
var r0 []*models.Scene
|
||||
if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.Scene); ok {
|
||||
r0 = rf(ctx, ids)
|
||||
} else {
|
||||
if ret.Get(0) != nil {
|
||||
r0 = ret.Get(0).([]*models.Scene)
|
||||
}
|
||||
}
|
||||
|
||||
var r1 error
|
||||
if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {
|
||||
r1 = rf(ctx, ids)
|
||||
} else {
|
||||
r1 = ret.Error(1)
|
||||
}
|
||||
|
||||
return r0, r1
|
||||
}
|
||||
|
||||
// FindByOSHash provides a mock function with given fields: ctx, oshash
|
||||
func (_m *SceneReaderWriter) FindByOSHash(ctx context.Context, oshash string) ([]*models.Scene, error) {
|
||||
ret := _m.Called(ctx, oshash)
|
||||
|
||||
@@ -18,6 +18,10 @@ func (s *sceneResolver) FindMany(ctx context.Context, ids []int) ([]*models.Scen
|
||||
return s.scenes, nil
|
||||
}
|
||||
|
||||
func (s *sceneResolver) FindByIDs(ctx context.Context, ids []int) ([]*models.Scene, error) {
|
||||
return s.scenes, nil
|
||||
}
|
||||
|
||||
func SceneQueryResult(scenes []*models.Scene, count int) *models.SceneQueryResult {
|
||||
ret := models.NewSceneQueryResult(&sceneResolver{
|
||||
scenes: scenes,
|
||||
|
||||
@@ -3,6 +3,7 @@ package models
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
@@ -400,6 +401,10 @@ type ScrapedTag struct {
|
||||
|
||||
func (ScrapedTag) IsScrapedContent() {}
|
||||
|
||||
func ScrapedTagSortFunction(a, b *ScrapedTag) int {
|
||||
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
|
||||
}
|
||||
|
||||
// A movie from a scraping operation...
|
||||
type ScrapedMovie struct {
|
||||
StoredID *string `json:"stored_id"`
|
||||
|
||||
@@ -10,6 +10,9 @@ type SceneGetter interface {
|
||||
// TODO - rename this to Find and remove existing method
|
||||
FindMany(ctx context.Context, ids []int) ([]*Scene, error)
|
||||
Find(ctx context.Context, id int) (*Scene, error)
|
||||
// FindByIDs works the same way as FindMany, but it ignores any scenes not found
|
||||
// Scenes are not guaranteed to be in the same order as the input
|
||||
FindByIDs(ctx context.Context, ids []int) ([]*Scene, error)
|
||||
}
|
||||
|
||||
// SceneFinder provides methods to find scenes.
|
||||
|
||||
@@ -33,7 +33,31 @@ func LoadFiles(ctx context.Context, scene *models.Scene, r models.SceneReader) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindMany retrieves multiple scenes by their IDs.
|
||||
// FindByIDs retrieves multiple scenes by their IDs.
|
||||
// Missing scenes will be ignored, and the returned scenes are unsorted.
|
||||
// This method will load the specified relationships for each scene.
|
||||
func (s *Service) FindByIDs(ctx context.Context, ids []int, load ...LoadRelationshipOption) ([]*models.Scene, error) {
|
||||
var scenes []*models.Scene
|
||||
qb := s.Repository
|
||||
|
||||
var err error
|
||||
scenes, err = qb.FindByIDs(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
|
||||
}
|
||||
|
||||
// FindMany retrieves multiple scenes by their IDs. Return value is guaranteed to be in the same order as the input.
|
||||
// Missing scenes will return an error.
|
||||
// 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
|
||||
|
||||
@@ -378,6 +378,11 @@ func (c config) matchesURL(url string, ty ScrapeContentType) bool {
|
||||
}
|
||||
}
|
||||
case ScrapeContentTypeMovie, ScrapeContentTypeGroup:
|
||||
for _, scraper := range c.GroupByURL {
|
||||
if scraper.matchesURL(url) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, scraper := range c.MovieByURL {
|
||||
if scraper.matchesURL(url) {
|
||||
return true
|
||||
|
||||
@@ -851,7 +851,10 @@ type mappedScraper struct {
|
||||
Gallery *mappedGalleryScraperConfig `yaml:"gallery"`
|
||||
Image *mappedImageScraperConfig `yaml:"image"`
|
||||
Performer *mappedPerformerScraperConfig `yaml:"performer"`
|
||||
Movie *mappedMovieScraperConfig `yaml:"movie"`
|
||||
Group *mappedMovieScraperConfig `yaml:"group"`
|
||||
|
||||
// deprecated
|
||||
Movie *mappedMovieScraperConfig `yaml:"movie"`
|
||||
}
|
||||
|
||||
type mappedResult map[string]interface{}
|
||||
@@ -1247,24 +1250,29 @@ func (s mappedScraper) scrapeGallery(ctx context.Context, q mappedQuery) (*model
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
func (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models.ScrapedMovie, error) {
|
||||
var ret models.ScrapedMovie
|
||||
func (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models.ScrapedGroup, error) {
|
||||
var ret models.ScrapedGroup
|
||||
|
||||
movieScraperConfig := s.Movie
|
||||
if movieScraperConfig == nil {
|
||||
// try group scraper first, falling back to movie
|
||||
groupScraperConfig := s.Group
|
||||
|
||||
if groupScraperConfig == nil {
|
||||
groupScraperConfig = s.Movie
|
||||
}
|
||||
if groupScraperConfig == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
movieMap := movieScraperConfig.mappedConfig
|
||||
groupMap := groupScraperConfig.mappedConfig
|
||||
|
||||
movieStudioMap := movieScraperConfig.Studio
|
||||
movieTagsMap := movieScraperConfig.Tags
|
||||
groupStudioMap := groupScraperConfig.Studio
|
||||
groupTagsMap := groupScraperConfig.Tags
|
||||
|
||||
results := movieMap.process(ctx, q, s.Common, urlsIsMulti)
|
||||
results := groupMap.process(ctx, q, s.Common, urlsIsMulti)
|
||||
|
||||
if movieStudioMap != nil {
|
||||
logger.Debug(`Processing movie studio:`)
|
||||
studioResults := movieStudioMap.process(ctx, q, s.Common, nil)
|
||||
if groupStudioMap != nil {
|
||||
logger.Debug(`Processing group studio:`)
|
||||
studioResults := groupStudioMap.process(ctx, q, s.Common, nil)
|
||||
|
||||
if len(studioResults) > 0 {
|
||||
studio := &models.ScrapedStudio{}
|
||||
@@ -1274,9 +1282,9 @@ func (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models.
|
||||
}
|
||||
|
||||
// now apply the tags
|
||||
if movieTagsMap != nil {
|
||||
logger.Debug(`Processing movie tags:`)
|
||||
tagResults := movieTagsMap.process(ctx, q, s.Common, nil)
|
||||
if groupTagsMap != nil {
|
||||
logger.Debug(`Processing group tags:`)
|
||||
tagResults := groupTagsMap.process(ctx, q, s.Common, nil)
|
||||
|
||||
for _, p := range tagResults {
|
||||
tag := &models.ScrapedTag{}
|
||||
|
||||
@@ -32,6 +32,8 @@ func FilterTags(excludeRegexps []*regexp.Regexp, tags []*models.ScrapedTag) (new
|
||||
return tags, nil
|
||||
}
|
||||
|
||||
newTags = make([]*models.ScrapedTag, 0, len(tags))
|
||||
|
||||
for _, t := range tags {
|
||||
ignore := false
|
||||
for _, reg := range excludeRegexps {
|
||||
|
||||
@@ -493,8 +493,11 @@ func (qb *SceneStore) Find(ctx context.Context, id int) (*models.Scene, error) {
|
||||
return ret, err
|
||||
}
|
||||
|
||||
func (qb *SceneStore) FindMany(ctx context.Context, ids []int) ([]*models.Scene, error) {
|
||||
scenes := make([]*models.Scene, len(ids))
|
||||
// FindByIDs finds multiple scenes by their IDs.
|
||||
// No check is made to see if the scenes exist, and the order of the returned scenes
|
||||
// is not guaranteed to be the same as the order of the input IDs.
|
||||
func (qb *SceneStore) FindByIDs(ctx context.Context, ids []int) ([]*models.Scene, error) {
|
||||
scenes := make([]*models.Scene, 0, len(ids))
|
||||
|
||||
table := qb.table()
|
||||
if err := batchExec(ids, defaultBatchSize, func(batch []int) error {
|
||||
@@ -504,16 +507,29 @@ func (qb *SceneStore) FindMany(ctx context.Context, ids []int) ([]*models.Scene,
|
||||
return err
|
||||
}
|
||||
|
||||
for _, s := range unsorted {
|
||||
i := slices.Index(ids, s.ID)
|
||||
scenes[i] = s
|
||||
}
|
||||
scenes = append(scenes, unsorted...)
|
||||
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return scenes, nil
|
||||
}
|
||||
|
||||
func (qb *SceneStore) FindMany(ctx context.Context, ids []int) ([]*models.Scene, error) {
|
||||
scenes := make([]*models.Scene, len(ids))
|
||||
|
||||
unsorted, err := qb.FindByIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, s := range unsorted {
|
||||
i := slices.Index(ids, s.ID)
|
||||
scenes[i] = s
|
||||
}
|
||||
|
||||
for i := range scenes {
|
||||
if scenes[i] == nil {
|
||||
return nil, fmt.Errorf("scene with id %d not found", ids[i])
|
||||
|
||||
@@ -562,7 +562,7 @@ func (qb *TagStore) All(ctx context.Context) ([]*models.Tag, error) {
|
||||
table := qb.table()
|
||||
|
||||
return qb.getMany(ctx, qb.selectDataset().Order(
|
||||
table.Col("name").Asc(),
|
||||
goqu.L("COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI").Asc(),
|
||||
table.Col(idColumn).Asc(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1018,6 +1018,7 @@ type StashBoxConfig struct {
|
||||
GuidelinesURL string `json:"guidelines_url"`
|
||||
RequireSceneDraft bool `json:"require_scene_draft"`
|
||||
EditUpdateLimit int `json:"edit_update_limit"`
|
||||
RequireTagRole bool `json:"require_tag_role"`
|
||||
}
|
||||
|
||||
type StringCriterionInput struct {
|
||||
@@ -2143,6 +2144,8 @@ const (
|
||||
// May grant and rescind invite tokens and resind invite keys
|
||||
RoleEnumManageInvites RoleEnum = "MANAGE_INVITES"
|
||||
RoleEnumBot RoleEnum = "BOT"
|
||||
RoleEnumReadOnly RoleEnum = "READ_ONLY"
|
||||
RoleEnumEditTags RoleEnum = "EDIT_TAGS"
|
||||
)
|
||||
|
||||
var AllRoleEnum = []RoleEnum{
|
||||
@@ -2154,11 +2157,13 @@ var AllRoleEnum = []RoleEnum{
|
||||
RoleEnumInvite,
|
||||
RoleEnumManageInvites,
|
||||
RoleEnumBot,
|
||||
RoleEnumReadOnly,
|
||||
RoleEnumEditTags,
|
||||
}
|
||||
|
||||
func (e RoleEnum) IsValid() bool {
|
||||
switch e {
|
||||
case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, RoleEnumBot:
|
||||
case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, RoleEnumBot, RoleEnumReadOnly, RoleEnumEditTags:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/json"
|
||||
"github.com/stashapp/stash/pkg/models/jsonschema"
|
||||
"github.com/stashapp/stash/pkg/sliceutil"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
)
|
||||
|
||||
@@ -55,6 +56,28 @@ func ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag)
|
||||
return &newTagJSON, nil
|
||||
}
|
||||
|
||||
// GetDependentTagIDs returns a slice of unique tag IDs that this tag references.
|
||||
func GetDependentTagIDs(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) ([]int, error) {
|
||||
var ret []int
|
||||
|
||||
parents, err := reader.FindByChildTagID(ctx, tag.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting parents: %v", err)
|
||||
}
|
||||
|
||||
for _, tt := range parents {
|
||||
toAdd, err := GetDependentTagIDs(ctx, reader, tt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error getting dependent tag IDs: %v", err)
|
||||
}
|
||||
|
||||
ret = sliceutil.AppendUniques(ret, toAdd)
|
||||
ret = sliceutil.AppendUnique(ret, tt.ID)
|
||||
}
|
||||
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
func GetIDs(tags []*models.Tag) []int {
|
||||
var results []int
|
||||
for _, tag := range tags {
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
"terser": "^5.9.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "~4.8.4",
|
||||
"vite": "^4.5.6",
|
||||
"vite": "^4.5.11",
|
||||
"vite-plugin-compression": "^0.5.1",
|
||||
"vite-tsconfig-paths": "^4.0.5"
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { useLightbox } from "src/hooks/Lightbox/hooks";
|
||||
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
|
||||
import Gallery from "react-photo-gallery";
|
||||
import Gallery, { PhotoClickHandler } from "react-photo-gallery";
|
||||
import "flexbin/flexbin.css";
|
||||
import {
|
||||
CriterionModifier,
|
||||
@@ -45,9 +45,9 @@ export const GalleryViewer: React.FC<IProps> = ({ galleryId }) => {
|
||||
}, [images]);
|
||||
|
||||
const showLightbox = useLightbox(lightboxState);
|
||||
const showLightboxOnClick = useCallback(
|
||||
const showLightboxOnClick: PhotoClickHandler = useCallback(
|
||||
(event, { index }) => {
|
||||
showLightbox(index);
|
||||
showLightbox({ initialIndex: index });
|
||||
},
|
||||
[showLightbox]
|
||||
);
|
||||
|
||||
71
ui/v2.5/src/components/Performers/PerformerPopover.tsx
Normal file
71
ui/v2.5/src/components/Performers/PerformerPopover.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
import { ErrorMessage } from "../Shared/ErrorMessage";
|
||||
import { LoadingIndicator } from "../Shared/LoadingIndicator";
|
||||
import { HoverPopover } from "../Shared/HoverPopover";
|
||||
import { useFindPerformer } from "../../core/StashService";
|
||||
import { PerformerCard } from "./PerformerCard";
|
||||
import { ConfigurationContext } from "../../hooks/Config";
|
||||
import { Placement } from "react-bootstrap/esm/Overlay";
|
||||
|
||||
interface IPeromerPopoverCardProps {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export const PerformerPopoverCard: React.FC<IPeromerPopoverCardProps> = ({
|
||||
id,
|
||||
}) => {
|
||||
const { data, loading, error } = useFindPerformer(id);
|
||||
|
||||
if (loading)
|
||||
return (
|
||||
<div className="tag-popover-card-placeholder">
|
||||
<LoadingIndicator card={true} message={""} />
|
||||
</div>
|
||||
);
|
||||
if (error) return <ErrorMessage error={error.message} />;
|
||||
if (!data?.findPerformer)
|
||||
return <ErrorMessage error={`No tag found with id ${id}.`} />;
|
||||
|
||||
const performer = data.findPerformer;
|
||||
|
||||
return (
|
||||
<div className="tag-popover-card">
|
||||
<PerformerCard performer={performer} zoomIndex={0} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface IPeroformerPopoverProps {
|
||||
id: string;
|
||||
hide?: boolean;
|
||||
placement?: Placement;
|
||||
target?: React.RefObject<HTMLElement>;
|
||||
}
|
||||
|
||||
export const PerformerPopover: React.FC<IPeroformerPopoverProps> = ({
|
||||
id,
|
||||
hide,
|
||||
children,
|
||||
placement = "top",
|
||||
target,
|
||||
}) => {
|
||||
const { configuration: config } = React.useContext(ConfigurationContext);
|
||||
|
||||
const showPerformerCardOnHover = config?.ui.showTagCardOnHover ?? true;
|
||||
|
||||
if (hide || !showPerformerCardOnHover) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverPopover
|
||||
target={target}
|
||||
placement={placement}
|
||||
enterDelay={500}
|
||||
leaveDelay={100}
|
||||
content={<PerformerPopoverCard id={id} />}
|
||||
>
|
||||
{children}
|
||||
</HoverPopover>
|
||||
);
|
||||
};
|
||||
@@ -30,6 +30,8 @@ import { sortByRelevance } from "src/utils/query";
|
||||
import { PatchComponent, PatchFunction } from "src/patch";
|
||||
import { TruncatedText } from "../Shared/TruncatedText";
|
||||
import TextUtils from "src/utils/text";
|
||||
import { PerformerPopover } from "./PerformerPopover";
|
||||
import { Placement } from "react-bootstrap/esm/Overlay";
|
||||
|
||||
export type SelectObject = {
|
||||
id: string;
|
||||
@@ -71,7 +73,12 @@ const performerSelectSort = PatchFunction(
|
||||
);
|
||||
|
||||
const _PerformerSelect: React.FC<
|
||||
IFilterProps & IFilterValueProps<Performer> & { ageFromDate?: string | null }
|
||||
IFilterProps &
|
||||
IFilterValueProps<Performer> & {
|
||||
ageFromDate?: string | null;
|
||||
hoverPlacementLabel?: Placement;
|
||||
hoverPlacementOptions?: Placement;
|
||||
}
|
||||
> = (props) => {
|
||||
const [createPerformer] = usePerformerCreate();
|
||||
|
||||
@@ -201,12 +208,17 @@ const _PerformerSelect: React.FC<
|
||||
thisOptionProps = {
|
||||
...optionProps,
|
||||
children: (
|
||||
<span className="performer-select-value">
|
||||
<span>{object.name}</span>
|
||||
{object.disambiguation && (
|
||||
<span className="performer-disambiguation">{` (${object.disambiguation})`}</span>
|
||||
)}
|
||||
</span>
|
||||
<PerformerPopover
|
||||
id={object.id}
|
||||
placement={props.hoverPlacementLabel ?? "top"}
|
||||
>
|
||||
<span className="performer-select-value">
|
||||
<span>{object.name}</span>
|
||||
{object.disambiguation && (
|
||||
<span className="performer-disambiguation">{` (${object.disambiguation})`}</span>
|
||||
)}
|
||||
</span>
|
||||
</PerformerPopover>
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
.vjs-marker-dot {
|
||||
position: absolute;
|
||||
background-color: #10b981;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
transform: translate(-50%, -50%);
|
||||
top: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.vjs-marker-dot:hover {
|
||||
transform: translate(-50%, -50%) scale(1.2);
|
||||
}
|
||||
|
||||
.vjs-marker-range {
|
||||
position: absolute;
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
height: 8px;
|
||||
border-radius: 2px;
|
||||
transform: translateY(-28px);
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
transition: none;
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
import videojs, { VideoJsPlayer } from "video.js";
|
||||
import "./markers.css";
|
||||
import CryptoJS from "crypto-js";
|
||||
|
||||
export interface IMarker {
|
||||
@@ -67,14 +66,16 @@ class MarkersPlugin extends videojs.getPlugin("plugin") {
|
||||
dot?: HTMLDivElement;
|
||||
range?: HTMLDivElement;
|
||||
} = {};
|
||||
const seekBar = this.player.el().querySelector(".vjs-progress-control");
|
||||
const seekBar = this.player.el().querySelector(".vjs-progress-holder");
|
||||
|
||||
markerSet.dot = videojs.dom.createEl("div") as HTMLDivElement;
|
||||
markerSet.dot.className = "vjs-marker-dot";
|
||||
markerSet.dot.className = "vjs-marker";
|
||||
if (duration) {
|
||||
// marker is 6px wide - adjust by 3px to align to center not left side
|
||||
markerSet.dot.style.left = `calc(${
|
||||
(marker.seconds / duration) * 100
|
||||
}% - 3px)`;
|
||||
markerSet.dot.style.visibility = "visible";
|
||||
}
|
||||
|
||||
// Add event listeners to dot
|
||||
@@ -110,11 +111,12 @@ class MarkersPlugin extends videojs.getPlugin("plugin") {
|
||||
|
||||
private renderRangeMarkers(markers: IMarker[], layer: number) {
|
||||
const duration = this.player.duration();
|
||||
const seekBar = this.player.el().querySelector(".vjs-progress-control");
|
||||
if (!seekBar || !duration) return;
|
||||
const parent = this.player.el().querySelector(".vjs-progress-control");
|
||||
const seekBar = this.player.el().querySelector(".vjs-progress-holder");
|
||||
if (!seekBar || !parent || !duration) return;
|
||||
|
||||
markers.forEach((marker) => {
|
||||
this.renderRangeMarker(marker, layer, duration, seekBar);
|
||||
this.renderRangeMarker(marker, layer, duration, seekBar, parent);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -122,7 +124,8 @@ class MarkersPlugin extends videojs.getPlugin("plugin") {
|
||||
marker: IMarker,
|
||||
layer: number,
|
||||
duration: number,
|
||||
seekBar: Element
|
||||
seekBar: Element,
|
||||
parent: Element
|
||||
) {
|
||||
if (!marker.end_seconds) return;
|
||||
|
||||
@@ -133,16 +136,18 @@ class MarkersPlugin extends videojs.getPlugin("plugin") {
|
||||
const rangeDiv = videojs.dom.createEl("div") as HTMLDivElement;
|
||||
rangeDiv.className = "vjs-marker-range";
|
||||
|
||||
const startPercent = (marker.seconds / duration) * 100;
|
||||
const endPercent = (marker.end_seconds / duration) * 100;
|
||||
let width = endPercent - startPercent;
|
||||
// Ensure the width is at least 8px
|
||||
const minWidth = (10 / seekBar.clientWidth) * 100; // Convert 8px to percentage
|
||||
if (width < minWidth) {
|
||||
width = minWidth;
|
||||
}
|
||||
rangeDiv.style.left = `${startPercent}%`;
|
||||
rangeDiv.style.width = `${width}%`;
|
||||
// start/end percent is relative to the parent element, which is the vjs-progress-control
|
||||
// vjs-progress-control has 15px margins on each side
|
||||
const left = seekBar.clientWidth * (marker.seconds / duration) + 15;
|
||||
|
||||
// minimum width of 8px
|
||||
const width = Math.max(
|
||||
seekBar.clientWidth * ((marker.end_seconds - marker.seconds) / duration),
|
||||
8
|
||||
);
|
||||
|
||||
rangeDiv.style.left = `${left}px`;
|
||||
rangeDiv.style.width = `${width}px`;
|
||||
rangeDiv.style.bottom = `${layer * this.layerHeight}px`; // Adjust height based on layer
|
||||
rangeDiv.style.display = "none"; // Initially hidden
|
||||
|
||||
@@ -171,7 +176,7 @@ class MarkersPlugin extends videojs.getPlugin("plugin") {
|
||||
this.hideMarkerTooltip();
|
||||
markerSet.range?.toggleAttribute("marker-tooltip-shown", false);
|
||||
});
|
||||
seekBar.appendChild(rangeDiv);
|
||||
parent.appendChild(rangeDiv);
|
||||
this.markers.push(marker);
|
||||
this.markerDivs.push(markerSet);
|
||||
}
|
||||
|
||||
@@ -266,6 +266,16 @@ $sceneTabWidth: 450px;
|
||||
}
|
||||
}
|
||||
|
||||
.vjs-marker-range {
|
||||
background-color: rgba(255, 255, 255, 0.4);
|
||||
border-radius: 2px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
height: 8px;
|
||||
position: absolute;
|
||||
transform: translateY(-28px);
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.vjs-marker-tooltip {
|
||||
background-color: #fff;
|
||||
background-color: rgba(255, 255, 255, 0.8);
|
||||
|
||||
@@ -36,7 +36,10 @@ export type SelectObject = {
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
export type Tag = Pick<GQL.Tag, "id" | "name" | "aliases" | "image_path">;
|
||||
export type Tag = Pick<
|
||||
GQL.Tag,
|
||||
"id" | "name" | "sort_name" | "aliases" | "image_path"
|
||||
>;
|
||||
type Option = SelectOption<Tag>;
|
||||
|
||||
type FindTagsResult = Awaited<
|
||||
@@ -57,6 +60,7 @@ const tagSelectSort = PatchFunction("TagSelect.sort", sortTagsByRelevance);
|
||||
export type TagSelectProps = IFilterProps &
|
||||
IFilterValueProps<Tag> & {
|
||||
hoverPlacement?: Placement;
|
||||
hoverPlacementLabel?: Placement;
|
||||
excludeIds?: string[];
|
||||
};
|
||||
|
||||
@@ -151,7 +155,14 @@ const _TagSelect: React.FC<TagSelectProps> = (props) => {
|
||||
|
||||
thisOptionProps = {
|
||||
...optionProps,
|
||||
children: object.name,
|
||||
children: (
|
||||
<TagPopover
|
||||
id={object.id}
|
||||
placement={props.hoverPlacementLabel ?? "top"}
|
||||
>
|
||||
<span>{object.name}</span>
|
||||
</TagPopover>
|
||||
),
|
||||
};
|
||||
|
||||
return <reactSelectComponents.MultiValueLabel {...thisOptionProps} />;
|
||||
@@ -285,7 +296,20 @@ const _TagIDSelect: React.FC<IFilterProps & IFilterIDProps<Tag>> = (props) => {
|
||||
|
||||
const load = async () => {
|
||||
const items = await loadObjectsByID(ids);
|
||||
setValues(items);
|
||||
|
||||
// #4684 - sort items by sort name/name
|
||||
const sortedItems = [...items];
|
||||
sortedItems.sort((a, b) => {
|
||||
const aName = a.sort_name || a.name;
|
||||
const bName = b.sort_name || b.name;
|
||||
|
||||
if (aName && bName) {
|
||||
return aName.localeCompare(bName);
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
setValues(sortedItems);
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
@@ -234,6 +234,7 @@ The above configuration would scrape from the value of `queryURL`, replacing `{f
|
||||
### scrapeXPath and scrapeJson use with `<scene|performer|gallery|group>ByURL`
|
||||
|
||||
For `sceneByURL`, `performerByURL`, `galleryByURL` the `queryURL` can also be present if we want to use `queryURLReplace`. The functionality is the same as `sceneByFragment`, the only placeholder field available though is the `url`:
|
||||
|
||||
* `{url}` - the url of the scene/performer/gallery
|
||||
|
||||
```yaml
|
||||
@@ -253,7 +254,9 @@ sceneByURL:
|
||||
|
||||
A different stash server can be configured as a scraping source. This action applies only to `performerByName`, `performerByFragment`, `sceneByName`, `sceneByQueryFragment` and `sceneByFragment`, types. This action requires that the top-level `stashServer` field is configured.
|
||||
|
||||
`stashServer` contains a single `url` field for the remote stash server. The username and password can be embedded in this string using `username:password@host`. Alternatively, the `apiKey` field can be used to authenticate with the remote stash server.
|
||||
- `stashServer` contains a single `url` field for the remote stash server.
|
||||
- The username and password can be embedded in this string using `username:password@host`.
|
||||
- Alternatively, the `apiKey` field can be used to authenticate with the remote stash server.
|
||||
|
||||
An example stash scrape configuration is below:
|
||||
|
||||
@@ -352,6 +355,7 @@ scene:
|
||||
### Post-processing options
|
||||
|
||||
Post-processing operations are contained in the `postProcess` key. Post-processing operations are performed in the order they are specified. The following post-processing operations are available:
|
||||
|
||||
* `javascript`: accepts a javascript code block, that must return a string value. The input string is declared in the `value` variable. If an error occurs while compiling or running the script, then the original value is returned.
|
||||
Example:
|
||||
```yaml
|
||||
@@ -365,11 +369,12 @@ performer:
|
||||
return value[0].toUpperCase() + value.substring(1)
|
||||
}
|
||||
```
|
||||
Note that the `otto` javascript engine is missing a few built-in methods and may not be consistent with other modern javascript implementations.
|
||||
|
||||
We use [`goja` javascript engine](https://github.com/dop251/goja) which is missing a few built-in methods and may not be consistent with other modern javascript implementations.
|
||||
|
||||
* `feetToCm`: converts a string containing feet and inches numbers into centimeters. Looks for up to two separate integers and interprets the first as the number of feet, and the second as the number of inches. The numbers can be separated by any non-numeric character including the `.` character. It does not handle decimal numbers. For example `6.3` and `6ft3.3` would both be interpreted as 6 feet, 3 inches before converting into centimeters.
|
||||
* `lbToKg`: converts a string containing lbs to kg.
|
||||
* `map`: contains a map of input values to output values. Where a value matches one of the input values, it is replaced with the matching output value. If no value is matched, then value is unmodified.
|
||||
|
||||
Example:
|
||||
```yaml
|
||||
performer:
|
||||
@@ -388,8 +393,11 @@ performer:
|
||||
postProcess:
|
||||
- lbToKg: true
|
||||
```
|
||||
Gets the contents of the selected div element, and sets the returned value to `Female` if the scraped value is `F`; `Male` if the scraped value is `M`.
|
||||
Height and weight are extracted from the selected spans and converted to `cm` and `kg`.
|
||||
Gets the contents of the selected div element, and sets the returned value to:
|
||||
- `Female` if the scraped value is `F`;
|
||||
- `Male` if the scraped value is `M`.
|
||||
|
||||
Height and weight are extracted from the selected spans and converted to `cm` and `kg`.
|
||||
|
||||
* `parseDate`: if present, the value is the date format using go's reference date (2006-01-02). For example, if an example date was `14-Mar-2003`, then the date format would be `02-Jan-2006`. See the [time.Parse documentation](https://golang.org/pkg/time/#Parse) for details. When present, the scraper will convert the input string into a date, then convert it to the string format used by stash (`YYYY-MM-DD`). Strings "Today", "Yesterday" are matched (case insensitive) and converted by the scraper so you don't need to edit/replace them.
|
||||
Unix timestamps (example: 1660169451) can also be parsed by selecting `unix` as the date format.
|
||||
@@ -414,7 +422,6 @@ Date:
|
||||
```
|
||||
|
||||
* `replace`: contains an array of sub-objects. Each sub-object must have a `regex` and `with` field. The `regex` field is the regex pattern to replace, and `with` is the string to replace it with. `$` is used to reference capture groups - `$1` is the first capture group, `$2` the second and so on. Replacements are performed in order of the array.
|
||||
|
||||
Example:
|
||||
```yaml
|
||||
CareerLength:
|
||||
@@ -429,9 +436,9 @@ Replaces `2001 to 2003` with `2001-2003`.
|
||||
* `subScraper`: if present, the sub-scraper will be executed after all other post-processes are complete and before parseDate. It then takes the value and performs an http request, using the value as the URL. Within the `subScraper` config is a nested scraping configuration. This allows you to traverse to other webpages to get the attribute value you are after. For more info and examples have a look at [#370](https://github.com/stashapp/stash/pull/370), [#606](https://github.com/stashapp/stash/pull/606)
|
||||
|
||||
Additionally, there are a number of fixed post-processing fields that are specified at the attribute level (not in `postProcess`) that are performed after the `postProcess` operations:
|
||||
|
||||
* `concat`: if an xpath matches multiple elements, and `concat` is present, then all of the elements will be concatenated together
|
||||
* `split`: the inverse of `concat`. Splits a string to more elements using the separator given. For more info and examples have a look at PR [#579](https://github.com/stashapp/stash/pull/579)
|
||||
|
||||
Example:
|
||||
```yaml
|
||||
Tags:
|
||||
|
||||
@@ -981,11 +981,11 @@
|
||||
integrity sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==
|
||||
|
||||
"@babel/runtime@^7.0.0", "@babel/runtime@^7.1.2", "@babel/runtime@^7.10.4", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.0", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.13.8", "@babel/runtime@^7.14.0", "@babel/runtime@^7.14.5", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.7", "@babel/runtime@^7.5.5", "@babel/runtime@^7.6.3", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7":
|
||||
version "7.21.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.21.0.tgz#5b55c9d394e5fcf304909a8b00c07dc217b56673"
|
||||
integrity sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==
|
||||
version "7.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.0.tgz#fbee7cf97c709518ecc1f590984481d5460d4762"
|
||||
integrity sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.11"
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/template@^7.18.10", "@babel/template@^7.20.7", "@babel/template@^7.22.15", "@babel/template@^7.23.9":
|
||||
version "7.23.9"
|
||||
@@ -6726,6 +6726,11 @@ regenerator-runtime@^0.13.11:
|
||||
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz#f6dca3e7ceec20590d07ada785636a90cdca17f9"
|
||||
integrity sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==
|
||||
|
||||
regenerator-runtime@^0.14.0:
|
||||
version "0.14.1"
|
||||
resolved "https://registry.yarnpkg.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz#356ade10263f685dda125100cd862c1db895327f"
|
||||
integrity sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==
|
||||
|
||||
regenerator-transform@^0.15.1:
|
||||
version "0.15.1"
|
||||
resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.1.tgz#f6c4e99fc1b4591f780db2586328e4d9a9d8dc56"
|
||||
@@ -8023,10 +8028,10 @@ vite-tsconfig-paths@^4.0.5:
|
||||
globrex "^0.1.2"
|
||||
tsconfck "^2.0.1"
|
||||
|
||||
vite@^4.5.6:
|
||||
version "4.5.6"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.6.tgz#48bbd97fe06e8241df2e625b31c581707e10b57d"
|
||||
integrity sha512-ElBNuVvJKslxcfY2gMmae5IjaKGqCYGicCNZ+8R56sAznobeE3pI9ctzI17cBS/6OJh5YuQNMSN4BP4dRjugBg==
|
||||
vite@^4.5.11:
|
||||
version "4.5.11"
|
||||
resolved "https://registry.yarnpkg.com/vite/-/vite-4.5.11.tgz#796797f40d5bf5ce673d3773c9e0c4cbe204e85a"
|
||||
integrity sha512-4mVdhLkZ0vpqZLGJhNm+X1n7juqXApEMGlUXcOQawA45UmpxivOYaMBkI/Js3FlBsNA8hCgEnX5X04moFitSGw==
|
||||
dependencies:
|
||||
esbuild "^0.18.10"
|
||||
postcss "^8.4.27"
|
||||
|
||||
Reference in New Issue
Block a user