Compare commits

..

17 Commits

Author SHA1 Message Date
DogmaDragon
05f2343421 Update runner image
Ubuntu 20.04 was deprecated
2025-04-28 10:33:18 +03:00
DogmaDragon
d0ece86bb8 Update markdown syntax for Scrapers Development page (#5829)
* Update markdown syntax for Scrapers Development

* Fix typo
2025-04-16 08:56:21 +10:00
WithoutPants
62d7076ff3 Add missing group scraper fields (#5820) 2025-04-16 08:55:27 +10:00
WithoutPants
f9fb33e8cc Fix scene gallery viewer displaying incorrect image 2025-04-09 12:30:04 +10:00
mz28k
2375bc6cac Fix to find a match for a parent studio (#5810) 2025-04-07 14:58:59 +10:00
WithoutPants
87d01e86fd Fix range marker alignment (#5804) 2025-04-04 15:32:52 +11:00
feederbox826
e774706f43 update Dockerfile-CUDA (#5689) 2025-04-04 14:47:22 +11:00
WithoutPants
8efae13afb Revert dot marker styling changes (#5801)
* Move marker css into styles

Removes vjs-marker-dot styling, using existing vjs-marker class instead

* Revert dot marker changes
2025-04-02 16:13:54 +11:00
WithoutPants
6ed66f3275 Ignore missing scenes when submitting fingerprints (#5799) 2025-04-02 15:48:07 +11:00
WithoutPants
2eb7bde95a Fix marker preview deleted when modifying marker with duration (#5800) 2025-04-02 14:57:24 +11:00
luigi611
edbd9b69eb Partial fix for #2761 - Add reverse proxy prefix to HLS links (#5791)
* Partial fix for #2761 - Add reverse proxy prefix to HLS links
---------
Co-authored-by: Guido <guido@test.com>
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2025-04-02 14:26:39 +11:00
its-josh4
db06eae7cb Sort tags by name while scraping or merging scenes (#5752)
* Sort tags by name while scraping scenes
* TagStore.All should sort by sort_name first
* Sort tag by sort name/name in TagIDSelect
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2025-04-02 14:26:14 +11:00
dependabot[bot]
0f2bc3e01d Bump vite from 4.5.6 to 4.5.11 in /ui/v2.5 (#5797)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 4.5.6 to 4.5.11.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v4.5.11/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v4.5.11/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 14:25:28 +11:00
WithoutPants
ffee4df8b7 Log to stdout on exit error (#5798) 2025-04-02 14:25:11 +11:00
dependabot[bot]
2d5160c576 Bump @babel/runtime from 7.21.0 to 7.27.0 in /ui/v2.5 (#5766)
Bumps [@babel/runtime](https://github.com/babel/babel/tree/HEAD/packages/babel-runtime) from 7.21.0 to 7.27.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.27.0/packages/babel-runtime)

---
updated-dependencies:
- dependency-name: "@babel/runtime"
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-02 13:28:50 +11:00
blaspheme-ship-it
3489dca83a Display tag and performer image on hover. on the scene edit page (#5739)
* add component for PerformerPopover
* show PerformerPopover for performer select values
* show TagPopover for tag select values
2025-04-02 13:27:35 +11:00
WithoutPants
1d3bc40a6b Import/export bug fixes (#5780)
* Include parent tags in export if including dependencies
* Handle uniqueness when sanitising filenames
2025-04-01 15:04:26 +11:00
36 changed files with 520 additions and 236 deletions

View File

@@ -16,7 +16,7 @@ env:
jobs:
build:
runs-on: ubuntu-20.04
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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.

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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"`

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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{}

View File

@@ -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 {

View File

@@ -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])

View File

@@ -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(),
))
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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"
}

View File

@@ -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]
);

View 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>
);
};

View File

@@ -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>
),
};

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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);

View File

@@ -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();

View File

@@ -2,10 +2,6 @@
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
@@ -238,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
@@ -257,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:
@@ -356,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
@@ -369,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:
@@ -392,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.
@@ -418,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:
@@ -433,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:
@@ -796,120 +799,83 @@ 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
```
Aliases
Birthdate
CareerLength
Circumcised
Country
DeathDate
Details
Disambiguation
Ethnicity
EyeColor
FakeTits
Gender
HairColor
Height
Measurements
Name
PenisLength
Piercings
Tags (see Tag fields)
Tattoos
URLs
Gender
URL
Twitter
Instagram
Birthdate
DeathDate
Ethnicity
Country
HairColor
EyeColor
Height
Weight
Measurements
FakeTits
CareerLength
Tattoos
Piercings
Aliases
Tags (see Tag fields)
Image
Details
```
> **Important**: `Name` field is required.
> **Note:** - `Gender` must be one of `male`, `female`, `transgender_male`, `transgender_female`, `intersex`, `non_binary` (case insensitive).
*Note:* - `Gender` must be one of `male`, `female`, `transgender_male`, `transgender_female`, `intersex`, `non_binary` (case insensitive).
### Scene
```
Code
Date
Details
Director
Groups (see Group Fields)
Image
Performers (see Performer fields)
Studio (see Studio Fields)
Tags (see Tag fields)
Title
URLs
Details
Code
Director
URL
Date
Image
Studio (see Studio Fields)
Groups (see Group Fields)
Tags (see Tag fields)
Performers (list of Performer fields)
```
> **Important**: `Title` field is required only if fileless.
### Studio
```
Name
URL
```
> **Important**: `Name` field is required.
### Tag
```
Name
```
> **Important**: `Name` field is required.
### 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)
```

View File

@@ -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"