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
This commit is contained in:
WithoutPants 2024-09-03 16:31:55 +10:00 committed by GitHub
parent 010a355e0b
commit a3c34a51aa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 131 additions and 49 deletions

View File

@ -1,4 +1,5 @@
type GalleryPathsType {
cover: String!
preview: String! # Resolver
}

View File

@ -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
}

View File

@ -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

View File

@ -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"
}

View File

@ -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)
}

View File

@ -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"

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-352 -104 1280 720">
<!--! Font Awesome Free 6.6.0 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) Copyright 2024 Fonticons, Inc.
Modified from https://github.com/FortAwesome/Font-Awesome/blob/6.x/svgs/solid/images.svg
Changed view box and fill style.
-->
<path d="M160 32c-35.3 0-64 28.7-64 64l0 224c0 35.3 28.7 64 64 64l352 0c35.3 0 64-28.7 64-64l0-224c0-35.3-28.7-64-64-64L160 32zM396 138.7l96 144c4.9 7.4 5.4 16.8 1.2 24.6S480.9 320 472 320l-144 0-48 0-80 0c-9.2 0-17.6-5.3-21.6-13.6s-2.9-18.2 2.9-25.4l64-80c4.6-5.7 11.4-9 18.7-9s14.2 3.3 18.7 9l17.3 21.6 56-84C360.5 132 368 128 376 128s15.5 4 20 10.7zM192 128a32 32 0 1 1 64 0 32 32 0 1 1 -64 0zM48 120c0-13.3-10.7-24-24-24S0 106.7 0 120L0 344c0 75.1 60.9 136 136 136l320 0c13.3 0 24-10.7 24-24s-10.7-24-24-24l-320 0c-48.6 0-88-39.4-88-88l0-224z" style="fill:#ffffff;fill-opacity:1"/></svg>

After

Width:  |  Height:  |  Size: 996 B

View File

@ -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

View File

@ -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)
}

View File

@ -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
}
}

View File

@ -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
}

View File

@ -30,7 +30,7 @@ export const GalleryPreview: React.FC<IScenePreviewProps> = ({
onScrubberClick,
}) => {
const [imgSrc, setImgSrc] = useState<string | undefined>(
gallery.cover?.paths.thumbnail ?? undefined
gallery.paths.cover ?? undefined
);
return (
@ -43,13 +43,15 @@ export const GalleryPreview: React.FC<IScenePreviewProps> = ({
src={imgSrc}
/>
)}
<GalleryPreviewScrubber
previewPath={gallery.paths.preview}
defaultPath={gallery.cover?.paths.thumbnail ?? ""}
imageCount={gallery.image_count}
onClick={onScrubberClick}
onPathChanged={setImgSrc}
/>
{gallery.image_count > 0 && (
<GalleryPreviewScrubber
previewPath={gallery.paths.preview}
defaultPath={gallery.paths.cover ?? ""}
imageCount={gallery.image_count}
onClick={onScrubberClick}
onPathChanged={setImgSrc}
/>
)}
</div>
);
};

View File

@ -43,14 +43,12 @@ export const GalleryListTable: React.FC<IGalleryListTableProps> = (
return (
<Link to={`/galleries/${gallery.id}`}>
{gallery.cover ? (
<img
loading="lazy"
alt={title}
className="image-thumbnail"
src={`${gallery.cover.paths.thumbnail}`}
/>
) : undefined}
<img
loading="lazy"
alt={title}
className="image-thumbnail"
src={gallery.paths.cover}
/>
</Link>
);
};

View File

@ -19,17 +19,20 @@ interface IProps {
const GalleryWallCard: React.FC<IProps> = ({ 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<HTMLImageElement, Event>) {
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<IProps> = ({ gallery }) => {
: performerNames;
async function showLightboxStart() {
if (gallery.image_count === 0) {
return;
}
showLightbox(0);
}
@ -51,7 +58,13 @@ const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
tabIndex={0}
>
<RatingSystem value={gallery.rating100} disabled withoutContext />
<img loading="lazy" src={cover} alt="" className={CLASSNAME_IMG} />
<img
loading="lazy"
src={cover}
alt=""
className={CLASSNAME_IMG}
onLoad={onImageLoad}
/>
<footer className={CLASSNAME_FOOTER}>
<Link
to={`/galleries/${gallery.id}`}