Compare commits

...

5 Commits

Author SHA1 Message Date
DogmaDragon
562b790c39 docs: add warning emojis to important notes across multiple documentation files 2026-01-27 07:35:25 +02:00
CJ
6bb22146b2 make ImageCard patchable for plugin extensibility (#6470)
* refactor(ui): make ImageCard patchable for plugin extensibility

Refactor ImageCard component to use PatchComponent wrapper.

Changes:
- Wrap ImageCard and sub-components with PatchComponent
- Extract ImageCardPopovers, ImageCardDetails, ImageCardOverlays,
  ImageCardImage as separate patchable components

* Add documentation
---------
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
2026-01-27 16:10:49 +11:00
DogmaDragon
09044b92bf docs: add missing patchable components and library (#6517) 2026-01-27 16:06:27 +11:00
Gykes
2c8e7d709f FR: Add Interfaces to Destroy File Database Entries (#6437) 2026-01-27 16:02:47 +11:00
Gykes
bef4e3fbd5 Feature: Add "Troubleshooting Mode" (#6343)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
Co-authored-by: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com>
2026-01-27 14:26:26 +11:00
48 changed files with 652 additions and 138 deletions

View File

@@ -422,6 +422,8 @@ type Mutation {
"""
moveFiles(input: MoveFilesInput!): Boolean!
deleteFiles(ids: [ID!]!): Boolean!
"Deletes file entries from the database without deleting the files from the filesystem"
destroyFiles(ids: [ID!]!): Boolean!
fileSetFingerprints(input: FileSetFingerprintsInput!): Boolean!

View File

@@ -395,6 +395,9 @@ input ConfigInterfaceInput {
customLocales: String
customLocalesEnabled: Boolean
"When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting"
disableCustomizations: Boolean
"Interface language"
language: String
@@ -469,6 +472,9 @@ type ConfigInterfaceResult {
customLocales: String
customLocalesEnabled: Boolean
"When true, disables all customizations (plugins, CSS, JavaScript, locales) for troubleshooting"
disableCustomizations: Boolean
"Interface language"
language: String

View File

@@ -100,6 +100,8 @@ input GalleryDestroyInput {
"""
delete_file: Boolean
delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
}
type FindGalleriesResultType {

View File

@@ -82,12 +82,16 @@ input ImageDestroyInput {
id: ID!
delete_file: Boolean
delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
}
input ImagesDestroyInput {
ids: [ID!]!
delete_file: Boolean
delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
}
type FindImagesResultType {

View File

@@ -196,12 +196,16 @@ input SceneDestroyInput {
id: ID!
delete_file: Boolean
delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
}
input ScenesDestroyInput {
ids: [ID!]!
delete_file: Boolean
delete_generated: Boolean
"If true, delete the file entry from the database if the file is not assigned to any other objects"
destroy_file_entry: Boolean
}
type FindScenesResultType {

View File

@@ -515,6 +515,8 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI
r.setConfigBool(config.CustomLocalesEnabled, input.CustomLocalesEnabled)
r.setConfigBool(config.DisableCustomizations, input.DisableCustomizations)
if input.DisableDropdownCreate != nil {
ddc := input.DisableDropdownCreate
r.setConfigBool(config.DisableDropdownCreatePerformer, ddc.Performer)

View File

@@ -210,6 +210,58 @@ func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret b
return true, nil
}
func (r *mutationResolver) DestroyFiles(ctx context.Context, ids []string) (ret bool, err error) {
fileIDs, err := stringslice.StringSliceToIntSlice(ids)
if err != nil {
return false, fmt.Errorf("converting ids: %w", err)
}
destroyer := &file.ZipDestroyer{
FileDestroyer: r.repository.File,
FolderDestroyer: r.repository.Folder,
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.File
for _, fileIDInt := range fileIDs {
fileID := models.FileID(fileIDInt)
f, err := qb.Find(ctx, fileID)
if err != nil {
return err
}
if len(f) == 0 {
return fmt.Errorf("file with id %d not found", fileID)
}
path := f[0].Base().Path
// ensure not a primary file
isPrimary, err := qb.IsPrimary(ctx, fileID)
if err != nil {
return fmt.Errorf("checking if file %s is primary: %w", path, err)
}
if isPrimary {
return fmt.Errorf("cannot destroy primary file entry %s", path)
}
// destroy DB entries only (no filesystem deletion)
const deleteFile = false
if err := destroyer.DestroyZip(ctx, f[0], nil, deleteFile); err != nil {
return fmt.Errorf("destroying file entry %s: %w", path, err)
}
}
return nil
}); err != nil {
return false, err
}
return true, nil
}
func (r *mutationResolver) FileSetFingerprints(ctx context.Context, input FileSetFingerprintsInput) (bool, error) {
fileIDInt, err := strconv.Atoi(input.ID)
if err != nil {

View File

@@ -346,6 +346,7 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall
deleteGenerated := utils.IsTrue(input.DeleteGenerated)
deleteFile := utils.IsTrue(input.DeleteFile)
destroyFileEntry := utils.IsTrue(input.DestroyFileEntry)
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Gallery
@@ -366,7 +367,7 @@ func (r *mutationResolver) GalleryDestroy(ctx context.Context, input models.Gall
galleries = append(galleries, gallery)
imgsDestroyed, err = r.galleryService.Destroy(ctx, gallery, fileDeleter, deleteGenerated, deleteFile)
imgsDestroyed, err = r.galleryService.Destroy(ctx, gallery, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)
if err != nil {
return err
}

View File

@@ -325,7 +325,7 @@ func (r *mutationResolver) ImageDestroy(ctx context.Context, input models.ImageD
return fmt.Errorf("image with id %d not found", imageID)
}
return r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile))
return r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile), utils.IsTrue(input.DestroyFileEntry))
}); err != nil {
fileDeleter.Rollback()
return false, err
@@ -372,7 +372,7 @@ func (r *mutationResolver) ImagesDestroy(ctx context.Context, input models.Image
images = append(images, i)
if err := r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile)); err != nil {
if err := r.imageService.Destroy(ctx, i, fileDeleter, utils.IsTrue(input.DeleteGenerated), utils.IsTrue(input.DeleteFile), utils.IsTrue(input.DestroyFileEntry)); err != nil {
return err
}
}

View File

@@ -441,6 +441,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
deleteGenerated := utils.IsTrue(input.DeleteGenerated)
deleteFile := utils.IsTrue(input.DeleteFile)
destroyFileEntry := utils.IsTrue(input.DestroyFileEntry)
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Scene
@@ -457,7 +458,7 @@ func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneD
// kill any running encoders
manager.KillRunningStreams(s, fileNamingAlgo)
return r.sceneService.Destroy(ctx, s, fileDeleter, deleteGenerated, deleteFile)
return r.sceneService.Destroy(ctx, s, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)
}); err != nil {
fileDeleter.Rollback()
return false, err
@@ -495,6 +496,7 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
deleteGenerated := utils.IsTrue(input.DeleteGenerated)
deleteFile := utils.IsTrue(input.DeleteFile)
destroyFileEntry := utils.IsTrue(input.DestroyFileEntry)
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Scene
@@ -513,7 +515,7 @@ func (r *mutationResolver) ScenesDestroy(ctx context.Context, input models.Scene
// kill any running encoders
manager.KillRunningStreams(scene, fileNamingAlgo)
if err := r.sceneService.Destroy(ctx, scene, fileDeleter, deleteGenerated, deleteFile); err != nil {
if err := r.sceneService.Destroy(ctx, scene, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
return err
}
}

View File

@@ -156,6 +156,7 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult {
javascriptEnabled := config.GetJavascriptEnabled()
customLocales := config.GetCustomLocales()
customLocalesEnabled := config.GetCustomLocalesEnabled()
disableCustomizations := config.GetDisableCustomizations()
language := config.GetLanguage()
handyKey := config.GetHandyKey()
scriptOffset := config.GetFunscriptOffset()
@@ -183,6 +184,7 @@ func makeConfigInterfaceResult() *ConfigInterfaceResult {
JavascriptEnabled: &javascriptEnabled,
CustomLocales: &customLocales,
CustomLocalesEnabled: &customLocalesEnabled,
DisableCustomizations: &disableCustomizations,
Language: &language,
ImageLightbox: &imageLightboxOptions,

View File

@@ -450,7 +450,7 @@ func cssHandler(c *config.Config) func(w http.ResponseWriter, r *http.Request) {
return func(w http.ResponseWriter, r *http.Request) {
var paths []string
if c.GetCSSEnabled() {
if c.GetCSSEnabled() && !c.GetDisableCustomizations() {
// search for custom.css in current directory, then $HOME/.stash
fn := c.GetCSSPath()
exists, _ := fsutil.FileExists(fn)
@@ -468,7 +468,7 @@ func javascriptHandler(c *config.Config) func(w http.ResponseWriter, r *http.Req
return func(w http.ResponseWriter, r *http.Request) {
var paths []string
if c.GetJavascriptEnabled() {
if c.GetJavascriptEnabled() && !c.GetDisableCustomizations() {
// search for custom.js in current directory, then $HOME/.stash
fn := c.GetJavascriptPath()
exists, _ := fsutil.FileExists(fn)
@@ -486,7 +486,7 @@ func customLocalesHandler(c *config.Config) func(w http.ResponseWriter, r *http.
return func(w http.ResponseWriter, r *http.Request) {
buffer := bytes.Buffer{}
if c.GetCustomLocalesEnabled() {
if c.GetCustomLocalesEnabled() && !c.GetDisableCustomizations() {
// search for custom-locales.json in current directory, then $HOME/.stash
path := c.GetCustomLocalesPath()
exists, _ := fsutil.FileExists(path)

View File

@@ -194,6 +194,7 @@ const (
CSSEnabled = "cssenabled"
JavascriptEnabled = "javascriptenabled"
CustomLocalesEnabled = "customlocalesenabled"
DisableCustomizations = "disable_customizations"
ShowScrubber = "show_scrubber"
showScrubberDefault = true
@@ -1479,6 +1480,13 @@ func (i *Config) GetCustomLocalesEnabled() bool {
return i.getBool(CustomLocalesEnabled)
}
// GetDisableCustomizations returns true if all customizations (plugins, custom CSS,
// custom JavaScript, and custom locales) should be disabled. This is useful for
// troubleshooting issues without permanently disabling individual customizations.
func (i *Config) GetDisableCustomizations() bool {
return i.getBool(DisableCustomizations)
}
func (i *Config) GetHandyKey() string {
return i.getString(HandyKey)
}

View File

@@ -13,14 +13,14 @@ type SceneService interface {
Create(ctx context.Context, input *models.Scene, fileIDs []models.FileID, coverImage []byte) (*models.Scene, error)
AssignFile(ctx context.Context, sceneID int, fileID models.FileID) error
Merge(ctx context.Context, sourceIDs []int, destinationID int, fileDeleter *scene.FileDeleter, options scene.MergeOptions) error
Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile bool) error
Destroy(ctx context.Context, scene *models.Scene, fileDeleter *scene.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error
FindByIDs(ctx context.Context, ids []int, load ...scene.LoadRelationshipOption) ([]*models.Scene, error)
sceneFingerprintGetter
}
type ImageService interface {
Destroy(ctx context.Context, image *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) error
Destroy(ctx context.Context, image *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error
DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error)
}
@@ -31,7 +31,7 @@ type GalleryService interface {
SetCover(ctx context.Context, g *models.Gallery, coverImageId int) error
ResetCover(ctx context.Context, g *models.Gallery) error
Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error)
Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error)
ValidateImageGalleryChange(ctx context.Context, i *models.Image, updateIDs models.UpdateIDs) error

View File

@@ -300,7 +300,10 @@ func (h *cleanHandler) handleRelatedScenes(ctx context.Context, fileDeleter *fil
// only delete if the scene has no other files
if len(scene.Files.List()) <= 1 {
logger.Infof("Deleting scene %q since it has no other related files", scene.DisplayName())
if err := mgr.SceneService.Destroy(ctx, scene, sceneFileDeleter, true, false); err != nil {
const deleteGenerated = true
const deleteFile = false
const destroyFileEntry = false
if err := mgr.SceneService.Destroy(ctx, scene, sceneFileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
return err
}
@@ -421,7 +424,10 @@ func (h *cleanHandler) handleRelatedImages(ctx context.Context, fileDeleter *fil
if len(i.Files.List()) <= 1 {
logger.Infof("Deleting image %q since it has no other related files", i.DisplayName())
if err := mgr.ImageService.Destroy(ctx, i, imageFileDeleter, true, false); err != nil {
const deleteGenerated = true
const deleteFile = false
const destroyFileEntry = false
if err := mgr.ImageService.Destroy(ctx, i, imageFileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
return err
}

View File

@@ -8,13 +8,13 @@ import (
"github.com/stashapp/stash/pkg/models"
)
func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) {
func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error) {
var imgsDestroyed []*models.Image
// chapter deletion is done via delete cascade, so we don't need to do anything here
// if this is a zip-based gallery, delete the images as well first
zipImgsDestroyed, err := s.destroyZipFileImages(ctx, i, fileDeleter, deleteGenerated, deleteFile)
zipImgsDestroyed, err := s.destroyZipFileImages(ctx, i, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)
if err != nil {
return nil, err
}
@@ -45,7 +45,7 @@ func DestroyChapter(ctx context.Context, galleryChapter *models.GalleryChapter,
return qb.Destroy(ctx, galleryChapter.ID)
}
func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) {
func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) ([]*models.Image, error) {
if err := i.LoadFiles(ctx, s.Repository); err != nil {
return nil, err
}
@@ -81,6 +81,12 @@ func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, f
if err := destroyer.DestroyZip(ctx, f, fileDeleter.Deleter, deleteFile); err != nil {
return nil, err
}
} else if destroyFileEntry {
// destroy file DB entry without deleting filesystem file
const deleteFileFromFS = false
if err := destroyer.DestroyZip(ctx, f, nil, deleteFileFromFS); err != nil {
return nil, err
}
}
}

View File

@@ -16,7 +16,7 @@ type ImageFinder interface {
}
type ImageService interface {
Destroy(ctx context.Context, i *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) error
Destroy(ctx context.Context, i *models.Image, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error
DestroyZipImages(ctx context.Context, zipFile models.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error)
DestroyFolderImages(ctx context.Context, folderID models.FolderID, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error)
}

View File

@@ -37,8 +37,8 @@ func (d *FileDeleter) MarkGeneratedFiles(image *models.Image) error {
}
// Destroy destroys an image, optionally marking the file and generated files for deletion.
func (s *Service) Destroy(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) error {
return s.destroyImage(ctx, i, fileDeleter, deleteGenerated, deleteFile)
func (s *Service) Destroy(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error {
return s.destroyImage(ctx, i, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry)
}
// DestroyZipImages destroys all images in zip, optionally marking the files and generated files for deletion.
@@ -75,7 +75,8 @@ func (s *Service) DestroyZipImages(ctx context.Context, zipFile models.File, fil
}
const deleteFileInZip = false
if err := s.destroyImage(ctx, img, fileDeleter, deleteGenerated, deleteFileInZip); err != nil {
const destroyFileEntry = false
if err := s.destroyImage(ctx, img, fileDeleter, deleteGenerated, deleteFileInZip, destroyFileEntry); err != nil {
return nil, err
}
@@ -135,7 +136,8 @@ func (s *Service) DestroyFolderImages(ctx context.Context, folderID models.Folde
continue
}
if err := s.Destroy(ctx, img, fileDeleter, deleteGenerated, deleteFile); err != nil {
const destroyFileEntry = false
if err := s.Destroy(ctx, img, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
return nil, err
}
@@ -146,11 +148,15 @@ func (s *Service) DestroyFolderImages(ctx context.Context, folderID models.Folde
}
// Destroy destroys an image, optionally marking the file and generated files for deletion.
func (s *Service) destroyImage(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) error {
func (s *Service) destroyImage(ctx context.Context, i *models.Image, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error {
if deleteFile {
if err := s.deleteFiles(ctx, i, fileDeleter); err != nil {
return err
}
} else if destroyFileEntry {
if err := s.destroyFileEntries(ctx, i); err != nil {
return err
}
}
if deleteGenerated {
@@ -192,3 +198,35 @@ func (s *Service) deleteFiles(ctx context.Context, i *models.Image, fileDeleter
return nil
}
// destroyFileEntries destroys file entries from the database without deleting
// the files from the filesystem
func (s *Service) destroyFileEntries(ctx context.Context, i *models.Image) error {
if err := i.LoadFiles(ctx, s.Repository); err != nil {
return err
}
for _, f := range i.Files.List() {
// only destroy file entries where there is no other associated image
otherImages, err := s.Repository.FindByFileID(ctx, f.Base().ID)
if err != nil {
return err
}
if len(otherImages) > 1 {
// other image associated, don't remove
continue
}
// don't destroy files in zip archives
if f.Base().ZipFileID == nil {
const deleteFile = false
logger.Info("Destroying image file entry: ", f.Base().Path)
if err := file.Destroy(ctx, s.File, f, nil, deleteFile); err != nil {
return err
}
}
}
return nil
}

View File

@@ -95,6 +95,7 @@ type GalleryDestroyInput struct {
// If true, then the zip file will be deleted if the gallery is zip-file-based.
// If gallery is folder-based, then any files not associated with other
// galleries will be deleted, along with the folder, if it is not empty.
DeleteFile *bool `json:"delete_file"`
DeleteGenerated *bool `json:"delete_generated"`
DeleteFile *bool `json:"delete_file"`
DeleteGenerated *bool `json:"delete_generated"`
DestroyFileEntry *bool `json:"destroy_file_entry"`
}

View File

@@ -88,15 +88,17 @@ type ImageUpdateInput struct {
}
type ImageDestroyInput struct {
ID string `json:"id"`
DeleteFile *bool `json:"delete_file"`
DeleteGenerated *bool `json:"delete_generated"`
ID string `json:"id"`
DeleteFile *bool `json:"delete_file"`
DeleteGenerated *bool `json:"delete_generated"`
DestroyFileEntry *bool `json:"destroy_file_entry"`
}
type ImagesDestroyInput struct {
Ids []string `json:"ids"`
DeleteFile *bool `json:"delete_file"`
DeleteGenerated *bool `json:"delete_generated"`
Ids []string `json:"ids"`
DeleteFile *bool `json:"delete_file"`
DeleteGenerated *bool `json:"delete_generated"`
DestroyFileEntry *bool `json:"destroy_file_entry"`
}
type ImageQueryOptions struct {

View File

@@ -204,15 +204,17 @@ type SceneUpdateInput struct {
}
type SceneDestroyInput struct {
ID string `json:"id"`
DeleteFile *bool `json:"delete_file"`
DeleteGenerated *bool `json:"delete_generated"`
ID string `json:"id"`
DeleteFile *bool `json:"delete_file"`
DeleteGenerated *bool `json:"delete_generated"`
DestroyFileEntry *bool `json:"destroy_file_entry"`
}
type ScenesDestroyInput struct {
Ids []string `json:"ids"`
DeleteFile *bool `json:"delete_file"`
DeleteGenerated *bool `json:"delete_generated"`
Ids []string `json:"ids"`
DeleteFile *bool `json:"delete_file"`
DeleteGenerated *bool `json:"delete_generated"`
DestroyFileEntry *bool `json:"destroy_file_entry"`
}
func NewSceneQueryResult(getter SceneGetter) *SceneQueryResult {

View File

@@ -109,7 +109,7 @@ func (d *FileDeleter) MarkMarkerFiles(scene *models.Scene, seconds int) error {
// Destroy deletes a scene and its associated relationships from the
// database.
func (s *Service) Destroy(ctx context.Context, scene *models.Scene, fileDeleter *FileDeleter, deleteGenerated, deleteFile bool) error {
func (s *Service) Destroy(ctx context.Context, scene *models.Scene, fileDeleter *FileDeleter, deleteGenerated, deleteFile, destroyFileEntry bool) error {
mqb := s.MarkerRepository
markers, err := mqb.FindBySceneID(ctx, scene.ID)
if err != nil {
@@ -126,6 +126,10 @@ func (s *Service) Destroy(ctx context.Context, scene *models.Scene, fileDeleter
if err := s.deleteFiles(ctx, scene, fileDeleter); err != nil {
return err
}
} else if destroyFileEntry {
if err := s.destroyFileEntries(ctx, scene); err != nil {
return err
}
}
if deleteGenerated {
@@ -180,6 +184,35 @@ func (s *Service) deleteFiles(ctx context.Context, scene *models.Scene, fileDele
return nil
}
// destroyFileEntries destroys file entries from the database without deleting
// the files from the filesystem
func (s *Service) destroyFileEntries(ctx context.Context, scene *models.Scene) error {
if err := scene.LoadFiles(ctx, s.Repository); err != nil {
return err
}
for _, f := range scene.Files.List() {
// only destroy file entries where there is no other associated scene
otherScenes, err := s.Repository.FindByFileID(ctx, f.ID)
if err != nil {
return err
}
if len(otherScenes) > 1 {
// other scenes associated, don't remove
continue
}
const deleteFile = false
logger.Info("Destroying scene file entry: ", f.Path)
if err := file.Destroy(ctx, s.File, f, nil, deleteFile); err != nil {
return err
}
}
return nil
}
// DestroyMarker deletes the scene marker from the database and returns a
// function that removes the generated files, to be executed after the
// transaction is successfully committed.

View File

@@ -120,7 +120,8 @@ func (s *Service) Merge(ctx context.Context, sourceIDs []int, destinationID int,
for _, src := range sources {
const deleteGenerated = true
const deleteFile = false
if err := s.Destroy(ctx, src, fileDeleter, deleteGenerated, deleteFile); err != nil {
const destroyFileEntry = false
if err := s.Destroy(ctx, src, fileDeleter, deleteGenerated, deleteFile, destroyFileEntry); err != nil {
return fmt.Errorf("deleting scene %d: %w", src.ID, err)
}
}

View File

@@ -92,6 +92,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
javascriptEnabled
customLocales
customLocalesEnabled
disableCustomizations
language
imageLightbox {
slideshowDelay

View File

@@ -49,6 +49,7 @@ import { PluginRoutes, PluginsLoader } from "./plugins";
// import plugin_api to run code
import "./pluginApi";
import { ConnectionMonitor } from "./ConnectionMonitor";
import { TroubleshootingModeOverlay } from "./components/TroubleshootingMode/TroubleshootingModeOverlay";
import { PatchFunction } from "./patch";
import moment from "moment/min/moment-with-locales";
@@ -352,11 +353,17 @@ export const App: React.FC = () => {
formats={intlFormats}
>
<ToastProvider>
<PluginsLoader>
<PluginsLoader
disableCustomizations={
config.data?.configuration?.interface?.disableCustomizations ??
false
}
>
<AppContainer>
<ConfigurationProvider configuration={config.data!.configuration}>
{maybeRenderReleaseNotes()}
<ConnectionMonitor />
<TroubleshootingModeOverlay />
<Suspense fallback={<LoadingIndicator />}>
<LightboxProvider>
<ManualProvider>

View File

@@ -23,6 +23,7 @@ import Interactive from "src/docs/en/Manual/Interactive.md";
import Captions from "src/docs/en/Manual/Captions.md";
import Identify from "src/docs/en/Manual/Identify.md";
import Browsing from "src/docs/en/Manual/Browsing.md";
import TroubleshootingMode from "src/docs/en/Manual/TroubleshootingMode.md";
import { MarkdownPage } from "../Shared/MarkdownPage";
interface IManualProps {
@@ -152,6 +153,11 @@ export const Manual: React.FC<IManualProps> = ({
title: "Keyboard Shortcuts",
content: KeyboardShortcuts,
},
{
key: "TroubleshootingMode.md",
title: "Troubleshooting Mode",
content: TroubleshootingMode,
},
{
key: "Contributing.md",
title: "Contributing",

View File

@@ -30,17 +30,9 @@ interface IImageCardProps {
onPreview?: (ev: MouseEvent) => void;
}
export const ImageCard: React.FC<IImageCardProps> = PatchComponent(
"ImageCard",
const ImageCardPopovers = PatchComponent(
"ImageCard.Popovers",
(props: IImageCardProps) => {
const file = useMemo(
() =>
props.image.visual_files.length > 0
? props.image.visual_files[0]
: undefined,
[props.image]
);
function maybeRenderTagPopoverButton() {
if (props.image.tags.length <= 0) return;
@@ -112,29 +104,65 @@ export const ImageCard: React.FC<IImageCardProps> = PatchComponent(
}
}
function maybeRenderPopoverButtonGroup() {
if (
props.image.tags.length > 0 ||
props.image.performers.length > 0 ||
props.image.o_counter ||
props.image.galleries.length > 0 ||
props.image.organized
) {
return (
<>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()}
{maybeRenderOCounter()}
{maybeRenderGallery()}
{maybeRenderOrganized()}
</ButtonGroup>
</>
);
}
if (
props.image.tags.length > 0 ||
props.image.performers.length > 0 ||
props.image.o_counter ||
props.image.galleries.length > 0 ||
props.image.organized
) {
return (
<>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()}
{maybeRenderOCounter()}
{maybeRenderGallery()}
{maybeRenderOrganized()}
</ButtonGroup>
</>
);
}
return null;
}
);
const ImageCardDetails = PatchComponent(
"ImageCard.Details",
(props: IImageCardProps) => {
return (
<div className="image-card__details">
<span className="image-card__date">{props.image.date}</span>
<TruncatedText
className="image-card__description"
text={props.image.details}
lineCount={3}
/>
</div>
);
}
);
const ImageCardOverlays = PatchComponent(
"ImageCard.Overlays",
(props: IImageCardProps) => {
return <StudioOverlay studio={props.image.studio} />;
}
);
const ImageCardImage = PatchComponent(
"ImageCard.Image",
(props: IImageCardProps) => {
const file = useMemo(
() =>
props.image.visual_files.length > 0
? props.image.visual_files[0]
: undefined,
[props.image]
);
function isPortrait() {
const width = file?.width ? file.width : 0;
const height = file?.height ? file.height : 0;
@@ -148,6 +176,34 @@ export const ImageCard: React.FC<IImageCardProps> = PatchComponent(
const video = source.includes("preview");
const ImagePreview = video ? "video" : "img";
return (
<>
<div className={cx("image-card-preview", { portrait: isPortrait() })}>
<ImagePreview
loop={video}
autoPlay={video}
playsInline={video}
className="image-card-preview-image"
alt={props.image.title ?? ""}
src={source}
/>
{props.onPreview ? (
<div className="preview-button">
<Button onClick={props.onPreview}>
<Icon icon={faSearch} />
</Button>
</div>
) : undefined}
</div>
<RatingBanner rating={props.image.rating100} />
</>
);
}
);
export const ImageCard: React.FC<IImageCardProps> = PatchComponent(
"ImageCard",
(props: IImageCardProps) => {
return (
<GridCard
className={`image-card zoom-${props.zoomIndex}`}
@@ -155,42 +211,10 @@ export const ImageCard: React.FC<IImageCardProps> = PatchComponent(
width={props.cardWidth}
title={imageTitle(props.image)}
linkClassName="image-card-link"
image={
<>
<div
className={cx("image-card-preview", { portrait: isPortrait() })}
>
<ImagePreview
loop={video}
autoPlay={video}
playsInline={video}
className="image-card-preview-image"
alt={props.image.title ?? ""}
src={source}
/>
{props.onPreview ? (
<div className="preview-button">
<Button onClick={props.onPreview}>
<Icon icon={faSearch} />
</Button>
</div>
) : undefined}
</div>
<RatingBanner rating={props.image.rating100} />
</>
}
details={
<div className="image-card__details">
<span className="image-card__date">{props.image.date}</span>
<TruncatedText
className="image-card__description"
text={props.image.details}
lineCount={3}
/>
</div>
}
overlays={<StudioOverlay studio={props.image.studio} />}
popovers={maybeRenderPopoverButtonGroup()}
image={<ImageCardImage {...props} />}
details={<ImageCardDetails {...props} />}
overlays={<ImageCardOverlays {...props} />}
popovers={<ImageCardPopovers {...props} />}
selected={props.selected}
selecting={props.selecting}
onSelectedChanged={props.onSelectedChanged}

View File

@@ -18,6 +18,8 @@ import { SettingsContext, useSettings } from "./context";
import { SettingsLibraryPanel } from "./SettingsLibraryPanel";
import { SettingsSecurityPanel } from "./SettingsSecurityPanel";
import Changelog from "../Changelog/Changelog";
import { TroubleshootingModeButton } from "../TroubleshootingMode/TroubleshootingModeButton";
import { useTroubleshootingMode } from "../TroubleshootingMode/useTroubleshootingMode";
const validTabs = [
"tasks",
@@ -43,6 +45,7 @@ function isTabKey(tab: string | null): tab is TabKey {
const SettingTabs: React.FC<{ tab: TabKey }> = ({ tab }) => {
const { advancedMode, setAdvancedMode } = useSettings();
const { isActive: troubleshootingModeActive } = useTroubleshootingMode();
const titleProps = useTitleProps({ id: "settings" });
@@ -148,6 +151,7 @@ const SettingTabs: React.FC<{ tab: TabKey }> = ({ tab }) => {
/>
</div>
</Nav.Item>
{!troubleshootingModeActive && <TroubleshootingModeButton />}
<hr className="d-sm-none" />
</Nav>
</Col>

View File

@@ -447,3 +447,15 @@
display: inline-block;
}
}
.troubleshooting-mode-button {
bottom: 1rem;
left: 1rem;
position: fixed;
z-index: 100;
@include media-breakpoint-down(xs) {
padding-left: 0.5rem;
position: static;
}
}

View File

@@ -0,0 +1,67 @@
import React, { useState } from "react";
import { Button } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { faBug } from "@fortawesome/free-solid-svg-icons";
import { ModalComponent } from "src/components/Shared/Modal";
import { useTroubleshootingMode } from "./useTroubleshootingMode";
const DIALOG_ITEMS = [
"config.ui.troubleshooting_mode.dialog_item_plugins",
"config.ui.troubleshooting_mode.dialog_item_css",
"config.ui.troubleshooting_mode.dialog_item_js",
"config.ui.troubleshooting_mode.dialog_item_locales",
] as const;
export const TroubleshootingModeButton: React.FC = () => {
const intl = useIntl();
const [showDialog, setShowDialog] = useState(false);
const { enable, isLoading } = useTroubleshootingMode();
return (
<>
<div className="troubleshooting-mode-button">
<Button variant="primary" size="sm" onClick={() => setShowDialog(true)}>
<FormattedMessage id="config.ui.troubleshooting_mode.button" />
</Button>
</div>
<ModalComponent
show={showDialog}
onHide={() => setShowDialog(false)}
header={intl.formatMessage({
id: "config.ui.troubleshooting_mode.dialog_title",
})}
icon={faBug}
accept={{
text: intl.formatMessage({
id: "config.ui.troubleshooting_mode.enable",
}),
variant: "primary",
onClick: enable,
}}
cancel={{
onClick: () => setShowDialog(false),
variant: "secondary",
}}
isRunning={isLoading}
>
<p>
<FormattedMessage id="config.ui.troubleshooting_mode.dialog_description" />
</p>
<ul>
{DIALOG_ITEMS.map((id) => (
<li key={id}>
<FormattedMessage id={id} />
</li>
))}
</ul>
<p>
<FormattedMessage id="config.ui.troubleshooting_mode.dialog_log_level" />
</p>
<p className="text-muted">
<FormattedMessage id="config.ui.troubleshooting_mode.dialog_reload_note" />
</p>
</ModalComponent>
</>
);
};

View File

@@ -0,0 +1,28 @@
import React from "react";
import { Button } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import { faBug } from "@fortawesome/free-solid-svg-icons";
import { Icon } from "src/components/Shared/Icon";
import { useTroubleshootingMode } from "./useTroubleshootingMode";
export const TroubleshootingModeOverlay: React.FC = () => {
const { isActive, isLoading, disable } = useTroubleshootingMode();
if (!isActive) {
return null;
}
return (
<div className="troubleshooting-mode-overlay">
<div className="troubleshooting-mode-alert">
<span>
<Icon icon={faBug} className="mr-2" />
<FormattedMessage id="config.ui.troubleshooting_mode.overlay_message" />
</span>
<Button variant="link" onClick={disable} disabled={isLoading}>
<FormattedMessage id="config.ui.troubleshooting_mode.exit" />
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,83 @@
import { useState, useRef, useEffect } from "react";
import {
useConfigureInterface,
useConfigureGeneral,
useConfiguration,
} from "src/core/StashService";
const ORIGINAL_LOG_LEVEL_KEY = "troubleshootingMode_originalLogLevel";
export function useTroubleshootingMode() {
const [isLoading, setIsLoading] = useState(false);
const isMounted = useRef(true);
const { data: config } = useConfiguration();
const [configureInterface] = useConfigureInterface();
const [configureGeneral] = useConfigureGeneral();
const isActive =
config?.configuration?.interface?.disableCustomizations ?? false;
const currentLogLevel = config?.configuration?.general?.logLevel || "Info";
useEffect(() => {
return () => {
isMounted.current = false;
};
}, []);
async function enable() {
setIsLoading(true);
try {
// Store original log level for restoration later
localStorage.setItem(ORIGINAL_LOG_LEVEL_KEY, currentLogLevel);
// Enable troubleshooting mode and set log level to Debug
await Promise.all([
configureInterface({
variables: { input: { disableCustomizations: true } },
}),
configureGeneral({
variables: { input: { logLevel: "Debug" } },
}),
]);
window.location.reload();
} catch (e) {
if (isMounted.current) {
setIsLoading(false);
}
throw e;
}
}
async function disable() {
setIsLoading(true);
try {
// Restore original log level
const originalLogLevel =
localStorage.getItem(ORIGINAL_LOG_LEVEL_KEY) || "Info";
// Disable troubleshooting mode and restore log level
await Promise.all([
configureInterface({
variables: { input: { disableCustomizations: false } },
}),
configureGeneral({
variables: { input: { logLevel: originalLogLevel } },
}),
]);
// Clean up localStorage
localStorage.removeItem(ORIGINAL_LOG_LEVEL_KEY);
window.location.reload();
} catch (e) {
if (isMounted.current) {
setIsLoading(false);
}
throw e;
}
}
return { isActive, isLoading, enable, disable };
}

View File

@@ -6,7 +6,7 @@ This task is part of the advanced settings mode.
## Rules
> **Important:** Auto Tag only works for names that already exist in your Stash database. It does not create new Performers, Studios, or Tags.
> **⚠️ Important:** Auto Tag only works for names that already exist in your Stash database. It does not create new Performers, Studios, or Tags.
- Multi-word names are matched when words appear in order and are separated by any of these characters: `.`, `-`, `_`, or whitespace. These separators are treated as word boundaries.
- Matching is case-insensitive but requires complete words within word boundaries. Partial words or misspelled words will not match.

View File

@@ -15,4 +15,4 @@ Where `{language_code}` is defined by the [ISO-6399-1](https://en.wikipedia.org/
Scenes with captions can be filtered with the `captions` criterion.
**Note:** If the caption file was added after the scene was initially added during scan, you will need to run a Selective Scan task for it to show up.
> **⚠️ Note:** If the caption file was added after the scene was initially added during scan, you will need to run a Selective Scan task for it to show up.

View File

@@ -31,7 +31,7 @@ Some examples:
- `"^/stash/videos/exclude/"` will exclude all directories that match `/stash/videos/exclude/` pattern.
- `"\\\\stash\\network\\share\\excl\\"` will exclude specific Windows network path `\\stash\network\share\excl\`.
> **Note:** If a directory is excluded for images and videos, then the directory will be excluded from scans completely.
> **⚠️ Note:** If a directory is excluded for images and videos, then the directory will be excluded from scans completely.
_There is a useful [regex101](https://regex101.com/) site that can help test and experiment with regexps._
@@ -87,7 +87,7 @@ This setting can be used to increase/decrease overall CPU utilisation in two sce
1. High performance 4+ core cpus.
2. Media files stored on remote/cloud filesystem.
Note: If this is set too high it will decrease overall performance and causes failures (out of memory).
> **⚠️ Note:** If this is set too high it will decrease overall performance and causes failures (out of memory).
## Hardware accelerated live transcoding
@@ -117,7 +117,7 @@ Some scrapers require a Chrome instance to function correctly. If left empty, st
`Chrome CDP path` can be set to a path to the chrome executable, or an http(s) address to remote chrome instance (for example: `http://localhost:9222/json/version`).
> **Important**: As of Chrome 136 you need to specify `--user-data-dir` alongside `--remote-debugging-port`. Read more on their [official post](https://developer.chrome.com/blog/remote-debugging-port).
> **⚠️ Important:** As of Chrome 136 you need to specify `--user-data-dir` alongside `--remote-debugging-port`. Read more on their [official post](https://developer.chrome.com/blog/remote-debugging-port).
## Authentication

View File

@@ -2,8 +2,10 @@
[The dupe checker](/sceneDuplicateChecker) searches your collection for scenes that are perceptually similar. This means that the files don't need to be identical, and will be identified even with different bitrates, resolutions, and intros/outros.
To achieve this stash needs to generate what's called a phash, or perceptual hash. Similar to sprite generation stash will generate a set of 25 images from fixed points in the scene. These images will be stitched together, and then hashed using the phash algorithm. The phash can then be used to find scenes that are the same or similar to others in the database. Phash generation can be run during scan, or as a separate task. Note that generation can take a while due to the work involved with extracting screenshots.
To achieve this stash needs to generate what's called a phash, or perceptual hash. Similar to sprite generation stash will generate a set of 25 images from fixed points in the scene. These images will be stitched together, and then hashed using the phash algorithm. The phash can then be used to find scenes that are the same or similar to others in the database. Phash generation can be run during scan, or as a separate task.
> **⚠️ Note:** Generation can take a while due to the work involved with extracting screenshots.
The dupe checker can be run with four different levels of accuracy. `Exact` looks for scenes that have exactly the same phash. This is a fast and accurate operation that should not yield any false positives except in very rare cases. The other accuracy levels look for duplicate files within a set distance of each other. This means the scenes don't have exactly the same phash, but are very similar. `High` and `Medium` should still yield very good results with few or no false positives. `Low` is likely to produce some false positives, but might still be useful for finding dupes.
Note that to generate a phash stash requires an uncorrupted file. If any errors are encountered during sprite generation the phash will not be generated. This is to prevent false positives.
> **⚠️ Note:** To generate a pHash Stash requires an uncorrupted file. If any errors are encountered during sprite generation the pHash will not be generated. This is to prevent false positives.

View File

@@ -10,7 +10,9 @@ Stash currently supports Javascript embedded plugin tasks using [goja](https://g
### Plugin input
The input is provided to Javascript plugin tasks using the `input` global variable, and is an object based on the structure provided in the `Plugin input` section of the [Plugins](/help/Plugins.md) page. Note that the `server_connection` field should not be necessary in most embedded plugins.
The input is provided to Javascript plugin tasks using the `input` global variable, and is an object based on the structure provided in the `Plugin input` section of the [Plugins](/help/Plugins.md) page.
> **⚠️ Note:** `server_connection` field should not be necessary in most embedded plugins.
### Plugin output

View File

@@ -11,7 +11,7 @@ You can add images to every gallery manually in the gallery detail page. Deletin
For best results, images in zip file should be stored without compression (copy, store or no compression options depending on the software you use. Eg on linux: `zip -0 -r gallery.zip foldertozip/`). This impacts **heavily** on the zip read performance.
> **:warning: Note:** AVIF files in ZIP archives are currently unsupported.
> **⚠️ Note:** AVIF files in ZIP archives are currently unsupported.
If a filename of an image in the gallery zip file ends with `cover.jpg`, it will be treated like a cover and presented first in the gallery view page and as a gallery cover in the gallery list view. If more than one images match the name the first one found in natural sort order is selected.

View File

@@ -2,6 +2,8 @@
Stash works by cataloging your media using the paths that you provide. Once you have [configured](/settings?tab=library) the locations where your media is stored, you can click the Scan button in [`Settings -> Tasks`](/settings?tab=tasks) and stash will begin scanning and importing your media into its library.
For the best experience, it is recommended that after a scan is finished, that video previews and sprites are generated. You can do this in [`Settings -> Tasks`](/settings?tab=tasks). Note that currently it is only possible to perform one task at a time and but there is a task queue, so the generate tasks should be performed after scan is complete.
For the best experience, it is recommended that after a scan is finished, that video previews and sprites are generated. You can do this in [`Settings -> Tasks`](/settings?tab=tasks).
> **⚠️ Note:** Currently it is only possible to perform one task at a time and but there is a task queue, so the generate tasks should be performed after scan is complete.
Once your media is imported, you are ready to begin creating Performers, Studios and Tags, and curating your content!

View File

@@ -24,7 +24,7 @@ When exported, files are named with different formats depending on the object ty
| Studios | `<name>.json` |
| Groups | `<name>.json` |
Note that the file naming is not significant when importing. All json files will be read from the subdirectories.
> **⚠️ Note:** The file naming is not significant when importing. All json files will be read from the subdirectories.
## Content of the json files

View File

@@ -240,7 +240,7 @@ hooks:
argKey: argValue
```
**Note:** it is possible for hooks to trigger eachother or themselves if they perform mutations. For safety, hooks will not be triggered if they have already been triggered in the context of the operation. Stash uses cookies to track this context, so it's important for plugins to send cookies when performing operations.
**⚠️ Note:** It is possible for hooks to trigger eachother or themselves if they perform mutations. For safety, hooks will not be triggered if they have already been triggered in the context of the operation. Stash uses cookies to track this context, so it's important for plugins to send cookies when performing operations.
#### Trigger types

View File

@@ -375,7 +375,7 @@ scene:
selector: //div[@data-host="{inputHostname}"]//span[@class="site-name"]
```
> **Note:** These placeholders represent the actual URL used to fetch the content, after any URL replacements have been applied.
> **⚠️ Note:** These placeholders represent the actual URL used to fetch the content, after any URL replacements have been applied.
### Common fragments
@@ -391,6 +391,7 @@ performer:
The `Measurements` xpath string will replace `$infoPiece` with `//div[@class="infoPiece"]/span`, resulting in: `//div[@class="infoPiece"]/span[text() = 'Measurements:']/../span[@class="smallInfo"]`.
> **⚠️ Note:** Recursive common fragments are **not** supported.
Referencing a common fragment within another common fragment will cause an error. For example:
```yaml
common:
@@ -881,7 +882,7 @@ Title
URLs
```
> **Important**: `Title` field is required.
> **⚠️ Important:** `Title` field is required.
### Group
@@ -900,7 +901,7 @@ Tags (see Tag fields)
URLs
```
> **Important**: `Name` field is required.
> **⚠️ Important:** `Name` field is required.
### Image
@@ -944,9 +945,9 @@ URLs
Weight
```
> **Important**: `Name` field is required.
> **⚠️ 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
@@ -964,7 +965,7 @@ Title
URLs
```
> **Important**: `Title` field is required only if fileless.
> **⚠️ Important:** `Title` field is required only if fileless.
### Studio
@@ -976,7 +977,7 @@ Tags (see Tag fields)
URL
```
> **Important**: `Name` field is required.
> **⚠️ Important:** `Name` field is required.
### Tag
@@ -984,4 +985,4 @@ URL
Name
```
> **Important**: `Name` field is required.
> **⚠️ Important:** `Name` field is required.

View File

@@ -4,9 +4,9 @@ Stash can be integrated with stash-box which acts as a centralized metadata data
## Searching
The fingerprint search matches your current selection of files against the remote stash-box instance. Any scenes with a matching fingerprint will be returned, although there is currently no validation of fingerprints so it&rsquo;s recommended to double-check the validity before saving.
The fingerprint search matches your current selection of files against the remote stash-box instance. Any scenes with a matching fingerprint will be returned, although there is currently no validation of fingerprints so it's recommended to double-check the validity before saving.
If no fingerprint match is found it&rsquo;s possible to search by keywords. The search works by matching the query against a scene&rsquo;s _title_, _release date_, _studio name_, and _performer names_. By default the tagger uses metadata set on the file, or parses the filename, this can be changed in the config.
If no fingerprint match is found it's possible to search by keywords. The search works by matching the query against a scene's _title_, _release date_, _studio name_, and _performer names_. By default the tagger uses metadata set on the file, or parses the filename, this can be changed in the config.
An important thing to note is that it only returns a match *if all query terms are a match*. As an example, if a scene is titled `"A Trip to the Mall"` with the performer `"Jane Doe"`, a search for `"Trip to the Mall 1080p"` will *not* match, however `"trip mall doe"` would. Usually a few pieces of info is enough, for instance performer name + release date or studio name. To avoid common non-related keywords you can add them to the blacklist in the tagger config. Any items in the blacklist are stripped out of the query.

View File

@@ -0,0 +1,7 @@
# Troubleshooting Mode
Troubleshooting Mode disables all plugins and all custom CSS, JavaScript, and locales. It also temporarily sets the log level to `DEBUG`. This is useful when you are experiencing issues with your Stash instance to eliminate the possibility that a plugin or custom code is causing the issue.
Troubleshooting Mode is enabled from the Settings page, by clicking the `Troubleshooting Mode` button at the bottom left of the page.
When Troubleshooting Mode is enabled, a red border and a banner will be displayed to remind you that you are in Troubleshooting Mode. To exit Troubleshooting Mode, click the `Exit` button in the banner.

View File

@@ -33,6 +33,7 @@ This namespace contains the generated graphql client interface. This is a low-le
- `FontAwesomeBrands`
- `Mousetrap`
- `MousetrapPause`
- `ReactFontAwesome`
- `ReactSelect`
### `register`
@@ -235,17 +236,30 @@ Returns `void`.
- `GalleryCard.Image`
- `GalleryCard.Overlays`
- `GalleryCard.Popovers`
- `GalleryCardGrid`
- `GalleryIDSelect`
- `GalleryRecommendationRow`
- `GallerySelect`
- `GallerySelect.sort`
- `GridCard`
- `GroupCard`
- `GroupCardGrid`
- `GroupIDSelect`
- `GroupRecommendationRow`
- `GroupSelect`
- `GroupSelect.sort`
- `HeaderImage`
- `HoverPopover`
- `Icon`
- `ImageCard`
- `ImageCard.Details`
- `ImageCard.Image`
- `ImageCard.Overlays`
- `ImageCard.Popovers`
- `ImageDetailPanel`
- `ImageGridCard`
- `ImageInput`
- `ImageRecommendationRow`
- `LightboxLink`
- `LoadingIndicator`
- `MainNavBar.MenuItems`
@@ -261,6 +275,7 @@ Returns `void`.
- `PerformerCard.Overlays`
- `PerformerCard.Popovers`
- `PerformerCard.Title`
- `PerformerCardGrid`
- `PerformerDetailsPanel`
- `PerformerDetailsPanel.DetailGroup`
- `PerformerGalleriesPanel`
@@ -269,6 +284,7 @@ Returns `void`.
- `PerformerIDSelect`
- `PerformerImagesPanel`
- `PerformerPage`
- `PerformerRecommendationRow`
- `PerformerScenesPanel`
- `PerformerSelect`
- `PerformerSelect.sort`
@@ -277,17 +293,26 @@ Returns `void`.
- `RatingNumber`
- `RatingStars`
- `RatingSystem`
- `RecommendationRow`
- `SceneCard`
- `SceneCard.Details`
- `SceneCard.Image`
- `SceneCard.Overlays`
- `SceneCard.Popovers`
- `SceneCardsGrid`
- `SceneFileInfoPanel`
- `SceneIDSelect`
- `SceneMarkerCard`
- `SceneMarkerCard.Details`
- `SceneMarkerCard.Image`
- `SceneMarkerCard.Popovers`
- `SceneMarkerCardsGrid`
- `SceneMarkerRecommendationRow`
- `ScenePage`
- `ScenePage.TabContent`
- `ScenePage.Tabs`
- `ScenePlayer`
- `SceneRecommendationRow`
- `SceneSelect`
- `SceneSelect.sort`
- `SelectSetting`
@@ -296,7 +321,11 @@ Returns `void`.
- `SettingModal`
- `StringListSetting`
- `StringSetting`
- `StudioCard`
- `StudioCardGrid`
- `StudioDetailsPanel`
- `StudioIDSelect`
- `StudioRecommendationRow`
- `StudioSelect`
- `StudioSelect.sort`
- `SweatDrops`
@@ -307,8 +336,10 @@ Returns `void`.
- `TagCard.Overlays`
- `TagCard.Popovers`
- `TagCard.Title`
- `TagCardGrid`
- `TagIDSelect`
- `TagLink`
- `TagRecommendationRow`
- `TagSelect`
- `TagSelect.sort`
- `TruncatedText`
@@ -319,6 +350,4 @@ Allows plugins to listen for Stash's events.
```js
PluginApi.Event.addEventListener("stash:location", (e) => console.log("Page Changed", e.detail.data.location.pathname))
```
```

View File

@@ -1438,3 +1438,40 @@ select {
h3 .TruncatedText {
line-height: 1.5;
}
// Troubleshooting Mode overlay banner
.troubleshooting-mode-overlay {
border: 5px solid $danger;
bottom: 0;
left: 0;
opacity: 0.75;
pointer-events: none;
position: fixed;
right: 0;
top: 0;
z-index: 1040;
.troubleshooting-mode-alert {
align-items: baseline;
border-radius: 0;
bottom: 0.5rem;
display: inline-flex;
margin: 0;
position: fixed;
right: 0.5rem;
@include media-breakpoint-down(xs) {
@media (orientation: portrait) {
bottom: $navbar-height;
& > span {
font-size: 0.75rem;
}
}
}
}
.btn {
pointer-events: auto;
}
}

View File

@@ -616,6 +616,20 @@
"heading": "Custom CSS",
"option_label": "Custom CSS enabled"
},
"troubleshooting_mode": {
"button": "Troubleshooting Mode",
"dialog_title": "Enable Troubleshooting Mode",
"dialog_description": "This will temporarily disable all customizations to help diagnose issues:",
"dialog_item_plugins": "All plugins",
"dialog_item_css": "Custom CSS",
"dialog_item_js": "Custom JavaScript",
"dialog_item_locales": "Custom locales",
"dialog_log_level": "Log level will be set to Debug for detailed diagnostics.",
"dialog_reload_note": "The page will reload automatically.",
"enable": "Enable & Reload",
"overlay_message": "Troubleshooting Mode is active - all customizations are disabled",
"exit": "Exit"
},
"custom_javascript": {
"description": "Page must be reloaded for changes to take effect. There is no guarantee of compatibility between custom Javascript and future releases of Stash.",
"heading": "Custom Javascript",

View File

@@ -59,7 +59,8 @@ function sortPlugins(plugins: PluginList) {
// load all plugins and their dependencies
// returns true when all plugins are loaded, regardess of success or failure
function useLoadPlugins() {
// if disableCustomizations is true, skip loading plugins entirely
function useLoadPlugins(disableCustomizations?: boolean) {
const {
data: plugins,
loading: pluginsLoading,
@@ -74,6 +75,12 @@ function useLoadPlugins() {
}, [plugins?.plugins, pluginsLoading, pluginsError]);
const pluginJavascripts = useMemoOnce(() => {
// Skip loading plugin JS if customizations are disabled.
// Note: We check inside useMemoOnce rather than early-returning from useLoadPlugins
// to comply with React's rules of hooks - hooks must be called unconditionally.
if (disableCustomizations) {
return [[], true];
}
return [
uniq(
sortedPlugins
@@ -83,9 +90,12 @@ function useLoadPlugins() {
),
!!sortedPlugins && !pluginsLoading && !pluginsError,
];
}, [sortedPlugins, pluginsLoading, pluginsError]);
}, [sortedPlugins, pluginsLoading, pluginsError, disableCustomizations]);
const pluginCSS = useMemoOnce(() => {
if (disableCustomizations) {
return [[], true];
}
return [
uniq(
sortedPlugins
@@ -95,7 +105,7 @@ function useLoadPlugins() {
),
!!sortedPlugins && !pluginsLoading && !pluginsError,
];
}, [sortedPlugins, pluginsLoading, pluginsError]);
}, [sortedPlugins, pluginsLoading, pluginsError, disableCustomizations]);
const pluginJavascriptLoaded = useScript(
pluginJavascripts ?? [],
@@ -109,11 +119,15 @@ function useLoadPlugins() {
};
}
export const PluginsLoader: React.FC<React.PropsWithChildren<{}>> = ({
children,
}) => {
interface IPluginsLoaderProps {
disableCustomizations?: boolean;
}
export const PluginsLoader: React.FC<
React.PropsWithChildren<IPluginsLoaderProps>
> = ({ disableCustomizations, children }) => {
const Toast = useToast();
const { loading: loaded, error } = useLoadPlugins();
const { loading: loaded, error } = useLoadPlugins(disableCustomizations);
useEffect(() => {
if (error) {