mirror of https://github.com/stashapp/stash.git
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:
parent
010a355e0b
commit
a3c34a51aa
|
@ -1,4 +1,5 @@
|
|||
type GalleryPathsType {
|
||||
cover: String!
|
||||
preview: String! # Resolver
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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 |
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}`}
|
||||
|
|
Loading…
Reference in New Issue