From a3c34a51aa3b5d33322c9cbe1139ba80e3616967 Mon Sep 17 00:00:00 2001
From: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
Date: Tue, 3 Sep 2024 16:31:55 +1000
Subject: [PATCH] Gallery cover url (#5182)
* Add default gallery image
* Add gallery cover URL path
* Use new cover URL in UI
* Hide gallery preview scrubber when gallery has no images
* Don't try to show lightbox for gallery without images
* Ignore unrelated lint issue
---
graphql/schema/types/gallery.graphql | 1 +
internal/api/resolver_model_gallery.go | 4 +-
internal/api/routes_gallery.go | 47 ++++++++++++++++++-
internal/api/urlbuilders/gallery.go | 4 ++
internal/dlna/dms.go | 4 ++
internal/static/embed.go | 5 +-
internal/static/gallery/gallery.svg | 6 +++
pkg/image/query.go | 27 ++++++++---
pkg/models/repository_image.go | 5 ++
ui/v2.5/graphql/data/gallery-slim.graphql | 10 +---
ui/v2.5/graphql/data/gallery.graphql | 4 +-
.../src/components/Galleries/GalleryCard.tsx | 18 +++----
.../components/Galleries/GalleryListTable.tsx | 14 +++---
.../components/Galleries/GalleryWallCard.tsx | 31 ++++++++----
14 files changed, 131 insertions(+), 49 deletions(-)
create mode 100644 internal/static/gallery/gallery.svg
diff --git a/graphql/schema/types/gallery.graphql b/graphql/schema/types/gallery.graphql
index fe8e3fab6..999a743f7 100644
--- a/graphql/schema/types/gallery.graphql
+++ b/graphql/schema/types/gallery.graphql
@@ -1,4 +1,5 @@
type GalleryPathsType {
+ cover: String!
preview: String! # Resolver
}
diff --git a/internal/api/resolver_model_gallery.go b/internal/api/resolver_model_gallery.go
index 7877e819d..9dc68b4c4 100644
--- a/internal/api/resolver_model_gallery.go
+++ b/internal/api/resolver_model_gallery.go
@@ -195,10 +195,10 @@ func (r *galleryResolver) Urls(ctx context.Context, obj *models.Gallery) ([]stri
func (r *galleryResolver) Paths(ctx context.Context, obj *models.Gallery) (*GalleryPathsType, error) {
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
builder := urlbuilders.NewGalleryURLBuilder(baseURL, obj)
- previewPath := builder.GetPreviewURL()
return &GalleryPathsType{
- Preview: previewPath,
+ Cover: builder.GetCoverURL(),
+ Preview: builder.GetPreviewURL(),
}, nil
}
diff --git a/internal/api/routes_gallery.go b/internal/api/routes_gallery.go
index fcadae5f9..e08663a70 100644
--- a/internal/api/routes_gallery.go
+++ b/internal/api/routes_gallery.go
@@ -8,8 +8,12 @@ import (
"github.com/go-chi/chi/v5"
+ "github.com/stashapp/stash/internal/manager/config"
+ "github.com/stashapp/stash/internal/static"
+ "github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/models"
+ "github.com/stashapp/stash/pkg/utils"
)
type GalleryFinder interface {
@@ -17,15 +21,17 @@ type GalleryFinder interface {
FindByChecksum(ctx context.Context, checksum string) ([]*models.Gallery, error)
}
-type ImageByIndexer interface {
+type GalleryImageFinder interface {
FindByGalleryIDIndex(ctx context.Context, galleryID int, index uint) (*models.Image, error)
+ image.Queryer
+ image.CoverQueryer
}
type galleryRoutes struct {
routes
imageRoutes imageRoutes
galleryFinder GalleryFinder
- imageFinder ImageByIndexer
+ imageFinder GalleryImageFinder
fileGetter models.FileGetter
}
@@ -35,12 +41,46 @@ func (rs galleryRoutes) Routes() chi.Router {
r.Route("/{galleryId}", func(r chi.Router) {
r.Use(rs.GalleryCtx)
+ r.Get("/cover", rs.Cover)
r.Get("/preview/{imageIndex}", rs.Preview)
})
return r
}
+func (rs galleryRoutes) Cover(w http.ResponseWriter, r *http.Request) {
+ g := r.Context().Value(galleryKey).(*models.Gallery)
+
+ var i *models.Image
+ _ = rs.withReadTxn(r, func(ctx context.Context) error {
+ // Find cover image first
+ i, _ = image.FindGalleryCover(ctx, rs.imageFinder, g.ID, config.GetInstance().GetGalleryCoverRegex())
+ if i == nil {
+ return nil
+ }
+
+ // serveThumbnail needs files populated
+ if err := i.LoadPrimaryFile(ctx, rs.fileGetter); err != nil {
+ if !errors.Is(err, context.Canceled) {
+ logger.Errorf("error loading primary file for image %d: %v", i.ID, err)
+ }
+ // set image to nil so that it doesn't try to use the primary file
+ i = nil
+ }
+
+ return nil
+ })
+
+ if i == nil {
+ // fallback to default image
+ image := static.ReadAll(static.DefaultGalleryImage)
+ utils.ServeImage(w, r, image)
+ return
+ }
+
+ rs.imageRoutes.serveThumbnail(w, r, i)
+}
+
func (rs galleryRoutes) Preview(w http.ResponseWriter, r *http.Request) {
g := r.Context().Value(galleryKey).(*models.Gallery)
indexQueryParam := chi.URLParam(r, "imageIndex")
@@ -55,6 +95,9 @@ func (rs galleryRoutes) Preview(w http.ResponseWriter, r *http.Request) {
_ = rs.withReadTxn(r, func(ctx context.Context) error {
qb := rs.imageFinder
i, _ = qb.FindByGalleryIDIndex(ctx, g.ID, uint(index))
+ if i == nil {
+ return nil
+ }
// TODO - handle errors?
// serveThumbnail needs files populated
diff --git a/internal/api/urlbuilders/gallery.go b/internal/api/urlbuilders/gallery.go
index 8aeff1e04..3e6c5ef08 100644
--- a/internal/api/urlbuilders/gallery.go
+++ b/internal/api/urlbuilders/gallery.go
@@ -21,3 +21,7 @@ func NewGalleryURLBuilder(baseURL string, gallery *models.Gallery) GalleryURLBui
func (b GalleryURLBuilder) GetPreviewURL() string {
return b.BaseURL + "/gallery/" + b.GalleryID + "/preview"
}
+
+func (b GalleryURLBuilder) GetCoverURL() string {
+ return b.BaseURL + "/gallery/" + b.GalleryID + "/cover"
+}
diff --git a/internal/dlna/dms.go b/internal/dlna/dms.go
index 571526fb8..3b27d607b 100644
--- a/internal/dlna/dms.go
+++ b/internal/dlna/dms.go
@@ -230,6 +230,10 @@ func (me *Server) ssdpInterface(if_ net.Interface) {
stopped := make(chan struct{})
go func() {
defer close(stopped)
+ // FIXME - this currently blocks forever unless it encounters an error
+ // See https://github.com/anacrolix/dms/pull/150
+ // Needs to be fixed upstream
+ //nolint:staticcheck
if err := s.Serve(); err != nil {
logger.Errorf("%q: %q\n", if_.Name, err)
}
diff --git a/internal/static/embed.go b/internal/static/embed.go
index 67125d826..91437a81f 100644
--- a/internal/static/embed.go
+++ b/internal/static/embed.go
@@ -8,7 +8,7 @@ import (
"io/fs"
)
-//go:embed performer performer_male scene image tag studio group
+//go:embed performer performer_male scene image gallery tag studio group
var data embed.FS
const (
@@ -21,6 +21,9 @@ const (
Image = "image"
DefaultImageImage = "image/image.svg"
+ Gallery = "gallery"
+ DefaultGalleryImage = "gallery/gallery.svg"
+
Tag = "tag"
DefaultTagImage = "tag/tag.svg"
diff --git a/internal/static/gallery/gallery.svg b/internal/static/gallery/gallery.svg
new file mode 100644
index 000000000..5fb2edc52
--- /dev/null
+++ b/internal/static/gallery/gallery.svg
@@ -0,0 +1,6 @@
+
\ No newline at end of file
diff --git a/pkg/image/query.go b/pkg/image/query.go
index 9e82cd09a..b9b9e6628 100644
--- a/pkg/image/query.go
+++ b/pkg/image/query.go
@@ -7,6 +7,19 @@ import (
"github.com/stashapp/stash/pkg/models"
)
+type Queryer interface {
+ Query(ctx context.Context, options models.ImageQueryOptions) (*models.ImageQueryResult, error)
+}
+
+type CoverQueryer interface {
+ Queryer
+ CoverByGalleryID(ctx context.Context, galleryId int) (*models.Image, error)
+}
+
+type QueryCounter interface {
+ QueryCount(ctx context.Context, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) (int, error)
+}
+
// QueryOptions returns a ImageQueryResult populated with the provided filters.
func QueryOptions(imageFilter *models.ImageFilterType, findFilter *models.FindFilterType, count bool) models.ImageQueryOptions {
return models.ImageQueryOptions{
@@ -19,7 +32,7 @@ func QueryOptions(imageFilter *models.ImageFilterType, findFilter *models.FindFi
}
// Query queries for images using the provided filters.
-func Query(ctx context.Context, qb models.ImageQueryer, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, error) {
+func Query(ctx context.Context, qb Queryer, imageFilter *models.ImageFilterType, findFilter *models.FindFilterType) ([]*models.Image, error) {
result, err := qb.Query(ctx, QueryOptions(imageFilter, findFilter, false))
if err != nil {
return nil, err
@@ -33,7 +46,7 @@ func Query(ctx context.Context, qb models.ImageQueryer, imageFilter *models.Imag
return images, nil
}
-func CountByPerformerID(ctx context.Context, r models.ImageQueryer, id int) (int, error) {
+func CountByPerformerID(ctx context.Context, r QueryCounter, id int) (int, error) {
filter := &models.ImageFilterType{
Performers: &models.MultiCriterionInput{
Value: []string{strconv.Itoa(id)},
@@ -44,7 +57,7 @@ func CountByPerformerID(ctx context.Context, r models.ImageQueryer, id int) (int
return r.QueryCount(ctx, filter, nil)
}
-func CountByStudioID(ctx context.Context, r models.ImageQueryer, id int, depth *int) (int, error) {
+func CountByStudioID(ctx context.Context, r QueryCounter, id int, depth *int) (int, error) {
filter := &models.ImageFilterType{
Studios: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(id)},
@@ -56,7 +69,7 @@ func CountByStudioID(ctx context.Context, r models.ImageQueryer, id int, depth *
return r.QueryCount(ctx, filter, nil)
}
-func CountByTagID(ctx context.Context, r models.ImageQueryer, id int, depth *int) (int, error) {
+func CountByTagID(ctx context.Context, r QueryCounter, id int, depth *int) (int, error) {
filter := &models.ImageFilterType{
Tags: &models.HierarchicalMultiCriterionInput{
Value: []string{strconv.Itoa(id)},
@@ -68,7 +81,7 @@ func CountByTagID(ctx context.Context, r models.ImageQueryer, id int, depth *int
return r.QueryCount(ctx, filter, nil)
}
-func FindByGalleryID(ctx context.Context, r models.ImageQueryer, galleryID int, sortBy string, sortDir models.SortDirectionEnum) ([]*models.Image, error) {
+func FindByGalleryID(ctx context.Context, r Queryer, galleryID int, sortBy string, sortDir models.SortDirectionEnum) ([]*models.Image, error) {
perPage := -1
findFilter := models.FindFilterType{
@@ -91,7 +104,7 @@ func FindByGalleryID(ctx context.Context, r models.ImageQueryer, galleryID int,
}, &findFilter)
}
-func FindGalleryCover(ctx context.Context, r models.ImageQueryer, galleryID int, galleryCoverRegex string) (*models.Image, error) {
+func FindGalleryCover(ctx context.Context, r CoverQueryer, galleryID int, galleryCoverRegex string) (*models.Image, error) {
const useCoverJpg = true
img, err := findGalleryCover(ctx, r, galleryID, useCoverJpg, galleryCoverRegex)
if err != nil {
@@ -106,7 +119,7 @@ func FindGalleryCover(ctx context.Context, r models.ImageQueryer, galleryID int,
return findGalleryCover(ctx, r, galleryID, !useCoverJpg, galleryCoverRegex)
}
-func findGalleryCover(ctx context.Context, r models.ImageQueryer, galleryID int, useCoverJpg bool, galleryCoverRegex string) (*models.Image, error) {
+func findGalleryCover(ctx context.Context, r CoverQueryer, galleryID int, useCoverJpg bool, galleryCoverRegex string) (*models.Image, error) {
img, err := r.CoverByGalleryID(ctx, galleryID)
if err != nil {
return nil, err
diff --git a/pkg/models/repository_image.go b/pkg/models/repository_image.go
index 274374b41..1d42a84ff 100644
--- a/pkg/models/repository_image.go
+++ b/pkg/models/repository_image.go
@@ -25,6 +25,9 @@ type ImageFinder interface {
type ImageQueryer interface {
Query(ctx context.Context, options ImageQueryOptions) (*ImageQueryResult, error)
QueryCount(ctx context.Context, imageFilter *ImageFilterType, findFilter *FindFilterType) (int, error)
+}
+
+type GalleryCoverFinder interface {
CoverByGalleryID(ctx context.Context, galleryId int) (*Image, error)
}
@@ -73,6 +76,8 @@ type ImageReader interface {
TagIDLoader
FileLoader
+ GalleryCoverFinder
+
All(ctx context.Context) ([]*Image, error)
Size(ctx context.Context) (float64, error)
}
diff --git a/ui/v2.5/graphql/data/gallery-slim.graphql b/ui/v2.5/graphql/data/gallery-slim.graphql
index 51036d0e3..633071e3f 100644
--- a/ui/v2.5/graphql/data/gallery-slim.graphql
+++ b/ui/v2.5/graphql/data/gallery-slim.graphql
@@ -15,15 +15,6 @@ fragment SlimGalleryData on Gallery {
...FolderData
}
image_count
- cover {
- id
- files {
- ...ImageFileData
- }
- paths {
- thumbnail
- }
- }
chapters {
id
title
@@ -49,6 +40,7 @@ fragment SlimGalleryData on Gallery {
...SlimSceneData
}
paths {
+ cover
preview
}
}
diff --git a/ui/v2.5/graphql/data/gallery.graphql b/ui/v2.5/graphql/data/gallery.graphql
index 9eb570f8e..c41f3e2b2 100644
--- a/ui/v2.5/graphql/data/gallery.graphql
+++ b/ui/v2.5/graphql/data/gallery.graphql
@@ -12,6 +12,7 @@ fragment GalleryData on Gallery {
organized
paths {
+ cover
preview
}
@@ -25,9 +26,6 @@ fragment GalleryData on Gallery {
chapters {
...GalleryChapterData
}
- cover {
- ...SlimImageData
- }
studio {
...SlimStudioData
}
diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx
index 26542f4af..130f6ffd5 100644
--- a/ui/v2.5/src/components/Galleries/GalleryCard.tsx
+++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx
@@ -30,7 +30,7 @@ export const GalleryPreview: React.FC = ({
onScrubberClick,
}) => {
const [imgSrc, setImgSrc] = useState(
- gallery.cover?.paths.thumbnail ?? undefined
+ gallery.paths.cover ?? undefined
);
return (
@@ -43,13 +43,15 @@ export const GalleryPreview: React.FC = ({
src={imgSrc}
/>
)}
-
+ {gallery.image_count > 0 && (
+
+ )}
);
};
diff --git a/ui/v2.5/src/components/Galleries/GalleryListTable.tsx b/ui/v2.5/src/components/Galleries/GalleryListTable.tsx
index 68f926587..017083b11 100644
--- a/ui/v2.5/src/components/Galleries/GalleryListTable.tsx
+++ b/ui/v2.5/src/components/Galleries/GalleryListTable.tsx
@@ -43,14 +43,12 @@ export const GalleryListTable: React.FC = (
return (
- {gallery.cover ? (
-
- ) : undefined}
+
);
};
diff --git a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx
index 82b2def92..f38103d53 100644
--- a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx
+++ b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx
@@ -19,17 +19,20 @@ interface IProps {
const GalleryWallCard: React.FC = ({ gallery }) => {
const intl = useIntl();
+ const [orientation, setOrientation] = React.useState<
+ "landscape" | "portrait"
+ >("landscape");
const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters);
- const coverFile = gallery?.cover?.files.length
- ? gallery.cover.files[0]
- : undefined;
+ const cover = gallery?.paths.cover;
+
+ function onImageLoad(e: React.SyntheticEvent) {
+ const target = e.target as HTMLImageElement;
+ setOrientation(
+ target.naturalWidth > target.naturalHeight ? "landscape" : "portrait"
+ );
+ }
- const orientation =
- (coverFile?.width ?? 0) > (coverFile?.height ?? 0)
- ? "landscape"
- : "portrait";
- const cover = gallery?.cover?.paths.thumbnail ?? "";
const title = galleryTitle(gallery);
const performerNames = gallery.performers.map((p) => p.name);
const performers =
@@ -38,6 +41,10 @@ const GalleryWallCard: React.FC = ({ gallery }) => {
: performerNames;
async function showLightboxStart() {
+ if (gallery.image_count === 0) {
+ return;
+ }
+
showLightbox(0);
}
@@ -51,7 +58,13 @@ const GalleryWallCard: React.FC = ({ gallery }) => {
tabIndex={0}
>
-
+