diff --git a/pkg/api/resolver_query_metadata.go b/pkg/api/resolver_query_metadata.go index 925d86d8e..3dfea9698 100644 --- a/pkg/api/resolver_query_metadata.go +++ b/pkg/api/resolver_query_metadata.go @@ -28,5 +28,6 @@ func (r *queryResolver) MetadataGenerate(ctx context.Context, input models.Gener } func (r *queryResolver) MetadataClean(ctx context.Context) (string, error) { - panic("not implemented") + manager.GetInstance().Clean() + return "todo", nil } diff --git a/pkg/manager/manager_tasks.go b/pkg/manager/manager_tasks.go index 0d8580fa0..877b54c1b 100644 --- a/pkg/manager/manager_tasks.go +++ b/pkg/manager/manager_tasks.go @@ -133,6 +133,41 @@ func (s *singleton) Generate(sprites bool, previews bool, markers bool, transcod }() } +func (s *singleton) Clean() { + if s.Status != Idle { + return + } + s.Status = Clean + + qb := models.NewSceneQueryBuilder() + go func() { + defer s.returnToIdleState() + + logger.Infof("Starting cleaning of tracked files") + scenes, err := qb.All() + if err != nil { + logger.Errorf("failed to fetch list of scenes for cleaning") + return + } + + var wg sync.WaitGroup + for _, scene := range scenes { + if scene == nil { + logger.Errorf("nil scene, skipping generate") + continue + } + + wg.Add(1) + + task := CleanTask{Scene: *scene} + go task.Start(&wg) + wg.Wait() + } + + logger.Info("Finished Cleaning") + }() +} + func (s *singleton) returnToIdleState() { if r := recover(); r != nil { logger.Info("recovered from ", r) diff --git a/pkg/manager/task_clean.go b/pkg/manager/task_clean.go new file mode 100644 index 000000000..594f674ac --- /dev/null +++ b/pkg/manager/task_clean.go @@ -0,0 +1,149 @@ +package manager + +import ( + "context" + "github.com/stashapp/stash/pkg/database" + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" + "os" + "path/filepath" + "strconv" + "sync" +) + +type CleanTask struct { + Scene models.Scene +} + +func (t *CleanTask) Start(wg *sync.WaitGroup) { + defer wg.Done() + + if t.fileExists(t.Scene.Path) { + logger.Debugf("File Found: %s", t.Scene.Path) + } else { + logger.Infof("File not found. Cleaning: %s", t.Scene.Path) + t.deleteScene(t.Scene.ID) + } +} + +func (t *CleanTask) deleteScene(sceneID int) { + ctx := context.TODO() + qb := models.NewSceneQueryBuilder() + jqb := models.NewJoinsQueryBuilder() + tx := database.DB.MustBeginTx(ctx, nil) + strSceneID := strconv.Itoa(sceneID) + defer tx.Commit() + + //check and make sure it still exists. scene is also used to delete generated files + scene, err := qb.Find(sceneID) + if err != nil { + _ = tx.Rollback() + } + + if err := jqb.DestroyScenesTags(sceneID, tx); err != nil { + _ = tx.Rollback() + } + + if err := jqb.DestroyPerformersScenes(sceneID, tx); err != nil { + _ = tx.Rollback() + } + + if err := jqb.DestroyScenesMarkers(sceneID, tx); err != nil { + _ = tx.Rollback() + } + + if err := jqb.DestroyScenesGalleries(sceneID, tx); err != nil { + _ = tx.Rollback() + } + + if err := qb.Destroy(strSceneID, tx); err != nil { + _ = tx.Rollback() + } + + t.deleteGeneratedSceneFiles(scene) +} + + +func (t *CleanTask) deleteGeneratedSceneFiles(scene *models.Scene) { + markersFolder := filepath.Join(instance.Paths.Generated.Markers, scene.Checksum) + + exists, _ := utils.FileExists(markersFolder) + if exists { + err := os.RemoveAll(markersFolder) + if err != nil { + logger.Warnf("Could not delete file %s: %s", scene.Path, err.Error()) + } + } + + thumbPath := instance.Paths.Scene.GetThumbnailScreenshotPath(scene.Checksum) + exists, _ = utils.FileExists(thumbPath) + if exists { + err := os.Remove(thumbPath) + if err != nil { + logger.Warnf("Could not delete file %s: %s", thumbPath, err.Error()) + } + } + + screenshotPath := instance.Paths.Scene.GetScreenshotPath(scene.Checksum) + exists, _ = utils.FileExists(screenshotPath) + if exists { + err := os.Remove(screenshotPath) + if err != nil { + logger.Warnf("Could not delete file %s: %s", screenshotPath, err.Error()) + } + } + + streamPreviewPath := instance.Paths.Scene.GetStreamPreviewPath(scene.Checksum) + exists, _ = utils.FileExists(streamPreviewPath) + if exists { + err := os.Remove(streamPreviewPath) + if err != nil { + logger.Warnf("Could not delete file %s: %s", streamPreviewPath, err.Error()) + } + } + + streamPreviewImagePath := instance.Paths.Scene.GetStreamPreviewImagePath(scene.Checksum) + exists, _ = utils.FileExists(streamPreviewImagePath) + if exists { + err := os.Remove(streamPreviewImagePath) + if err != nil { + logger.Warnf("Could not delete file %s: %s", streamPreviewImagePath, err.Error()) + } + } + + transcodePath := instance.Paths.Scene.GetTranscodePath(scene.Checksum) + exists, _ = utils.FileExists(transcodePath) + if exists { + err := os.Remove(transcodePath) + if err != nil { + logger.Warnf("Could not delete file %s: %s", transcodePath, err.Error()) + } + } + + spritePath := instance.Paths.Scene.GetSpriteImageFilePath(scene.Checksum) + exists, _ = utils.FileExists(spritePath) + if exists { + err := os.Remove(spritePath) + if err != nil { + logger.Warnf("Could not delete file %s: %s", spritePath, err.Error()) + } + } + + vttPath := instance.Paths.Scene.GetSpriteVttFilePath(scene.Checksum) + exists, _ = utils.FileExists(vttPath) + if exists { + err := os.Remove(vttPath) + if err != nil { + logger.Warnf("Could not delete file %s: %s", vttPath, err.Error()) + } + } +} + +func (t *CleanTask) fileExists(filename string) bool { + info, err := os.Stat(filename) + if os.IsNotExist(err) { + return false + } + return !info.IsDir() +} diff --git a/pkg/models/querybuilder_joins.go b/pkg/models/querybuilder_joins.go index efcf435f2..5e534294b 100644 --- a/pkg/models/querybuilder_joins.go +++ b/pkg/models/querybuilder_joins.go @@ -33,6 +33,14 @@ func (qb *JoinsQueryBuilder) UpdatePerformersScenes(sceneID int, updatedJoins [] return qb.CreatePerformersScenes(updatedJoins, tx) } +func (qb *JoinsQueryBuilder) DestroyPerformersScenes(sceneID int, tx *sqlx.Tx) error { + ensureTx(tx) + + // Delete the existing joins + _, err := tx.Exec("DELETE FROM performers_scenes WHERE scene_id = ?", sceneID) + return err +} + func (qb *JoinsQueryBuilder) CreateScenesTags(newJoins []ScenesTags, tx *sqlx.Tx) error { ensureTx(tx) for _, join := range newJoins { @@ -47,6 +55,16 @@ func (qb *JoinsQueryBuilder) CreateScenesTags(newJoins []ScenesTags, tx *sqlx.Tx return nil } +func (qb *JoinsQueryBuilder) DestroyScenesTags(sceneID int, tx *sqlx.Tx) error { + ensureTx(tx) + + // Delete the existing joins + _, err := tx.Exec("DELETE FROM scenes_tags WHERE scene_id = ?", sceneID) + + return err +} + + func (qb *JoinsQueryBuilder) UpdateScenesTags(sceneID int, updatedJoins []ScenesTags, tx *sqlx.Tx) error { ensureTx(tx) @@ -82,3 +100,32 @@ func (qb *JoinsQueryBuilder) UpdateSceneMarkersTags(sceneMarkerID int, updatedJo } return qb.CreateSceneMarkersTags(updatedJoins, tx) } + +func (qb *JoinsQueryBuilder) DestroySceneMarkersTags(sceneMarkerID int, updatedJoins []SceneMarkersTags, tx *sqlx.Tx) error { + ensureTx(tx) + + // Delete the existing joins + _, err := tx.Exec("DELETE FROM scene_markers_tags WHERE scene_marker_id = ?", sceneMarkerID) + return err +} + +func (qb *JoinsQueryBuilder) DestroyScenesGalleries(sceneID int, tx *sqlx.Tx) error { + ensureTx(tx) + + // Unset the existing scene id from galleries + _, err := tx.Exec("UPDATE galleries SET scene_id = null WHERE scene_id = ?", sceneID) + + return err +} + +func (qb *JoinsQueryBuilder) DestroyScenesMarkers(sceneID int, tx *sqlx.Tx) error { + ensureTx(tx) + + // Delete the scene marker tags + _, err := tx.Exec("DELETE t FROM scene_markers_tags t join scene_markers m on t.scene_marker_id = m.id WHERE m.scene_id = ?", sceneID) + + // Delete the existing joins + _, err = tx.Exec("DELETE FROM scene_markers WHERE scene_id = ?", sceneID) + + return err +} diff --git a/pkg/models/querybuilder_scene.go b/pkg/models/querybuilder_scene.go index a6d3fc6f9..13cc37c87 100644 --- a/pkg/models/querybuilder_scene.go +++ b/pkg/models/querybuilder_scene.go @@ -74,6 +74,10 @@ func (qb *SceneQueryBuilder) Update(updatedScene ScenePartial, tx *sqlx.Tx) (*Sc return qb.find(updatedScene.ID, tx) } +func (qb *SceneQueryBuilder) Destroy(id string, tx *sqlx.Tx) error { + return executeDeleteQuery("scenes", id, tx) +} + func (qb *SceneQueryBuilder) Find(id int) (*Scene, error) { return qb.find(id, nil) } @@ -292,3 +296,4 @@ func (qb *SceneQueryBuilder) queryScenes(query string, args []interface{}, tx *s return scenes, nil } + diff --git a/ui/v2/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx b/ui/v2/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx index d1c5e5ab4..0e0a15676 100644 --- a/ui/v2/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx +++ b/ui/v2/src/components/Settings/SettingsTasksPanel/SettingsTasksPanel.tsx @@ -18,6 +18,7 @@ interface IProps {} export const SettingsTasksPanel: FunctionComponent = (props: IProps) => { const [isImportAlertOpen, setIsImportAlertOpen] = useState(false); + const [isCleanAlertOpen, setIsCleanAlertOpen] = useState(false); const [nameFromMetadata, setNameFromMetadata] = useState(true); function onImport() { @@ -44,6 +45,31 @@ export const SettingsTasksPanel: FunctionComponent = (props: IProps) => ); } + function onClean() { + setIsCleanAlertOpen(false); + StashService.queryMetadataClean(); + } + + function renderCleanAlert() { + return ( + setIsCleanAlertOpen(false)} + onConfirm={() => onClean()} + > +

+ Are you sure you want to Clean? + This will delete db information and generated content + for all scenes that are no longer found in the filesystem. +

+
+ ); + } + async function onScan() { try { await StashService.queryMetadataScan({nameFromMetadata}); @@ -56,6 +82,7 @@ export const SettingsTasksPanel: FunctionComponent = (props: IProps) => return ( <> {renderImportAlert()} + {renderCleanAlert()}

Library

= (props: IProps) =>

Generated Content

-