mirror of
https://github.com/stashapp/stash.git
synced 2026-06-11 07:41:08 -05:00
Compare commits
23 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aeb68a5851 | ||
|
|
5cf28cf8af | ||
|
|
08b73581a6 | ||
|
|
95a2c8d13f | ||
|
|
0b131f76df | ||
|
|
6271f18979 | ||
|
|
ca976a0994 | ||
|
|
9859ec61fb | ||
|
|
a998497004 | ||
|
|
f5e3fe77b7 | ||
|
|
743ab9a52c | ||
|
|
d23cecfc18 | ||
|
|
d8990e655d | ||
|
|
5b9a96b843 | ||
|
|
b968aa3f31 | ||
|
|
910c7025dc | ||
|
|
ea503833c5 | ||
|
|
6848dec5f4 | ||
|
|
bd7d4ac7ff | ||
|
|
5a6504b4ba | ||
|
|
f8a93789bb | ||
|
|
82cbeff9b5 | ||
|
|
f32d60f208 |
@@ -24,9 +24,11 @@ For further information you can consult the [documentation](https://docs.stashap
|
||||
|
||||
# Installing Stash
|
||||
|
||||
<img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> MacOS| <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker
|
||||
<img src="docs/readme_assets/windows_logo.svg" width="100%" height="75"> Windows | <img src="docs/readme_assets/mac_logo.svg" width="100%" height="75"> macOS | <img src="docs/readme_assets/linux_logo.svg" width="100%" height="75"> Linux | <img src="docs/readme_assets/docker_logo.svg" width="100%" height="75"> Docker
|
||||
:---:|:---:|:---:|:---:
|
||||
[Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe)</sub></sup> | [Latest Release (Apple Silicon)](https://github.com/stashapp/stash/releases/latest/download/stash-macos-applesilicon) <br />[Latest Release (Intel)](https://github.com/stashapp/stash/releases/latest/download/stash-macos-intel) <br /><sup><sub>[Development Preview (Universal)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-macos)</sub></sup> | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux) <br /> <sup><sub>[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)</sub></sup> <br /> [More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md) <br /> <sup><sub> [Sample docker-compose.yml](docker/production/docker-compose.yml)</sub></sup>
|
||||
[Latest Release](https://github.com/stashapp/stash/releases/latest/download/stash-win.exe) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/stash-win.exe)</sub></sup> | [Latest Release](https://github.com/stashapp/stash/releases/latest/download/Stash.app.zip) <br /> <sup><sub>[Development Preview](https://github.com/stashapp/stash/releases/download/latest_develop/Stash.app.zip)</sub></sup> | [Latest Release (amd64)](https://github.com/stashapp/stash/releases/latest/download/stash-linux) <br /> <sup><sub>[Development Preview (amd64)](https://github.com/stashapp/stash/releases/download/latest_develop/stash-linux)</sub></sup> <br /> [More Architectures...](https://github.com/stashapp/stash/releases/latest) | [Instructions](docker/production/README.md) <br /> <sup><sub>[Sample docker-compose.yml](docker/production/docker-compose.yml)</sub></sup>
|
||||
|
||||
Download links for other platforms and architectures are available on the [Releases page](https://github.com/stashapp/stash/releases).
|
||||
|
||||
## First Run
|
||||
|
||||
|
||||
@@ -11,8 +11,13 @@ RUN if [ "$TARGETPLATFORM" = "linux/arm/v6" ]; then BIN=stash-linux-arm32v6; \
|
||||
|
||||
FROM --platform=$TARGETPLATFORM alpine:latest AS app
|
||||
COPY --from=binary /stash /usr/bin/
|
||||
|
||||
# vips version 8.15.0-r0 breaks thumbnail generation on arm32v6
|
||||
# need to use 8.14.3-r0 from alpine 3.18 instead
|
||||
|
||||
RUN apk add --no-cache --virtual .build-deps gcc python3-dev musl-dev \
|
||||
&& apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg vips-tools ruby tzdata \
|
||||
&& apk add --no-cache ca-certificates python3 py3-requests py3-requests-toolbelt py3-lxml py3-pip ffmpeg ruby tzdata \
|
||||
&& apk add --no-cache --repository http://dl-cdn.alpinelinux.org/alpine/v3.18/community vips=8.14.3-r0 vips-tools=8.14.3-r0 \
|
||||
&& pip install --user --break-system-packages mechanicalsoup cloudscraper bencoder.pyx \
|
||||
&& gem install faraday \
|
||||
&& apk del .build-deps
|
||||
|
||||
@@ -9,11 +9,11 @@ https://docs.docker.com/engine/install/
|
||||
|
||||
### Get the docker-compose.yml file
|
||||
|
||||
Now you can either navigate to the [docker-compose.yml](https://raw.githubusercontent.com/stashapp/stash/master/docker/production/docker-compose.yml) in the repository, or if you have curl, you can make your Linux console do it for you:
|
||||
Now you can either navigate to the [docker-compose.yml](https://raw.githubusercontent.com/stashapp/stash/develop/docker/production/docker-compose.yml) in the repository, or if you have curl, you can make your Linux console do it for you:
|
||||
|
||||
```
|
||||
mkdir stashapp && cd stashapp
|
||||
curl -o docker-compose.yml https://raw.githubusercontent.com/stashapp/stash/master/docker/production/docker-compose.yml
|
||||
curl -o docker-compose.yml https://raw.githubusercontent.com/stashapp/stash/develop/docker/production/docker-compose.yml
|
||||
```
|
||||
|
||||
Once you have that file where you want it, modify the settings as you please, and then run:
|
||||
|
||||
@@ -61,16 +61,10 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio
|
||||
if err := r.withTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.repository.Studio
|
||||
|
||||
if err := studio.EnsureStudioNameUnique(ctx, 0, newStudio.Name, qb); err != nil {
|
||||
if err := studio.ValidateCreate(ctx, newStudio, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(input.Aliases) > 0 {
|
||||
if err := studio.EnsureAliasesUnique(ctx, 0, input.Aliases, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = qb.Create(ctx, &newStudio)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -392,7 +392,7 @@ func getScenePartial(scene *models.Scene, scraped *scraper.ScrapedScene, fieldOp
|
||||
switch getFieldStrategy(fieldOptions["url"]) {
|
||||
case FieldStrategyOverwrite:
|
||||
// only overwrite if not equal
|
||||
if len(sliceutil.Exclude(scene.URLs.List(), scraped.URLs)) != 0 {
|
||||
if len(sliceutil.Exclude(scraped.URLs, scene.URLs.List())) != 0 {
|
||||
partial.URLs = &models.UpdateStrings{
|
||||
Values: scraped.URLs,
|
||||
Mode: models.RelationshipUpdateModeSet,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/performer"
|
||||
"github.com/stashapp/stash/pkg/scraper/stashbox"
|
||||
"github.com/stashapp/stash/pkg/studio"
|
||||
)
|
||||
@@ -155,6 +156,10 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m
|
||||
|
||||
partial := p.ToPartial(t.box.Endpoint, excluded, existingStashIDs)
|
||||
|
||||
if err := performer.ValidateUpdate(ctx, t.performer.ID, partial, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := qb.UpdatePartial(ctx, t.performer.ID, partial); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -185,6 +190,10 @@ func (t *StashBoxBatchTagTask) processMatchedPerformer(ctx context.Context, p *m
|
||||
err = r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Performer
|
||||
|
||||
if err := performer.ValidateCreate(ctx, *newPerformer, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := qb.Create(ctx, newPerformer); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -346,6 +355,10 @@ func (t *StashBoxBatchTagTask) processMatchedStudio(ctx context.Context, s *mode
|
||||
err = r.WithTxn(ctx, func(ctx context.Context) error {
|
||||
qb := r.Studio
|
||||
|
||||
if err := studio.ValidateCreate(ctx, *newStudio, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := qb.Create(ctx, newStudio); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -44,22 +44,21 @@ func ScrapedPerformer(ctx context.Context, qb PerformerFinder, p *models.Scraped
|
||||
}
|
||||
|
||||
performers, err := qb.FindByNames(ctx, []string{*p.Name}, true)
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if performers == nil || len(performers) != 1 {
|
||||
// try matching a single performer by exact alias
|
||||
if len(performers) == 0 {
|
||||
// if no names matched, try match an exact alias
|
||||
performers, err = performer.ByAlias(ctx, qb, *p.Name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if performers == nil || len(performers) != 1 {
|
||||
// ignore - cannot match
|
||||
return nil
|
||||
}
|
||||
if len(performers) != 1 {
|
||||
// ignore - cannot match
|
||||
return nil
|
||||
}
|
||||
|
||||
id := strconv.Itoa(performers[0].ID)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
@@ -206,7 +207,15 @@ func convertHooks(hooks []HookTriggerEnum) []string {
|
||||
func (c Config) getPluginSettings() []PluginSetting {
|
||||
ret := []PluginSetting{}
|
||||
|
||||
for k, o := range c.Settings {
|
||||
var keys []string
|
||||
for k := range c.Settings {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
|
||||
sort.Strings(keys)
|
||||
|
||||
for _, k := range keys {
|
||||
o := c.Settings[k]
|
||||
t := o.Type
|
||||
if t == "" {
|
||||
t = PluginSettingTypeEnumString
|
||||
@@ -248,6 +257,7 @@ func (c Config) toPlugin() *Plugin {
|
||||
ExternalCSS: c.UI.getExternalCSS(),
|
||||
Javascript: c.UI.getJavascriptFiles(c),
|
||||
CSS: c.UI.getCSSFiles(c),
|
||||
CSP: c.UI.CSP,
|
||||
Assets: c.UI.Assets,
|
||||
},
|
||||
Settings: c.getPluginSettings(),
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/stashapp/stash/pkg/fsutil"
|
||||
"github.com/stashapp/stash/pkg/logger"
|
||||
@@ -129,6 +130,12 @@ func (s *Service) mergeSceneMarkers(ctx context.Context, dest *models.Scene, src
|
||||
destExists, _ := fsutil.FileExists(e.dest)
|
||||
|
||||
if srcExists && !destExists {
|
||||
destDir := filepath.Dir(e.dest)
|
||||
if err := fsutil.EnsureDir(destDir); err != nil {
|
||||
logger.Errorf("Error creating generated marker folder %s: %v", destDir, err)
|
||||
continue
|
||||
}
|
||||
|
||||
if err := os.Rename(e.src, e.dest); err != nil {
|
||||
logger.Errorf("Error renaming generated marker file from %s to %s: %v", e.src, e.dest, err)
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/scraper"
|
||||
"github.com/stashapp/stash/pkg/scraper/stashbox/graphql"
|
||||
"github.com/stashapp/stash/pkg/sliceutil"
|
||||
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
|
||||
"github.com/stashapp/stash/pkg/txn"
|
||||
"github.com/stashapp/stash/pkg/utils"
|
||||
@@ -669,6 +670,12 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc
|
||||
}
|
||||
|
||||
if len(p.Aliases) > 0 {
|
||||
// #4437 - stash-box may return aliases that are equal to the performer name
|
||||
// filter these out
|
||||
p.Aliases = sliceutil.Filter(p.Aliases, func(s string) bool {
|
||||
return !strings.EqualFold(s, p.Name)
|
||||
})
|
||||
|
||||
alias := strings.Join(p.Aliases, ", ")
|
||||
sp.Aliases = &alias
|
||||
}
|
||||
|
||||
@@ -542,6 +542,9 @@ func getPathSearchClauseMany(pathColumn, basenameColumn, p string, addWildcards,
|
||||
func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if c != nil {
|
||||
if addJoinFn != nil {
|
||||
addJoinFn(f)
|
||||
}
|
||||
clause, args := getIntCriterionWhereClause(column, *c)
|
||||
f.addWhere(clause, args...)
|
||||
}
|
||||
@@ -551,6 +554,9 @@ func intCriterionHandler(c *models.IntCriterionInput, column string, addJoinFn f
|
||||
func floatCriterionHandler(c *models.FloatCriterionInput, column string, addJoinFn func(f *filterBuilder)) criterionHandlerFunc {
|
||||
return func(ctx context.Context, f *filterBuilder) {
|
||||
if c != nil {
|
||||
if addJoinFn != nil {
|
||||
addJoinFn(f)
|
||||
}
|
||||
clause, args := getFloatCriterionWhereClause(column, *c)
|
||||
f.addWhere(clause, args...)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNameMissing = errors.New("studio name must not be blank")
|
||||
ErrStudioOwnAncestor = errors.New("studio cannot be an ancestor of itself")
|
||||
)
|
||||
|
||||
@@ -70,6 +71,32 @@ func EnsureAliasesUnique(ctx context.Context, id int, aliases []string, qb model
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateCreate(ctx context.Context, studio models.Studio, qb models.StudioQueryer) error {
|
||||
if err := validateName(ctx, 0, studio.Name, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if studio.Aliases.Loaded() && len(studio.Aliases.List()) > 0 {
|
||||
if err := EnsureAliasesUnique(ctx, 0, studio.Aliases.List(), qb); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateName(ctx context.Context, studioID int, name string, qb models.StudioQueryer) error {
|
||||
if name == "" {
|
||||
return ErrNameMissing
|
||||
}
|
||||
|
||||
if err := EnsureStudioNameUnique(ctx, studioID, name, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type ValidateModifyReader interface {
|
||||
models.StudioGetter
|
||||
models.StudioQueryer
|
||||
@@ -110,7 +137,7 @@ func ValidateModify(ctx context.Context, s models.StudioPartial, qb ValidateModi
|
||||
}
|
||||
|
||||
if s.Name.Set && s.Name.Value != existing.Name {
|
||||
if err := EnsureStudioNameUnique(ctx, 0, s.Name.Value, qb); err != nil {
|
||||
if err := validateName(ctx, s.ID, s.Name.Value, qb); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
104
pkg/studio/validate_test.go
Normal file
104
pkg/studio/validate_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package studio
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stashapp/stash/pkg/models"
|
||||
"github.com/stashapp/stash/pkg/models/mocks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
func nameFilter(n string) *models.StudioFilterType {
|
||||
return &models.StudioFilterType{
|
||||
Name: &models.StringCriterionInput{
|
||||
Value: n,
|
||||
Modifier: models.CriterionModifierEquals,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateName(t *testing.T) {
|
||||
db := mocks.NewDatabase()
|
||||
|
||||
const (
|
||||
name1 = "name 1"
|
||||
newName = "new name"
|
||||
)
|
||||
|
||||
existing1 := models.Studio{
|
||||
ID: 1,
|
||||
Name: name1,
|
||||
}
|
||||
|
||||
pp := 1
|
||||
findFilter := &models.FindFilterType{
|
||||
PerPage: &pp,
|
||||
}
|
||||
|
||||
db.Studio.On("Query", testCtx, nameFilter(name1), findFilter).Return([]*models.Studio{&existing1}, 1, nil)
|
||||
db.Studio.On("Query", testCtx, mock.Anything, findFilter).Return(nil, 0, nil)
|
||||
|
||||
tests := []struct {
|
||||
tName string
|
||||
name string
|
||||
want error
|
||||
}{
|
||||
{"missing name", "", ErrNameMissing},
|
||||
{"new name", newName, nil},
|
||||
{"existing name", name1, &NameExistsError{name1}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.tName, func(t *testing.T) {
|
||||
got := validateName(testCtx, 0, tt.name, db.Studio)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUpdateName(t *testing.T) {
|
||||
db := mocks.NewDatabase()
|
||||
|
||||
const (
|
||||
name1 = "name 1"
|
||||
name2 = "name 2"
|
||||
newName = "new name"
|
||||
)
|
||||
|
||||
existing1 := models.Studio{
|
||||
ID: 1,
|
||||
Name: name1,
|
||||
}
|
||||
existing2 := models.Studio{
|
||||
ID: 2,
|
||||
Name: name2,
|
||||
}
|
||||
|
||||
pp := 1
|
||||
findFilter := &models.FindFilterType{
|
||||
PerPage: &pp,
|
||||
}
|
||||
|
||||
db.Studio.On("Query", testCtx, nameFilter(name1), findFilter).Return([]*models.Studio{&existing1}, 1, nil)
|
||||
db.Studio.On("Query", testCtx, nameFilter(name2), findFilter).Return([]*models.Studio{&existing2}, 2, nil)
|
||||
db.Studio.On("Query", testCtx, mock.Anything, findFilter).Return(nil, 0, nil)
|
||||
|
||||
tests := []struct {
|
||||
tName string
|
||||
studio models.Studio
|
||||
name string
|
||||
want error
|
||||
}{
|
||||
{"missing name", existing1, "", ErrNameMissing},
|
||||
{"same name", existing2, name2, nil},
|
||||
{"new name", existing1, newName, nil},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.tName, func(t *testing.T) {
|
||||
got := validateName(testCtx, tt.studio.ID, tt.name, db.Studio)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -491,7 +491,6 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
if (!player) return;
|
||||
|
||||
function onplay(this: VideoJsPlayer) {
|
||||
this.persistVolume().enabled = true;
|
||||
if (scene.interactive && interactiveReady.current) {
|
||||
interactiveClient.play(this.currentTime());
|
||||
}
|
||||
@@ -767,13 +766,7 @@ export const ScenePlayer: React.FC<IScenePlayerProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
player.play()?.catch(() => {
|
||||
// Browser probably blocking non-muted autoplay, so mute and try again
|
||||
player.persistVolume().enabled = false;
|
||||
player.muted(true);
|
||||
|
||||
player.play();
|
||||
});
|
||||
player.play();
|
||||
auto.current = false;
|
||||
}, [getPlayer, scene, ready, interactiveClient, currentScript]);
|
||||
|
||||
|
||||
@@ -32,15 +32,21 @@ export const ExternalPlayerButton: React.FC<IExternalPlayerButtonProps> = ({
|
||||
streamURL.hash = `Intent;action=android.intent.action.VIEW;scheme=${scheme};type=video/mp4;S.title=${encodeURI(
|
||||
title
|
||||
)};end`;
|
||||
streamURL.protocol = "intent";
|
||||
url = streamURL.toString();
|
||||
|
||||
// #4401 - not allowed to set the protocol from a "special" protocol to a non-special protocol
|
||||
url = streamURL
|
||||
.toString()
|
||||
.replace(new RegExp(`^${streamURL.protocol}`), "intent:");
|
||||
} else if (isAppleDevice) {
|
||||
streamURL.host = "x-callback-url";
|
||||
streamURL.port = "";
|
||||
streamURL.pathname = "stream";
|
||||
streamURL.search = `url=${encodeURIComponent(stream)}`;
|
||||
streamURL.protocol = "vlc-x-callback";
|
||||
url = streamURL.toString();
|
||||
|
||||
// #4401 - not allowed to set the protocol from a "special" protocol to a non-special protocol
|
||||
url = streamURL
|
||||
.toString()
|
||||
.replace(new RegExp(`^${streamURL.protocol}`), "vlc-x-callback:");
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,7 +15,7 @@ import { objectTitle } from "src/core/files";
|
||||
import { QueuedScene } from "src/models/sceneQueue";
|
||||
|
||||
export interface IPlaylistViewer {
|
||||
scenes?: QueuedScene[];
|
||||
scenes: QueuedScene[];
|
||||
currentID?: string;
|
||||
start?: number;
|
||||
continue?: boolean;
|
||||
@@ -47,7 +47,7 @@ export const QueueViewer: React.FC<IPlaylistViewer> = ({
|
||||
const [lessLoading, setLessLoading] = useState(false);
|
||||
const [moreLoading, setMoreLoading] = useState(false);
|
||||
|
||||
const currentIndex = scenes?.findIndex((s) => s.id === currentID) ?? 0;
|
||||
const currentIndex = scenes.findIndex((s) => s.id === currentID);
|
||||
|
||||
useEffect(() => {
|
||||
setLessLoading(false);
|
||||
@@ -130,7 +130,7 @@ export const QueueViewer: React.FC<IPlaylistViewer> = ({
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
{currentIndex < (scenes ?? []).length - 1 || hasMoreScenes ? (
|
||||
{currentIndex < scenes.length - 1 || hasMoreScenes ? (
|
||||
<Button
|
||||
className="minimal"
|
||||
variant="secondary"
|
||||
@@ -162,7 +162,7 @@ export const QueueViewer: React.FC<IPlaylistViewer> = ({
|
||||
</Button>
|
||||
</div>
|
||||
) : undefined}
|
||||
<ol start={start}>{(scenes ?? []).map(renderPlaylistEntry)}</ol>
|
||||
<ol start={start}>{scenes.map(renderPlaylistEntry)}</ol>
|
||||
{hasMoreScenes ? (
|
||||
<div className="d-flex justify-content-center">
|
||||
<Button onClick={() => moreClicked()} disabled={moreLoading}>
|
||||
|
||||
@@ -81,9 +81,9 @@ interface IProps {
|
||||
onQueueNext: () => void;
|
||||
onQueuePrevious: () => void;
|
||||
onQueueRandom: () => void;
|
||||
onQueueSceneClicked: (sceneID: string) => void;
|
||||
onDelete: () => void;
|
||||
continuePlaylist: boolean;
|
||||
loadScene: (sceneID: string) => void;
|
||||
queueHasMoreScenes: boolean;
|
||||
onQueueMoreScenes: () => void;
|
||||
onQueueLessScenes: () => void;
|
||||
@@ -104,9 +104,9 @@ const ScenePage: React.FC<IProps> = ({
|
||||
onQueueNext,
|
||||
onQueuePrevious,
|
||||
onQueueRandom,
|
||||
onQueueSceneClicked,
|
||||
onDelete,
|
||||
continuePlaylist,
|
||||
loadScene,
|
||||
queueHasMoreScenes,
|
||||
onQueueMoreScenes,
|
||||
onQueueLessScenes,
|
||||
@@ -359,7 +359,7 @@ const ScenePage: React.FC<IProps> = ({
|
||||
<FormattedMessage id="details" />
|
||||
</Nav.Link>
|
||||
</Nav.Item>
|
||||
{(queueScenes ?? []).length > 0 ? (
|
||||
{queueScenes.length > 0 ? (
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="scene-queue-panel">
|
||||
<FormattedMessage id="queue" />
|
||||
@@ -445,7 +445,7 @@ const ScenePage: React.FC<IProps> = ({
|
||||
currentID={scene.id}
|
||||
continue={continuePlaylist}
|
||||
setContinue={setContinuePlaylist}
|
||||
onSceneClicked={loadScene}
|
||||
onSceneClicked={onQueueSceneClicked}
|
||||
onNext={onQueueNext}
|
||||
onPrevious={onQueuePrevious}
|
||||
onRandom={onQueueRandom}
|
||||
@@ -594,8 +594,11 @@ const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({
|
||||
const [queueStart, setQueueStart] = useState(1);
|
||||
|
||||
const autoplay = queryParams.get("autoplay") === "true";
|
||||
const autoPlayOnSelected =
|
||||
configuration?.interface.autostartVideoOnPlaySelected ?? false;
|
||||
|
||||
const currentQueueIndex = useMemo(
|
||||
() => (queueScenes ? queueScenes.findIndex((s) => s.id === id) : -1),
|
||||
() => queueScenes.findIndex((s) => s.id === id),
|
||||
[queueScenes, id]
|
||||
);
|
||||
|
||||
@@ -692,61 +695,46 @@ const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({
|
||||
history.replace(sceneLink);
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (
|
||||
continuePlaylist &&
|
||||
queueScenes &&
|
||||
currentQueueIndex >= 0 &&
|
||||
currentQueueIndex < queueScenes.length - 1
|
||||
) {
|
||||
loadScene(queueScenes[currentQueueIndex + 1].id);
|
||||
} else {
|
||||
history.push("/scenes");
|
||||
}
|
||||
}
|
||||
async function queueNext(autoPlay: boolean) {
|
||||
if (currentQueueIndex === -1) return;
|
||||
|
||||
async function onQueueNext() {
|
||||
if (!queueScenes) return;
|
||||
|
||||
if (currentQueueIndex >= 0 && currentQueueIndex < queueScenes.length - 1) {
|
||||
loadScene(queueScenes[currentQueueIndex + 1].id, true);
|
||||
if (currentQueueIndex < queueScenes.length - 1) {
|
||||
loadScene(queueScenes[currentQueueIndex + 1].id, autoPlay);
|
||||
} else {
|
||||
// if we're at the end of the queue, load more scenes
|
||||
if (
|
||||
currentQueueIndex >= 0 &&
|
||||
currentQueueIndex === queueScenes.length - 1 &&
|
||||
queueHasMoreScenes
|
||||
) {
|
||||
if (currentQueueIndex === queueScenes.length - 1 && queueHasMoreScenes) {
|
||||
const loadedScenes = await onQueueMoreScenes();
|
||||
if (loadedScenes && loadedScenes.length > 0) {
|
||||
// set the page to the next page
|
||||
const newPage = (sceneQueue.query?.currentPage ?? 0) + 1;
|
||||
loadScene(loadedScenes[0].id, true, newPage);
|
||||
loadScene(loadedScenes[0].id, autoPlay, newPage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onQueuePrevious() {
|
||||
if (!queueScenes) return;
|
||||
async function queuePrevious(autoPlay: boolean) {
|
||||
if (currentQueueIndex === -1) return;
|
||||
|
||||
if (currentQueueIndex > 0) {
|
||||
loadScene(queueScenes[currentQueueIndex - 1].id, true);
|
||||
loadScene(queueScenes[currentQueueIndex - 1].id, autoPlay);
|
||||
} else {
|
||||
// if we're at the beginning of the queue, load the previous page
|
||||
if (currentQueueIndex === 0 && queueStart > 1) {
|
||||
if (queueStart > 1) {
|
||||
const loadedScenes = await onQueueLessScenes();
|
||||
if (loadedScenes && loadedScenes.length > 0) {
|
||||
const newPage = (sceneQueue.query?.currentPage ?? 0) - 1;
|
||||
loadScene(loadedScenes[loadedScenes.length - 1].id, true, newPage);
|
||||
loadScene(
|
||||
loadedScenes[loadedScenes.length - 1].id,
|
||||
autoPlay,
|
||||
newPage
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onQueueRandom() {
|
||||
if (!queueScenes) return;
|
||||
|
||||
async function queueRandom(autoPlay: boolean) {
|
||||
if (sceneQueue.query) {
|
||||
const { query } = sceneQueue;
|
||||
const pages = Math.ceil(queueTotal / query.itemsPerPage);
|
||||
@@ -760,20 +748,30 @@ const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({
|
||||
if (queryResults.data.findScenes.scenes.length > index) {
|
||||
const { id: sceneID } = queryResults.data.findScenes.scenes[index];
|
||||
// navigate to the image player page
|
||||
loadScene(sceneID, undefined, page);
|
||||
loadScene(sceneID, autoPlay, page);
|
||||
}
|
||||
} else {
|
||||
} else if (queueTotal !== 0) {
|
||||
const index = Math.floor(Math.random() * queueTotal);
|
||||
loadScene(queueScenes[index].id);
|
||||
loadScene(queueScenes[index].id, autoPlay);
|
||||
}
|
||||
}
|
||||
|
||||
function onComplete() {
|
||||
if (!queueScenes) return;
|
||||
|
||||
// load the next scene if we're continuing
|
||||
if (continuePlaylist) {
|
||||
onQueueNext();
|
||||
queueNext(true);
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
if (
|
||||
continuePlaylist &&
|
||||
currentQueueIndex >= 0 &&
|
||||
currentQueueIndex < queueScenes.length - 1
|
||||
) {
|
||||
loadScene(queueScenes[currentQueueIndex + 1].id);
|
||||
} else {
|
||||
history.push("/scenes");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -789,8 +787,8 @@ const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({
|
||||
return Math.floor((index + queueStart - 1) / perPage) + 1;
|
||||
}
|
||||
|
||||
function onSceneClicked(sceneID: string) {
|
||||
loadScene(sceneID, true, getScenePage(sceneID));
|
||||
function onQueueSceneClicked(sceneID: string) {
|
||||
loadScene(sceneID, autoPlayOnSelected, getScenePage(sceneID));
|
||||
}
|
||||
|
||||
if (!scene) {
|
||||
@@ -804,14 +802,14 @@ const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({
|
||||
<ScenePage
|
||||
scene={scene}
|
||||
setTimestamp={setTimestamp}
|
||||
queueScenes={queueScenes ?? []}
|
||||
queueScenes={queueScenes}
|
||||
queueStart={queueStart}
|
||||
onDelete={onDelete}
|
||||
onQueueNext={onQueueNext}
|
||||
onQueuePrevious={onQueuePrevious}
|
||||
onQueueRandom={onQueueRandom}
|
||||
onQueueNext={() => queueNext(autoPlayOnSelected)}
|
||||
onQueuePrevious={() => queuePrevious(autoPlayOnSelected)}
|
||||
onQueueRandom={() => queueRandom(autoPlayOnSelected)}
|
||||
onQueueSceneClicked={onQueueSceneClicked}
|
||||
continuePlaylist={continuePlaylist}
|
||||
loadScene={onSceneClicked}
|
||||
queueHasMoreScenes={queueHasMoreScenes}
|
||||
onQueueLessScenes={onQueueLessScenes}
|
||||
onQueueMoreScenes={onQueueMoreScenes}
|
||||
@@ -829,8 +827,8 @@ const SceneLoader: React.FC<RouteComponentProps<ISceneParams>> = ({
|
||||
initialTimestamp={initialTimestamp}
|
||||
sendSetTimestamp={getSetTimestamp}
|
||||
onComplete={onComplete}
|
||||
onNext={onQueueNext}
|
||||
onPrevious={onQueuePrevious}
|
||||
onNext={() => queueNext(true)}
|
||||
onPrevious={() => queuePrevious(true)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -161,6 +161,7 @@ export const AvailableScraperPackages: React.FC = () => {
|
||||
addSource={addSource}
|
||||
editSource={editSource}
|
||||
deleteSource={deleteSource}
|
||||
allowSelectAll
|
||||
/>
|
||||
</div>
|
||||
</SettingSection>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from "react";
|
||||
import { Tab, Nav, Row, Col } from "react-bootstrap";
|
||||
import { useHistory, useLocation } from "react-router-dom";
|
||||
import { Redirect, RouteComponentProps } from "react-router-dom";
|
||||
import { LinkContainer } from "react-router-bootstrap";
|
||||
import { FormattedMessage } from "react-intl";
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTitleProps } from "src/hooks/title";
|
||||
@@ -18,83 +19,133 @@ import { SettingsLibraryPanel } from "./SettingsLibraryPanel";
|
||||
import { SettingsSecurityPanel } from "./SettingsSecurityPanel";
|
||||
import Changelog from "../Changelog/Changelog";
|
||||
|
||||
export const Settings: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const history = useHistory();
|
||||
const defaultTab = new URLSearchParams(location.search).get("tab") ?? "tasks";
|
||||
const validTabs = [
|
||||
"tasks",
|
||||
"library",
|
||||
"interface",
|
||||
"security",
|
||||
"metadata-providers",
|
||||
"services",
|
||||
"system",
|
||||
"plugins",
|
||||
"logs",
|
||||
"tools",
|
||||
"changelog",
|
||||
"about",
|
||||
] as const;
|
||||
type TabKey = (typeof validTabs)[number];
|
||||
|
||||
const onSelect = (val: string) => history.push(`?tab=${val}`);
|
||||
const defaultTab: TabKey = "tasks";
|
||||
|
||||
function isTabKey(tab: string | null): tab is TabKey {
|
||||
return validTabs.includes(tab as TabKey);
|
||||
}
|
||||
|
||||
const Settings: React.FC<RouteComponentProps> = ({ location }) => {
|
||||
const tab = new URLSearchParams(location.search).get("tab");
|
||||
|
||||
const titleProps = useTitleProps({ id: "settings" });
|
||||
|
||||
if (!isTabKey(tab)) {
|
||||
return (
|
||||
<Redirect
|
||||
to={{
|
||||
...location,
|
||||
search: `tab=${defaultTab}`,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Tab.Container
|
||||
activeKey={defaultTab}
|
||||
id="configuration-tabs"
|
||||
onSelect={(tab) => tab && onSelect(tab)}
|
||||
>
|
||||
<Tab.Container activeKey={tab} id="configuration-tabs">
|
||||
<Helmet {...titleProps} />
|
||||
<Row>
|
||||
<Col id="settings-menu-container" sm={3} md={3} xl={2}>
|
||||
<Nav variant="pills" className="flex-column">
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="tasks">
|
||||
<FormattedMessage id="config.categories.tasks" />
|
||||
</Nav.Link>
|
||||
<LinkContainer to="/settings?tab=tasks">
|
||||
<Nav.Link eventKey="tasks">
|
||||
<FormattedMessage id="config.categories.tasks" />
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="library">
|
||||
<FormattedMessage id="library" />
|
||||
</Nav.Link>
|
||||
<LinkContainer to="/settings?tab=library">
|
||||
<Nav.Link eventKey="library">
|
||||
<FormattedMessage id="library" />
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="interface">
|
||||
<FormattedMessage id="config.categories.interface" />
|
||||
</Nav.Link>
|
||||
<LinkContainer to="/settings?tab=interface">
|
||||
<Nav.Link eventKey="interface">
|
||||
<FormattedMessage id="config.categories.interface" />
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="security">
|
||||
<FormattedMessage id="config.categories.security" />
|
||||
</Nav.Link>
|
||||
<LinkContainer to="/settings?tab=security">
|
||||
<Nav.Link eventKey="security">
|
||||
<FormattedMessage id="config.categories.security" />
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="metadata-providers">
|
||||
<FormattedMessage id="config.categories.metadata_providers" />
|
||||
</Nav.Link>
|
||||
<LinkContainer to="/settings?tab=metadata-providers">
|
||||
<Nav.Link eventKey="metadata-providers">
|
||||
<FormattedMessage id="config.categories.metadata_providers" />
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="services">
|
||||
<FormattedMessage id="config.categories.services" />
|
||||
</Nav.Link>
|
||||
<LinkContainer to="/settings?tab=services">
|
||||
<Nav.Link eventKey="services">
|
||||
<FormattedMessage id="config.categories.services" />
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="system">
|
||||
<FormattedMessage id="config.categories.system" />
|
||||
</Nav.Link>
|
||||
<LinkContainer to="/settings?tab=system">
|
||||
<Nav.Link eventKey="system">
|
||||
<FormattedMessage id="config.categories.system" />
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="plugins">
|
||||
<FormattedMessage id="config.categories.plugins" />
|
||||
</Nav.Link>
|
||||
<LinkContainer to="/settings?tab=plugins">
|
||||
<Nav.Link eventKey="plugins">
|
||||
<FormattedMessage id="config.categories.plugins" />
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="logs">
|
||||
<FormattedMessage id="config.categories.logs" />
|
||||
</Nav.Link>
|
||||
<LinkContainer to="/settings?tab=logs">
|
||||
<Nav.Link eventKey="logs">
|
||||
<FormattedMessage id="config.categories.logs" />
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="tools">
|
||||
<FormattedMessage id="config.categories.tools" />
|
||||
</Nav.Link>
|
||||
<LinkContainer to="/settings?tab=tools">
|
||||
<Nav.Link eventKey="tools">
|
||||
<FormattedMessage id="config.categories.tools" />
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="changelog">
|
||||
<FormattedMessage id="config.categories.changelog" />
|
||||
</Nav.Link>
|
||||
<LinkContainer to="/settings?tab=changelog">
|
||||
<Nav.Link eventKey="changelog">
|
||||
<FormattedMessage id="config.categories.changelog" />
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<Nav.Item>
|
||||
<Nav.Link eventKey="about">
|
||||
<FormattedMessage id="config.categories.about" />
|
||||
</Nav.Link>
|
||||
<LinkContainer to="/settings?tab=about">
|
||||
<Nav.Link eventKey="about">
|
||||
<FormattedMessage id="config.categories.about" />
|
||||
</Nav.Link>
|
||||
</LinkContainer>
|
||||
</Nav.Item>
|
||||
<hr className="d-sm-none" />
|
||||
</Nav>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useLayoutEffect, useRef } from "react";
|
||||
import { remToPx } from "src/utils/units";
|
||||
|
||||
const DEFAULT_WIDTH = "200";
|
||||
const DEFAULT_WIDTH = Math.round(remToPx(30));
|
||||
|
||||
// Props used by the <img> element
|
||||
type IDetailImageProps = JSX.IntrinsicElements["img"];
|
||||
@@ -17,7 +18,7 @@ export const DetailImage = (props: IDetailImageProps) => {
|
||||
// If the naturalWidth is zero, it means the image either hasn't loaded yet
|
||||
// or we're on Firefox and it is an SVG w/o an intrinsic size.
|
||||
// So set the width to our fallback width.
|
||||
img.setAttribute("width", DEFAULT_WIDTH);
|
||||
img.setAttribute("width", String(DEFAULT_WIDTH));
|
||||
} else {
|
||||
// If we have a `naturalWidth`, this could either be the actual intrinsic width
|
||||
// of the image, or the image is an SVG w/o an intrinsic size and we're on Chrome or Safari,
|
||||
@@ -26,7 +27,7 @@ export const DetailImage = (props: IDetailImageProps) => {
|
||||
// so we need to clone the image to disconnect it from the DOM, and then get the `naturalWidth` of the clone,
|
||||
// in order to always return the same `naturalWidth` for a given src.
|
||||
const i = img.cloneNode() as HTMLImageElement;
|
||||
img.setAttribute("width", (i.naturalWidth || DEFAULT_WIDTH).toString());
|
||||
img.setAttribute("width", String(i.naturalWidth || DEFAULT_WIDTH));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -60,24 +60,6 @@ export const ModalComponent: React.FC<IModal> = ({
|
||||
<div>{leftFooterButtons}</div>
|
||||
<div>
|
||||
{footerButtons}
|
||||
<Button
|
||||
disabled={isRunning || disabled}
|
||||
variant={accept?.variant ?? "primary"}
|
||||
onClick={accept?.onClick}
|
||||
className="ml-2"
|
||||
>
|
||||
{isRunning ? (
|
||||
<Spinner animation="border" role="status" size="sm" />
|
||||
) : (
|
||||
accept?.text ?? (
|
||||
<FormattedMessage
|
||||
id="actions.close"
|
||||
defaultMessage="Close"
|
||||
description="Closes the current modal."
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Button>
|
||||
{cancel ? (
|
||||
<Button
|
||||
disabled={isRunning}
|
||||
@@ -96,6 +78,24 @@ export const ModalComponent: React.FC<IModal> = ({
|
||||
) : (
|
||||
""
|
||||
)}
|
||||
<Button
|
||||
disabled={isRunning || disabled}
|
||||
variant={accept?.variant ?? "primary"}
|
||||
onClick={accept?.onClick}
|
||||
className="ml-2"
|
||||
>
|
||||
{isRunning ? (
|
||||
<Spinner animation="border" role="status" size="sm" />
|
||||
) : (
|
||||
accept?.text ?? (
|
||||
<FormattedMessage
|
||||
id="actions.close"
|
||||
defaultMessage="Close"
|
||||
description="Closes the current modal."
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
|
||||
@@ -224,6 +224,9 @@ const InstalledPackagesList: React.FC<{
|
||||
</th>
|
||||
) : undefined}
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border-row" colSpan={100}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{renderBody()}</tbody>
|
||||
</Table>
|
||||
@@ -620,6 +623,7 @@ const SourcePackagesList: React.FC<{
|
||||
loadSource: () => Promise<RemotePackage[]>;
|
||||
selectedOnly: boolean;
|
||||
selectedPackages: RemotePackage[];
|
||||
allowSelectAll?: boolean;
|
||||
setSelectedPackages: React.Dispatch<React.SetStateAction<RemotePackage[]>>;
|
||||
renderDescription?: (pkg: RemotePackage) => React.ReactNode;
|
||||
editSource: () => void;
|
||||
@@ -627,6 +631,7 @@ const SourcePackagesList: React.FC<{
|
||||
}> = ({
|
||||
source,
|
||||
loadSource,
|
||||
allowSelectAll,
|
||||
selectedOnly,
|
||||
selectedPackages,
|
||||
setSelectedPackages,
|
||||
@@ -785,7 +790,7 @@ const SourcePackagesList: React.FC<{
|
||||
<>
|
||||
<tr className="package-source">
|
||||
<td>
|
||||
{packages !== undefined ? (
|
||||
{allowSelectAll && packages !== undefined ? (
|
||||
<Form.Check
|
||||
checked={sourceChecked ?? false}
|
||||
onChange={() => toggleSource()}
|
||||
@@ -844,6 +849,7 @@ const AvailablePackagesList: React.FC<{
|
||||
React.SetStateAction<Record<string, RemotePackage[]>>
|
||||
>;
|
||||
selectedOnly: boolean;
|
||||
allowSourceSelectAll?: boolean;
|
||||
addSource: (src: GQL.PackageSource) => void;
|
||||
editSource: (existing: GQL.PackageSource, changed: GQL.PackageSource) => void;
|
||||
deleteSource: (source: GQL.PackageSource) => void;
|
||||
@@ -859,6 +865,7 @@ const AvailablePackagesList: React.FC<{
|
||||
addSource,
|
||||
editSource,
|
||||
deleteSource,
|
||||
allowSourceSelectAll,
|
||||
}) => {
|
||||
const [deletingSource, setDeletingSource] = useState<GQL.PackageSource>();
|
||||
const [editingSource, setEditingSource] = useState<GQL.PackageSource>();
|
||||
@@ -920,6 +927,7 @@ const AvailablePackagesList: React.FC<{
|
||||
setSelectedPackages={(v) => setSelectedSourcePackages(src, v)}
|
||||
editSource={() => setEditingSource(src)}
|
||||
deleteSource={() => setDeletingSource(src)}
|
||||
allowSelectAll={allowSourceSelectAll}
|
||||
/>
|
||||
))}
|
||||
<tr className="add-package-source">
|
||||
@@ -983,6 +991,9 @@ const AvailablePackagesList: React.FC<{
|
||||
<FormattedMessage id="package_manager.description" />
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border-row" colSpan={100}></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>{renderBody()}</tbody>
|
||||
</Table>
|
||||
@@ -1000,6 +1011,7 @@ export const AvailablePackages: React.FC<{
|
||||
addSource: (src: GQL.PackageSource) => void;
|
||||
editSource: (existing: GQL.PackageSource, changed: GQL.PackageSource) => void;
|
||||
deleteSource: (source: GQL.PackageSource) => void;
|
||||
allowSelectAll?: boolean;
|
||||
}> = ({
|
||||
sources,
|
||||
loadSource,
|
||||
@@ -1009,6 +1021,7 @@ export const AvailablePackages: React.FC<{
|
||||
addSource,
|
||||
editSource,
|
||||
deleteSource,
|
||||
allowSelectAll,
|
||||
}) => {
|
||||
const [checkedPackages, setCheckedPackages] = useState<
|
||||
Record<string, RemotePackage[]>
|
||||
@@ -1060,6 +1073,7 @@ export const AvailablePackages: React.FC<{
|
||||
addSource={addSource}
|
||||
editSource={editSource}
|
||||
deleteSource={deleteSource}
|
||||
allowSourceSelectAll={allowSelectAll}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
.package-manager-table-container {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
|
||||
th {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
table thead {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useLocalForage } from "src/hooks/LocalForage";
|
||||
import { useToast } from "src/hooks/Toast";
|
||||
import { ConfigurationContext } from "src/hooks/Config";
|
||||
import { ITaggerSource, SCRAPER_PREFIX, STASH_BOX_PREFIX } from "./constants";
|
||||
import { errorToString } from "src/utils";
|
||||
|
||||
export interface ITaggerContextState {
|
||||
config: ITaggerConfig;
|
||||
@@ -293,21 +294,29 @@ export const TaggerContext: React.FC = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await queryScrapeScene(currentSource.sourceInput, sceneID);
|
||||
let newResult: ISceneQueryResult;
|
||||
|
||||
if (results.error) {
|
||||
newResult = { error: results.error.message };
|
||||
} else if (results.errors) {
|
||||
newResult = { error: results.errors.toString() };
|
||||
} else {
|
||||
newResult = {
|
||||
results: results.data.scrapeSingleScene.map((r) => ({
|
||||
...r,
|
||||
// scenes are already resolved if they are scraped via fragment
|
||||
resolved: true,
|
||||
})),
|
||||
};
|
||||
try {
|
||||
const results = await queryScrapeScene(
|
||||
currentSource.sourceInput,
|
||||
sceneID
|
||||
);
|
||||
|
||||
if (results.error) {
|
||||
newResult = { error: results.error.message };
|
||||
} else if (results.errors) {
|
||||
newResult = { error: results.errors.toString() };
|
||||
} else {
|
||||
newResult = {
|
||||
results: results.data.scrapeSingleScene.map((r) => ({
|
||||
...r,
|
||||
// scenes are already resolved if they are scraped via fragment
|
||||
resolved: true,
|
||||
})),
|
||||
};
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
newResult = { error: errorToString(err) };
|
||||
}
|
||||
|
||||
setSearchResults((current) => {
|
||||
|
||||
@@ -690,27 +690,30 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
}
|
||||
}
|
||||
|
||||
const renderTagsField = () => (
|
||||
<div className="mt-2">
|
||||
<div>
|
||||
<Form.Group controlId="tags" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: `${intl.formatMessage({ id: "tags" })}:`,
|
||||
})}
|
||||
<Col sm={9} xl={12}>
|
||||
<TagSelect
|
||||
isMulti
|
||||
onSelect={(items) => {
|
||||
setTagIDs(items.map((i) => i.id));
|
||||
}}
|
||||
ids={tagIDs}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
</div>
|
||||
{scene.tags
|
||||
?.filter((t) => !t.stored_id)
|
||||
.map((t) => (
|
||||
function maybeRenderTagsField() {
|
||||
if (!config.setTags) return;
|
||||
|
||||
const createTags = scene.tags?.filter((t) => !t.stored_id);
|
||||
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<div>
|
||||
<Form.Group controlId="tags" as={Row}>
|
||||
{FormUtils.renderLabel({
|
||||
title: `${intl.formatMessage({ id: "tags" })}:`,
|
||||
})}
|
||||
<Col sm={9} xl={12}>
|
||||
<TagSelect
|
||||
isMulti
|
||||
onSelect={(items) => {
|
||||
setTagIDs(items.map((i) => i.id));
|
||||
}}
|
||||
ids={tagIDs}
|
||||
/>
|
||||
</Col>
|
||||
</Form.Group>
|
||||
</div>
|
||||
{createTags?.map((t) => (
|
||||
<Badge
|
||||
className="tag-item"
|
||||
variant="secondary"
|
||||
@@ -725,8 +728,9 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
</Button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <LoadingIndicator card />;
|
||||
@@ -769,7 +773,7 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
|
||||
<div className="col-lg-6">
|
||||
{maybeRenderStudioField()}
|
||||
{renderPerformerField()}
|
||||
{renderTagsField()}
|
||||
{maybeRenderTagsField()}
|
||||
|
||||
<div className="row no-gutters mt-2 align-items-center justify-content-end">
|
||||
<OperationButton operation={handleSave}>
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
* Added option to Duplicate Checker to select all files except the highest resolution. ([#4286](https://github.com/stashapp/stash/pull/4286))
|
||||
|
||||
### 🎨 Improvements
|
||||
* **[0.24.2]** Hide Tags input in Tagger when Set Tags is disabled. ([#4440](https://github.com/stashapp/stash/pull/4440))
|
||||
* Show Performer image in Performer select list. ([#4227](https://github.com/stashapp/stash/pull/4227))
|
||||
* Match Performers by alias during scraping and tagging if no Performer is found with the exact name (only if a single performer is found with the alias). ([#4182](https://github.com/stashapp/stash/pull/4182))
|
||||
* Show Performer disambiguation and add stash-box links to Studio in tagger results. ([#4180](https://github.com/stashapp/stash/pull/4180))
|
||||
@@ -21,6 +22,21 @@
|
||||
* Added support for setting plugins path from the UI. ([#4382](https://github.com/stashapp/stash/pull/4382))
|
||||
|
||||
### 🐛 Bug fixes
|
||||
* **[0.24.3]** Fixed error when editing case of existing studio name. ([#4447](https://github.com/stashapp/stash/pull/4447))
|
||||
* **[0.24.3]** Fixed videos muting after auto-play fails. ([#4450](https://github.com/stashapp/stash/pull/4450))
|
||||
* **[0.24.2]** Fixed error when renaming marker files during scene merge operation ([#4446](https://github.com/stashapp/stash/pull/4446))
|
||||
* **[0.24.2]** Fixed error when creating/updating a Performer where an alias is the same as the Performer name. ([#4443](https://github.com/stashapp/stash/pull/4443))
|
||||
* **[0.24.2]** Errors during the tagger Scrape All operation now output to the scene card and no longer stop the operation. ([#4442](https://github.com/stashapp/stash/pull/4442))
|
||||
* **[0.24.2]** Fixed studio image sizing on details pages. ([#4441](https://github.com/stashapp/stash/pull/4441))
|
||||
* **[0.24.2]** Fixed URL not being overwritten when specified during Identify ([#4412](https://github.com/stashapp/stash/pull/4412))
|
||||
* **[0.24.2]** Fixed plugin settings to be sorted alphabetically, instead of being displayed in a random order. ([#4435](https://github.com/stashapp/stash/pull/4435))
|
||||
* **[0.24.2]** Fixed scene queue not respecting the Auto-start video setting. ([#4428](https://github.com/stashapp/stash/pull/4428))
|
||||
* **[0.24.2]** Fixed performers incorrectly being matched by alias during scraping. ([#4432](https://github.com/stashapp/stash/pull/4432))
|
||||
* **[0.24.2]** Fixed error when filtering on Scene interactive speed. ([#4414](https://github.com/stashapp/stash/pull/4414))
|
||||
* **[0.24.2]** Fixed plugin CSP not being enacted. ([#4424](https://github.com/stashapp/stash/pull/4424))
|
||||
* **[0.24.1]** Fixed external player button not working correctly. ([#4403](https://github.com/stashapp/stash/pull/4403))
|
||||
* **[0.24.1]** Fixed image thumbnail generation on arm devices. ([#4402](https://github.com/stashapp/stash/pull/4402))
|
||||
* **[0.24.1]** Reverted change to modal button order. ([#4400](https://github.com/stashapp/stash/pull/4400))
|
||||
* Fixed submitting to stash-box not working after switching to another scene using the queue. ([#4354](https://github.com/stashapp/stash/pull/4354))
|
||||
* Fixed UI crash when clearing a value from a URL or alias list. ([#4344](https://github.com/stashapp/stash/pull/4344))
|
||||
* Fixed panic when exporting galleries. ([#4311](https://github.com/stashapp/stash/pull/4311))
|
||||
|
||||
@@ -6,6 +6,7 @@ import React, {
|
||||
useMemo,
|
||||
} from "react";
|
||||
import { Toast } from "react-bootstrap";
|
||||
import { errorToString } from "src/utils";
|
||||
|
||||
interface IToast {
|
||||
header?: string;
|
||||
@@ -67,16 +68,7 @@ export const useToast = () => {
|
||||
});
|
||||
},
|
||||
error(error: unknown) {
|
||||
let message;
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
}
|
||||
if (!message) {
|
||||
message = String(error);
|
||||
}
|
||||
if (!message) {
|
||||
message = "Unknown error";
|
||||
}
|
||||
const message = errorToString(error);
|
||||
|
||||
console.error(error);
|
||||
addToast({
|
||||
|
||||
@@ -491,6 +491,14 @@ textarea.text-input {
|
||||
}
|
||||
}
|
||||
|
||||
/* stylelint-disable declaration-no-important */
|
||||
.border-row {
|
||||
background-color: #414c53;
|
||||
height: 1px;
|
||||
padding: 0 !important;
|
||||
}
|
||||
/* stylelint-enable declaration-no-important */
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.row.justify-content-center {
|
||||
margin-left: 0;
|
||||
|
||||
@@ -112,8 +112,8 @@ export class SceneQueue {
|
||||
let params = [
|
||||
this.makeQueryParameters(options.sceneIndex, options.newPage),
|
||||
];
|
||||
if (options.autoPlay !== undefined) {
|
||||
params.push("autoplay=" + options.autoPlay);
|
||||
if (options.autoPlay) {
|
||||
params.push("autoplay=true");
|
||||
}
|
||||
if (options.continue !== undefined) {
|
||||
params.push("continue=" + options.continue);
|
||||
|
||||
@@ -2,3 +2,18 @@ import { ApolloError } from "@apollo/client";
|
||||
|
||||
export const apolloError = (error: unknown) =>
|
||||
error instanceof ApolloError ? error.message : "";
|
||||
|
||||
export function errorToString(error: unknown) {
|
||||
let message;
|
||||
if (error instanceof Error) {
|
||||
message = error.message;
|
||||
}
|
||||
if (!message) {
|
||||
message = String(error);
|
||||
}
|
||||
if (!message) {
|
||||
message = "Unknown error";
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
@@ -15,3 +15,7 @@ export function cmToInches(cm: number) {
|
||||
const inches = cm * cmInInches;
|
||||
return inches;
|
||||
}
|
||||
|
||||
export function remToPx(rem: number) {
|
||||
return rem * parseFloat(getComputedStyle(document.documentElement).fontSize);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user