Gallery list improvement (#622)

* Add grid view to galleries
* Show scene in gallery card
* Add is missing scene gallery filter
* Don't store galleries with no images
This commit is contained in:
WithoutPants
2020-06-21 21:43:57 +10:00
committed by GitHub
parent e50f1d01be
commit d3ababf0a1
26 changed files with 296 additions and 35 deletions

View File

@@ -8,4 +8,9 @@ fragment GalleryData on Gallery {
name
path
}
scene {
id
title
path
}
}

View File

@@ -1,5 +1,5 @@
query FindGalleries($filter: FindFilterType) {
findGalleries(filter: $filter) {
query FindGalleries($filter: FindFilterType, $gallery_filter: GalleryFilterType) {
findGalleries(gallery_filter: $gallery_filter, filter: $filter) {
count
galleries {
...GalleryData

View File

@@ -28,7 +28,7 @@ type Query {
findMovies(movie_filter: MovieFilterType, filter: FindFilterType): FindMoviesResultType!
findGallery(id: ID!): Gallery
findGalleries(filter: FindFilterType): FindGalleriesResultType!
findGalleries(gallery_filter: GalleryFilterType, filter: FindFilterType): FindGalleriesResultType!
findTag(id: ID!): Tag

View File

@@ -96,6 +96,11 @@ input StudioFilterType {
parents: MultiCriterionInput
}
input GalleryFilterType {
"""Filter to only include galleries missing this property"""
is_missing: String
}
enum CriterionModifier {
"""="""
EQUALS,

View File

@@ -4,6 +4,7 @@ type Gallery {
checksum: String!
path: String!
title: String
scene: Scene
"""The files in the gallery"""
files: [GalleryFilesType!]! # Resolver

View File

@@ -2,6 +2,7 @@ package api
import (
"context"
"github.com/stashapp/stash/pkg/models"
)
@@ -13,3 +14,12 @@ func (r *galleryResolver) Files(ctx context.Context, obj *models.Gallery) ([]*mo
baseURL, _ := ctx.Value(BaseURLCtxKey).(string)
return obj.GetFiles(baseURL), nil
}
func (r *galleryResolver) Scene(ctx context.Context, obj *models.Gallery) (*models.Scene, error) {
if !obj.SceneID.Valid {
return nil, nil
}
qb := models.NewSceneQueryBuilder()
return qb.Find(int(obj.SceneID.Int64))
}

View File

@@ -2,8 +2,9 @@ package api
import (
"context"
"github.com/stashapp/stash/pkg/models"
"strconv"
"github.com/stashapp/stash/pkg/models"
)
func (r *queryResolver) FindGallery(ctx context.Context, id string) (*models.Gallery, error) {
@@ -12,9 +13,9 @@ func (r *queryResolver) FindGallery(ctx context.Context, id string) (*models.Gal
return qb.Find(idInt)
}
func (r *queryResolver) FindGalleries(ctx context.Context, filter *models.FindFilterType) (*models.FindGalleriesResultType, error) {
func (r *queryResolver) FindGalleries(ctx context.Context, galleryFilter *models.GalleryFilterType, filter *models.FindFilterType) (*models.FindGalleriesResultType, error) {
qb := models.NewGalleryQueryBuilder()
galleries, total := qb.Query(filter)
galleries, total := qb.Query(galleryFilter, filter)
return &models.FindGalleriesResultType{
Count: total,
Galleries: galleries,

View File

@@ -26,7 +26,7 @@ func (t *CleanTask) Start(wg *sync.WaitGroup) {
t.deleteScene(t.Scene.ID)
}
if t.Gallery != nil && t.shouldClean(t.Gallery.Path) {
if t.Gallery != nil && t.shouldCleanGallery(t.Gallery) {
t.deleteGallery(t.Gallery.ID)
}
}
@@ -46,6 +46,19 @@ func (t *CleanTask) shouldClean(path string) bool {
return false
}
func (t *CleanTask) shouldCleanGallery(g *models.Gallery) bool {
if t.shouldClean(g.Path) {
return true
}
if t.Gallery.CountFiles() == 0 {
logger.Infof("Gallery has 0 images. Cleaning: \"%s\"", g.Path)
return true
}
return false
}
func (t *CleanTask) deleteScene(sceneID int) {
ctx := context.TODO()
qb := models.NewSceneQueryBuilder()

View File

@@ -64,7 +64,6 @@ func (t *ScanTask) scanGallery() {
_, err = qb.Update(*gallery, tx)
}
} else {
logger.Infof("%s doesn't exist. Creating new item...", t.FilePath)
currentTime := time.Now()
newGallery := models.Gallery{
@@ -73,7 +72,12 @@ func (t *ScanTask) scanGallery() {
CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
}
_, err = qb.Create(newGallery, tx)
// don't create gallery if it has no images
if newGallery.CountFiles() > 0 {
logger.Infof("%s doesn't exist. Creating new item...", t.FilePath)
_, err = qb.Create(newGallery, tx)
}
}
if err != nil {

View File

@@ -4,17 +4,18 @@ import (
"archive/zip"
"bytes"
"database/sql"
"github.com/disintegration/imaging"
"github.com/stashapp/stash/pkg/api/urlbuilders"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/utils"
_ "golang.org/x/image/webp"
"image"
"image/jpeg"
"io/ioutil"
"path/filepath"
"sort"
"strings"
"github.com/disintegration/imaging"
"github.com/stashapp/stash/pkg/api/urlbuilders"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/utils"
_ "golang.org/x/image/webp"
)
type Gallery struct {
@@ -28,6 +29,16 @@ type Gallery struct {
const DefaultGthumbWidth int = 200
func (g *Gallery) CountFiles() int {
filteredFiles, readCloser, err := g.listZipContents()
if err != nil {
return 0
}
defer readCloser.Close()
return len(filteredFiles)
}
func (g *Gallery) GetFiles(baseURL string) []*GalleryFilesType {
var galleryFiles []*GalleryFilesType
filteredFiles, readCloser, err := g.listZipContents()

View File

@@ -9,6 +9,8 @@ import (
"github.com/stashapp/stash/pkg/database"
)
const galleryTable = "galleries"
type GalleryQueryBuilder struct{}
func NewGalleryQueryBuilder() GalleryQueryBuilder {
@@ -112,25 +114,36 @@ func (qb *GalleryQueryBuilder) All() ([]*Gallery, error) {
return qb.queryGalleries(selectAll("galleries")+qb.getGallerySort(nil), nil, nil)
}
func (qb *GalleryQueryBuilder) Query(findFilter *FindFilterType) ([]*Gallery, int) {
func (qb *GalleryQueryBuilder) Query(galleryFilter *GalleryFilterType, findFilter *FindFilterType) ([]*Gallery, int) {
if galleryFilter == nil {
galleryFilter = &GalleryFilterType{}
}
if findFilter == nil {
findFilter = &FindFilterType{}
}
var whereClauses []string
var havingClauses []string
var args []interface{}
body := selectDistinctIDs("galleries")
query := queryBuilder{
tableName: galleryTable,
}
query.body = selectDistinctIDs("galleries")
if q := findFilter.Q; q != nil && *q != "" {
searchColumns := []string{"galleries.path", "galleries.checksum"}
clause, thisArgs := getSearchBinding(searchColumns, *q, false)
whereClauses = append(whereClauses, clause)
args = append(args, thisArgs...)
query.addWhere(clause)
query.addArg(thisArgs...)
}
sortAndPagination := qb.getGallerySort(findFilter) + getPagination(findFilter)
idsResult, countResult := executeFindQuery("galleries", body, args, sortAndPagination, whereClauses, havingClauses)
if isMissingFilter := galleryFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
switch *isMissingFilter {
case "scene":
query.addWhere("galleries.scene_id IS NULL")
}
}
query.sortAndPagination = qb.getGallerySort(findFilter) + getPagination(findFilter)
idsResult, countResult := query.executeFind()
var galleries []*Gallery
for _, id := range idsResult {

View File

@@ -98,6 +98,58 @@ func TestGalleryFindBySceneID(t *testing.T) {
assert.Nil(t, gallery)
}
func TestGalleryQueryQ(t *testing.T) {
const galleryIdx = 0
q := getGalleryStringValue(galleryIdx, pathField)
sqb := models.NewGalleryQueryBuilder()
galleryQueryQ(t, sqb, q, galleryIdx)
}
func galleryQueryQ(t *testing.T, qb models.GalleryQueryBuilder, q string, expectedGalleryIdx int) {
filter := models.FindFilterType{
Q: &q,
}
galleries, _ := qb.Query(nil, &filter)
assert.Len(t, galleries, 1)
gallery := galleries[0]
assert.Equal(t, galleryIDs[expectedGalleryIdx], gallery.ID)
// no Q should return all results
filter.Q = nil
galleries, _ = qb.Query(nil, &filter)
assert.Len(t, galleries, totalGalleries)
}
func TestGalleryQueryIsMissingScene(t *testing.T) {
qb := models.NewGalleryQueryBuilder()
isMissing := "scene"
galleryFilter := models.GalleryFilterType{
IsMissing: &isMissing,
}
q := getGalleryStringValue(galleryIdxWithScene, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
galleries, _ := qb.Query(&galleryFilter, &findFilter)
assert.Len(t, galleries, 0)
findFilter.Q = nil
galleries, _ = qb.Query(&galleryFilter, &findFilter)
// ensure non of the ids equal the one with gallery
for _, gallery := range galleries {
assert.NotEqual(t, galleryIDs[galleryIdxWithScene], gallery.ID)
}
}
// TODO ValidGalleriesForScenePath
// TODO Count
// TODO All

View File

@@ -24,7 +24,7 @@ const performersNameCase = 3
const performersNameNoCase = 2
const moviesNameCase = 2
const moviesNameNoCase = 1
const totalGalleries = 1
const totalGalleries = 2
const tagsNameNoCase = 2
const tagsNameCase = 5
const studiosNameCase = 4

View File

@@ -6,6 +6,9 @@ const markup = `
* Add support for parent/child studios.
### 🎨 Improvements
* Add gallery grid view.
* Add is-missing scene filter for gallery query.
* Don't import galleries with no images, and delete galleries with no images during clean.
* Show pagination at top as well as bottom of the page.
* Add split xpath post-processing action.
* Improved the layout of the scene page.

View File

@@ -0,0 +1,71 @@
import { Card, Button, ButtonGroup } from "react-bootstrap";
import React from "react";
import { Link } from "react-router-dom";
import * as GQL from "src/core/generated-graphql";
import { FormattedPlural } from "react-intl";
import { HoverPopover, Icon, TagLink } from "../Shared";
interface IProps {
gallery: GQL.GalleryDataFragment;
zoomIndex: number;
}
export const GalleryCard: React.FC<IProps> = ({ gallery, zoomIndex }) => {
function maybeRenderScenePopoverButton() {
if (!gallery.scene) return;
const popoverContent = (
<TagLink key={gallery.scene.id} scene={gallery.scene} />
);
return (
<HoverPopover placement="bottom" content={popoverContent}>
<Link to={`/scenes/${gallery.scene.id}`}>
<Button className="minimal">
<Icon icon="play-circle" />
</Button>
</Link>
</HoverPopover>
);
}
function maybeRenderPopoverButtonGroup() {
if (gallery.scene) {
return (
<>
<hr />
<ButtonGroup className="card-popovers">
{maybeRenderScenePopoverButton()}
</ButtonGroup>
</>
);
}
}
return (
<Card className={`gallery-card zoom-${zoomIndex}`}>
<Link to={`/galleries/${gallery.id}`} className="gallery-card-header">
{gallery.files.length > 0 ? (
<img
className="gallery-card-image"
alt={gallery.path}
src={`${gallery.files[0].path}?thumb=true`}
/>
) : undefined}
</Link>
<div className="card-section">
<h5 className="card-section-title">{gallery.path}</h5>
<span>
{gallery.files.length}&nbsp;
<FormattedPlural
value={gallery.files.length ?? 0}
one="image"
other="images"
/>
.
</span>
</div>
{maybeRenderPopoverButtonGroup()}
</Card>
);
};

View File

@@ -5,21 +5,35 @@ import { FindGalleriesQueryResult } from "src/core/generated-graphql";
import { useGalleriesList } from "src/hooks";
import { ListFilterModel } from "src/models/list-filter/filter";
import { DisplayMode } from "src/models/list-filter/types";
import { GalleryCard } from "./GalleryCard";
export const GalleryList: React.FC = () => {
const listData = useGalleriesList({
zoomable: true,
renderContent,
});
function renderContent(
result: FindGalleriesQueryResult,
filter: ListFilterModel
filter: ListFilterModel,
selectedIds: Set<string>,
zoomIndex: number
) {
if (!result.data || !result.data.findGalleries) {
return;
}
if (filter.displayMode === DisplayMode.Grid) {
return <h1>TODO</h1>;
return (
<div className="row justify-content-center">
{result.data.findGalleries.galleries.map((gallery) => (
<GalleryCard
key={gallery.id}
gallery={gallery}
zoomIndex={zoomIndex}
/>
))}
</div>
);
}
if (filter.displayMode === DisplayMode.List) {
return (

View File

@@ -5,3 +5,12 @@
}
}
/* stylelint-enable selector-class-pattern */
.gallery-card {
padding: 0.5rem;
&-image {
object-fit: contain;
vertical-align: middle;
}
}

View File

@@ -203,7 +203,7 @@ export const SceneCard: React.FC<ISceneCardProps> = (
return (
<>
<hr />
<ButtonGroup className="scene-popovers">
<ButtonGroup className="card-popovers">
{maybeRenderTagPopoverButton()}
{maybeRenderPerformerPopoverButton()}
{maybeRenderMoviePopoverButton()}

View File

@@ -1,4 +1,4 @@
.scene-popovers {
.card-popovers {
display: flex;
justify-content: center;
margin-bottom: 10px;

View File

@@ -6,6 +6,7 @@ import {
SceneMarkerDataFragment,
TagDataFragment,
MovieDataFragment,
SceneDataFragment,
} from "src/core/generated-graphql";
import { NavUtils, TextUtils } from "src/utils";
@@ -14,6 +15,7 @@ interface IProps {
performer?: Partial<PerformerDataFragment>;
marker?: Partial<SceneMarkerDataFragment>;
movie?: Partial<MovieDataFragment>;
scene?: Partial<SceneDataFragment>;
className?: string;
}
@@ -34,6 +36,11 @@ export const TagLink: React.FC<IProps> = (props: IProps) => {
title = `${props.marker.title} - ${TextUtils.secondsToTimestamp(
props.marker.seconds || 0
)}`;
} else if (props.scene) {
link = `/scenes/${props.scene.id}`;
title = props.scene.title
? props.scene.title
: TextUtils.fileNameFromPath(props.scene.path ?? "");
}
return (
<Badge className={`tag-item ${props.className}`} variant="secondary">

View File

@@ -33,6 +33,7 @@ export const useFindGalleries = (filter: ListFilterModel) =>
GQL.useFindGalleriesQuery({
variables: {
filter: filter.makeFindFilter(),
gallery_filter: filter.makeGalleryFilter(),
},
});

View File

@@ -98,7 +98,8 @@ textarea.text-input {
.zoom-0 {
width: 240px;
.scene-card-video {
.scene-card-video,
.gallery-card-image {
max-height: 180px;
}
@@ -110,7 +111,8 @@ textarea.text-input {
.zoom-1 {
width: 320px;
.scene-card-video {
.scene-card-video,
.gallery-card-image {
max-height: 240px;
}
@@ -122,7 +124,8 @@ textarea.text-input {
.zoom-2 {
width: 480px;
.scene-card-video {
.scene-card-video,
.gallery-card-image {
max-height: 360px;
}
@@ -134,7 +137,8 @@ textarea.text-input {
.zoom-3 {
width: 640px;
.scene-card-video {
.scene-card-video,
.gallery-card-image {
max-height: 480px;
}
@@ -144,7 +148,8 @@ textarea.text-input {
}
}
.scene-card-video {
.scene-card-video,
.gallery-card-image {
height: auto;
width: 100%;
}

View File

@@ -14,6 +14,7 @@ export type CriterionType =
| "hasMarkers"
| "sceneIsMissing"
| "performerIsMissing"
| "galleryIsMissing"
| "tags"
| "sceneTags"
| "performers"
@@ -58,6 +59,8 @@ export abstract class Criterion {
return "Is Missing";
case "performerIsMissing":
return "Is Missing";
case "galleryIsMissing":
return "Is Missing";
case "tags":
return "Tags";
case "sceneTags":

View File

@@ -53,3 +53,13 @@ export class PerformerIsMissingCriterionOption implements ICriterionOption {
public label: string = Criterion.getLabel("performerIsMissing");
public value: CriterionType = "performerIsMissing";
}
export class GalleryIsMissingCriterion extends IsMissingCriterion {
public type: CriterionType = "galleryIsMissing";
public options: string[] = ["scene"];
}
export class GalleryIsMissingCriterionOption implements ICriterionOption {
public label: string = Criterion.getLabel("galleryIsMissing");
public value: CriterionType = "galleryIsMissing";
}

View File

@@ -12,6 +12,7 @@ import { HasMarkersCriterion } from "./has-markers";
import {
PerformerIsMissingCriterion,
SceneIsMissingCriterion,
GalleryIsMissingCriterion,
} from "./is-missing";
import { NoneCriterion } from "./none";
import { PerformersCriterion } from "./performers";
@@ -42,6 +43,8 @@ export function makeCriteria(type: CriterionType = "none") {
return new SceneIsMissingCriterion();
case "performerIsMissing":
return new PerformerIsMissingCriterion();
case "galleryIsMissing":
return new GalleryIsMissingCriterion();
case "tags":
return new TagsCriterion("tags");
case "sceneTags":

View File

@@ -8,6 +8,7 @@ import {
SortDirectionEnum,
MovieFilterType,
StudioFilterType,
GalleryFilterType,
} from "src/core/generated-graphql";
import { stringToGender } from "src/core/StashService";
import {
@@ -31,6 +32,7 @@ import {
IsMissingCriterion,
PerformerIsMissingCriterionOption,
SceneIsMissingCriterionOption,
GalleryIsMissingCriterionOption,
} from "./criteria/is-missing";
import { NoneCriterionOption } from "./criteria/none";
import {
@@ -182,8 +184,11 @@ export class ListFilterModel {
case FilterMode.Galleries:
this.sortBy = "path";
this.sortByOptions = ["path"];
this.displayModeOptions = [DisplayMode.List];
this.criterionOptions = [new NoneCriterionOption()];
this.displayModeOptions = [DisplayMode.Grid, DisplayMode.List];
this.criterionOptions = [
new NoneCriterionOption(),
new GalleryIsMissingCriterionOption(),
];
break;
case FilterMode.SceneMarkers:
this.sortBy = "title";
@@ -601,6 +606,21 @@ export class ListFilterModel {
// no default
}
});
return result;
}
public makeGalleryFilter(): GalleryFilterType {
const result: GalleryFilterType = {};
this.criteria.forEach((criterion) => {
switch (criterion.type) {
case "galleryIsMissing":
result.is_missing = (criterion as IsMissingCriterion).value;
break;
// no default
}
});
return result;
}
}