mirror of
https://github.com/stashapp/stash.git
synced 2026-06-11 07:41:08 -05:00
Compare commits
5 Commits
docs-patch
...
docs-fix-e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
562b790c39 | ||
|
|
6bb22146b2 | ||
|
|
09044b92bf | ||
|
|
2c8e7d709f | ||
|
|
bef4e3fbd5 |
@@ -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!
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,6 +92,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult {
|
||||
javascriptEnabled
|
||||
customLocales
|
||||
customLocalesEnabled
|
||||
disableCustomizations
|
||||
language
|
||||
imageLightbox {
|
||||
slideshowDelay
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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!
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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’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’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.
|
||||
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.
|
||||
|
||||
|
||||
7
ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md
Normal file
7
ui/v2.5/src/docs/en/Manual/TroubleshootingMode.md
Normal 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.
|
||||
@@ -252,6 +252,10 @@ Returns `void`.
|
||||
- `HoverPopover`
|
||||
- `Icon`
|
||||
- `ImageCard`
|
||||
- `ImageCard.Details`
|
||||
- `ImageCard.Image`
|
||||
- `ImageCard.Overlays`
|
||||
- `ImageCard.Popovers`
|
||||
- `ImageDetailPanel`
|
||||
- `ImageGridCard`
|
||||
- `ImageInput`
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user