diff --git a/graphql/documents/data/gallery.graphql b/graphql/documents/data/gallery.graphql index 66dd8ead2..2f77518b4 100644 --- a/graphql/documents/data/gallery.graphql +++ b/graphql/documents/data/gallery.graphql @@ -8,4 +8,9 @@ fragment GalleryData on Gallery { name path } + scene { + id + title + path + } } diff --git a/graphql/documents/queries/gallery.graphql b/graphql/documents/queries/gallery.graphql index 20535611d..205f661bc 100644 --- a/graphql/documents/queries/gallery.graphql +++ b/graphql/documents/queries/gallery.graphql @@ -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 diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 6760dec8e..e72adadf7 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -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 diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 80250181d..2ba65b876 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -96,6 +96,11 @@ input StudioFilterType { parents: MultiCriterionInput } +input GalleryFilterType { + """Filter to only include galleries missing this property""" + is_missing: String +} + enum CriterionModifier { """=""" EQUALS, diff --git a/graphql/schema/types/gallery.graphql b/graphql/schema/types/gallery.graphql index fdd031f0e..e94be7be1 100644 --- a/graphql/schema/types/gallery.graphql +++ b/graphql/schema/types/gallery.graphql @@ -4,6 +4,7 @@ type Gallery { checksum: String! path: String! title: String + scene: Scene """The files in the gallery""" files: [GalleryFilesType!]! # Resolver diff --git a/pkg/api/resolver_model_gallery.go b/pkg/api/resolver_model_gallery.go index 1fcf260ee..ebee45420 100644 --- a/pkg/api/resolver_model_gallery.go +++ b/pkg/api/resolver_model_gallery.go @@ -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)) +} diff --git a/pkg/api/resolver_query_find_gallery.go b/pkg/api/resolver_query_find_gallery.go index d014e05ef..9468b73bc 100644 --- a/pkg/api/resolver_query_find_gallery.go +++ b/pkg/api/resolver_query_find_gallery.go @@ -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, diff --git a/pkg/manager/task_clean.go b/pkg/manager/task_clean.go index 8cca80bbd..3a2182be4 100644 --- a/pkg/manager/task_clean.go +++ b/pkg/manager/task_clean.go @@ -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() diff --git a/pkg/manager/task_scan.go b/pkg/manager/task_scan.go index 3e784ae56..9e9fc9f37 100644 --- a/pkg/manager/task_scan.go +++ b/pkg/manager/task_scan.go @@ -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 { diff --git a/pkg/models/model_gallery.go b/pkg/models/model_gallery.go index 9f8a83f5d..fe864b866 100644 --- a/pkg/models/model_gallery.go +++ b/pkg/models/model_gallery.go @@ -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() diff --git a/pkg/models/querybuilder_gallery.go b/pkg/models/querybuilder_gallery.go index 0f2b06e06..cf5aa8517 100644 --- a/pkg/models/querybuilder_gallery.go +++ b/pkg/models/querybuilder_gallery.go @@ -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 { diff --git a/pkg/models/querybuilder_gallery_test.go b/pkg/models/querybuilder_gallery_test.go index d3a409d08..ad2234092 100644 --- a/pkg/models/querybuilder_gallery_test.go +++ b/pkg/models/querybuilder_gallery_test.go @@ -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 diff --git a/pkg/models/setup_test.go b/pkg/models/setup_test.go index 95e0a1230..73285c5b5 100644 --- a/pkg/models/setup_test.go +++ b/pkg/models/setup_test.go @@ -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 diff --git a/ui/v2.5/src/components/Changelog/versions/v030.tsx b/ui/v2.5/src/components/Changelog/versions/v030.tsx index 4077a3a9a..1f3476fea 100644 --- a/ui/v2.5/src/components/Changelog/versions/v030.tsx +++ b/ui/v2.5/src/components/Changelog/versions/v030.tsx @@ -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. diff --git a/ui/v2.5/src/components/Galleries/GalleryCard.tsx b/ui/v2.5/src/components/Galleries/GalleryCard.tsx new file mode 100644 index 000000000..cf72fe4c6 --- /dev/null +++ b/ui/v2.5/src/components/Galleries/GalleryCard.tsx @@ -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 = ({ gallery, zoomIndex }) => { + function maybeRenderScenePopoverButton() { + if (!gallery.scene) return; + + const popoverContent = ( + + ); + + return ( + + + + + + ); + } + + function maybeRenderPopoverButtonGroup() { + if (gallery.scene) { + return ( + <> +
+ + {maybeRenderScenePopoverButton()} + + + ); + } + } + + return ( + + + {gallery.files.length > 0 ? ( + {gallery.path} + ) : undefined} + +
+
{gallery.path}
+ + {gallery.files.length}  + + . + +
+ {maybeRenderPopoverButtonGroup()} +
+ ); +}; diff --git a/ui/v2.5/src/components/Galleries/GalleryList.tsx b/ui/v2.5/src/components/Galleries/GalleryList.tsx index 6067c82bf..e3e31e926 100644 --- a/ui/v2.5/src/components/Galleries/GalleryList.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryList.tsx @@ -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, + zoomIndex: number ) { if (!result.data || !result.data.findGalleries) { return; } if (filter.displayMode === DisplayMode.Grid) { - return

TODO

; + return ( +
+ {result.data.findGalleries.galleries.map((gallery) => ( + + ))} +
+ ); } if (filter.displayMode === DisplayMode.List) { return ( diff --git a/ui/v2.5/src/components/Galleries/styles.scss b/ui/v2.5/src/components/Galleries/styles.scss index ec64677fa..6d7e58f23 100644 --- a/ui/v2.5/src/components/Galleries/styles.scss +++ b/ui/v2.5/src/components/Galleries/styles.scss @@ -5,3 +5,12 @@ } } /* stylelint-enable selector-class-pattern */ + +.gallery-card { + padding: 0.5rem; + + &-image { + object-fit: contain; + vertical-align: middle; + } +} diff --git a/ui/v2.5/src/components/Scenes/SceneCard.tsx b/ui/v2.5/src/components/Scenes/SceneCard.tsx index 07e5ca12d..3f916283e 100644 --- a/ui/v2.5/src/components/Scenes/SceneCard.tsx +++ b/ui/v2.5/src/components/Scenes/SceneCard.tsx @@ -203,7 +203,7 @@ export const SceneCard: React.FC = ( return ( <>
- + {maybeRenderTagPopoverButton()} {maybeRenderPerformerPopoverButton()} {maybeRenderMoviePopoverButton()} diff --git a/ui/v2.5/src/components/Scenes/styles.scss b/ui/v2.5/src/components/Scenes/styles.scss index 609ca1218..39f179204 100644 --- a/ui/v2.5/src/components/Scenes/styles.scss +++ b/ui/v2.5/src/components/Scenes/styles.scss @@ -1,4 +1,4 @@ -.scene-popovers { +.card-popovers { display: flex; justify-content: center; margin-bottom: 10px; diff --git a/ui/v2.5/src/components/Shared/TagLink.tsx b/ui/v2.5/src/components/Shared/TagLink.tsx index 9ca28f8b9..155714d39 100644 --- a/ui/v2.5/src/components/Shared/TagLink.tsx +++ b/ui/v2.5/src/components/Shared/TagLink.tsx @@ -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; marker?: Partial; movie?: Partial; + scene?: Partial; className?: string; } @@ -34,6 +36,11 @@ export const TagLink: React.FC = (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 ( diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index 103dd5a57..a801f5be1 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -33,6 +33,7 @@ export const useFindGalleries = (filter: ListFilterModel) => GQL.useFindGalleriesQuery({ variables: { filter: filter.makeFindFilter(), + gallery_filter: filter.makeGalleryFilter(), }, }); diff --git a/ui/v2.5/src/index.scss b/ui/v2.5/src/index.scss index 95db2dd18..e87a29995 100755 --- a/ui/v2.5/src/index.scss +++ b/ui/v2.5/src/index.scss @@ -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%; } diff --git a/ui/v2.5/src/models/list-filter/criteria/criterion.ts b/ui/v2.5/src/models/list-filter/criteria/criterion.ts index d94c65ad8..a10433844 100644 --- a/ui/v2.5/src/models/list-filter/criteria/criterion.ts +++ b/ui/v2.5/src/models/list-filter/criteria/criterion.ts @@ -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": diff --git a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts index aa40aaefc..7e537d0fd 100644 --- a/ui/v2.5/src/models/list-filter/criteria/is-missing.ts +++ b/ui/v2.5/src/models/list-filter/criteria/is-missing.ts @@ -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"; +} diff --git a/ui/v2.5/src/models/list-filter/criteria/utils.ts b/ui/v2.5/src/models/list-filter/criteria/utils.ts index 5907b03b7..1ccf12ea6 100644 --- a/ui/v2.5/src/models/list-filter/criteria/utils.ts +++ b/ui/v2.5/src/models/list-filter/criteria/utils.ts @@ -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": diff --git a/ui/v2.5/src/models/list-filter/filter.ts b/ui/v2.5/src/models/list-filter/filter.ts index 57dd106d3..9320e6644 100644 --- a/ui/v2.5/src/models/list-filter/filter.ts +++ b/ui/v2.5/src/models/list-filter/filter.ts @@ -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; } }