mirror of
https://github.com/stashapp/stash.git
synced 2026-06-10 21:42:03 -05:00
Compare commits
2 Commits
update-tri
...
docs-updat
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0df4077ace | ||
|
|
b14b2796f9 |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -16,7 +16,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-20.04
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
|
||||
10
README.md
10
README.md
@@ -5,6 +5,7 @@
|
||||
[](https://github.com/sponsors/stashapp)
|
||||
[](https://opencollective.com/stashapp)
|
||||
[](https://goreportcard.com/report/github.com/stashapp/stash)
|
||||
[](https://matrix.to/#/#stashapp:unredacted.org)
|
||||
[](https://discord.gg/2TsNFKt)
|
||||
[](https://github.com/stashapp/stash/releases/latest)
|
||||
[](https://github.com/stashapp/stash/labels/bounty)
|
||||
@@ -67,24 +68,19 @@ Stash is available in 32 languages (so far!) and it could be in your language to
|
||||
|
||||
[](https://translate.codeberg.org/engage/stash/)
|
||||
|
||||
## Join Our Community
|
||||
|
||||
We are excited to announce that we have a new home for support, feature requests, and discussions related to Stash and its associated projects. Join our community on the [Discourse forum](https://discourse.stashapp.cc) to connect with other users, share your ideas, and get help from fellow enthusiasts.
|
||||
|
||||
# Support (FAQ)
|
||||
|
||||
Check out our documentation on [Stash-Docs](https://docs.stashapp.cc) for information about the software, questions, guides, add-ons and more.
|
||||
|
||||
For more help you can:
|
||||
* Check the in-app documentation, in the top right corner of the app (it's also mirrored on [Stash-Docs](https://docs.stashapp.cc/in-app-manual))
|
||||
* Join our [community forum](https://discourse.stashapp.cc)
|
||||
* Join the [Discord server](https://discord.gg/2TsNFKt)
|
||||
* Join the [Matrix space](https://matrix.to/#/#stashapp:unredacted.org)
|
||||
* Join the [Discord server](https://discord.gg/2TsNFKt), where the community can offer support.
|
||||
* Start a [discussion on GitHub](https://github.com/stashapp/stash/discussions)
|
||||
|
||||
# Customization
|
||||
|
||||
## Themes and CSS Customization
|
||||
|
||||
There is a [directory of community-created themes](https://docs.stashapp.cc/themes/list) on Stash-Docs.
|
||||
|
||||
You can also change the Stash interface to fit your desired style with various snippets from [Custom CSS snippets](https://docs.stashapp.cc/themes/custom-css-snippets).
|
||||
|
||||
@@ -152,9 +152,6 @@ 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,5 +1,4 @@
|
||||
# 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
|
||||
@@ -35,26 +34,19 @@ ARG STASH_VERSION
|
||||
RUN make flags-release flags-pie stash
|
||||
|
||||
# Final Runnable Image
|
||||
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/
|
||||
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/
|
||||
|
||||
# NVENC Patch
|
||||
RUN mkdir -p /usr/local/bin /patched-lib
|
||||
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
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
@@ -16,12 +16,12 @@ import (
|
||||
|
||||
const (
|
||||
tripwireActivatedErrMsg = "Stash is exposed to the public internet without authentication, and is not serving any more content to protect your privacy. " +
|
||||
"More information and fixes are available at https://discourse.stashapp.cc/t/-/1658"
|
||||
"More information and fixes are available at https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet"
|
||||
|
||||
externalAccessErrMsg = "You have attempted to access Stash over the internet, and authentication is not enabled. " +
|
||||
"This is extremely dangerous! The whole world can see your your stash page and browse your files! " +
|
||||
"Stash is not answering any other requests to protect your privacy. " +
|
||||
"Please read the log entry or visit https://discourse.stashapp.cc/t/-/1658"
|
||||
"Please read the log entry or visit https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet"
|
||||
)
|
||||
|
||||
func allowUnauthenticated(r *http.Request) bool {
|
||||
|
||||
@@ -694,13 +694,6 @@ 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 {
|
||||
@@ -791,7 +784,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 || float64OrZero(existingMarker.EndSeconds) != float64OrZero(newMarker.EndSeconds) {
|
||||
if existingMarker.SceneID != newMarker.SceneID || existingMarker.Seconds != newMarker.Seconds || existingMarker.EndSeconds != 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.FindByIDs(ctx, ids, scene.LoadStashIDs, scene.LoadFiles)
|
||||
scenes, err = r.sceneService.FindMany(ctx, ids, scene.LoadStashIDs, scene.LoadFiles)
|
||||
return err
|
||||
}); err != nil {
|
||||
return false, err
|
||||
|
||||
@@ -62,11 +62,7 @@ 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)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
return err
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strconv"
|
||||
|
||||
"github.com/stashapp/stash/pkg/match"
|
||||
@@ -101,12 +100,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.ScrapeContentTypeGroup)
|
||||
content, err := r.scraperCache().ScrapeURL(ctx, url, scraper.ScrapeContentTypeMovie)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ret, err := marshalScrapedGroup(content)
|
||||
ret, err := marshalScrapedMovie(content)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -208,10 +207,6 @@ 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,30 +113,7 @@ func marshalScrapedMovies(content []scraper.ScrapedContent) ([]*models.ScrapedMo
|
||||
case models.ScrapedMovie:
|
||||
ret = append(ret, &m)
|
||||
default:
|
||||
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)
|
||||
return nil, fmt.Errorf("%w: cannot turn ScrapedConetnt into ScrapedMovie", models.ErrConversion)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,13 +169,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -1534,7 +1534,7 @@ func (i *Config) GetDefaultGenerateSettings() *models.GenerateMetadataOptions {
|
||||
}
|
||||
|
||||
// GetDangerousAllowPublicWithoutAuth determines if the security feature is enabled.
|
||||
// See https://discourse.stashapp.cc/t/-/1658
|
||||
// See https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet
|
||||
func (i *Config) GetDangerousAllowPublicWithoutAuth() bool {
|
||||
return i.getBool(dangerousAllowPublicWithoutAuth)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
FindByIDs(ctx context.Context, ids []int, load ...scene.LoadRelationshipOption) ([]*models.Scene, error)
|
||||
FindMany(ctx context.Context, ids []int, load ...scene.LoadRelationshipOption) ([]*models.Scene, error)
|
||||
sceneFingerprintGetter
|
||||
}
|
||||
|
||||
|
||||
@@ -1042,43 +1042,23 @@ func (t *ExportTask) ExportTags(ctx context.Context, workers int) {
|
||||
logger.Info("[tags] exporting")
|
||||
startTime := time.Now()
|
||||
|
||||
tagIdx := 0
|
||||
if t.tags != nil {
|
||||
tagIdx = len(t.tags.IDs)
|
||||
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)
|
||||
}
|
||||
|
||||
for {
|
||||
jobCh := make(chan *models.Tag, workers*2) // make a buffered channel to feed workers
|
||||
for i, tag := range tags {
|
||||
index := i + 1
|
||||
logger.Progressf("[tags] %d of %d", index, len(tags))
|
||||
|
||||
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)
|
||||
jobCh <- tag // feed workers
|
||||
}
|
||||
|
||||
close(jobCh)
|
||||
tagsWg.Wait()
|
||||
|
||||
logger.Infof("[tags] export complete in %s. %d workers used.", time.Since(startTime), workers)
|
||||
}
|
||||
|
||||
@@ -1095,15 +1075,6 @@ 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,11 +426,9 @@ func serveHLSManifest(sm *StreamManager, w http.ResponseWriter, r *http.Request,
|
||||
return
|
||||
}
|
||||
|
||||
prefix := r.Header.Get("X-Forwarded-Prefix")
|
||||
|
||||
baseUrl := *r.URL
|
||||
baseUrl.RawQuery = ""
|
||||
baseURL := prefix + baseUrl.String()
|
||||
baseURL := baseUrl.String()
|
||||
|
||||
urlQuery := url.Values{}
|
||||
apikey := r.URL.Query().Get(apiKeyParamKey)
|
||||
@@ -561,11 +559,9 @@ 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 = prefix + baseUrl.String()
|
||||
m.BaseURL = baseUrl.String()
|
||||
|
||||
video, _ := m.AddNewAdaptationSetVideo(MimeWebmVideo, "progressive", true, 1)
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package fsutil
|
||||
|
||||
import (
|
||||
"crypto/sha1"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -153,12 +151,7 @@ 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 -
|
||||
@@ -170,7 +163,7 @@ func SanitiseBasename(v string) string {
|
||||
// remove multiple hyphens
|
||||
v = multiHyphenRE.ReplaceAllString(v, "-")
|
||||
|
||||
return strings.TrimSpace(v) + "-" + shortHash
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
// 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-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"},
|
||||
{"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"},
|
||||
}
|
||||
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, thisStudio, endpoint); err != nil {
|
||||
if err := ScrapedStudio(ctx, r.StudioFinder, s.Studio, endpoint); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -549,29 +549,6 @@ 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,10 +18,6 @@ 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,7 +3,6 @@ package models
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
@@ -401,10 +400,6 @@ 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,9 +10,6 @@ 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,31 +33,7 @@ func LoadFiles(ctx context.Context, scene *models.Scene, r models.SceneReader) e
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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
|
||||
|
||||
@@ -378,11 +378,6 @@ 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,10 +851,7 @@ type mappedScraper struct {
|
||||
Gallery *mappedGalleryScraperConfig `yaml:"gallery"`
|
||||
Image *mappedImageScraperConfig `yaml:"image"`
|
||||
Performer *mappedPerformerScraperConfig `yaml:"performer"`
|
||||
Group *mappedMovieScraperConfig `yaml:"group"`
|
||||
|
||||
// deprecated
|
||||
Movie *mappedMovieScraperConfig `yaml:"movie"`
|
||||
Movie *mappedMovieScraperConfig `yaml:"movie"`
|
||||
}
|
||||
|
||||
type mappedResult map[string]interface{}
|
||||
@@ -1250,29 +1247,24 @@ func (s mappedScraper) scrapeGallery(ctx context.Context, q mappedQuery) (*model
|
||||
return &ret, nil
|
||||
}
|
||||
|
||||
func (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models.ScrapedGroup, error) {
|
||||
var ret models.ScrapedGroup
|
||||
func (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models.ScrapedMovie, error) {
|
||||
var ret models.ScrapedMovie
|
||||
|
||||
// try group scraper first, falling back to movie
|
||||
groupScraperConfig := s.Group
|
||||
|
||||
if groupScraperConfig == nil {
|
||||
groupScraperConfig = s.Movie
|
||||
}
|
||||
if groupScraperConfig == nil {
|
||||
movieScraperConfig := s.Movie
|
||||
if movieScraperConfig == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
groupMap := groupScraperConfig.mappedConfig
|
||||
movieMap := movieScraperConfig.mappedConfig
|
||||
|
||||
groupStudioMap := groupScraperConfig.Studio
|
||||
groupTagsMap := groupScraperConfig.Tags
|
||||
movieStudioMap := movieScraperConfig.Studio
|
||||
movieTagsMap := movieScraperConfig.Tags
|
||||
|
||||
results := groupMap.process(ctx, q, s.Common, urlsIsMulti)
|
||||
results := movieMap.process(ctx, q, s.Common, urlsIsMulti)
|
||||
|
||||
if groupStudioMap != nil {
|
||||
logger.Debug(`Processing group studio:`)
|
||||
studioResults := groupStudioMap.process(ctx, q, s.Common, nil)
|
||||
if movieStudioMap != nil {
|
||||
logger.Debug(`Processing movie studio:`)
|
||||
studioResults := movieStudioMap.process(ctx, q, s.Common, nil)
|
||||
|
||||
if len(studioResults) > 0 {
|
||||
studio := &models.ScrapedStudio{}
|
||||
@@ -1282,9 +1274,9 @@ func (s mappedScraper) scrapeGroup(ctx context.Context, q mappedQuery) (*models.
|
||||
}
|
||||
|
||||
// now apply the tags
|
||||
if groupTagsMap != nil {
|
||||
logger.Debug(`Processing group tags:`)
|
||||
tagResults := groupTagsMap.process(ctx, q, s.Common, nil)
|
||||
if movieTagsMap != nil {
|
||||
logger.Debug(`Processing movie tags:`)
|
||||
tagResults := movieTagsMap.process(ctx, q, s.Common, nil)
|
||||
|
||||
for _, p := range tagResults {
|
||||
tag := &models.ScrapedTag{}
|
||||
|
||||
@@ -32,8 +32,6 @@ 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 {
|
||||
|
||||
@@ -81,6 +81,6 @@ func LogExternalAccessError(err ExternalAccessError) {
|
||||
"You probably forwarded a port from your router. At the very least, add a password to stash in the settings. \n"+
|
||||
"Stash will not serve requests until you edit config.yml, remove the security_tripwire_accessed_from_public_internet key and restart stash. \n"+
|
||||
"This behaviour can be overridden (but not recommended) by setting dangerous_allow_public_without_auth to true in config.yml. \n"+
|
||||
"More information is available at https://discourse.stashapp.cc/t/-/1658 \n"+
|
||||
"More information is available at https://docs.stashapp.cc/faq/setup/#protecting-against-accidental-exposure-to-the-internet \n"+
|
||||
"Stash is not answering any other requests to protect your privacy.", net.IP(err).String())
|
||||
}
|
||||
|
||||
@@ -493,11 +493,8 @@ func (qb *SceneStore) Find(ctx context.Context, id int) (*models.Scene, error) {
|
||||
return ret, err
|
||||
}
|
||||
|
||||
// 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))
|
||||
func (qb *SceneStore) FindMany(ctx context.Context, ids []int) ([]*models.Scene, error) {
|
||||
scenes := make([]*models.Scene, len(ids))
|
||||
|
||||
table := qb.table()
|
||||
if err := batchExec(ids, defaultBatchSize, func(batch []int) error {
|
||||
@@ -507,29 +504,16 @@ func (qb *SceneStore) FindByIDs(ctx context.Context, ids []int) ([]*models.Scene
|
||||
return err
|
||||
}
|
||||
|
||||
scenes = append(scenes, unsorted...)
|
||||
for _, s := range unsorted {
|
||||
i := slices.Index(ids, s.ID)
|
||||
scenes[i] = s
|
||||
}
|
||||
|
||||
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(
|
||||
goqu.L("COALESCE(tags.sort_name, tags.name) COLLATE NATURAL_CI").Asc(),
|
||||
table.Col("name").Asc(),
|
||||
table.Col(idColumn).Asc(),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -1018,7 +1018,6 @@ 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 {
|
||||
@@ -2144,8 +2143,6 @@ 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{
|
||||
@@ -2157,13 +2154,11 @@ var AllRoleEnum = []RoleEnum{
|
||||
RoleEnumInvite,
|
||||
RoleEnumManageInvites,
|
||||
RoleEnumBot,
|
||||
RoleEnumReadOnly,
|
||||
RoleEnumEditTags,
|
||||
}
|
||||
|
||||
func (e RoleEnum) IsValid() bool {
|
||||
switch e {
|
||||
case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, RoleEnumBot, RoleEnumReadOnly, RoleEnumEditTags:
|
||||
case RoleEnumRead, RoleEnumVote, RoleEnumEdit, RoleEnumModify, RoleEnumAdmin, RoleEnumInvite, RoleEnumManageInvites, RoleEnumBot:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -8,7 +8,6 @@ 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"
|
||||
)
|
||||
|
||||
@@ -56,28 +55,6 @@ 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.11",
|
||||
"vite": "^4.5.6",
|
||||
"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, { PhotoClickHandler } from "react-photo-gallery";
|
||||
import Gallery 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: PhotoClickHandler = useCallback(
|
||||
const showLightboxOnClick = useCallback(
|
||||
(event, { index }) => {
|
||||
showLightbox({ initialIndex: index });
|
||||
showLightbox(index);
|
||||
},
|
||||
[showLightbox]
|
||||
);
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
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,8 +30,6 @@ 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;
|
||||
@@ -73,12 +71,7 @@ const performerSelectSort = PatchFunction(
|
||||
);
|
||||
|
||||
const _PerformerSelect: React.FC<
|
||||
IFilterProps &
|
||||
IFilterValueProps<Performer> & {
|
||||
ageFromDate?: string | null;
|
||||
hoverPlacementLabel?: Placement;
|
||||
hoverPlacementOptions?: Placement;
|
||||
}
|
||||
IFilterProps & IFilterValueProps<Performer> & { ageFromDate?: string | null }
|
||||
> = (props) => {
|
||||
const [createPerformer] = usePerformerCreate();
|
||||
|
||||
@@ -208,17 +201,12 @@ const _PerformerSelect: React.FC<
|
||||
thisOptionProps = {
|
||||
...optionProps,
|
||||
children: (
|
||||
<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>
|
||||
<span className="performer-select-value">
|
||||
<span>{object.name}</span>
|
||||
{object.disambiguation && (
|
||||
<span className="performer-disambiguation">{` (${object.disambiguation})`}</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
};
|
||||
|
||||
|
||||
27
ui/v2.5/src/components/ScenePlayer/markers.css
Normal file
27
ui/v2.5/src/components/ScenePlayer/markers.css
Normal file
@@ -0,0 +1,27 @@
|
||||
.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,4 +1,5 @@
|
||||
import videojs, { VideoJsPlayer } from "video.js";
|
||||
import "./markers.css";
|
||||
import CryptoJS from "crypto-js";
|
||||
|
||||
export interface IMarker {
|
||||
@@ -66,16 +67,14 @@ class MarkersPlugin extends videojs.getPlugin("plugin") {
|
||||
dot?: HTMLDivElement;
|
||||
range?: HTMLDivElement;
|
||||
} = {};
|
||||
const seekBar = this.player.el().querySelector(".vjs-progress-holder");
|
||||
const seekBar = this.player.el().querySelector(".vjs-progress-control");
|
||||
|
||||
markerSet.dot = videojs.dom.createEl("div") as HTMLDivElement;
|
||||
markerSet.dot.className = "vjs-marker";
|
||||
markerSet.dot.className = "vjs-marker-dot";
|
||||
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
|
||||
@@ -111,12 +110,11 @@ class MarkersPlugin extends videojs.getPlugin("plugin") {
|
||||
|
||||
private renderRangeMarkers(markers: IMarker[], layer: number) {
|
||||
const duration = this.player.duration();
|
||||
const parent = this.player.el().querySelector(".vjs-progress-control");
|
||||
const seekBar = this.player.el().querySelector(".vjs-progress-holder");
|
||||
if (!seekBar || !parent || !duration) return;
|
||||
const seekBar = this.player.el().querySelector(".vjs-progress-control");
|
||||
if (!seekBar || !duration) return;
|
||||
|
||||
markers.forEach((marker) => {
|
||||
this.renderRangeMarker(marker, layer, duration, seekBar, parent);
|
||||
this.renderRangeMarker(marker, layer, duration, seekBar);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,8 +122,7 @@ class MarkersPlugin extends videojs.getPlugin("plugin") {
|
||||
marker: IMarker,
|
||||
layer: number,
|
||||
duration: number,
|
||||
seekBar: Element,
|
||||
parent: Element
|
||||
seekBar: Element
|
||||
) {
|
||||
if (!marker.end_seconds) return;
|
||||
|
||||
@@ -136,18 +133,16 @@ class MarkersPlugin extends videojs.getPlugin("plugin") {
|
||||
const rangeDiv = videojs.dom.createEl("div") as HTMLDivElement;
|
||||
rangeDiv.className = "vjs-marker-range";
|
||||
|
||||
// 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`;
|
||||
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}%`;
|
||||
rangeDiv.style.bottom = `${layer * this.layerHeight}px`; // Adjust height based on layer
|
||||
rangeDiv.style.display = "none"; // Initially hidden
|
||||
|
||||
@@ -176,7 +171,7 @@ class MarkersPlugin extends videojs.getPlugin("plugin") {
|
||||
this.hideMarkerTooltip();
|
||||
markerSet.range?.toggleAttribute("marker-tooltip-shown", false);
|
||||
});
|
||||
parent.appendChild(rangeDiv);
|
||||
seekBar.appendChild(rangeDiv);
|
||||
this.markers.push(marker);
|
||||
this.markerDivs.push(markerSet);
|
||||
}
|
||||
|
||||
@@ -266,16 +266,6 @@ $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,10 +36,7 @@ export type SelectObject = {
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
export type Tag = Pick<
|
||||
GQL.Tag,
|
||||
"id" | "name" | "sort_name" | "aliases" | "image_path"
|
||||
>;
|
||||
export type Tag = Pick<GQL.Tag, "id" | "name" | "aliases" | "image_path">;
|
||||
type Option = SelectOption<Tag>;
|
||||
|
||||
type FindTagsResult = Awaited<
|
||||
@@ -60,7 +57,6 @@ const tagSelectSort = PatchFunction("TagSelect.sort", sortTagsByRelevance);
|
||||
export type TagSelectProps = IFilterProps &
|
||||
IFilterValueProps<Tag> & {
|
||||
hoverPlacement?: Placement;
|
||||
hoverPlacementLabel?: Placement;
|
||||
excludeIds?: string[];
|
||||
};
|
||||
|
||||
@@ -155,14 +151,7 @@ const _TagSelect: React.FC<TagSelectProps> = (props) => {
|
||||
|
||||
thisOptionProps = {
|
||||
...optionProps,
|
||||
children: (
|
||||
<TagPopover
|
||||
id={object.id}
|
||||
placement={props.hoverPlacementLabel ?? "top"}
|
||||
>
|
||||
<span>{object.name}</span>
|
||||
</TagPopover>
|
||||
),
|
||||
children: object.name,
|
||||
};
|
||||
|
||||
return <reactSelectComponents.MultiValueLabel {...thisOptionProps} />;
|
||||
@@ -296,20 +285,7 @@ const _TagIDSelect: React.FC<IFilterProps & IFilterIDProps<Tag>> = (props) => {
|
||||
|
||||
const load = async () => {
|
||||
const items = await loadObjectsByID(ids);
|
||||
|
||||
// #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);
|
||||
setValues(items);
|
||||
};
|
||||
|
||||
load();
|
||||
|
||||
@@ -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
|
||||
@@ -234,7 +238,6 @@ 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
|
||||
@@ -254,9 +257,7 @@ 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:
|
||||
|
||||
@@ -355,7 +356,6 @@ 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
|
||||
@@ -369,12 +369,11 @@ performer:
|
||||
return value[0].toUpperCase() + value.substring(1)
|
||||
}
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
Note that the `otto` javascript engine 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:
|
||||
@@ -393,11 +392,8 @@ 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.
|
||||
@@ -422,6 +418,7 @@ 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:
|
||||
@@ -436,9 +433,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:
|
||||
@@ -799,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.
|
||||
|
||||
@@ -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.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.27.0.tgz#fbee7cf97c709518ecc1f590984481d5460d4762"
|
||||
integrity sha512-VtPOkrdPHZsKc/clNqyi9WUA8TINkZ4cGk63UUE3u4pmB2k+ZMQRDuIOagv8UVd6j7k0T3+RRIb7beKTebNbcw==
|
||||
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==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
regenerator-runtime "^0.13.11"
|
||||
|
||||
"@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,11 +6726,6 @@ 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"
|
||||
@@ -8028,10 +8023,10 @@ vite-tsconfig-paths@^4.0.5:
|
||||
globrex "^0.1.2"
|
||||
tsconfck "^2.0.1"
|
||||
|
||||
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==
|
||||
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==
|
||||
dependencies:
|
||||
esbuild "^0.18.10"
|
||||
postcss "^8.4.27"
|
||||
|
||||
Reference in New Issue
Block a user