Support for assigning any image from a gallery as the cover (#5053)

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
sezzim 2024-08-28 18:24:52 -07:00 committed by GitHub
parent 8133aa8c91
commit 68738bd227
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 383 additions and 10 deletions

View File

@ -317,6 +317,8 @@ type Mutation {
addGalleryImages(input: GalleryAddInput!): Boolean!
removeGalleryImages(input: GalleryRemoveInput!): Boolean!
setGalleryCover(input: GallerySetCoverInput!): Boolean!
resetGalleryCover(input: GalleryResetCoverInput!): Boolean!
galleryChapterCreate(input: GalleryChapterCreateInput!): GalleryChapter
galleryChapterUpdate(input: GalleryChapterUpdateInput!): GalleryChapter

View File

@ -115,3 +115,12 @@ input GalleryRemoveInput {
gallery_id: ID!
image_ids: [ID!]!
}
input GallerySetCoverInput {
gallery_id: ID!
cover_image_id: ID!
}
input GalleryResetCoverInput {
gallery_id: ID!
}

View File

@ -478,6 +478,61 @@ func (r *mutationResolver) RemoveGalleryImages(ctx context.Context, input Galler
return true, nil
}
func (r *mutationResolver) SetGalleryCover(ctx context.Context, input GallerySetCoverInput) (bool, error) {
galleryID, err := strconv.Atoi(input.GalleryID)
if err != nil {
return false, fmt.Errorf("converting gallery id: %w", err)
}
coverImageID, err := strconv.Atoi(input.CoverImageID)
if err != nil {
return false, fmt.Errorf("converting cover image id: %w", err)
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Gallery
gallery, err := qb.Find(ctx, galleryID)
if err != nil {
return err
}
if gallery == nil {
return fmt.Errorf("gallery with id %d not found", galleryID)
}
return r.galleryService.SetCover(ctx, gallery, coverImageID)
}); err != nil {
return false, err
}
return true, nil
}
func (r *mutationResolver) ResetGalleryCover(ctx context.Context, input GalleryResetCoverInput) (bool, error) {
galleryID, err := strconv.Atoi(input.GalleryID)
if err != nil {
return false, fmt.Errorf("converting gallery id: %w", err)
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.Gallery
gallery, err := qb.Find(ctx, galleryID)
if err != nil {
return err
}
if gallery == nil {
return fmt.Errorf("gallery with id %d not found", galleryID)
}
return r.galleryService.ResetCover(ctx, gallery)
}); err != nil {
return false, err
}
return true, nil
}
func (r *mutationResolver) getGalleryChapter(ctx context.Context, id int) (ret *models.GalleryChapter, err error) {
if err := r.withTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.GalleryChapter.Find(ctx, id)

View File

@ -24,6 +24,9 @@ type GalleryService interface {
AddImages(ctx context.Context, g *models.Gallery, toAdd ...int) error
RemoveImages(ctx context.Context, g *models.Gallery, toRemove ...int) error
SetCover(ctx context.Context, g *models.Gallery, coverImageId int) error
ResetCover(ctx context.Context, g *models.Gallery) error
Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error)
ValidateImageGalleryChange(ctx context.Context, i *models.Image, updateIDs models.UpdateIDs) error

View File

@ -52,6 +52,22 @@ func (s *Service) RemoveImages(ctx context.Context, g *models.Gallery, toRemove
return s.Updated(ctx, g.ID)
}
func (s *Service) SetCover(ctx context.Context, g *models.Gallery, coverImageID int) error {
if err := s.Repository.SetCover(ctx, g.ID, coverImageID); err != nil {
return fmt.Errorf("failed to set cover: %w", err)
}
return s.Updated(ctx, g.ID)
}
func (s *Service) ResetCover(ctx context.Context, g *models.Gallery) error {
if err := s.Repository.ResetCover(ctx, g.ID); err != nil {
return fmt.Errorf("failed to reset cover: %w", err)
}
return s.Updated(ctx, g.ID)
}
func AddPerformer(ctx context.Context, qb models.GalleryUpdater, o *models.Gallery, performerID int) error {
galleryPartial := models.NewGalleryPartial()
galleryPartial.PerformerIDs = &models.UpdateIDs{

View File

@ -107,6 +107,13 @@ func FindGalleryCover(ctx context.Context, r models.ImageQueryer, galleryID int,
}
func findGalleryCover(ctx context.Context, r models.ImageQueryer, galleryID int, useCoverJpg bool, galleryCoverRegex string) (*models.Image, error) {
img, err := r.CoverByGalleryID(ctx, galleryID)
if err != nil {
return nil, err
} else if img != nil {
return img, nil
}
// try to find cover.jpg in the gallery
perPage := 1
sortBy := "path"

View File

@ -628,6 +628,34 @@ func (_m *GalleryReaderWriter) RemoveImages(ctx context.Context, galleryID int,
return r0
}
// ResetCover provides a mock function with given fields: ctx, galleryID
func (_m *GalleryReaderWriter) ResetCover(ctx context.Context, galleryID int) error {
ret := _m.Called(ctx, galleryID)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int) error); ok {
r0 = rf(ctx, galleryID)
} else {
r0 = ret.Error(0)
}
return r0
}
// SetCover provides a mock function with given fields: ctx, galleryID, coverImageID
func (_m *GalleryReaderWriter) SetCover(ctx context.Context, galleryID int, coverImageID int) error {
ret := _m.Called(ctx, galleryID, coverImageID)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int, int) error); ok {
r0 = rf(ctx, galleryID, coverImageID)
} else {
r0 = ret.Error(0)
}
return r0
}
// Update provides a mock function with given fields: ctx, updatedGallery
func (_m *GalleryReaderWriter) Update(ctx context.Context, updatedGallery *models.Gallery) error {
ret := _m.Called(ctx, updatedGallery)

View File

@ -114,6 +114,29 @@ func (_m *ImageReaderWriter) CountByGalleryID(ctx context.Context, galleryID int
return r0, r1
}
// CoverByGalleryID provides a mock function with given fields: ctx, galleryId
func (_m *ImageReaderWriter) CoverByGalleryID(ctx context.Context, galleryId int) (*models.Image, error) {
ret := _m.Called(ctx, galleryId)
var r0 *models.Image
if rf, ok := ret.Get(0).(func(context.Context, int) *models.Image); ok {
r0 = rf(ctx, galleryId)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.Image)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, galleryId)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Create provides a mock function with given fields: ctx, newImage, fileIDs
func (_m *ImageReaderWriter) Create(ctx context.Context, newImage *models.Image, fileIDs []models.FileID) error {
ret := _m.Called(ctx, newImage, fileIDs)

View File

@ -83,6 +83,8 @@ type GalleryWriter interface {
AddFileID(ctx context.Context, id int, fileID FileID) error
AddImages(ctx context.Context, galleryID int, imageIDs ...int) error
RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error
SetCover(ctx context.Context, galleryID int, coverImageID int) error
ResetCover(ctx context.Context, galleryID int) error
}
// GalleryReaderWriter provides all gallery methods.

View File

@ -25,6 +25,7 @@ type ImageFinder interface {
type ImageQueryer interface {
Query(ctx context.Context, options ImageQueryOptions) (*ImageQueryResult, error)
QueryCount(ctx context.Context, imageFilter *ImageFilterType, findFilter *FindFilterType) (int, error)
CoverByGalleryID(ctx context.Context, galleryId int) (*Image, error)
}
// ImageCounter provides methods to count images.

View File

@ -30,7 +30,7 @@ const (
dbConnTimeout = 30
)
var appSchemaVersion uint = 65
var appSchemaVersion uint = 66
//go:embed migrations/*.sql
var migrationsBox embed.FS

View File

@ -890,6 +890,14 @@ func (qb *GalleryStore) UpdateImages(ctx context.Context, galleryID int, imageID
return galleryRepository.images.replace(ctx, galleryID, imageIDs)
}
func (qb *GalleryStore) SetCover(ctx context.Context, galleryID int, coverImageID int) error {
return imageGalleriesTableMgr.setCover(ctx, coverImageID, galleryID)
}
func (qb *GalleryStore) ResetCover(ctx context.Context, galleryID int) error {
return imageGalleriesTableMgr.resetCover(ctx, galleryID)
}
func (qb *GalleryStore) GetSceneIDs(ctx context.Context, id int) ([]int, error) {
return galleryRepository.scenes.getIDs(ctx, id)
}

View File

@ -2973,6 +2973,34 @@ func TestGalleryQueryHasChapters(t *testing.T) {
})
}
func TestGallerySetAndResetCover(t *testing.T) {
withTxn(func(ctx context.Context) error {
sqb := db.Gallery
imagePath2 := getFilePath(folderIdxWithImageFiles, getImageBasename(imageIdx2WithGallery))
result, err := db.Image.CoverByGalleryID(ctx, galleryIDs[galleryIdxWithTwoImages])
assert.Nil(t, err)
assert.Nil(t, result)
err = sqb.SetCover(ctx, galleryIDs[galleryIdxWithTwoImages], imageIDs[imageIdx2WithGallery])
assert.Nil(t, err)
result, err = db.Image.CoverByGalleryID(ctx, galleryIDs[galleryIdxWithTwoImages])
assert.Nil(t, err)
assert.Equal(t, result.Path, imagePath2)
err = sqb.ResetCover(ctx, galleryIDs[galleryIdxWithTwoImages])
assert.Nil(t, err)
result, err = db.Image.CoverByGalleryID(ctx, galleryIDs[galleryIdxWithTwoImages])
assert.Nil(t, err)
assert.Nil(t, result)
return nil
})
}
// TODO Count
// TODO All
// TODO Query

View File

@ -480,6 +480,42 @@ func (qb *ImageStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*mo
return ret, nil
}
// Returns the custom cover for the gallery, if one has been set.
func (qb *ImageStore) CoverByGalleryID(ctx context.Context, galleryID int) (*models.Image, error) {
table := qb.table()
sq := dialect.From(table).
InnerJoin(
galleriesImagesJoinTable,
goqu.On(table.Col(idColumn).Eq(galleriesImagesJoinTable.Col(imageIDColumn))),
).
Select(table.Col(idColumn)).
Where(goqu.And(
galleriesImagesJoinTable.Col("gallery_id").Eq(galleryID),
galleriesImagesJoinTable.Col("cover").Eq(true),
))
q := qb.selectDataset().Prepared(true).Where(
table.Col(idColumn).Eq(
sq,
),
)
ret, err := qb.getMany(ctx, q)
if err != nil {
return nil, fmt.Errorf("getting cover for gallery %d: %w", galleryID, err)
}
switch {
case len(ret) > 1:
return nil, fmt.Errorf("internal error: multiple covers returned for gallery %d", galleryID)
case len(ret) == 1:
return ret[0], nil
default:
return nil, nil
}
}
func (qb *ImageStore) GetFiles(ctx context.Context, id int) ([]models.File, error) {
fileIDs, err := imageRepository.files.get(ctx, id)
if err != nil {

View File

@ -0,0 +1,2 @@
ALTER TABLE `galleries_images` ADD COLUMN `cover` BOOLEAN NOT NULL DEFAULT 0;
CREATE UNIQUE INDEX `index_galleries_images_gallery_id_cover` on `galleries_images` (`gallery_id`, `cover`) WHERE `cover` = 1;

View File

@ -710,6 +710,45 @@ func (t *scenesGroupsTable) modifyJoins(ctx context.Context, id int, v []models.
return nil
}
type imageGalleriesTable struct {
joinTable
}
func (t *imageGalleriesTable) setCover(ctx context.Context, id int, galleryID int) error {
if err := t.resetCover(ctx, galleryID); err != nil {
return err
}
table := t.table.table
q := dialect.Update(table).Prepared(true).Set(goqu.Record{
"cover": true,
}).Where(t.idColumn.Eq(id), table.Col(galleryIDColumn).Eq(galleryID))
if _, err := exec(ctx, q); err != nil {
return fmt.Errorf("setting cover flag in %s: %w", t.table.table.GetTable(), err)
}
return nil
}
func (t *imageGalleriesTable) resetCover(ctx context.Context, galleryID int) error {
table := t.table.table
q := dialect.Update(table).Prepared(true).Set(goqu.Record{
"cover": false,
}).Where(
table.Col(galleryIDColumn).Eq(galleryID),
table.Col("cover").Eq(true),
)
if _, err := exec(ctx, q); err != nil {
return fmt.Errorf("unsetting cover flags in %s: %w", t.table.table.GetTable(), err)
}
return nil
}
type relatedFilesTable struct {
table
}

View File

@ -57,12 +57,14 @@ var (
},
}
imageGalleriesTableMgr = &joinTable{
table: table{
table: galleriesImagesJoinTable,
idColumn: galleriesImagesJoinTable.Col(imageIDColumn),
imageGalleriesTableMgr = &imageGalleriesTable{
joinTable: joinTable{
table: table{
table: galleriesImagesJoinTable,
idColumn: galleriesImagesJoinTable.Col(imageIDColumn),
},
fkColumn: galleriesImagesJoinTable.Col(galleryIDColumn),
},
fkColumn: galleriesImagesJoinTable.Col(galleryIDColumn),
}
imagesTagsTableMgr = &joinTable{

View File

@ -43,3 +43,13 @@ mutation AddGalleryImages($gallery_id: ID!, $image_ids: [ID!]!) {
mutation RemoveGalleryImages($gallery_id: ID!, $image_ids: [ID!]!) {
removeGalleryImages(input: { gallery_id: $gallery_id, image_ids: $image_ids })
}
mutation SetGalleryCover($gallery_id: ID!, $cover_image_id: ID!) {
setGalleryCover(
input: { gallery_id: $gallery_id, cover_image_id: $cover_image_id }
)
}
mutation ResetGalleryCover($gallery_id: ID!) {
resetGalleryCover(input: { gallery_id: $gallery_id })
}

View File

@ -11,6 +11,7 @@ import { Helmet } from "react-helmet";
import * as GQL from "src/core/generated-graphql";
import {
mutateMetadataScan,
mutateResetGalleryCover,
useFindGallery,
useGalleryUpdate,
} from "src/core/StashService";
@ -138,6 +139,25 @@ export const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {
);
}
async function onResetCover() {
try {
await mutateResetGalleryCover({
gallery_id: gallery.id!,
});
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase(),
}
)
);
} catch (e) {
Toast.error(e);
}
}
async function onClickChapter(imageindex: number) {
showLightbox(imageindex - 1);
}
@ -176,7 +196,6 @@ export const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {
<Dropdown.Menu className="bg-secondary text-white">
{path ? (
<Dropdown.Item
key="rescan"
className="bg-secondary text-white"
onClick={() => onRescan()}
>
@ -184,7 +203,12 @@ export const GalleryPage: React.FC<IProps> = ({ gallery, add }) => {
</Dropdown.Item>
) : undefined}
<Dropdown.Item
key="delete-gallery"
className="bg-secondary text-white"
onClick={() => onResetCover()}
>
<FormattedMessage id="actions.reset_cover" />
</Dropdown.Item>
<Dropdown.Item
className="bg-secondary text-white"
onClick={() => setIsDeleteAlertOpen(true)}
>

View File

@ -3,8 +3,14 @@ import * as GQL from "src/core/generated-graphql";
import { GalleriesCriterion } from "src/models/list-filter/criteria/galleries";
import { ListFilterModel } from "src/models/list-filter/filter";
import { ImageList } from "src/components/Images/ImageList";
import { mutateRemoveGalleryImages } from "src/core/StashService";
import { showWhenSelected } from "src/components/List/ItemList";
import {
mutateRemoveGalleryImages,
mutateSetGalleryCover,
} from "src/core/StashService";
import {
showWhenSelected,
showWhenSingleSelection,
} from "src/components/List/ItemList";
import { useToast } from "src/hooks/Toast";
import { useIntl } from "react-intl";
import { faMinus } from "@fortawesome/free-solid-svg-icons";
@ -58,6 +64,35 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> = ({
return filter;
}
async function setCover(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel,
selectedIds: Set<string>
) {
const coverImageID = selectedIds.values().next();
if (coverImageID.done) {
// operation should only be displayed when exactly one image is selected
return;
}
try {
await mutateSetGalleryCover({
gallery_id: gallery.id!,
cover_image_id: coverImageID.value,
});
Toast.success(
intl.formatMessage(
{ id: "toast.updated_entity" },
{
entity: intl.formatMessage({ id: "gallery" }).toLocaleLowerCase(),
}
)
);
} catch (e) {
Toast.error(e);
}
}
async function removeImages(
result: GQL.FindImagesQueryResult,
filter: ListFilterModel,
@ -85,6 +120,11 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> = ({
}
const otherOperations = [
{
text: intl.formatMessage({ id: "actions.set_cover" }),
onClick: setCover,
isDisplayed: showWhenSingleSelection,
},
{
text: intl.formatMessage({ id: "actions.remove_from_gallery" }),
onClick: removeImages,

View File

@ -335,3 +335,11 @@ export const showWhenSelected = <T extends QueryResult>(
) => {
return selectedIds.size > 0;
};
export const showWhenSingleSelection = <T extends QueryResult>(
result: T,
filter: ListFilterModel,
selectedIds: Set<string>
) => {
return selectedIds.size == 1;
};

View File

@ -1526,6 +1526,34 @@ export const mutateAddGalleryImages = (input: GQL.GalleryAddInput) =>
},
});
export const mutateSetGalleryCover = (input: GQL.GallerySetCoverInput) =>
client.mutate<GQL.SetGalleryCoverMutation>({
mutation: GQL.SetGalleryCoverDocument,
variables: input,
update(cache, result) {
if (!result.data?.setGalleryCover) return;
cache.evict({
id: cache.identify({ __typename: "Gallery", id: input.gallery_id }),
fieldName: "cover",
});
},
});
export const mutateResetGalleryCover = (input: GQL.GalleryResetCoverInput) =>
client.mutate<GQL.ResetGalleryCoverMutation>({
mutation: GQL.ResetGalleryCoverDocument,
variables: input,
update(cache, result) {
if (!result.data?.resetGalleryCover) return;
cache.evict({
id: cache.identify({ __typename: "Gallery", id: input.gallery_id }),
fieldName: "cover",
});
},
});
export const mutateRemoveGalleryImages = (input: GQL.GalleryRemoveInput) =>
client.mutate<GQL.RemoveGalleryImagesMutation>({
mutation: GQL.RemoveGalleryImagesDocument,

View File

@ -94,6 +94,7 @@
"remove_from_gallery": "Remove from Gallery",
"rename_gen_files": "Rename generated files",
"rescan": "Rescan",
"reset_cover": "Restore Default Cover",
"reshuffle": "Reshuffle",
"running": "running",
"save": "Save",
@ -114,6 +115,7 @@
"selective_scan": "Selective Scan",
"set_as_default": "Set as default",
"set_back_image": "Back image…",
"set_cover": "Set as Cover",
"set_front_image": "Front image…",
"set_image": "Set image…",
"show": "Show",