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 ? ( - {title} - ) : undefined} + {title} ); }; 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} > - +