Add Clean generated files task (#4607)

* Add clean generate task
* Add to library tasks
* Save and read defaults
* Stop handling and logging
* Make filename parsing more robust
This commit is contained in:
WithoutPants 2024-02-23 15:56:00 +11:00 committed by GitHub
parent 4a3ce8b6ec
commit ba1ebba6c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 994 additions and 1 deletions

View file

@ -70,6 +70,8 @@ models:
model: github.com/stashapp/stash/internal/manager/config.ConfigDisableDropdownCreate model: github.com/stashapp/stash/internal/manager/config.ConfigDisableDropdownCreate
ScanMetadataOptions: ScanMetadataOptions:
model: github.com/stashapp/stash/internal/manager/config.ScanMetadataOptions model: github.com/stashapp/stash/internal/manager/config.ScanMetadataOptions
CleanGeneratedInput:
model: github.com/stashapp/stash/internal/manager/task.CleanGeneratedOptions
AutoTagMetadataOptions: AutoTagMetadataOptions:
model: github.com/stashapp/stash/internal/manager/config.AutoTagMetadataOptions model: github.com/stashapp/stash/internal/manager/config.AutoTagMetadataOptions
SystemStatus: SystemStatus:

View file

@ -378,6 +378,8 @@ type Mutation {
metadataAutoTag(input: AutoTagMetadataInput!): ID! metadataAutoTag(input: AutoTagMetadataInput!): ID!
"Clean metadata. Returns the job ID" "Clean metadata. Returns the job ID"
metadataClean(input: CleanMetadataInput!): ID! metadataClean(input: CleanMetadataInput!): ID!
"Clean generated files. Returns the job ID"
metadataCleanGenerated(input: CleanGeneratedInput!): ID!
"Identifies scenes using scrapers. Returns the job ID" "Identifies scenes using scrapers. Returns the job ID"
metadataIdentify(input: IdentifyMetadataInput!): ID! metadataIdentify(input: IdentifyMetadataInput!): ID!

View file

@ -118,6 +118,26 @@ input CleanMetadataInput {
dryRun: Boolean! dryRun: Boolean!
} }
input CleanGeneratedInput {
"Clean blob files without blob entries"
blobFiles: Boolean
"Clean sprite and vtt files without scene entries"
sprites: Boolean
"Clean preview files without scene entries"
screenshots: Boolean
"Clean scene transcodes without scene entries"
transcodes: Boolean
"Clean marker files without marker entries"
markers: Boolean
"Clean image thumbnails/clips without image entries"
imageThumbnails: Boolean
"Do a dry run. Don't delete any files"
dryRun: Boolean
}
input AutoTagMetadataInput { input AutoTagMetadataInput {
"Paths to tag, null for all files" "Paths to tag, null for all files"
paths: [String!] paths: [String!]

View file

@ -10,6 +10,7 @@ import (
"github.com/stashapp/stash/internal/identify" "github.com/stashapp/stash/internal/identify"
"github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/internal/manager/config" "github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/internal/manager/task"
"github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/logger"
) )
@ -98,6 +99,21 @@ func (r *mutationResolver) MetadataClean(ctx context.Context, input manager.Clea
return strconv.Itoa(jobID), nil return strconv.Itoa(jobID), nil
} }
func (r *mutationResolver) MetadataCleanGenerated(ctx context.Context, input task.CleanGeneratedOptions) (string, error) {
mgr := manager.GetInstance()
t := &task.CleanGeneratedJob{
Options: input,
Paths: mgr.Paths,
BlobsStorageType: mgr.Config.GetBlobsStorage(),
VideoFileNamingAlgorithm: mgr.Config.GetVideoFileNamingAlgorithm(),
Repository: mgr.Repository,
BlobCleaner: mgr.Repository.Blob,
}
jobID := mgr.JobManager.Add(ctx, "Cleaning generated files...", t)
return strconv.Itoa(jobID), nil
}
func (r *mutationResolver) MigrateHashNaming(ctx context.Context) (string, error) { func (r *mutationResolver) MigrateHashNaming(ctx context.Context) (string, error) {
jobID := manager.GetInstance().MigrateHash(ctx) jobID := manager.GetInstance().MigrateHash(ctx)
return strconv.Itoa(jobID), nil return strconv.Itoa(jobID), nil

View file

@ -0,0 +1,727 @@
package task
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strconv"
"github.com/stashapp/stash/internal/manager/config"
"github.com/stashapp/stash/pkg/job"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/paths"
)
type CleanGeneratedOptions struct {
BlobFiles bool `json:"blobs"`
Sprites bool `json:"sprites"`
Screenshots bool `json:"screenshots"`
Transcodes bool `json:"transcodes"`
Markers bool `json:"markers"`
ImageThumbnails bool `json:"imageThumbnails"`
DryRun bool `json:"dryRun"`
}
type BlobCleaner interface {
EntryExists(ctx context.Context, checksum string) (bool, error)
}
type CleanGeneratedJob struct {
Options CleanGeneratedOptions
Paths *paths.Paths
BlobsStorageType config.BlobsStorageType
VideoFileNamingAlgorithm models.HashAlgorithm
BlobCleaner BlobCleaner
Repository models.Repository
dryRunPrefix string
totalTasks int
tasksComplete int
}
func (j *CleanGeneratedJob) deleteFile(path string) {
if j.Options.DryRun {
logger.Debugf("would delete file: %s", path)
return
}
if err := os.Remove(path); err != nil {
logger.Errorf("error deleting file %s: %v", path, err)
}
}
func (j *CleanGeneratedJob) deleteDir(path string) {
if j.Options.DryRun {
logger.Debugf("would delete file: %s", path)
return
}
if err := os.RemoveAll(path); err != nil {
logger.Errorf("error deleting directory %s: %v", path, err)
}
}
func (j *CleanGeneratedJob) countTasks() int {
tasks := 0
if j.Options.BlobFiles {
tasks++
}
if j.Options.Sprites {
tasks++
}
if j.Options.Screenshots {
tasks++
}
if j.Options.Transcodes {
tasks++
}
if j.Options.Markers {
tasks++
}
if j.Options.ImageThumbnails {
tasks++
}
return tasks
}
func (j *CleanGeneratedJob) taskComplete(progress *job.Progress) {
j.tasksComplete++
progress.SetPercent(float64(j.tasksComplete) / float64(j.totalTasks))
}
func (j *CleanGeneratedJob) logError(err error) {
if !errors.Is(err, context.Canceled) {
logger.Error(err)
}
}
func (j *CleanGeneratedJob) Execute(ctx context.Context, progress *job.Progress) {
j.tasksComplete = 0
if !j.BlobsStorageType.IsValid() {
logger.Errorf("invalid blobs storage type: %s", j.BlobsStorageType)
return
}
if !j.VideoFileNamingAlgorithm.IsValid() {
logger.Errorf("invalid video file naming algorithm: %s", j.VideoFileNamingAlgorithm)
return
}
if j.Options.DryRun {
j.dryRunPrefix = "[dry run] "
}
logger.Infof("Cleaning generated files %s", j.dryRunPrefix)
j.totalTasks = j.countTasks()
if j.Options.BlobFiles {
progress.ExecuteTask("Cleaning blob files", func() {
if err := j.cleanBlobFiles(ctx, progress); err != nil {
j.logError(fmt.Errorf("error cleaning blob files: %w", err))
}
})
j.taskComplete(progress)
}
if j.Options.Sprites {
progress.ExecuteTask("Cleaning sprite files", func() {
if err := j.cleanSpriteFiles(ctx, progress); err != nil {
j.logError(fmt.Errorf("error cleaning sprite files: %w", err))
}
})
j.taskComplete(progress)
}
if j.Options.Screenshots {
progress.ExecuteTask("Cleaning screenshot files", func() {
if err := j.cleanScreenshotFiles(ctx, progress); err != nil {
j.logError(fmt.Errorf("error cleaning screenshot files: %w", err))
}
})
j.taskComplete(progress)
}
if j.Options.Transcodes {
progress.ExecuteTask("Cleaning transcode files", func() {
if err := j.cleanTranscodeFiles(ctx, progress); err != nil {
j.logError(fmt.Errorf("error cleaning transcode files: %w", err))
}
})
j.taskComplete(progress)
}
if j.Options.Markers {
progress.ExecuteTask("Cleaning marker files", func() {
if err := j.cleanMarkerFiles(ctx, progress); err != nil {
j.logError(fmt.Errorf("error cleaning marker files: %w", err))
}
})
j.taskComplete(progress)
}
if j.Options.ImageThumbnails {
progress.ExecuteTask("Cleaning thumbnail files", func() {
if err := j.cleanThumbnailFiles(ctx, progress); err != nil {
j.logError(fmt.Errorf("error cleaning thumbnail files: %w", err))
}
})
j.taskComplete(progress)
}
if job.IsCancelled(ctx) {
logger.Info("Stopping due to user request")
return
}
logger.Infof("Finished cleaning generated files")
}
func (j *CleanGeneratedJob) setTaskProgress(taskProgress float64, progress *job.Progress) {
progress.SetPercent((float64(j.tasksComplete) + taskProgress) / float64(j.totalTasks))
}
func (j *CleanGeneratedJob) logDelete(format string, args ...interface{}) {
logger.Infof(j.dryRunPrefix+format, args...)
}
// estimates the progress by the hash prefix - first two characters
// this is a rough estimate, but it's better than nothing
// the prefix ranges from 00 to ff
func (j *CleanGeneratedJob) estimateProgress(hashPrefix string) (float64, error) {
toInt, err := strconv.ParseInt(hashPrefix, 16, 64)
if err != nil {
return 0, err
}
const total = 256 // ff
return float64(toInt) / total, nil
}
func (j *CleanGeneratedJob) setProgressFromFilename(prefix string, progress *job.Progress) {
p, err := j.estimateProgress(prefix)
if err != nil {
logger.Errorf("error estimating progress: %v", err)
return
}
j.setTaskProgress(p, progress)
}
func (j *CleanGeneratedJob) getIntraFolderPrefix(basename string) (string, error) {
var hash string
_, err := fmt.Sscanf(basename, "%2x", &hash)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", hash), nil
}
func (j *CleanGeneratedJob) getBlobFileHash(basename string) (string, error) {
var hash string
_, err := fmt.Sscanf(basename, "%32x", &hash)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", hash), nil
}
func (j *CleanGeneratedJob) cleanBlobFiles(ctx context.Context, progress *job.Progress) error {
if job.IsCancelled(ctx) {
return nil
}
if j.BlobsStorageType != config.BlobStorageTypeFilesystem {
logger.Debugf("skipping blob file cleanup, storage type is not filesystem")
return nil
}
logger.Infof("Cleaning blob files")
// walk through the blob directory
if err := filepath.Walk(j.Paths.Blobs, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if err = ctx.Err(); err != nil {
return err
}
if info.IsDir() {
if path == j.Paths.Blobs {
return nil
}
// ignore any directory that isn't a two character hash prefix
_, err := j.getIntraFolderPrefix(info.Name())
if err != nil {
logger.Warnf("Ignoring unknown directory: %s", path)
return fs.SkipDir
}
// estimate progress by the hash prefix
if filepath.Dir(path) == j.Paths.Blobs {
hashPrefix := filepath.Base(path)
j.setProgressFromFilename(hashPrefix, progress)
}
return nil
}
blobname := info.Name()
// ignore any files that aren't a 32 character hash
_, err = j.getBlobFileHash(blobname)
if err != nil {
logger.Warnf("ignoring unknown blob file: %s", blobname)
return nil
}
// if blob entry does not exist, delete the file
if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error {
exists, err := j.BlobCleaner.EntryExists(ctx, blobname)
if err != nil {
return err
}
if !exists {
j.logDelete("deleting unused blob file: %s", blobname)
j.deleteFile(path)
}
return nil
}); err != nil {
logger.Errorf("error checking blob entry: %v", err)
return nil
}
return nil
}); err != nil {
return err
}
return nil
}
func (j *CleanGeneratedJob) getScenesWithHash(ctx context.Context, hash string) ([]*models.Scene, error) {
fp := models.Fingerprint{
Fingerprint: hash,
}
if j.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 {
fp.Type = models.FingerprintTypeMD5
} else {
fp.Type = models.FingerprintTypeOshash
}
return j.Repository.Scene.FindByFingerprints(ctx, []models.Fingerprint{fp})
}
const (
md5Length = 32
oshashLength = 16
)
func (j *CleanGeneratedJob) hashPatternPrefix() string {
hashLen := oshashLength
if j.VideoFileNamingAlgorithm == models.HashAlgorithmMd5 {
hashLen = md5Length
}
return fmt.Sprintf("%%%dx", hashLen)
}
func (j *CleanGeneratedJob) getSpriteFileHash(basename string) (string, error) {
patternPrefix := j.hashPatternPrefix()
spritePattern := patternPrefix + "_sprite.jpg"
var hash string
_, err := fmt.Sscanf(basename, spritePattern, &hash)
if err != nil {
// also try thumbs
thumbPattern := patternPrefix + "_thumbs.vtt"
_, err = fmt.Sscanf(basename, thumbPattern, &hash)
if err != nil {
return "", err
}
}
return fmt.Sprintf("%x", hash), nil
}
func (j *CleanGeneratedJob) cleanSpriteFiles(ctx context.Context, progress *job.Progress) error {
if job.IsCancelled(ctx) {
return nil
}
logger.Infof("Cleaning sprite files")
// walk through the sprite directory
if err := filepath.Walk(j.Paths.Generated.Vtt, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if err = ctx.Err(); err != nil {
return err
}
if info.IsDir() {
return nil
}
filename := info.Name()
hash, err := j.getSpriteFileHash(filename)
if err != nil {
logger.Warnf("Ignoring unknown sprite file: %s", filename)
return nil
}
j.setProgressFromFilename(hash[0:2], progress)
var exists []*models.Scene
if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error {
exists, err = j.getScenesWithHash(ctx, hash)
return err
}); err != nil {
logger.Errorf("error checking scene entry for sprite: %v", err)
return nil
}
if len(exists) == 0 {
j.logDelete("deleting unused sprite file: %s", filename)
j.deleteFile(path)
}
return nil
}); err != nil {
return err
}
return nil
}
func (j *CleanGeneratedJob) cleanSceneFiles(ctx context.Context, path string, typ string, getSceneFileHash func(filename string) (string, error), progress *job.Progress) error {
if job.IsCancelled(ctx) {
return nil
}
logger.Infof("Cleaning %s files", typ)
// walk through the sprite directory
if err := filepath.Walk(path, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
return nil
}
if err = ctx.Err(); err != nil {
return err
}
filename := info.Name()
hash, err := getSceneFileHash(filename)
if err != nil {
logger.Warnf("Ignoring unknown %s file: %s", typ, filename)
return nil
}
j.setProgressFromFilename(hash[0:2], progress)
var exists []*models.Scene
if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error {
exists, err = j.getScenesWithHash(ctx, hash)
return err
}); err != nil {
logger.Errorf("error checking scene entry: %v", err)
return nil
}
if len(exists) == 0 {
j.logDelete("deleting unused %s file: %s", typ, filename)
j.deleteFile(path)
}
return nil
}); err != nil {
return err
}
return nil
}
func (j *CleanGeneratedJob) getScreenshotFileHash(basename string) (string, error) {
var hash string
var ext string
// include the extension - which could be mp4/jpg/webp
_, err := fmt.Sscanf(basename, j.hashPatternPrefix()+".%s", &hash, &ext)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", hash), nil
}
func (j *CleanGeneratedJob) cleanScreenshotFiles(ctx context.Context, progress *job.Progress) error {
return j.cleanSceneFiles(ctx, j.Paths.Generated.Screenshots, "screenshot", j.getScreenshotFileHash, progress)
}
func (j *CleanGeneratedJob) getTranscodeFileHash(basename string) (string, error) {
var hash string
_, err := fmt.Sscanf(basename, j.hashPatternPrefix()+".mp4", &hash)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", hash), nil
}
func (j *CleanGeneratedJob) cleanTranscodeFiles(ctx context.Context, progress *job.Progress) error {
return j.cleanSceneFiles(ctx, j.Paths.Generated.Transcodes, "transcode", j.getTranscodeFileHash, progress)
}
func (j *CleanGeneratedJob) getMarkerSceneFileHash(basename string) (string, error) {
var hash string
_, err := fmt.Sscanf(basename, j.hashPatternPrefix(), &hash)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", hash), nil
}
func (j *CleanGeneratedJob) getMarkerFileSeconds(basename string) (int, error) {
var ret int
var ext string
// include the extension - which could be mp4/jpg/webp
_, err := fmt.Sscanf(basename, "%d.%s", &ret, &ext)
if err != nil {
return 0, err
}
return ret, nil
}
func (j *CleanGeneratedJob) cleanMarkerFiles(ctx context.Context, progress *job.Progress) error {
if job.IsCancelled(ctx) {
return nil
}
logger.Infof("Cleaning marker files")
var scenes []*models.Scene
var sceneHash string
var markers []*models.SceneMarker
// walk through the markers directory
if err := filepath.Walk(j.Paths.Generated.Markers, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if err = ctx.Err(); err != nil {
return err
}
if info.IsDir() {
// ignore markers directory
if path == j.Paths.Generated.Markers {
return nil
}
markers = nil
if filepath.Dir(path) != j.Paths.Generated.Markers {
logger.Warnf("Ignoring unknown marker directory: %s", path)
return nil
}
sceneHash, err = j.getMarkerSceneFileHash(info.Name())
if err != nil {
logger.Warnf("Ignoring unknown marker directory: %s", path)
return nil
}
j.setProgressFromFilename(sceneHash[0:2], progress)
// check if the scene exists
if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error {
var err error
scenes, err = j.getScenesWithHash(ctx, sceneHash)
if err != nil {
return fmt.Errorf("error checking scene entry: %v", err)
}
if len(scenes) == 0 {
j.logDelete("deleting unused marker directory: %s", sceneHash)
j.deleteDir(path)
} else {
// get the markers now
for _, scene := range scenes {
thisMarkers, err := j.Repository.SceneMarker.FindBySceneID(ctx, scene.ID)
if err != nil {
return fmt.Errorf("error getting markers for scene: %v", err)
}
markers = append(markers, thisMarkers...)
}
}
return nil
}); err != nil {
logger.Error(err.Error())
}
return nil
}
filename := info.Name()
seconds, err := j.getMarkerFileSeconds(filename)
if err != nil {
logger.Warnf("Ignoring unknown marker file: %s", filename)
return nil
}
// scenes should be set by the directory walk
hash := filepath.Base(filepath.Dir(path))
if hash != sceneHash {
logger.Errorf("internal error: scene hash mismatch: %s != %s", hash, sceneHash)
return nil
}
if len(scenes) == 0 {
logger.Errorf("no scenes found for marker file: %s", filename)
return nil
}
// find the marker
var marker *models.SceneMarker
for _, m := range markers {
if int(m.Seconds) == seconds {
marker = m
break
}
}
if marker == nil {
// not found, delete the file
j.logDelete("deleting unused marker file: %s", filename)
j.deleteFile(path)
}
return nil
}); err != nil {
return err
}
return nil
}
func (j *CleanGeneratedJob) getImagesWithHash(ctx context.Context, checksum string) ([]*models.Image, error) {
var exists []*models.Image
if err := j.Repository.WithReadTxn(ctx, func(ctx context.Context) error {
// if scene entry does not exist, delete the file
var err error
exists, err = j.Repository.Image.FindByChecksum(ctx, checksum)
return err
}); err != nil {
return nil, err
}
return exists, nil
}
func (j *CleanGeneratedJob) getThumbnailFileHash(basename string) (string, error) {
var hash string
var width int
_, err := fmt.Sscanf(basename, "%32x_%d.jpg", &hash, &width)
if err != nil {
return "", err
}
return fmt.Sprintf("%x", hash), nil
}
func (j *CleanGeneratedJob) cleanThumbnailFiles(ctx context.Context, progress *job.Progress) error {
if job.IsCancelled(ctx) {
return nil
}
logger.Infof("Cleaning image thumbnail files")
// walk through the sprite directory
if err := filepath.Walk(j.Paths.Generated.Thumbnails, func(path string, info fs.FileInfo, err error) error {
if err != nil {
return err
}
if err = ctx.Err(); err != nil {
return err
}
if info.IsDir() {
if path == j.Paths.Generated.Thumbnails {
return nil
}
// ensure the directory is a hash prefix
_, err := j.getIntraFolderPrefix(info.Name())
if err != nil {
logger.Warnf("Ignoring unknown thumbnail directory: %s", path)
return nil
}
// estimate progress by the hash prefix
if filepath.Dir(path) == j.Paths.Generated.Thumbnails {
hashPrefix := filepath.Base(path)
j.setProgressFromFilename(hashPrefix, progress)
}
return nil
}
filename := info.Name()
checksum, err := j.getThumbnailFileHash(filename)
if err != nil {
logger.Warnf("Ignoring unknown thumbnail file: %s", filename)
return nil
}
exists, err := j.getImagesWithHash(ctx, checksum)
if err != nil {
logger.Errorf("error checking image entry: %v", err)
return nil
}
if len(exists) == 0 {
j.logDelete("deleting unused thumbnail file: %s", filename)
j.deleteFile(path)
}
return nil
}); err != nil {
return err
}
return nil
}

View file

@ -14,6 +14,7 @@ type TxnManager interface {
type Repository struct { type Repository struct {
TxnManager TxnManager TxnManager TxnManager
Blob BlobReader
File FileReaderWriter File FileReaderWriter
Folder FolderReaderWriter Folder FolderReaderWriter
Gallery GalleryReaderWriter Gallery GalleryReaderWriter

View file

@ -0,0 +1,8 @@
package models
import "context"
// FileGetter provides methods to get files by ID.
type BlobReader interface {
EntryExists(ctx context.Context, checksum string) (bool, error)
}

View file

@ -235,6 +235,17 @@ func (qb *BlobStore) readFromFilesystem(ctx context.Context, checksum string) ([
} }
} }
func (qb *BlobStore) EntryExists(ctx context.Context, checksum string) (bool, error) {
q := dialect.From(qb.table()).Select(goqu.COUNT("*")).Where(qb.tableMgr.byID(checksum))
var found int
if err := querySimple(ctx, q, &found); err != nil {
return false, fmt.Errorf("querying %s: %w", qb.table(), err)
}
return found != 0, nil
}
// Read reads the data from the database or filesystem, depending on which is enabled. // Read reads the data from the database or filesystem, depending on which is enabled.
func (qb *BlobStore) Read(ctx context.Context, checksum string) ([]byte, error) { func (qb *BlobStore) Read(ctx context.Context, checksum string) ([]byte, error) {
if !qb.options.UseDatabase && !qb.options.UseFilesystem { if !qb.options.UseDatabase && !qb.options.UseFilesystem {

View file

@ -126,6 +126,7 @@ func (db *Database) IsLocked(err error) bool {
func (db *Database) Repository() models.Repository { func (db *Database) Repository() models.Repository {
return models.Repository{ return models.Repository{
TxnManager: db, TxnManager: db,
Blob: db.Blobs,
File: db.File, File: db.File,
Folder: db.Folder, Folder: db.Folder,
Gallery: db.Gallery, Gallery: db.Gallery,

View file

@ -34,6 +34,10 @@ mutation MetadataClean($input: CleanMetadataInput!) {
metadataClean(input: $input) metadataClean(input: $input)
} }
mutation MetadataCleanGenerated($input: CleanGeneratedInput!) {
metadataCleanGenerated(input: $input)
}
mutation MigrateHashNaming { mutation MigrateHashNaming {
migrateHashNaming migrateHashNaming
} }

View file

@ -0,0 +1,132 @@
import React, { useEffect, useState } from "react";
import { FormattedMessage, useIntl } from "react-intl";
import { ModalComponent } from "src/components/Shared/Modal";
import * as GQL from "src/core/generated-graphql";
import { BooleanSetting } from "../Inputs";
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
import { SettingSection } from "../SettingSection";
import { useSettings } from "../context";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
const CleanGeneratedOptions: React.FC<{
options: GQL.CleanGeneratedInput;
setOptions: (s: GQL.CleanGeneratedInput) => void;
}> = ({ options, setOptions: setOptionsState }) => {
function setOptions(input: Partial<GQL.CleanGeneratedInput>) {
setOptionsState({ ...options, ...input });
}
return (
<>
<BooleanSetting
id="clean-generated-blob-files"
checked={options.blobFiles ?? false}
headingID="config.tasks.clean_generated.blob_files"
onChange={(v) => setOptions({ blobFiles: v })}
/>
<BooleanSetting
id="clean-generated-screenshots"
checked={options.screenshots ?? false}
headingID="config.tasks.clean_generated.previews"
subHeadingID="config.tasks.clean_generated.previews_desc"
onChange={(v) => setOptions({ screenshots: v })}
/>
<BooleanSetting
id="clean-generated-sprites"
checked={options.sprites ?? false}
headingID="config.tasks.clean_generated.sprites"
onChange={(v) => setOptions({ sprites: v })}
/>
<BooleanSetting
id="clean-generated-transcodes"
checked={options.transcodes ?? false}
headingID="config.tasks.clean_generated.transcodes"
onChange={(v) => setOptions({ transcodes: v })}
/>
<BooleanSetting
id="clean-generated-markers"
checked={options.markers ?? false}
headingID="config.tasks.clean_generated.markers"
onChange={(v) => setOptions({ markers: v })}
/>
<BooleanSetting
id="clean-generated-image-thumbnails"
checked={options.imageThumbnails ?? false}
headingID="config.tasks.clean_generated.image_thumbnails"
subHeadingID="config.tasks.clean_generated.image_thumbnails_desc"
onChange={(v) => setOptions({ imageThumbnails: v })}
/>
<BooleanSetting
id="clean-generated-dryrun"
checked={options.dryRun ?? false}
headingID="config.tasks.only_dry_run"
onChange={(v) => setOptions({ dryRun: v })}
/>
</>
);
};
export const CleanGeneratedDialog: React.FC<{
onClose: (input?: GQL.CleanGeneratedInput) => void;
}> = ({ onClose }) => {
const intl = useIntl();
const { ui, saveUI, loading } = useSettings();
const [options, setOptions] = useState<GQL.CleanGeneratedInput>({
blobFiles: true,
imageThumbnails: true,
markers: true,
screenshots: true,
sprites: true,
transcodes: true,
dryRun: false,
});
useEffect(() => {
const defaults = ui.taskDefaults?.cleanGenerated;
if (defaults) {
setOptions(defaults);
}
}, [ui?.taskDefaults?.cleanGenerated]);
function confirm() {
saveUI({
taskDefaults: {
...ui.taskDefaults,
cleanGenerated: options,
},
});
onClose(options);
}
if (loading) return <LoadingIndicator />;
return (
<ModalComponent
show
header={<FormattedMessage id="actions.clean_generated" />}
icon={faTrashAlt}
accept={{
text: intl.formatMessage({ id: "actions.clean_generated" }),
variant: "danger",
onClick: () => confirm(),
}}
cancel={{ onClick: () => onClose() }}
>
<div className="dialog-container">
<p>
<FormattedMessage id="config.tasks.clean_generated.description" />
</p>
<SettingSection>
<CleanGeneratedOptions options={options} setOptions={setOptions} />
</SettingSection>
{options.dryRun && (
<p>
<FormattedMessage id="actions.tasks.dry_mode_selected" />
</p>
)}
</div>
</ModalComponent>
);
};

View file

@ -11,6 +11,7 @@ import {
mutateMigrateSceneScreenshots, mutateMigrateSceneScreenshots,
mutateMigrateBlobs, mutateMigrateBlobs,
mutateOptimiseDatabase, mutateOptimiseDatabase,
mutateCleanGenerated,
} from "src/core/StashService"; } from "src/core/StashService";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import downloadFile from "src/utils/download"; import downloadFile from "src/utils/download";
@ -29,6 +30,7 @@ import {
faQuestionCircle, faQuestionCircle,
faTrashAlt, faTrashAlt,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import { CleanGeneratedDialog } from "./CleanGeneratedDialog";
interface ICleanDialog { interface ICleanDialog {
pathSelection?: boolean; pathSelection?: boolean;
@ -167,6 +169,7 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
import: false, import: false,
clean: false, clean: false,
cleanAlert: false, cleanAlert: false,
cleanGenerated: false,
}); });
const [cleanOptions, setCleanOptions] = useState<GQL.CleanMetadataInput>({ const [cleanOptions, setCleanOptions] = useState<GQL.CleanMetadataInput>({
@ -252,6 +255,27 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
} }
} }
async function onCleanGenerated(options: GQL.CleanGeneratedInput) {
try {
await mutateCleanGenerated({
...options,
});
Toast.success(
intl.formatMessage(
{ id: "config.tasks.added_job_to_queue" },
{
operation_name: intl.formatMessage({
id: "actions.clean_generated",
}),
}
)
);
} catch (e) {
Toast.error(e);
}
}
async function onMigrateHashNaming() { async function onMigrateHashNaming() {
try { try {
await mutateMigrateHashNaming(); await mutateMigrateHashNaming();
@ -404,6 +428,17 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
) : ( ) : (
dialogOpen.clean dialogOpen.clean
)} )}
{dialogOpen.cleanGenerated && (
<CleanGeneratedDialog
onClose={(options) => {
if (options) {
onCleanGenerated(options);
}
setDialogOpen({ cleanGenerated: false });
}}
/>
)}
<SettingSection headingID="config.tasks.maintenance"> <SettingSection headingID="config.tasks.maintenance">
<div className="setting-group"> <div className="setting-group">
@ -439,6 +474,21 @@ export const DataManagementTasks: React.FC<IDataManagementTasks> = ({
/> />
</div> </div>
<div className="setting-group">
<Setting
heading={<FormattedMessage id="actions.clean_generated" />}
subHeadingID="config.tasks.clean_generated.description"
>
<Button
variant="danger"
type="submit"
onClick={() => setDialogOpen({ cleanGenerated: true })}
>
<FormattedMessage id="actions.clean_generated" />
</Button>
</Setting>
</div>
<Setting <Setting
headingID="actions.optimise_database" headingID="actions.optimise_database"
subHeading={ subHeading={

View file

@ -74,7 +74,6 @@ export const LibraryTasks: React.FC = () => {
const [configureDefaults] = useConfigureDefaults(); const [configureDefaults] = useConfigureDefaults();
const [dialogOpen, setDialogOpenState] = useState({ const [dialogOpen, setDialogOpenState] = useState({
clean: false,
scan: false, scan: false,
autoTag: false, autoTag: false,
identify: false, identify: false,

View file

@ -2506,6 +2506,12 @@ export const mutateMetadataClean = (input: GQL.CleanMetadataInput) =>
variables: { input }, variables: { input },
}); });
export const mutateCleanGenerated = (input: GQL.CleanGeneratedInput) =>
client.mutate<GQL.MetadataCleanGeneratedMutation>({
mutation: GQL.MetadataCleanGeneratedDocument,
variables: { input },
});
export const mutateRunPluginTask = ( export const mutateRunPluginTask = (
pluginId: string, pluginId: string,
taskName: string, taskName: string,

View file

@ -84,6 +84,8 @@ export interface IUIConfig {
tableColumns?: Record<string, string[]>; tableColumns?: Record<string, string[]>;
advancedMode?: boolean; advancedMode?: boolean;
taskDefaults?: Record<string, {}>;
} }
interface ISavedFilterRowBroken extends ISavedFilterRow { interface ISavedFilterRowBroken extends ISavedFilterRow {

View file

@ -18,6 +18,7 @@
"cancel": "Cancel", "cancel": "Cancel",
"choose_date": "Choose a date", "choose_date": "Choose a date",
"clean": "Clean", "clean": "Clean",
"clean_generated": "Clean generated files",
"clear": "Clear", "clear": "Clear",
"clear_back_image": "Clear back image", "clear_back_image": "Clear back image",
"clear_date_data": "Clear date data", "clear_date_data": "Clear date data",
@ -443,6 +444,17 @@
"backup_and_download": "Performs a backup of the database and downloads the resulting file.", "backup_and_download": "Performs a backup of the database and downloads the resulting file.",
"backup_database": "Performs a backup of the database to the backups directory, with the filename format {filename_format}", "backup_database": "Performs a backup of the database to the backups directory, with the filename format {filename_format}",
"cleanup_desc": "Check for missing files and remove them from the database. This is a destructive action.", "cleanup_desc": "Check for missing files and remove them from the database. This is a destructive action.",
"clean_generated": {
"blob_files": "Blob files",
"description": "Removes generated files without a corresponding database entry.",
"image_thumbnails": "Image Thumbnails",
"image_thumbnails_desc": "Image thumbnails and clips",
"markers": "Marker Previews",
"previews": "Scene Previews",
"previews_desc": "Scene previews and thumbnails",
"sprites": "Scene Sprites",
"transcodes": "Scene Transcodes"
},
"data_management": "Data management", "data_management": "Data management",
"defaults_set": "Defaults have been set and will be used when clicking the {action} button on the Tasks page.", "defaults_set": "Defaults have been set and will be used when clicking the {action} button on the Tasks page.",
"dont_include_file_extension_as_part_of_the_title": "Don't include file extension as part of the title", "dont_include_file_extension_as_part_of_the_title": "Don't include file extension as part of the title",