From e16118f551bf7f29752691774a043c07fb68cc95 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Wed, 12 Aug 2020 09:19:27 +1000 Subject: [PATCH] Clear image (#722) * Allow clearing of tag images * Allow clearing of studio images * Allow clearing of performer images * Allow clearing of movie images * Add filtering for missing images --- graphql/schema/types/filters.graphql | 4 ++ pkg/api/images.go | 17 ++++++ pkg/api/resolver_mutation_movie.go | 57 +++++++++++++------ pkg/api/resolver_mutation_performer.go | 19 ++++--- pkg/api/resolver_mutation_studio.go | 9 ++- pkg/api/resolver_mutation_tag.go | 9 ++- pkg/api/routes_movie.go | 12 ++++ pkg/api/routes_performer.go | 6 ++ pkg/api/routes_studio.go | 6 ++ pkg/api/routes_tag.go | 3 +- pkg/models/querybuilder_movies.go | 15 +++++ pkg/models/querybuilder_performer.go | 4 ++ pkg/models/querybuilder_studio.go | 11 ++++ pkg/utils/crypto.go | 7 +++ .../components/Movies/MovieDetails/Movie.tsx | 28 ++++++++- .../Performers/PerformerDetails/Performer.tsx | 13 ++++- .../PerformerDetailsPanel.tsx | 16 +++++- .../components/Shared/DetailsEditNavbar.tsx | 24 ++++++++ .../Studios/StudioDetails/Studio.tsx | 18 +++++- .../src/components/Tags/TagDetails/Tag.tsx | 12 +++- .../models/list-filter/criteria/criterion.ts | 4 ++ .../models/list-filter/criteria/is-missing.ts | 21 +++++++ .../src/models/list-filter/criteria/utils.ts | 6 ++ ui/v2.5/src/models/list-filter/filter.ts | 8 +++ 24 files changed, 287 insertions(+), 42 deletions(-) diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 2499f4cfe..cda7adb6d 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -89,11 +89,15 @@ input SceneFilterType { input MovieFilterType { """Filter to only include movies with this studio""" studios: MultiCriterionInput + """Filter to only include movies missing this property""" + is_missing: String } input StudioFilterType { """Filter to only include studios with this parent studio""" parents: MultiCriterionInput + """Filter to only include studios missing this property""" + is_missing: String } input GalleryFilterType { diff --git a/pkg/api/images.go b/pkg/api/images.go index bec90eb82..013df4be3 100644 --- a/pkg/api/images.go +++ b/pkg/api/images.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/gobuffalo/packr/v2" + "github.com/stashapp/stash/pkg/utils" ) var performerBox *packr.Box @@ -30,3 +31,19 @@ func getRandomPerformerImage(gender string) ([]byte, error) { index := rand.Intn(len(imageFiles)) return box.Find(imageFiles[index]) } + +func getRandomPerformerImageUsingName(name, gender string) ([]byte, error) { + var box *packr.Box + switch strings.ToUpper(gender) { + case "FEMALE": + box = performerBox + case "MALE": + box = performerBoxMale + default: + box = performerBox + + } + imageFiles := box.List() + index := utils.IntFromString(name) % uint64(len(imageFiles)) + return box.Find(imageFiles[index]) +} diff --git a/pkg/api/resolver_mutation_movie.go b/pkg/api/resolver_mutation_movie.go index 5d919d708..85c8f63f0 100644 --- a/pkg/api/resolver_mutation_movie.go +++ b/pkg/api/resolver_mutation_movie.go @@ -19,21 +19,26 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input models.MovieCr var backimageData []byte var err error - if input.FrontImage == nil { + // HACK: if back image is being set, set the front image to the default. + // This is because we can't have a null front image with a non-null back image. + if input.FrontImage == nil && input.BackImage != nil { input.FrontImage = &models.DefaultMovieImage } - if input.BackImage == nil { - input.BackImage = &models.DefaultMovieImage - } + // Process the base 64 encoded image string - _, frontimageData, err = utils.ProcessBase64Image(*input.FrontImage) - if err != nil { - return nil, err + if input.FrontImage != nil { + _, frontimageData, err = utils.ProcessBase64Image(*input.FrontImage) + if err != nil { + return nil, err + } } + // Process the base 64 encoded image string - _, backimageData, err = utils.ProcessBase64Image(*input.BackImage) - if err != nil { - return nil, err + if input.BackImage != nil { + _, backimageData, err = utils.ProcessBase64Image(*input.BackImage) + if err != nil { + return nil, err + } } // Populate a new movie from the input @@ -114,12 +119,14 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp } var frontimageData []byte var err error + frontImageIncluded := wasFieldIncluded(ctx, "front_image") if input.FrontImage != nil { _, frontimageData, err = utils.ProcessBase64Image(*input.FrontImage) if err != nil { return nil, err } } + backImageIncluded := wasFieldIncluded(ctx, "back_image") var backimageData []byte if input.BackImage != nil { _, backimageData, err = utils.ProcessBase64Image(*input.BackImage) @@ -185,25 +192,39 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUp } // update image table - if len(frontimageData) > 0 || len(backimageData) > 0 { - if len(frontimageData) == 0 { + if frontImageIncluded || backImageIncluded { + if !frontImageIncluded { frontimageData, err = qb.GetFrontImage(updatedMovie.ID, tx) if err != nil { - _ = tx.Rollback() + tx.Rollback() return nil, err } } - if len(backimageData) == 0 { + if !backImageIncluded { backimageData, err = qb.GetBackImage(updatedMovie.ID, tx) if err != nil { - _ = tx.Rollback() + tx.Rollback() return nil, err } } - if err := qb.UpdateMovieImages(movie.ID, frontimageData, backimageData, tx); err != nil { - _ = tx.Rollback() - return nil, err + if len(frontimageData) == 0 && len(backimageData) == 0 { + // both images are being nulled. Destroy them. + if err := qb.DestroyMovieImages(movie.ID, tx); err != nil { + tx.Rollback() + return nil, err + } + } else { + // HACK - if front image is null and back image is not null, then set the front image + // to the default image since we can't have a null front image and a non-null back image + if frontimageData == nil && backimageData != nil { + _, frontimageData, _ = utils.ProcessBase64Image(models.DefaultMovieImage) + } + + if err := qb.UpdateMovieImages(movie.ID, frontimageData, backimageData, tx); err != nil { + _ = tx.Rollback() + return nil, err + } } } diff --git a/pkg/api/resolver_mutation_performer.go b/pkg/api/resolver_mutation_performer.go index 647bddfb0..a00d568a6 100644 --- a/pkg/api/resolver_mutation_performer.go +++ b/pkg/api/resolver_mutation_performer.go @@ -18,13 +18,7 @@ func (r *mutationResolver) PerformerCreate(ctx context.Context, input models.Per var imageData []byte var err error - if input.Image == nil { - gender := "" - if input.Gender != nil { - gender = input.Gender.String() - } - imageData, err = getRandomPerformerImage(gender) - } else { + if input.Image != nil { _, imageData, err = utils.ProcessBase64Image(*input.Image) } @@ -127,6 +121,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per } var imageData []byte var err error + imageIncluded := wasFieldIncluded(ctx, "image") if input.Image != nil { _, imageData, err = utils.ProcessBase64Image(*input.Image) if err != nil { @@ -196,14 +191,20 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per qb := models.NewPerformerQueryBuilder() performer, err := qb.Update(updatedPerformer, tx) if err != nil { - _ = tx.Rollback() + tx.Rollback() return nil, err } // update image table if len(imageData) > 0 { if err := qb.UpdatePerformerImage(performer.ID, imageData, tx); err != nil { - _ = tx.Rollback() + tx.Rollback() + return nil, err + } + } else if imageIncluded { + // must be unsetting + if err := qb.DestroyPerformerImage(performer.ID, tx); err != nil { + tx.Rollback() return nil, err } } diff --git a/pkg/api/resolver_mutation_studio.go b/pkg/api/resolver_mutation_studio.go index f248104d1..1069137f6 100644 --- a/pkg/api/resolver_mutation_studio.go +++ b/pkg/api/resolver_mutation_studio.go @@ -80,6 +80,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio } var imageData []byte + imageIncluded := wasFieldIncluded(ctx, "image") if input.Image != nil { var err error _, imageData, err = utils.ProcessBase64Image(*input.Image) @@ -123,7 +124,13 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio // update image table if len(imageData) > 0 { if err := qb.UpdateStudioImage(studio.ID, imageData, tx); err != nil { - _ = tx.Rollback() + tx.Rollback() + return nil, err + } + } else if imageIncluded { + // must be unsetting + if err := qb.DestroyStudioImage(studio.ID, tx); err != nil { + tx.Rollback() return nil, err } } diff --git a/pkg/api/resolver_mutation_tag.go b/pkg/api/resolver_mutation_tag.go index e62db0b65..51ea9d527 100644 --- a/pkg/api/resolver_mutation_tag.go +++ b/pkg/api/resolver_mutation_tag.go @@ -76,6 +76,7 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdate var imageData []byte var err error + imageIncluded := wasFieldIncluded(ctx, "image") if input.Image != nil { _, imageData, err = utils.ProcessBase64Image(*input.Image) @@ -116,7 +117,13 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input models.TagUpdate // update image table if len(imageData) > 0 { if err := qb.UpdateTagImage(tag.ID, imageData, tx); err != nil { - _ = tx.Rollback() + tx.Rollback() + return nil, err + } + } else if imageIncluded { + // must be unsetting + if err := qb.DestroyTagImage(tag.ID, tx); err != nil { + tx.Rollback() return nil, err } } diff --git a/pkg/api/routes_movie.go b/pkg/api/routes_movie.go index a42621aae..00c882d6e 100644 --- a/pkg/api/routes_movie.go +++ b/pkg/api/routes_movie.go @@ -28,6 +28,12 @@ func (rs movieRoutes) FrontImage(w http.ResponseWriter, r *http.Request) { movie := r.Context().Value(movieKey).(*models.Movie) qb := models.NewMovieQueryBuilder() image, _ := qb.GetFrontImage(movie.ID, nil) + + defaultParam := r.URL.Query().Get("default") + if len(image) == 0 || defaultParam == "true" { + _, image, _ = utils.ProcessBase64Image(models.DefaultMovieImage) + } + utils.ServeImage(image, w, r) } @@ -35,6 +41,12 @@ func (rs movieRoutes) BackImage(w http.ResponseWriter, r *http.Request) { movie := r.Context().Value(movieKey).(*models.Movie) qb := models.NewMovieQueryBuilder() image, _ := qb.GetBackImage(movie.ID, nil) + + defaultParam := r.URL.Query().Get("default") + if len(image) == 0 || defaultParam == "true" { + _, image, _ = utils.ProcessBase64Image(models.DefaultMovieImage) + } + utils.ServeImage(image, w, r) } diff --git a/pkg/api/routes_performer.go b/pkg/api/routes_performer.go index c988bf5ee..d552b2e89 100644 --- a/pkg/api/routes_performer.go +++ b/pkg/api/routes_performer.go @@ -27,6 +27,12 @@ func (rs performerRoutes) Image(w http.ResponseWriter, r *http.Request) { performer := r.Context().Value(performerKey).(*models.Performer) qb := models.NewPerformerQueryBuilder() image, _ := qb.GetPerformerImage(performer.ID, nil) + + defaultParam := r.URL.Query().Get("default") + if len(image) == 0 || defaultParam == "true" { + image, _ = getRandomPerformerImageUsingName(performer.Name.String, performer.Gender.String) + } + utils.ServeImage(image, w, r) } diff --git a/pkg/api/routes_studio.go b/pkg/api/routes_studio.go index 4dbc023ba..d1c2b51c9 100644 --- a/pkg/api/routes_studio.go +++ b/pkg/api/routes_studio.go @@ -27,6 +27,12 @@ func (rs studioRoutes) Image(w http.ResponseWriter, r *http.Request) { studio := r.Context().Value(studioKey).(*models.Studio) qb := models.NewStudioQueryBuilder() image, _ := qb.GetStudioImage(studio.ID, nil) + + defaultParam := r.URL.Query().Get("default") + if len(image) == 0 || defaultParam == "true" { + _, image, _ = utils.ProcessBase64Image(models.DefaultStudioImage) + } + utils.ServeImage(image, w, r) } diff --git a/pkg/api/routes_tag.go b/pkg/api/routes_tag.go index d932d720e..0364eb95d 100644 --- a/pkg/api/routes_tag.go +++ b/pkg/api/routes_tag.go @@ -29,7 +29,8 @@ func (rs tagRoutes) Image(w http.ResponseWriter, r *http.Request) { image, _ := qb.GetTagImage(tag.ID, nil) // use default image if not present - if len(image) == 0 { + defaultParam := r.URL.Query().Get("default") + if len(image) == 0 || defaultParam == "true" { image = models.DefaultTagImage } diff --git a/pkg/models/querybuilder_movies.go b/pkg/models/querybuilder_movies.go index e9e1e193b..311548a31 100644 --- a/pkg/models/querybuilder_movies.go +++ b/pkg/models/querybuilder_movies.go @@ -152,6 +152,21 @@ func (qb *MovieQueryBuilder) Query(movieFilter *MovieFilterType, findFilter *Fin havingClauses = appendClause(havingClauses, havingClause) } + if isMissingFilter := movieFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { + switch *isMissingFilter { + case "front_image": + body += `left join movies_images on movies_images.movie_id = movies.id + ` + whereClauses = appendClause(whereClauses, "movies_images.front_image IS NULL") + case "back_image": + body += `left join movies_images on movies_images.movie_id = movies.id + ` + whereClauses = appendClause(whereClauses, "movies_images.back_image IS NULL") + default: + whereClauses = appendClause(whereClauses, "movies."+*isMissingFilter+" IS NULL") + } + } + sortAndPagination := qb.getMovieSort(findFilter) + getPagination(findFilter) idsResult, countResult := executeFindQuery("movies", body, args, sortAndPagination, whereClauses, havingClauses) diff --git a/pkg/models/querybuilder_performer.go b/pkg/models/querybuilder_performer.go index e31405974..a2cdefd52 100644 --- a/pkg/models/querybuilder_performer.go +++ b/pkg/models/querybuilder_performer.go @@ -178,6 +178,10 @@ func (qb *PerformerQueryBuilder) Query(performerFilter *PerformerFilterType, fin switch *isMissingFilter { case "scenes": query.addWhere("scenes_join.scene_id IS NULL") + case "image": + query.body += `left join performers_image on performers_image.performer_id = performers.id + ` + query.addWhere("performers_image.performer_id IS NULL") default: query.addWhere("performers." + *isMissingFilter + " IS NULL") } diff --git a/pkg/models/querybuilder_studio.go b/pkg/models/querybuilder_studio.go index 57d56bb0c..67a931df8 100644 --- a/pkg/models/querybuilder_studio.go +++ b/pkg/models/querybuilder_studio.go @@ -146,6 +146,17 @@ func (qb *StudioQueryBuilder) Query(studioFilter *StudioFilterType, findFilter * havingClauses = appendClause(havingClauses, havingClause) } + if isMissingFilter := studioFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" { + switch *isMissingFilter { + case "image": + body += `left join studios_image on studios_image.studio_id = studios.id + ` + whereClauses = appendClause(whereClauses, "studios_image.studio_id IS NULL") + default: + whereClauses = appendClause(whereClauses, "studios."+*isMissingFilter+" IS NULL") + } + } + sortAndPagination := qb.getStudioSort(findFilter) + getPagination(findFilter) idsResult, countResult := executeFindQuery("studios", body, args, sortAndPagination, whereClauses, havingClauses) diff --git a/pkg/utils/crypto.go b/pkg/utils/crypto.go index ab765b6a0..06e11c9f9 100644 --- a/pkg/utils/crypto.go +++ b/pkg/utils/crypto.go @@ -4,6 +4,7 @@ import ( "crypto/md5" "crypto/rand" "fmt" + "hash/fnv" "io" "os" ) @@ -38,3 +39,9 @@ func GenerateRandomKey(l int) string { rand.Read(b) return fmt.Sprintf("%x", b) } + +func IntFromString(str string) uint64 { + h := fnv.New64a() + h.Write([]byte(str)) + return h.Sum64() +} diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index 58fdc0594..116cddf06 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -42,8 +42,12 @@ export const Movie: React.FC = () => { const [isImageAlertOpen, setIsImageAlertOpen] = useState(false); // Editing movie state - const [frontImage, setFrontImage] = useState(undefined); - const [backImage, setBackImage] = useState(undefined); + const [frontImage, setFrontImage] = useState( + undefined + ); + const [backImage, setBackImage] = useState( + undefined + ); const [name, setName] = useState(undefined); const [aliases, setAliases] = useState(undefined); const [duration, setDuration] = useState(undefined); @@ -432,6 +436,24 @@ export const Movie: React.FC = () => { setScrapedMovie(undefined); } + function onClearFrontImage() { + setFrontImage(null); + setImagePreview( + movie.front_image_path + ? `${movie.front_image_path}?default=true` + : undefined + ); + } + + function onClearBackImage() { + setBackImage(null); + setBackImagePreview( + movie.back_image_path + ? `${movie.back_image_path}?default=true` + : undefined + ); + } + if (isLoading) return ; // TODO: CSS class @@ -538,7 +560,9 @@ export const Movie: React.FC = () => { onToggleEdit={onToggleEdit} onSave={onSave} onImageChange={onFrontImageChange} + onClearImage={onClearFrontImage} onBackImageChange={onBackImageChange} + onClearBackImage={onClearBackImage} onDelete={onDelete} /> diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx index dfdc2c227..1ef5fe40a 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/Performer.tsx @@ -27,10 +27,17 @@ export const Performer: React.FC = () => { const [performer, setPerformer] = useState< Partial >({}); - const [imagePreview, setImagePreview] = useState(); + const [imagePreview, setImagePreview] = useState(); const [imageEncoding, setImageEncoding] = useState(false); const [lightboxIsOpen, setLightboxIsOpen] = useState(false); - const activeImage = imagePreview ?? performer.image_path ?? ""; + + // if undefined then get the existing image + // if null then get the default (no) image + // otherwise get the set image + const activeImage = + imagePreview === undefined + ? performer.image_path ?? "" + : imagePreview ?? `${performer.image_path}?default=true`; // Network state const [isLoading, setIsLoading] = useState(false); @@ -47,7 +54,7 @@ export const Performer: React.FC = () => { if (data?.findPerformer) setPerformer(data.findPerformer); }, [data]); - const onImageChange = (image?: string) => setImagePreview(image); + const onImageChange = (image?: string | null) => setImagePreview(image); const onImageEncoding = (isEncoding = false) => setImageEncoding(isEncoding); diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx index d2bfc5e51..c23443e1f 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerDetailsPanel.tsx @@ -40,7 +40,7 @@ interface IPerformerDetails { | Partial ) => void; onDelete?: () => void; - onImageChange?: (image?: string) => void; + onImageChange?: (image?: string | null) => void; onImageEncoding?: (loading?: boolean) => void; } @@ -66,7 +66,7 @@ export const PerformerDetailsPanel: React.FC = ({ const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); // Editing performer state - const [image, setImage] = useState(); + const [image, setImage] = useState(); const [name, setName] = useState(); const [aliases, setAliases] = useState(); const [favorite, setFavorite] = useState(); @@ -241,7 +241,6 @@ export const PerformerDetailsPanel: React.FC = ({ }); useEffect(() => { - setImage(undefined); updatePerformerEditState(performer); }, [performer]); @@ -563,6 +562,17 @@ export const PerformerDetailsPanel: React.FC = ({ isEditing={!!isEditing} onImageChange={onImageChangeHandler} /> + {isEditing ? ( + + ) : ( + "" + )} ); } diff --git a/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx b/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx index de943ac75..f2b3ed0fc 100644 --- a/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx +++ b/ui/v2.5/src/components/Shared/DetailsEditNavbar.tsx @@ -12,6 +12,8 @@ interface IProps { onAutoTag?: () => void; onImageChange: (event: React.FormEvent) => void; onBackImageChange?: (event: React.FormEvent) => void; + onClearImage?: () => void; + onClearBackImage?: () => void; acceptSVG?: boolean; } @@ -116,7 +118,29 @@ export const DetailsEditNavbar: React.FC = (props: IProps) => { onImageChange={props.onImageChange} acceptSVG={props.acceptSVG ?? false} /> + {props.isEditing && props.onClearImage ? ( + + ) : ( + "" + )} {renderBackImageInput()} + {props.isEditing && props.onClearBackImage ? ( + + ) : ( + "" + )} {renderAutoTagButton()} {renderSaveButton()} {renderDeleteButton()} diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index eed165465..0acad3bb2 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -35,14 +35,14 @@ export const Studio: React.FC = () => { const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); // Editing studio state - const [image, setImage] = useState(); + const [image, setImage] = useState(); const [name, setName] = useState(); const [url, setUrl] = useState(); const [parentStudioId, setParentStudioId] = useState(); // Studio state const [studio, setStudio] = useState>({}); - const [imagePreview, setImagePreview] = useState(); + const [imagePreview, setImagePreview] = useState(); const { data, error, loading } = useFindStudio(id); const [updateStudio] = useStudioUpdate( @@ -185,6 +185,13 @@ export const Studio: React.FC = () => { updateStudioData(studio); } + function onClearImage() { + setImage(null); + setImagePreview( + studio.image_path ? `${studio.image_path}?default=true` : undefined + ); + } + return (
{
{imageEncoding ? ( - ) : ( + ) : imagePreview ? ( {name} + ) : ( + "" )}
@@ -238,6 +247,9 @@ export const Studio: React.FC = () => { onToggleEdit={onToggleEdit} onSave={onSave} onImageChange={onImageChangeHandler} + onClearImage={() => { + onClearImage(); + }} onAutoTag={onAutoTag} onDelete={onDelete} acceptSVG diff --git a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx index c48eda6fe..1572f6628 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/Tag.tsx @@ -34,7 +34,7 @@ export const Tag: React.FC = () => { const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); // Editing tag state - const [image, setImage] = useState(); + const [image, setImage] = useState(); const [name, setName] = useState(); // Tag state @@ -172,6 +172,13 @@ export const Tag: React.FC = () => { updateTagData(tag); } + function onClearImage() { + setImage(null); + setImagePreview( + tag.image_path ? `${tag.image_path}?default=true` : undefined + ); + } + return (
{ onToggleEdit={onToggleEdit} onSave={onSave} onImageChange={onImageChangeHandler} + onClearImage={() => { + onClearImage(); + }} onAutoTag={onAutoTag} onDelete={onDelete} acceptSVG diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index 02164b8b5..cfc6af532 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -16,6 +16,8 @@ export type CriterionType = | "performerIsMissing" | "galleryIsMissing" | "tagIsMissing" + | "studioIsMissing" + | "movieIsMissing" | "tags" | "sceneTags" | "performers" @@ -62,6 +64,8 @@ export abstract class Criterion { case "performerIsMissing": case "galleryIsMissing": case "tagIsMissing": + case "studioIsMissing": + case "movieIsMissing": return "Is Missing"; case "tags": return "Tags"; diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index c877bde1e..ac3686773 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -46,6 +46,7 @@ export class PerformerIsMissingCriterion extends IsMissingCriterion { "aliases", "gender", "scenes", + "image", ]; } @@ -73,3 +74,23 @@ export class TagIsMissingCriterionOption implements ICriterionOption { public label: string = Criterion.getLabel("tagIsMissing"); public value: CriterionType = "tagIsMissing"; } + +export class StudioIsMissingCriterion extends IsMissingCriterion { + public type: CriterionType = "studioIsMissing"; + public options: string[] = ["image"]; +} + +export class StudioIsMissingCriterionOption implements ICriterionOption { + public label: string = Criterion.getLabel("studioIsMissing"); + public value: CriterionType = "studioIsMissing"; +} + +export class MovieIsMissingCriterion extends IsMissingCriterion { + public type: CriterionType = "movieIsMissing"; + public options: string[] = ["front_image", "back_image"]; +} + +export class MovieIsMissingCriterionOption implements ICriterionOption { + public label: string = Criterion.getLabel("movieIsMissing"); + public value: CriterionType = "movieIsMissing"; +} diff --git a/ui/v2.5/src/models/list-filter/criteria/utils.ts b/ui/v2.5/src/models/list-filter/criteria/utils.ts index 8b1cf9725..13ef6d774 100644 --- a/ui/v2.5/src/models/list-filter/criteria/utils.ts +++ b/ui/v2.5/src/models/list-filter/criteria/utils.ts @@ -14,6 +14,8 @@ import { SceneIsMissingCriterion, GalleryIsMissingCriterion, TagIsMissingCriterion, + StudioIsMissingCriterion, + MovieIsMissingCriterion, } from "./is-missing"; import { NoneCriterion } from "./none"; import { PerformersCriterion } from "./performers"; @@ -50,6 +52,10 @@ export function makeCriteria(type: CriterionType = "none") { return new GalleryIsMissingCriterion(); case "tagIsMissing": return new TagIsMissingCriterion(); + case "studioIsMissing": + return new StudioIsMissingCriterion(); + case "movieIsMissing": + return new MovieIsMissingCriterion(); case "tags": return new TagsCriterion("tags"); case "sceneTags": diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index c69aebecb..237c2802e 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -35,6 +35,8 @@ import { SceneIsMissingCriterionOption, GalleryIsMissingCriterionOption, TagIsMissingCriterionOption, + StudioIsMissingCriterionOption, + MovieIsMissingCriterionOption, } from "./criteria/is-missing"; import { NoneCriterionOption } from "./criteria/none"; import { @@ -178,6 +180,7 @@ export class ListFilterModel { this.criterionOptions = [ new NoneCriterionOption(), new ParentStudiosCriterionOption(), + new StudioIsMissingCriterionOption(), ]; break; case FilterMode.Movies: @@ -187,6 +190,7 @@ export class ListFilterModel { this.criterionOptions = [ new NoneCriterionOption(), new StudiosCriterionOption(), + new MovieIsMissingCriterionOption(), ]; break; case FilterMode.Galleries: @@ -610,6 +614,8 @@ export class ListFilterModel { }; break; } + case "movieIsMissing": + result.is_missing = (criterion as IsMissingCriterion).value; // no default } }); @@ -628,6 +634,8 @@ export class ListFilterModel { }; break; } + case "studioIsMissing": + result.is_missing = (criterion as IsMissingCriterion).value; // no default } });