diff --git a/graphql/documents/mutations/scene.graphql b/graphql/documents/mutations/scene.graphql index aa7cea886..43231808b 100644 --- a/graphql/documents/mutations/scene.graphql +++ b/graphql/documents/mutations/scene.graphql @@ -24,4 +24,8 @@ mutation SceneUpdate( }) { ...SceneData } +} + +mutation SceneDestroy($id: ID!, $delete_file: Boolean, $delete_generated : Boolean) { + sceneDestroy(input: {id: $id, delete_file: $delete_file, delete_generated: $delete_generated}) } \ No newline at end of file diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index b90b4bc75..cfff40f30 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -75,6 +75,7 @@ type Query { type Mutation { sceneUpdate(input: SceneUpdateInput!): Scene + sceneDestroy(input: SceneDestroyInput!): Boolean! sceneMarkerCreate(input: SceneMarkerCreateInput!): SceneMarker sceneMarkerUpdate(input: SceneMarkerUpdateInput!): SceneMarker diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index bf9a55fd2..a23b2d13b 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -53,6 +53,12 @@ input SceneUpdateInput { tag_ids: [ID!] } +input SceneDestroyInput { + id: ID! + delete_file: Boolean + delete_generated: Boolean +} + type FindScenesResultType { count: Int! scenes: [Scene!]! diff --git a/pkg/api/resolver_mutation_scene.go b/pkg/api/resolver_mutation_scene.go index 2cdf55635..66ccf4af6 100644 --- a/pkg/api/resolver_mutation_scene.go +++ b/pkg/api/resolver_mutation_scene.go @@ -7,6 +7,7 @@ import ( "time" "github.com/stashapp/stash/pkg/database" + "github.com/stashapp/stash/pkg/manager" "github.com/stashapp/stash/pkg/models" ) @@ -118,6 +119,38 @@ func (r *mutationResolver) SceneUpdate(ctx context.Context, input models.SceneUp return scene, nil } +func (r *mutationResolver) SceneDestroy(ctx context.Context, input models.SceneDestroyInput) (bool, error) { + qb := models.NewSceneQueryBuilder() + tx := database.DB.MustBeginTx(ctx, nil) + + sceneID, _ := strconv.Atoi(input.ID) + scene, err := qb.Find(sceneID) + err = manager.DestroyScene(sceneID, tx) + + if err != nil { + tx.Rollback() + return false, err + } + + if err := tx.Commit(); err != nil { + return false, err + } + + // if delete generated is true, then delete the generated files + // for the scene + if input.DeleteGenerated != nil && *input.DeleteGenerated { + manager.DeleteGeneratedSceneFiles(scene) + } + + // if delete file is true, then delete the file as well + // if it fails, just log a message + if input.DeleteFile != nil && *input.DeleteFile { + manager.DeleteSceneFile(scene) + } + + return true, nil +} + func (r *mutationResolver) SceneMarkerCreate(ctx context.Context, input models.SceneMarkerCreateInput) (*models.SceneMarker, error) { primaryTagID, _ := strconv.Atoi(input.PrimaryTagID) sceneID, _ := strconv.Atoi(input.SceneID) diff --git a/pkg/api/routes_scene.go b/pkg/api/routes_scene.go index ebc36b7fc..6e3d02e6f 100644 --- a/pkg/api/routes_scene.go +++ b/pkg/api/routes_scene.go @@ -45,11 +45,13 @@ func (rs sceneRoutes) Stream(w http.ResponseWriter, r *http.Request) { // detect if not a streamable file and try to transcode it instead filepath := manager.GetInstance().Paths.Scene.GetStreamPath(scene.Path, scene.Checksum) - videoCodec := scene.VideoCodec.String hasTranscode, _ := manager.HasTranscode(scene) if ffmpeg.IsValidCodec(videoCodec) || hasTranscode { + manager.RegisterStream(filepath, &w) http.ServeFile(w, r, filepath) + manager.WaitAndDeregisterStream(filepath, &w, r) + return } diff --git a/pkg/ffmpeg/encoder.go b/pkg/ffmpeg/encoder.go index 2cf03d483..28fc4b3a0 100644 --- a/pkg/ffmpeg/encoder.go +++ b/pkg/ffmpeg/encoder.go @@ -1,11 +1,13 @@ package ffmpeg import ( + "fmt" "io" "io/ioutil" "os" "os/exec" "strings" + "time" "github.com/stashapp/stash/pkg/logger" ) @@ -14,12 +16,63 @@ type Encoder struct { Path string } +var runningEncoders map[string][]*os.Process = make(map[string][]*os.Process) + func NewEncoder(ffmpegPath string) Encoder { return Encoder{ Path: ffmpegPath, } } +func registerRunningEncoder(path string, process *os.Process) { + processes := runningEncoders[path] + + runningEncoders[path] = append(processes, process) +} + +func deregisterRunningEncoder(path string, process *os.Process) { + processes := runningEncoders[path] + + for i, v := range processes { + if v == process { + runningEncoders[path] = append(processes[:i], processes[i+1:]...) + return + } + } +} + +func waitAndDeregister(path string, cmd *exec.Cmd) error { + err := cmd.Wait() + deregisterRunningEncoder(path, cmd.Process) + + return err +} + +func KillRunningEncoders(path string) { + processes := runningEncoders[path] + + for _, process := range processes { + // assume it worked, don't check for error + fmt.Printf("Killing encoder process for file: %s", path) + process.Kill() + + // wait for the process to die before returning + // don't wait more than a few seconds + done := make(chan error) + go func() { + _, err := process.Wait() + done <- err + }() + + select { + case <-done: + return + case <-time.After(5 * time.Second): + return + } + } +} + func (e *Encoder) run(probeResult VideoFile, args []string) (string, error) { cmd := exec.Command(e.Path, args...) @@ -56,7 +109,10 @@ func (e *Encoder) run(probeResult VideoFile, args []string) (string, error) { stdoutData, _ := ioutil.ReadAll(stdout) stdoutString := string(stdoutData) - if err := cmd.Wait(); err != nil { + registerRunningEncoder(probeResult.Path, cmd.Process) + err = waitAndDeregister(probeResult.Path, cmd) + + if err != nil { logger.Errorf("ffmpeg error when running command <%s>: %s", strings.Join(cmd.Args, " "), stdoutString) return stdoutString, err } @@ -76,5 +132,8 @@ func (e *Encoder) stream(probeResult VideoFile, args []string) (io.ReadCloser, * return nil, nil, err } + registerRunningEncoder(probeResult.Path, cmd.Process) + go waitAndDeregister(probeResult.Path, cmd) + return stdout, cmd.Process, nil } diff --git a/pkg/manager/running_streams.go b/pkg/manager/running_streams.go new file mode 100644 index 000000000..f573d7aa4 --- /dev/null +++ b/pkg/manager/running_streams.go @@ -0,0 +1,58 @@ +package manager + +import ( + "net/http" + + "github.com/stashapp/stash/pkg/ffmpeg" + "github.com/stashapp/stash/pkg/logger" +) + +var streamingFiles = make(map[string][]*http.ResponseWriter) + +func RegisterStream(filepath string, w *http.ResponseWriter) { + streams := streamingFiles[filepath] + + streamingFiles[filepath] = append(streams, w) +} + +func deregisterStream(filepath string, w *http.ResponseWriter) { + streams := streamingFiles[filepath] + + for i, v := range streams { + if v == w { + streamingFiles[filepath] = append(streams[:i], streams[i+1:]...) + return + } + } +} + +func WaitAndDeregisterStream(filepath string, w *http.ResponseWriter, r *http.Request) { + notify := r.Context().Done() + go func() { + <-notify + deregisterStream(filepath, w) + }() +} + +func KillRunningStreams(path string) { + ffmpeg.KillRunningEncoders(path) + + streams := streamingFiles[path] + + for _, w := range streams { + hj, ok := (*w).(http.Hijacker) + if !ok { + // if we can't close the connection can't really do anything else + logger.Warnf("cannot close running stream for: %s", path) + return + } + + // hijack and close the connection + conn, _, err := hj.Hijack() + if err != nil { + logger.Errorf("cannot close running stream for '%s' due to error: %s", path, err.Error()) + } else { + conn.Close() + } + } +} diff --git a/pkg/manager/scene.go b/pkg/manager/scene.go new file mode 100644 index 000000000..e222f50c4 --- /dev/null +++ b/pkg/manager/scene.go @@ -0,0 +1,133 @@ +package manager + +import ( + "os" + "path/filepath" + "strconv" + + "github.com/jmoiron/sqlx" + + "github.com/stashapp/stash/pkg/logger" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/utils" +) + +func DestroyScene(sceneID int, tx *sqlx.Tx) (error) { + qb := models.NewSceneQueryBuilder() + jqb := models.NewJoinsQueryBuilder() + + _, err := qb.Find(sceneID) + if err != nil { + return err + } + + if err := jqb.DestroyScenesTags(sceneID, tx); err != nil { + return err + } + + if err := jqb.DestroyPerformersScenes(sceneID, tx); err != nil { + return err + } + + if err := jqb.DestroyScenesMarkers(sceneID, tx); err != nil { + return err + } + + if err := jqb.DestroyScenesGalleries(sceneID, tx); err != nil { + return err + } + + if err := qb.Destroy(strconv.Itoa(sceneID), tx); err != nil { + return err + } + + return nil +} + +func DeleteGeneratedSceneFiles(scene *models.Scene) { + markersFolder := filepath.Join(GetInstance().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 := GetInstance().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()) + } + } + + normalPath := GetInstance().Paths.Scene.GetScreenshotPath(scene.Checksum) + exists, _ = utils.FileExists(normalPath) + if exists { + err := os.Remove(normalPath) + if err != nil { + logger.Warnf("Could not delete file %s: %s", normalPath, err.Error()) + } + } + + streamPreviewPath := GetInstance().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 := GetInstance().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 := GetInstance().Paths.Scene.GetTranscodePath(scene.Checksum) + exists, _ = utils.FileExists(transcodePath) + if exists { + // kill any running streams + KillRunningStreams(transcodePath) + + err := os.Remove(transcodePath) + if err != nil { + logger.Warnf("Could not delete file %s: %s", transcodePath, err.Error()) + } + } + + spritePath := GetInstance().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 := GetInstance().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 DeleteSceneFile(scene *models.Scene) { + // kill any running encoders + KillRunningStreams(scene.Path) + + err := os.Remove(scene.Path) + if err != nil { + logger.Warnf("Could not delete file %s: %s", scene.Path, err.Error()) + } +} \ No newline at end of file diff --git a/pkg/manager/task_clean.go b/pkg/manager/task_clean.go index 594f674ac..1f4c0ae9f 100644 --- a/pkg/manager/task_clean.go +++ b/pkg/manager/task_clean.go @@ -5,10 +5,7 @@ import ( "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" ) @@ -30,114 +27,23 @@ func (t *CleanTask) Start(wg *sync.WaitGroup) { 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) + err = DestroyScene(sceneID, tx) + if err != nil { - _ = tx.Rollback() + logger.Infof("Error deleting scene from database: %s", err.Error()) + tx.Rollback() + return } - 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()) - } + if err := tx.Commit(); err != nil { + logger.Infof("Error deleting scene from database: %s", err.Error()) + return } + + DeleteGeneratedSceneFiles(scene) } func (t *CleanTask) fileExists(filename string) bool { diff --git a/pkg/models/querybuilder_joins.go b/pkg/models/querybuilder_joins.go index 5e534294b..a16961ca7 100644 --- a/pkg/models/querybuilder_joins.go +++ b/pkg/models/querybuilder_joins.go @@ -55,16 +55,6 @@ 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) @@ -76,6 +66,15 @@ func (qb *JoinsQueryBuilder) UpdateScenesTags(sceneID int, updatedJoins []Scenes return qb.CreateScenesTags(updatedJoins, tx) } +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) CreateSceneMarkersTags(newJoins []SceneMarkersTags, tx *sqlx.Tx) error { ensureTx(tx) for _, join := range newJoins { diff --git a/pkg/models/querybuilder_scene.go b/pkg/models/querybuilder_scene.go index 13cc37c87..fc33aaab1 100644 --- a/pkg/models/querybuilder_scene.go +++ b/pkg/models/querybuilder_scene.go @@ -77,7 +77,6 @@ func (qb *SceneQueryBuilder) Update(updatedScene ScenePartial, tx *sqlx.Tx) (*Sc 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) } diff --git a/ui/v2/src/components/scenes/SceneDetails/Scene.tsx b/ui/v2/src/components/scenes/SceneDetails/Scene.tsx index c8ff0a051..b2ba64b10 100644 --- a/ui/v2/src/components/scenes/SceneDetails/Scene.tsx +++ b/ui/v2/src/components/scenes/SceneDetails/Scene.tsx @@ -82,7 +82,12 @@ export const Scene: FunctionComponent = (props: ISceneProps) => { setScene(newScene)} />} + panel={ + setScene(newScene)} + onDelete={() => props.history.push("/scenes")} + />} /> diff --git a/ui/v2/src/components/scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2/src/components/scenes/SceneDetails/SceneEditPanel.tsx index 27cc6a8f6..737122f84 100644 --- a/ui/v2/src/components/scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2/src/components/scenes/SceneDetails/SceneEditPanel.tsx @@ -1,5 +1,8 @@ import { Button, + Classes, + Checkbox, + Dialog, FormGroup, HTMLSelect, InputGroup, @@ -19,6 +22,7 @@ import { ValidGalleriesSelect } from "../../select/ValidGalleriesSelect"; interface IProps { scene: GQL.SceneDataFragment; onUpdate: (scene: GQL.SceneDataFragment) => void; + onDelete: () => void; } export const SceneEditPanel: FunctionComponent = (props: IProps) => { @@ -33,10 +37,15 @@ export const SceneEditPanel: FunctionComponent = (props: IProps) => { const [performerIds, setPerformerIds] = useState(undefined); const [tagIds, setTagIds] = useState(undefined); + const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); + const [deleteFile, setDeleteFile] = useState(false); + const [deleteGenerated, setDeleteGenerated] = useState(true); + // Network state const [isLoading, setIsLoading] = useState(false); const updateScene = StashService.useSceneUpdate(getSceneInput()); + const deleteScene = StashService.useSceneDestroy(getSceneDeleteInput()); function updateSceneEditState(state: Partial) { const perfIds = !!state.performers ? state.performers.map((performer) => performer.id) : undefined; @@ -89,6 +98,28 @@ export const SceneEditPanel: FunctionComponent = (props: IProps) => { setIsLoading(false); } + function getSceneDeleteInput(): GQL.SceneDestroyInput { + return { + id: props.scene.id, + delete_file: deleteFile, + delete_generated: deleteGenerated + }; + } + + async function onDelete() { + setIsDeleteAlertOpen(false); + setIsLoading(true); + try { + await deleteScene(); + ToastUtils.success("Deleted scene"); + } catch (e) { + ErrorUtils.handle(e); + } + setIsLoading(false); + + props.onDelete(); + } + function renderMultiSelect(type: "performers" | "tags", initialIds: string[] | undefined) { return ( = (props: IProps) => { ); } + function renderDeleteAlert() { + return ( + <> + +
+

+ Are you sure you want to delete this scene? Unless the file is also deleted, this scene will be re-added when scan is performed. +

+ setDeleteFile(!deleteFile)} /> + setDeleteGenerated(!deleteGenerated)} /> +
+ +
+
+ + +
+
+
+ + ); + } + return ( <> + {renderDeleteAlert()} {isLoading ? : undefined}
@@ -170,7 +232,8 @@ export const SceneEditPanel: FunctionComponent = (props: IProps) => { {renderMultiSelect("tags", tagIds)}
-