[Files Refactor] Object file management (#2790)

* Add Make Primary file function
* Add delete file functionality
This commit is contained in:
WithoutPants
2022-10-06 14:50:06 +11:00
committed by GitHub
parent 83359b00d5
commit ef9e138a2d
22 changed files with 759 additions and 106 deletions

View File

@@ -4,6 +4,7 @@ fragment FolderData on Folder {
}
fragment VideoFileData on VideoFile {
id
path
size
duration
@@ -20,6 +21,7 @@ fragment VideoFileData on VideoFile {
}
fragment ImageFileData on ImageFile {
id
path
size
width
@@ -31,6 +33,7 @@ fragment ImageFileData on ImageFile {
}
fragment GalleryFileData on GalleryFile {
id
path
size
fingerprints {

View File

@@ -0,0 +1,3 @@
mutation DeleteFiles($ids: [ID!]!) {
deleteFiles(ids: $ids)
}

View File

@@ -227,6 +227,8 @@ type Mutation {
tagsDestroy(ids: [ID!]!): Boolean!
tagsMerge(input: TagsMergeInput!): Tag
deleteFiles(ids: [ID!]!): Boolean!
# Saved filters
saveFilter(input: SaveFilterInput!): SavedFilter!
destroySavedFilter(input: DestroyFilterInput!): Boolean!

View File

@@ -53,6 +53,8 @@ input GalleryUpdateInput {
studio_id: ID
tag_ids: [ID!]
performer_ids: [ID!]
primary_file_id: ID
}
input BulkGalleryUpdateInput {

View File

@@ -44,6 +44,8 @@ input ImageUpdateInput {
performer_ids: [ID!]
tag_ids: [ID!]
gallery_ids: [ID!]
primary_file_id: ID
}
input BulkImageUpdateInput {

View File

@@ -90,6 +90,8 @@ input SceneUpdateInput {
"""This should be a URL or a base64 encoded data URL"""
cover_image: String
stash_ids: [StashIDInput!]
primary_file_id: ID
}
enum BulkUpdateIdMode {

View File

@@ -0,0 +1,74 @@
package api
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
)
func (r *mutationResolver) DeleteFiles(ctx context.Context, ids []string) (ret bool, err error) {
fileIDs, err := stringslice.StringSliceToIntSlice(ids)
if err != nil {
return false, err
}
fileDeleter := file.NewDeleter()
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 := file.ID(fileIDInt)
f, err := qb.Find(ctx, fileID)
if err != nil {
return err
}
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 delete primary file %s", path)
}
// destroy files in zip file
inZip, err := qb.FindByZipFileID(ctx, fileID)
if err != nil {
return fmt.Errorf("finding zip file contents for %s: %w", path, err)
}
for _, ff := range inZip {
const deleteFileInZip = false
if err := file.Destroy(ctx, qb, ff, fileDeleter, deleteFileInZip); err != nil {
return fmt.Errorf("destroying file %s: %w", ff.Base().Path, err)
}
}
const deleteFile = true
if err := destroyer.DestroyZip(ctx, f[0], fileDeleter, deleteFile); err != nil {
return fmt.Errorf("deleting file %s: %w", path, err)
}
}
return nil
}); err != nil {
fileDeleter.Rollback()
return false, err
}
// perform the post-commit actions
fileDeleter.Commit()
return true, nil
}

View File

@@ -194,6 +194,32 @@ func (r *mutationResolver) galleryUpdate(ctx context.Context, input models.Galle
}
updatedGallery.Organized = translator.optionalBool(input.Organized, "organized")
if input.PrimaryFileID != nil {
primaryFileID, err := strconv.Atoi(*input.PrimaryFileID)
if err != nil {
return nil, fmt.Errorf("converting primary file id: %w", err)
}
converted := file.ID(primaryFileID)
updatedGallery.PrimaryFileID = &converted
if err := originalGallery.LoadFiles(ctx, r.repository.Gallery); err != nil {
return nil, err
}
// ensure that new primary file is associated with scene
var f file.File
for _, ff := range originalGallery.Files.List() {
if ff.Base().ID == converted {
f = ff
}
}
if f == nil {
return nil, fmt.Errorf("file with id %d not associated with gallery", converted)
}
}
if translator.hasField("performer_ids") {
updatedGallery.PerformerIDs, err = translateUpdateIDs(input.PerformerIds, models.RelationshipUpdateModeSet)
if err != nil {

View File

@@ -110,6 +110,32 @@ func (r *mutationResolver) imageUpdate(ctx context.Context, input ImageUpdateInp
}
updatedImage.Organized = translator.optionalBool(input.Organized, "organized")
if input.PrimaryFileID != nil {
primaryFileID, err := strconv.Atoi(*input.PrimaryFileID)
if err != nil {
return nil, fmt.Errorf("converting primary file id: %w", err)
}
converted := file.ID(primaryFileID)
updatedImage.PrimaryFileID = &converted
if err := i.LoadFiles(ctx, r.repository.Image); err != nil {
return nil, err
}
// ensure that new primary file is associated with scene
var f *file.ImageFile
for _, ff := range i.Files.List() {
if ff.ID == converted {
f = ff
}
}
if f == nil {
return nil, fmt.Errorf("file with id %d not associated with image", converted)
}
}
if translator.hasField("gallery_ids") {
updatedImage.GalleryIDs, err = translateUpdateIDs(input.GalleryIds, models.RelationshipUpdateModeSet)
if err != nil {

View File

@@ -15,6 +15,7 @@ import (
"github.com/stashapp/stash/pkg/scene"
"github.com/stashapp/stash/pkg/sliceutil/intslice"
"github.com/stashapp/stash/pkg/sliceutil/stringslice"
"github.com/stashapp/stash/pkg/txn"
"github.com/stashapp/stash/pkg/utils"
)
@@ -96,6 +97,17 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
return nil, err
}
qb := r.repository.Scene
s, err := qb.Find(ctx, sceneID)
if err != nil {
return nil, err
}
if s == nil {
return nil, fmt.Errorf("scene with id %d not found", sceneID)
}
var coverImageData []byte
updatedScene := models.NewScenePartial()
@@ -111,6 +123,46 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
updatedScene.Organized = translator.optionalBool(input.Organized, "organized")
if input.PrimaryFileID != nil {
primaryFileID, err := strconv.Atoi(*input.PrimaryFileID)
if err != nil {
return nil, fmt.Errorf("converting primary file id: %w", err)
}
converted := file.ID(primaryFileID)
updatedScene.PrimaryFileID = &converted
// if file hash has changed, we should migrate generated files
// after commit
if err := s.LoadFiles(ctx, r.repository.Scene); err != nil {
return nil, err
}
// ensure that new primary file is associated with scene
var f *file.VideoFile
for _, ff := range s.Files.List() {
if ff.ID == converted {
f = ff
}
}
if f == nil {
return nil, fmt.Errorf("file with id %d not associated with scene", converted)
}
fileNamingAlgorithm := config.GetInstance().GetVideoFileNamingAlgorithm()
oldHash := scene.GetHash(s.Files.Primary(), fileNamingAlgorithm)
newHash := scene.GetHash(f, fileNamingAlgorithm)
if oldHash != "" && newHash != "" && oldHash != newHash {
// perform migration after commit
txn.AddPostCommitHook(ctx, func(ctx context.Context) error {
scene.MigrateHash(manager.GetInstance().Paths, oldHash, newHash)
return nil
})
}
}
if translator.hasField("performer_ids") {
updatedScene.PerformerIDs, err = translateUpdateIDs(input.PerformerIds, models.RelationshipUpdateModeSet)
if err != nil {
@@ -158,8 +210,7 @@ func (r *mutationResolver) sceneUpdate(ctx context.Context, input models.SceneUp
// update the cover after updating the scene
}
qb := r.repository.Scene
s, err := qb.UpdatePartial(ctx, sceneID, updatedScene)
s, err = qb.UpdatePartial(ctx, sceneID, updatedScene)
if err != nil {
return nil, err
}

View File

@@ -38,6 +38,7 @@ type FileReaderWriter interface {
file.Finder
Query(ctx context.Context, options models.FileQueryOptions) (*models.FileQueryResult, error)
GetCaptions(ctx context.Context, fileID file.ID) ([]*models.VideoCaption, error)
IsPrimary(ctx context.Context, fileID file.ID) (bool, error)
}
type FolderReaderWriter interface {

View File

@@ -63,6 +63,7 @@ type GalleryUpdateInput struct {
StudioID *string `json:"studio_id"`
TagIds []string `json:"tag_ids"`
PerformerIds []string `json:"performer_ids"`
PrimaryFileID *string `json:"primary_file_id"`
}
type GalleryDestroyInput struct {

View File

@@ -178,8 +178,9 @@ type SceneUpdateInput struct {
Movies []*SceneMovieInput `json:"movies"`
TagIds []string `json:"tag_ids"`
// This should be a URL or a base64 encoded data URL
CoverImage *string `json:"cover_image"`
StashIds []StashID `json:"stash_ids"`
CoverImage *string `json:"cover_image"`
StashIds []StashID `json:"stash_ids"`
PrimaryFileID *string `json:"primary_file_id"`
}
// UpdateInput constructs a SceneUpdateInput using the populated fields in the ScenePartial object.

View File

@@ -686,6 +686,40 @@ func (qb *FileStore) FindByZipFileID(ctx context.Context, zipFileID file.ID) ([]
return qb.getMany(ctx, q)
}
func (qb *FileStore) IsPrimary(ctx context.Context, fileID file.ID) (bool, error) {
joinTables := []exp.IdentifierExpression{
scenesFilesJoinTable,
galleriesFilesJoinTable,
imagesFilesJoinTable,
}
var sq *goqu.SelectDataset
for _, t := range joinTables {
qq := dialect.From(t).Select(t.Col(fileIDColumn)).Where(
t.Col(fileIDColumn).Eq(fileID),
t.Col("primary").Eq(1),
)
if sq == nil {
sq = qq
} else {
sq = sq.Union(qq)
}
}
q := dialect.Select(goqu.COUNT("*").As("count")).Prepared(true).From(
sq,
)
var ret int
if err := querySimple(ctx, q, &ret); err != nil {
return false, err
}
return ret > 0, nil
}
func (qb *FileStore) validateFilter(fileFilter *models.FileFilterType) error {
const and = "AND"
const or = "OR"

View File

@@ -613,3 +613,52 @@ func TestFileStore_FindByFingerprint(t *testing.T) {
})
}
}
func TestFileStore_IsPrimary(t *testing.T) {
tests := []struct {
name string
fileID file.ID
want bool
}{
{
"scene file",
sceneFileIDs[sceneIdx1WithPerformer],
true,
},
{
"image file",
imageFileIDs[imageIdx1WithGallery],
true,
},
{
"gallery file",
galleryFileIDs[galleryIdx1WithImage],
true,
},
{
"orphan file",
fileIDs[fileIdxZip],
false,
},
{
"invalid file",
invalidFileID,
false,
},
}
qb := db.File
for _, tt := range tests {
runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) {
assert := assert.New(t)
got, err := qb.IsPrimary(ctx, tt.fileID)
if err != nil {
t.Errorf("FileStore.IsPrimary() error = %v", err)
return
}
assert.Equal(tt.want, got)
})
}
}

View File

@@ -1,13 +1,22 @@
import React, { useMemo } from "react";
import { Accordion, Card } from "react-bootstrap";
import React, { useMemo, useState } from "react";
import { Accordion, Button, Card } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import { TruncatedText } from "src/components/Shared";
import DeleteFilesDialog from "src/components/Shared/DeleteFilesDialog";
import * as GQL from "src/core/generated-graphql";
import { mutateGallerySetPrimaryFile } from "src/core/StashService";
import { useToast } from "src/hooks";
import { TextUtils } from "src/utils";
import { TextField, URLField } from "src/utils/field";
interface IFileInfoPanelProps {
folder?: Pick<GQL.Folder, "id" | "path">;
file?: GQL.GalleryFileDataFragment;
primary?: boolean;
ofMany?: boolean;
onSetPrimaryFile?: () => void;
onDeleteFile?: () => void;
loading?: boolean;
}
const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
@@ -18,15 +27,43 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
const id = props.folder ? "folder" : "path";
return (
<dl className="container gallery-file-info details-list">
<TextField id="media_info.checksum" value={checksum?.value} truncate />
<URLField
id={id}
url={`file://${path}`}
value={`file://${path}`}
truncate
/>
</dl>
<div>
<dl className="container gallery-file-info details-list">
{props.primary && (
<>
<dt></dt>
<dd className="primary-file">
<FormattedMessage id="primary_file" />
</dd>
</>
)}
<TextField id="media_info.checksum" value={checksum?.value} truncate />
<URLField
id={id}
url={`file://${path}`}
value={`file://${path}`}
truncate
/>
</dl>
{props.ofMany && props.onSetPrimaryFile && !props.primary && (
<div>
<Button
className="edit-button"
disabled={props.loading}
onClick={props.onSetPrimaryFile}
>
<FormattedMessage id="actions.make_primary" />
</Button>
<Button
variant="danger"
disabled={props.loading}
onClick={props.onDeleteFile}
>
<FormattedMessage id="actions.delete_file" />
</Button>
</div>
)}
</div>
);
};
interface IGalleryFileInfoPanelProps {
@@ -36,6 +73,13 @@ interface IGalleryFileInfoPanelProps {
export const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = (
props: IGalleryFileInfoPanelProps
) => {
const Toast = useToast();
const [loading, setLoading] = useState(false);
const [deletingFile, setDeletingFile] = useState<
GQL.GalleryFileDataFragment | undefined
>();
const filesPanel = useMemo(() => {
if (props.gallery.folder) {
return <FileInfoPanel folder={props.gallery.folder} />;
@@ -49,23 +93,47 @@ export const GalleryFileInfoPanel: React.FC<IGalleryFileInfoPanelProps> = (
return <FileInfoPanel file={props.gallery.files[0]} />;
}
async function onSetPrimaryFile(fileID: string) {
try {
setLoading(true);
await mutateGallerySetPrimaryFile(props.gallery.id, fileID);
} catch (e) {
Toast.error(e);
} finally {
setLoading(false);
}
}
return (
<Accordion defaultActiveKey="0">
<Accordion defaultActiveKey={props.gallery.files[0].id}>
{deletingFile && (
<DeleteFilesDialog
onClose={() => setDeletingFile(undefined)}
selected={[deletingFile]}
/>
)}
{props.gallery.files.map((file, index) => (
<Card key={index} className="gallery-file-card">
<Accordion.Toggle as={Card.Header} eventKey={index.toString()}>
<Card key={file.id} className="gallery-file-card">
<Accordion.Toggle as={Card.Header} eventKey={file.id}>
<TruncatedText text={TextUtils.fileNameFromPath(file.path)} />
</Accordion.Toggle>
<Accordion.Collapse eventKey={index.toString()}>
<Accordion.Collapse eventKey={file.id}>
<Card.Body>
<FileInfoPanel file={file} />
<FileInfoPanel
file={file}
primary={index === 0}
ofMany
onSetPrimaryFile={() => onSetPrimaryFile(file.id)}
loading={loading}
onDeleteFile={() => setDeletingFile(file)}
/>
</Card.Body>
</Accordion.Collapse>
</Card>
))}
</Accordion>
);
}, [props.gallery]);
}, [props.gallery, loading, Toast, deletingFile]);
return (
<>

View File

@@ -1,13 +1,21 @@
import React from "react";
import { Accordion, Card } from "react-bootstrap";
import { FormattedNumber } from "react-intl";
import React, { useState } from "react";
import { Accordion, Button, Card } from "react-bootstrap";
import { FormattedMessage, FormattedNumber } from "react-intl";
import { TruncatedText } from "src/components/Shared";
import DeleteFilesDialog from "src/components/Shared/DeleteFilesDialog";
import * as GQL from "src/core/generated-graphql";
import { mutateImageSetPrimaryFile } from "src/core/StashService";
import { useToast } from "src/hooks";
import { TextUtils } from "src/utils";
import { TextField, URLField } from "src/utils/field";
interface IFileInfoPanelProps {
file: GQL.ImageFileDataFragment;
primary?: boolean;
ofMany?: boolean;
onSetPrimaryFile?: () => void;
onDeleteFile?: () => void;
loading?: boolean;
}
const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
@@ -39,21 +47,49 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
const checksum = props.file.fingerprints.find((f) => f.type === "md5");
return (
<dl className="container image-file-info details-list">
<TextField id="media_info.checksum" value={checksum?.value} truncate />
<URLField
id="path"
url={`file://${props.file.path}`}
value={`file://${props.file.path}`}
truncate
/>
{renderFileSize()}
<TextField
id="dimensions"
value={`${props.file.width} x ${props.file.height}`}
truncate
/>
</dl>
<div>
<dl className="container image-file-info details-list">
{props.primary && (
<>
<dt></dt>
<dd className="primary-file">
<FormattedMessage id="primary_file" />
</dd>
</>
)}
<TextField id="media_info.checksum" value={checksum?.value} truncate />
<URLField
id="path"
url={`file://${props.file.path}`}
value={`file://${props.file.path}`}
truncate
/>
{renderFileSize()}
<TextField
id="dimensions"
value={`${props.file.width} x ${props.file.height}`}
truncate
/>
</dl>
{props.ofMany && props.onSetPrimaryFile && !props.primary && (
<div>
<Button
className="edit-button"
disabled={props.loading}
onClick={props.onSetPrimaryFile}
>
<FormattedMessage id="actions.make_primary" />
</Button>
<Button
variant="danger"
disabled={props.loading}
onClick={props.onDeleteFile}
>
<FormattedMessage id="actions.delete_file" />
</Button>
</div>
)}
</div>
);
};
interface IImageFileInfoPanelProps {
@@ -63,6 +99,13 @@ interface IImageFileInfoPanelProps {
export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (
props: IImageFileInfoPanelProps
) => {
const Toast = useToast();
const [loading, setLoading] = useState(false);
const [deletingFile, setDeletingFile] = useState<
GQL.ImageFileDataFragment | undefined
>();
if (props.image.files.length === 0) {
return <></>;
}
@@ -71,16 +114,40 @@ export const ImageFileInfoPanel: React.FC<IImageFileInfoPanelProps> = (
return <FileInfoPanel file={props.image.files[0]} />;
}
async function onSetPrimaryFile(fileID: string) {
try {
setLoading(true);
await mutateImageSetPrimaryFile(props.image.id, fileID);
} catch (e) {
Toast.error(e);
} finally {
setLoading(false);
}
}
return (
<Accordion defaultActiveKey="0">
<Accordion defaultActiveKey={props.image.files[0].id}>
{deletingFile && (
<DeleteFilesDialog
onClose={() => setDeletingFile(undefined)}
selected={[deletingFile]}
/>
)}
{props.image.files.map((file, index) => (
<Card key={index} className="image-file-card">
<Accordion.Toggle as={Card.Header} eventKey={index.toString()}>
<Card key={file.id} className="image-file-card">
<Accordion.Toggle as={Card.Header} eventKey={file.id}>
<TruncatedText text={TextUtils.fileNameFromPath(file.path)} />
</Accordion.Toggle>
<Accordion.Collapse eventKey={index.toString()}>
<Accordion.Collapse eventKey={file.id}>
<Card.Body>
<FileInfoPanel file={file} />
<FileInfoPanel
file={file}
primary={index === 0}
ofMany
onSetPrimaryFile={() => onSetPrimaryFile(file.id)}
onDeleteFile={() => setDeletingFile(file)}
loading={loading}
/>
</Card.Body>
</Accordion.Collapse>
</Card>

View File

@@ -1,13 +1,21 @@
import React, { useMemo } from "react";
import { Accordion, Card } from "react-bootstrap";
import React, { useMemo, useState } from "react";
import { Accordion, Button, Card } from "react-bootstrap";
import { FormattedMessage, FormattedNumber, useIntl } from "react-intl";
import { TruncatedText } from "src/components/Shared";
import DeleteFilesDialog from "src/components/Shared/DeleteFilesDialog";
import * as GQL from "src/core/generated-graphql";
import { mutateSceneSetPrimaryFile } from "src/core/StashService";
import { useToast } from "src/hooks";
import { NavUtils, TextUtils, getStashboxBase } from "src/utils";
import { TextField, URLField } from "src/utils/field";
interface IFileInfoPanelProps {
file: GQL.VideoFileDataFragment;
primary?: boolean;
ofMany?: boolean;
onSetPrimaryFile?: () => void;
onDeleteFile?: () => void;
loading?: boolean;
}
const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
@@ -40,62 +48,90 @@ const FileInfoPanel: React.FC<IFileInfoPanelProps> = (
const checksum = props.file.fingerprints.find((f) => f.type === "md5");
return (
<dl className="container scene-file-info details-list">
<TextField id="media_info.hash" value={oshash?.value} truncate />
<TextField id="media_info.checksum" value={checksum?.value} truncate />
<URLField
id="media_info.phash"
abbr="Perceptual hash"
value={phash?.value}
url={NavUtils.makeScenesPHashMatchUrl(phash?.value)}
target="_self"
truncate
trusted
/>
<URLField
id="path"
url={`file://${props.file.path}`}
value={`file://${props.file.path}`}
truncate
/>
{renderFileSize()}
<TextField
id="duration"
value={TextUtils.secondsToTimestamp(props.file.duration ?? 0)}
truncate
/>
<TextField
id="dimensions"
value={`${props.file.width} x ${props.file.height}`}
truncate
/>
<TextField id="framerate">
<FormattedMessage
id="frames_per_second"
values={{ value: intl.formatNumber(props.file.frame_rate ?? 0) }}
<div>
<dl className="container scene-file-info details-list">
{props.primary && (
<>
<dt></dt>
<dd className="primary-file">
<FormattedMessage id="primary_file" />
</dd>
</>
)}
<TextField id="media_info.hash" value={oshash?.value} truncate />
<TextField id="media_info.checksum" value={checksum?.value} truncate />
<URLField
id="media_info.phash"
abbr="Perceptual hash"
value={phash?.value}
url={NavUtils.makeScenesPHashMatchUrl(phash?.value)}
target="_self"
truncate
trusted
/>
</TextField>
<TextField id="bitrate">
<FormattedMessage
id="megabits_per_second"
values={{
value: intl.formatNumber((props.file.bit_rate ?? 0) / 1000000, {
maximumFractionDigits: 2,
}),
}}
<URLField
id="path"
url={`file://${props.file.path}`}
value={`file://${props.file.path}`}
truncate
/>
</TextField>
<TextField
id="media_info.video_codec"
value={props.file.video_codec ?? ""}
truncate
/>
<TextField
id="media_info.audio_codec"
value={props.file.audio_codec ?? ""}
truncate
/>
</dl>
{renderFileSize()}
<TextField
id="duration"
value={TextUtils.secondsToTimestamp(props.file.duration ?? 0)}
truncate
/>
<TextField
id="dimensions"
value={`${props.file.width} x ${props.file.height}`}
truncate
/>
<TextField id="framerate">
<FormattedMessage
id="frames_per_second"
values={{ value: intl.formatNumber(props.file.frame_rate ?? 0) }}
/>
</TextField>
<TextField id="bitrate">
<FormattedMessage
id="megabits_per_second"
values={{
value: intl.formatNumber((props.file.bit_rate ?? 0) / 1000000, {
maximumFractionDigits: 2,
}),
}}
/>
</TextField>
<TextField
id="media_info.video_codec"
value={props.file.video_codec ?? ""}
truncate
/>
<TextField
id="media_info.audio_codec"
value={props.file.audio_codec ?? ""}
truncate
/>
</dl>
{props.ofMany && props.onSetPrimaryFile && !props.primary && (
<div>
<Button
className="edit-button"
disabled={props.loading}
onClick={props.onSetPrimaryFile}
>
<FormattedMessage id="actions.make_primary" />
</Button>
<Button
variant="danger"
disabled={props.loading}
onClick={props.onDeleteFile}
>
<FormattedMessage id="actions.delete_file" />
</Button>
</div>
)}
</div>
);
};
@@ -106,6 +142,13 @@ interface ISceneFileInfoPanelProps {
export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
props: ISceneFileInfoPanelProps
) => {
const Toast = useToast();
const [loading, setLoading] = useState(false);
const [deletingFile, setDeletingFile] = useState<
GQL.VideoFileDataFragment | undefined
>();
function renderStashIDs() {
if (!props.scene.stash_ids.length) {
return;
@@ -173,23 +216,47 @@ export const SceneFileInfoPanel: React.FC<ISceneFileInfoPanelProps> = (
return <FileInfoPanel file={props.scene.files[0]} />;
}
async function onSetPrimaryFile(fileID: string) {
try {
setLoading(true);
await mutateSceneSetPrimaryFile(props.scene.id, fileID);
} catch (e) {
Toast.error(e);
} finally {
setLoading(false);
}
}
return (
<Accordion defaultActiveKey="0">
<Accordion defaultActiveKey={props.scene.files[0].id}>
{deletingFile && (
<DeleteFilesDialog
onClose={() => setDeletingFile(undefined)}
selected={[deletingFile]}
/>
)}
{props.scene.files.map((file, index) => (
<Card key={index} className="scene-file-card">
<Accordion.Toggle as={Card.Header} eventKey={index.toString()}>
<Card key={file.id} className="scene-file-card">
<Accordion.Toggle as={Card.Header} eventKey={file.id}>
<TruncatedText text={TextUtils.fileNameFromPath(file.path)} />
</Accordion.Toggle>
<Accordion.Collapse eventKey={index.toString()}>
<Accordion.Collapse eventKey={file.id}>
<Card.Body>
<FileInfoPanel file={file} />
<FileInfoPanel
file={file}
primary={index === 0}
ofMany
onSetPrimaryFile={() => onSetPrimaryFile(file.id)}
onDeleteFile={() => setDeletingFile(file)}
loading={loading}
/>
</Card.Body>
</Accordion.Collapse>
</Card>
))}
</Accordion>
);
}, [props.scene]);
}, [props.scene, loading, Toast, deletingFile]);
return (
<>

View File

@@ -0,0 +1,113 @@
import React, { useState } from "react";
import { mutateDeleteFiles } from "src/core/StashService";
import { Modal } from "src/components/Shared";
import { useToast } from "src/hooks";
import { FormattedMessage, useIntl } from "react-intl";
import { faTrashAlt } from "@fortawesome/free-solid-svg-icons";
interface IFile {
id: string;
path: string;
}
interface IDeleteSceneDialogProps {
selected: IFile[];
onClose: (confirmed: boolean) => void;
}
export const DeleteFilesDialog: React.FC<IDeleteSceneDialogProps> = (
props: IDeleteSceneDialogProps
) => {
const intl = useIntl();
const singularEntity = intl.formatMessage({ id: "file" });
const pluralEntity = intl.formatMessage({ id: "files" });
const header = intl.formatMessage(
{ id: "dialogs.delete_entity_title" },
{ count: props.selected.length, singularEntity, pluralEntity }
);
const toastMessage = intl.formatMessage(
{ id: "toast.delete_past_tense" },
{ count: props.selected.length, singularEntity, pluralEntity }
);
const message = intl.formatMessage(
{ id: "dialogs.delete_entity_simple_desc" },
{ count: props.selected.length, singularEntity, pluralEntity }
);
const Toast = useToast();
// Network state
const [isDeleting, setIsDeleting] = useState(false);
async function onDelete() {
setIsDeleting(true);
try {
await mutateDeleteFiles(props.selected.map((f) => f.id));
Toast.success({ content: toastMessage });
props.onClose(true);
} catch (e) {
Toast.error(e);
props.onClose(false);
}
setIsDeleting(false);
}
function renderDeleteFileAlert() {
const deletedFiles = props.selected.map((f) => f.path);
return (
<div className="delete-dialog alert alert-danger text-break">
<p className="font-weight-bold">
<FormattedMessage
values={{
count: props.selected.length,
singularEntity: intl.formatMessage({ id: "file" }),
pluralEntity: intl.formatMessage({ id: "files" }),
}}
id="dialogs.delete_alert"
/>
</p>
<ul>
{deletedFiles.slice(0, 5).map((s) => (
<li key={s}>{s}</li>
))}
{deletedFiles.length > 5 && (
<FormattedMessage
values={{
count: deletedFiles.length - 5,
singularEntity: intl.formatMessage({ id: "file" }),
pluralEntity: intl.formatMessage({ id: "files" }),
}}
id="dialogs.delete_object_overflow"
/>
)}
</ul>
</div>
);
}
return (
<Modal
show
icon={faTrashAlt}
header={header}
accept={{
variant: "danger",
onClick: onDelete,
text: intl.formatMessage({ id: "actions.delete" }),
}}
cancel={{
onClick: () => props.onClose(false),
text: intl.formatMessage({ id: "actions.cancel" }),
variant: "secondary",
}}
isRunning={isDeleting}
>
<p>{message}</p>
{renderDeleteFileAlert()}
</Modal>
);
};
export default DeleteFilesDialog;

View File

@@ -495,6 +495,18 @@ export const useSceneGenerateScreenshot = () =>
update: deleteCache([GQL.FindScenesDocument]),
});
export const mutateSceneSetPrimaryFile = (id: string, fileID: string) =>
client.mutate<GQL.SceneUpdateMutation>({
mutation: GQL.SceneUpdateDocument,
variables: {
input: {
id,
primary_file_id: fileID,
},
},
update: deleteCache(sceneMutationImpactedQueries),
});
const imageMutationImpactedQueries = [
GQL.FindPerformerDocument,
GQL.FindPerformersDocument,
@@ -617,6 +629,18 @@ export const mutateImageResetO = (id: string) =>
},
});
export const mutateImageSetPrimaryFile = (id: string, fileID: string) =>
client.mutate<GQL.ImageUpdateMutation>({
mutation: GQL.ImageUpdateDocument,
variables: {
input: {
id,
primary_file_id: fileID,
},
},
update: deleteCache(imageMutationImpactedQueries),
});
const galleryMutationImpactedQueries = [
GQL.FindPerformerDocument,
GQL.FindPerformersDocument,
@@ -665,6 +689,18 @@ export const mutateRemoveGalleryImages = (input: GQL.GalleryRemoveInput) =>
update: deleteCache(galleryMutationImpactedQueries),
});
export const mutateGallerySetPrimaryFile = (id: string, fileID: string) =>
client.mutate<GQL.GalleryUpdateMutation>({
mutation: GQL.GalleryUpdateDocument,
variables: {
input: {
id,
primary_file_id: fileID,
},
},
update: deleteCache(galleryMutationImpactedQueries),
});
export const studioMutationImpactedQueries = [
GQL.FindStudiosDocument,
GQL.FindSceneDocument,
@@ -672,6 +708,24 @@ export const studioMutationImpactedQueries = [
GQL.AllStudiosForFilterDocument,
];
export const mutateDeleteFiles = (ids: string[]) =>
client.mutate<GQL.DeleteFilesMutation>({
mutation: GQL.DeleteFilesDocument,
variables: {
ids,
},
update: deleteCache([
...sceneMutationImpactedQueries,
...imageMutationImpactedQueries,
...galleryMutationImpactedQueries,
]),
refetchQueries: getQueryNames([
GQL.FindSceneDocument,
GQL.FindImageDocument,
GQL.FindGalleryDocument,
]),
});
export const useStudioCreate = () =>
GQL.useStudioCreateMutation({
refetchQueries: getQueryNames([GQL.AllStudiosForFilterDocument]),

View File

@@ -829,3 +829,7 @@ select {
.left-spacing {
margin-left: 0.5em;
}
.primary-file {
font-weight: bold;
}

View File

@@ -54,6 +54,7 @@
"import": "Import…",
"import_from_file": "Import from file",
"logout": "Log out",
"make_primary": "Make Primary",
"merge": "Merge",
"merge_from": "Merge from",
"merge_into": "Merge into",
@@ -632,6 +633,7 @@
"delete_alert": "The following {count, plural, one {{singularEntity}} other {{pluralEntity}}} will be deleted permanently:",
"delete_confirm": "Are you sure you want to delete {entityName}?",
"delete_entity_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}? Unless the file is also deleted, this {singularEntity} will be re-added when scan is performed.} other {Are you sure you want to delete these {pluralEntity}? Unless the files are also deleted, these {pluralEntity} will be re-added when scan is performed.}}",
"delete_entity_simple_desc": "{count, plural, one {Are you sure you want to delete this {singularEntity}?} other {Are you sure you want to delete these {pluralEntity}?}}",
"delete_entity_title": "{count, plural, one {Delete {singularEntity}} other {Delete {pluralEntity}}}",
"delete_galleries_extra": "…plus any image files not attached to any other gallery.",
"delete_gallery_files": "Delete gallery folder/zip file and any images not attached to any other gallery.",
@@ -905,6 +907,7 @@
},
"performers": "Performers",
"piercings": "Piercings",
"primary_file": "Primary file",
"queue": "Queue",
"random": "Random",
"rating": "Rating",