Add Chapters for Galleries (#3289)

Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
yoshnopa 2023-03-16 05:04:54 +01:00 committed by GitHub
parent 32c91c4855
commit 7e8f941155
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 1685 additions and 133 deletions

View file

@ -0,0 +1,9 @@
fragment GalleryChapterData on GalleryChapter {
id
title
image_index
gallery {
id
}
}

View file

@ -22,6 +22,11 @@ fragment SlimGalleryData on Gallery {
thumbnail
}
}
chapters {
id
title
image_index
}
studio {
id
name

View file

@ -16,6 +16,9 @@ fragment GalleryData on Gallery {
...FolderData
}
chapters {
...GalleryChapterData
}
cover {
...SlimImageData
}

View file

@ -0,0 +1,31 @@
mutation GalleryChapterCreate(
$title: String!,
$image_index: Int!,
$gallery_id: ID!) {
galleryChapterCreate(input: {
title: $title,
image_index: $image_index,
gallery_id: $gallery_id,
}) {
...GalleryChapterData
}
}
mutation GalleryChapterUpdate(
$id: ID!,
$title: String!,
$image_index: Int!,
$gallery_id: ID!) {
galleryChapterUpdate(input: {
id: $id,
title: $title,
image_index: $image_index,
gallery_id: $gallery_id,
}) {
...GalleryChapterData
}
}
mutation GalleryChapterDestroy($id: ID!) {
galleryChapterDestroy(id: $id)
}

View file

@ -218,6 +218,10 @@ type Mutation {
addGalleryImages(input: GalleryAddInput!): Boolean!
removeGalleryImages(input: GalleryRemoveInput!): Boolean!
galleryChapterCreate(input: GalleryChapterCreateInput!): GalleryChapter
galleryChapterUpdate(input: GalleryChapterUpdateInput!): GalleryChapter
galleryChapterDestroy(id: ID!): Boolean!
performerCreate(input: PerformerCreateInput!): Performer
performerUpdate(input: PerformerUpdateInput!): Performer
performerDestroy(input: PerformerDestroyInput!): Boolean!

View file

@ -324,6 +324,8 @@ input GalleryFilterType {
organized: Boolean
"""Filter by average image resolution"""
average_resolution: ResolutionCriterionInput
"""Filter to only include galleries that have chapters. `true` or `false`"""
has_chapters: String
"""Filter to only include galleries with this studio"""
studios: HierarchicalMultiCriterionInput
"""Filter to only include galleries with these tags"""

View file

@ -0,0 +1,26 @@
type GalleryChapter {
id: ID!
gallery: Gallery!
title: String!
image_index: Int!
created_at: Time!
updated_at: Time!
}
input GalleryChapterCreateInput {
gallery_id: ID!
title: String!
image_index: Int!
}
input GalleryChapterUpdateInput {
id: ID!
gallery_id: ID!
title: String!
image_index: Int!
}
type FindGalleryChaptersResultType {
count: Int!
chapters: [GalleryChapter!]!
}

View file

@ -19,6 +19,7 @@ type Gallery {
files: [GalleryFile!]!
folder: Folder
chapters: [GalleryChapter!]!
scenes: [Scene!]!
studio: Studio
image_count: Int!

View file

@ -47,6 +47,9 @@ func (r *Resolver) scraperCache() *scraper.Cache {
func (r *Resolver) Gallery() GalleryResolver {
return &galleryResolver{r}
}
func (r *Resolver) GalleryChapter() GalleryChapterResolver {
return &galleryChapterResolver{r}
}
func (r *Resolver) Mutation() MutationResolver {
return &mutationResolver{r}
}
@ -83,6 +86,7 @@ type queryResolver struct{ *Resolver }
type subscriptionResolver struct{ *Resolver }
type galleryResolver struct{ *Resolver }
type galleryChapterResolver struct{ *Resolver }
type performerResolver struct{ *Resolver }
type sceneResolver struct{ *Resolver }
type sceneMarkerResolver struct{ *Resolver }

View file

@ -249,3 +249,14 @@ func (r *galleryResolver) ImageCount(ctx context.Context, obj *models.Gallery) (
return ret, nil
}
func (r *galleryResolver) Chapters(ctx context.Context, obj *models.Gallery) (ret []*models.GalleryChapter, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.GalleryChapter.FindByGalleryID(ctx, obj.ID)
return err
}); err != nil {
return nil, err
}
return ret, nil
}

View file

@ -0,0 +1,32 @@
package api
import (
"context"
"time"
"github.com/stashapp/stash/pkg/models"
)
func (r *galleryChapterResolver) Gallery(ctx context.Context, obj *models.GalleryChapter) (ret *models.Gallery, err error) {
if !obj.GalleryID.Valid {
panic("Invalid gallery id")
}
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
galleryID := int(obj.GalleryID.Int64)
ret, err = r.repository.Gallery.Find(ctx, galleryID)
return err
}); err != nil {
return nil, err
}
return ret, nil
}
func (r *galleryChapterResolver) CreatedAt(ctx context.Context, obj *models.GalleryChapter) (*time.Time, error) {
return &obj.CreatedAt.Timestamp, nil
}
func (r *galleryChapterResolver) UpdatedAt(ctx context.Context, obj *models.GalleryChapter) (*time.Time, error) {
return &obj.UpdatedAt.Timestamp, nil
}

View file

@ -2,6 +2,7 @@ package api
import (
"context"
"database/sql"
"errors"
"fmt"
"os"
@ -10,6 +11,7 @@ import (
"github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin"
@ -489,3 +491,150 @@ func (r *mutationResolver) RemoveGalleryImages(ctx context.Context, input Galler
return true, nil
}
func (r *mutationResolver) getGalleryChapter(ctx context.Context, id int) (ret *models.GalleryChapter, err error) {
if err := r.withTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.GalleryChapter.Find(ctx, id)
return err
}); err != nil {
return nil, err
}
return ret, nil
}
func (r *mutationResolver) GalleryChapterCreate(ctx context.Context, input GalleryChapterCreateInput) (*models.GalleryChapter, error) {
galleryID, err := strconv.Atoi(input.GalleryID)
if err != nil {
return nil, err
}
var imageCount int
if err := r.withTxn(ctx, func(ctx context.Context) error {
imageCount, err = r.repository.Image.CountByGalleryID(ctx, galleryID)
return err
}); err != nil {
return nil, err
}
// Sanity Check of Index
if input.ImageIndex > imageCount || input.ImageIndex < 1 {
return nil, errors.New("Image # must greater than zero and in range of the gallery images")
}
currentTime := time.Now()
newGalleryChapter := models.GalleryChapter{
Title: input.Title,
ImageIndex: input.ImageIndex,
GalleryID: sql.NullInt64{Int64: int64(galleryID), Valid: galleryID != 0},
CreatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
UpdatedAt: models.SQLiteTimestamp{Timestamp: currentTime},
}
if err != nil {
return nil, err
}
ret, err := r.changeChapter(ctx, create, newGalleryChapter)
if err != nil {
return nil, err
}
r.hookExecutor.ExecutePostHooks(ctx, ret.ID, plugin.GalleryChapterCreatePost, input, nil)
return r.getGalleryChapter(ctx, ret.ID)
}
func (r *mutationResolver) GalleryChapterUpdate(ctx context.Context, input GalleryChapterUpdateInput) (*models.GalleryChapter, error) {
// Populate gallery chapter from the input
galleryChapterID, err := strconv.Atoi(input.ID)
if err != nil {
return nil, err
}
galleryID, err := strconv.Atoi(input.GalleryID)
if err != nil {
return nil, err
}
var imageCount int
if err := r.withTxn(ctx, func(ctx context.Context) error {
imageCount, err = r.repository.Image.CountByGalleryID(ctx, galleryID)
return err
}); err != nil {
return nil, err
}
// Sanity Check of Index
if input.ImageIndex > imageCount || input.ImageIndex < 1 {
return nil, errors.New("Image # must greater than zero and in range of the gallery images")
}
updatedGalleryChapter := models.GalleryChapter{
ID: galleryChapterID,
Title: input.Title,
ImageIndex: input.ImageIndex,
GalleryID: sql.NullInt64{Int64: int64(galleryID), Valid: galleryID != 0},
UpdatedAt: models.SQLiteTimestamp{Timestamp: time.Now()},
}
ret, err := r.changeChapter(ctx, update, updatedGalleryChapter)
if err != nil {
return nil, err
}
translator := changesetTranslator{
inputMap: getUpdateInputMap(ctx),
}
r.hookExecutor.ExecutePostHooks(ctx, ret.ID, plugin.GalleryChapterUpdatePost, input, translator.getFields())
return r.getGalleryChapter(ctx, ret.ID)
}
func (r *mutationResolver) GalleryChapterDestroy(ctx context.Context, id string) (bool, error) {
chapterID, err := strconv.Atoi(id)
if err != nil {
return false, err
}
if err := r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.GalleryChapter
chapter, err := qb.Find(ctx, chapterID)
if err != nil {
return err
}
if chapter == nil {
return fmt.Errorf("Chapter with id %d not found", chapterID)
}
return gallery.DestroyChapter(ctx, chapter, qb)
}); err != nil {
return false, err
}
r.hookExecutor.ExecutePostHooks(ctx, chapterID, plugin.GalleryChapterDestroyPost, id, nil)
return true, nil
}
func (r *mutationResolver) changeChapter(ctx context.Context, changeType int, changedChapter models.GalleryChapter) (*models.GalleryChapter, error) {
var galleryChapter *models.GalleryChapter
// Start the transaction and save the gallery chapter
var err = r.withTxn(ctx, func(ctx context.Context) error {
qb := r.repository.GalleryChapter
var err error
switch changeType {
case create:
galleryChapter, err = qb.Create(ctx, changedChapter)
case update:
galleryChapter, err = qb.Update(ctx, changedChapter)
if err != nil {
return err
}
}
return err
})
return galleryChapter, err
}

View file

@ -49,18 +49,19 @@ type FolderReaderWriter interface {
type Repository struct {
models.TxnManager
File FileReaderWriter
Folder FolderReaderWriter
Gallery GalleryReaderWriter
Image ImageReaderWriter
Movie models.MovieReaderWriter
Performer models.PerformerReaderWriter
Scene SceneReaderWriter
SceneMarker models.SceneMarkerReaderWriter
ScrapedItem models.ScrapedItemReaderWriter
Studio models.StudioReaderWriter
Tag models.TagReaderWriter
SavedFilter models.SavedFilterReaderWriter
File FileReaderWriter
Folder FolderReaderWriter
Gallery GalleryReaderWriter
GalleryChapter models.GalleryChapterReaderWriter
Image ImageReaderWriter
Movie models.MovieReaderWriter
Performer models.PerformerReaderWriter
Scene SceneReaderWriter
SceneMarker models.SceneMarkerReaderWriter
ScrapedItem models.ScrapedItemReaderWriter
Studio models.StudioReaderWriter
Tag models.TagReaderWriter
SavedFilter models.SavedFilterReaderWriter
}
func (r *Repository) WithTxn(ctx context.Context, fn txn.TxnFunc) error {
@ -79,19 +80,20 @@ func sqliteRepository(d *sqlite.Database) Repository {
txnRepo := d.TxnRepository()
return Repository{
TxnManager: txnRepo,
File: d.File,
Folder: d.Folder,
Gallery: d.Gallery,
Image: d.Image,
Movie: txnRepo.Movie,
Performer: txnRepo.Performer,
Scene: d.Scene,
SceneMarker: txnRepo.SceneMarker,
ScrapedItem: txnRepo.ScrapedItem,
Studio: txnRepo.Studio,
Tag: txnRepo.Tag,
SavedFilter: txnRepo.SavedFilter,
TxnManager: txnRepo,
File: d.File,
Folder: d.Folder,
Gallery: d.Gallery,
GalleryChapter: txnRepo.GalleryChapter,
Image: d.Image,
Movie: txnRepo.Movie,
Performer: txnRepo.Performer,
Scene: d.Scene,
SceneMarker: txnRepo.SceneMarker,
ScrapedItem: txnRepo.ScrapedItem,
Studio: txnRepo.Studio,
Tag: txnRepo.Tag,
SavedFilter: txnRepo.SavedFilter,
}
}

View file

@ -765,6 +765,7 @@ func exportGallery(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *mode
studioReader := repo.Studio
performerReader := repo.Performer
tagReader := repo.Tag
galleryChapterReader := repo.GalleryChapter
for g := range jobChan {
if err := g.LoadFiles(ctx, repo.Gallery); err != nil {
@ -821,6 +822,12 @@ func exportGallery(ctx context.Context, wg *sync.WaitGroup, jobChan <-chan *mode
continue
}
newGalleryJSON.Chapters, err = gallery.GetGalleryChaptersJSON(ctx, galleryChapterReader, g)
if err != nil {
logger.Errorf("[galleries] <%s> error getting gallery chapters JSON: %s", galleryHash, err.Error())
continue
}
newGalleryJSON.Tags = tag.GetNames(tags)
if t.includeDependencies {

View file

@ -487,6 +487,7 @@ func (t *ImportTask) ImportGalleries(ctx context.Context) {
tagWriter := r.Tag
performerWriter := r.Performer
studioWriter := r.Studio
chapterWriter := r.GalleryChapter
galleryImporter := &gallery.Importer{
ReaderWriter: readerWriter,
@ -499,7 +500,25 @@ func (t *ImportTask) ImportGalleries(ctx context.Context) {
MissingRefBehaviour: t.MissingRefBehaviour,
}
return performImport(ctx, galleryImporter, t.DuplicateBehaviour)
if err := performImport(ctx, galleryImporter, t.DuplicateBehaviour); err != nil {
return err
}
// import the gallery chapters
for _, m := range galleryJSON.Chapters {
chapterImporter := &gallery.ChapterImporter{
GalleryID: galleryImporter.ID,
Input: m,
MissingRefBehaviour: t.MissingRefBehaviour,
ReaderWriter: chapterWriter,
}
if err := performImport(ctx, chapterImporter, t.DuplicateBehaviour); err != nil {
return err
}
}
return nil
}); err != nil {
logger.Errorf("[galleries] <%s> import failed to commit: %s", fi.Name(), err.Error())
continue

View file

@ -0,0 +1,83 @@
package gallery
import (
"context"
"database/sql"
"fmt"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/jsonschema"
)
type ChapterCreatorUpdater interface {
Create(ctx context.Context, newGalleryChapter models.GalleryChapter) (*models.GalleryChapter, error)
Update(ctx context.Context, updatedGalleryChapter models.GalleryChapter) (*models.GalleryChapter, error)
FindByGalleryID(ctx context.Context, galleryID int) ([]*models.GalleryChapter, error)
}
type ChapterImporter struct {
GalleryID int
ReaderWriter ChapterCreatorUpdater
Input jsonschema.GalleryChapter
MissingRefBehaviour models.ImportMissingRefEnum
chapter models.GalleryChapter
}
func (i *ChapterImporter) PreImport(ctx context.Context) error {
i.chapter = models.GalleryChapter{
Title: i.Input.Title,
ImageIndex: i.Input.ImageIndex,
GalleryID: sql.NullInt64{Int64: int64(i.GalleryID), Valid: true},
CreatedAt: models.SQLiteTimestamp{Timestamp: i.Input.CreatedAt.GetTime()},
UpdatedAt: models.SQLiteTimestamp{Timestamp: i.Input.UpdatedAt.GetTime()},
}
return nil
}
func (i *ChapterImporter) Name() string {
return fmt.Sprintf("%s (%d)", i.Input.Title, i.Input.ImageIndex)
}
func (i *ChapterImporter) PostImport(ctx context.Context, id int) error {
return nil
}
func (i *ChapterImporter) FindExistingID(ctx context.Context) (*int, error) {
existingChapters, err := i.ReaderWriter.FindByGalleryID(ctx, i.GalleryID)
if err != nil {
return nil, err
}
for _, m := range existingChapters {
if m.ImageIndex == i.chapter.ImageIndex {
id := m.ID
return &id, nil
}
}
return nil, nil
}
func (i *ChapterImporter) Create(ctx context.Context) (*int, error) {
created, err := i.ReaderWriter.Create(ctx, i.chapter)
if err != nil {
return nil, fmt.Errorf("error creating chapter: %v", err)
}
id := created.ID
return &id, nil
}
func (i *ChapterImporter) Update(ctx context.Context, id int) error {
chapter := i.chapter
chapter.ID = id
_, err := i.ReaderWriter.Update(ctx, chapter)
if err != nil {
return fmt.Errorf("error updating existing chapter: %v", err)
}
return nil
}

View file

@ -11,6 +11,8 @@ import (
func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) {
var imgsDestroyed []*models.Image
// chapter deletion is done via delete cascade, so we don't need to do anything here
// if this is a zip-based gallery, delete the images as well first
zipImgsDestroyed, err := s.destroyZipFileImages(ctx, i, fileDeleter, deleteGenerated, deleteFile)
if err != nil {
@ -39,6 +41,15 @@ func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *i
return imgsDestroyed, nil
}
type ChapterDestroyer interface {
FindByGalleryID(ctx context.Context, galleryID int) ([]*models.GalleryChapter, error)
Destroy(ctx context.Context, id int) error
}
func DestroyChapter(ctx context.Context, galleryChapter *models.GalleryChapter, qb ChapterDestroyer) error {
return qb.Destroy(ctx, galleryChapter.ID)
}
func (s *Service) destroyZipFileImages(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) {
if err := i.LoadFiles(ctx, s.Repository); err != nil {
return nil, err

View file

@ -2,6 +2,7 @@ package gallery
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/json"
@ -9,6 +10,10 @@ import (
"github.com/stashapp/stash/pkg/studio"
)
type ChapterFinder interface {
FindByGalleryID(ctx context.Context, galleryID int) ([]*models.GalleryChapter, error)
}
// ToBasicJSON converts a gallery object into its JSON object equivalent. It
// does not convert the relationships to other objects.
func ToBasicJSON(gallery *models.Gallery) (*jsonschema.Gallery, error) {
@ -58,6 +63,30 @@ func GetStudioName(ctx context.Context, reader studio.Finder, gallery *models.Ga
return "", nil
}
// GetGalleryChaptersJSON returns a slice of GalleryChapter JSON representation
// objects corresponding to the provided gallery's chapters.
func GetGalleryChaptersJSON(ctx context.Context, chapterReader ChapterFinder, gallery *models.Gallery) ([]jsonschema.GalleryChapter, error) {
galleryChapters, err := chapterReader.FindByGalleryID(ctx, gallery.ID)
if err != nil {
return nil, fmt.Errorf("error getting gallery chapters: %v", err)
}
var results []jsonschema.GalleryChapter
for _, galleryChapter := range galleryChapters {
galleryChapterJSON := jsonschema.GalleryChapter{
Title: galleryChapter.Title,
ImageIndex: galleryChapter.ImageIndex,
CreatedAt: json.JSONTime{Time: galleryChapter.CreatedAt.Timestamp},
UpdatedAt: json.JSONTime{Time: galleryChapter.UpdatedAt.Timestamp},
}
results = append(results, galleryChapterJSON)
}
return results, nil
}
func GetIDs(galleries []*models.Gallery) []int {
var results []int
for _, gallery := range galleries {

View file

@ -22,6 +22,9 @@ const (
errStudioID = 6
// noTagsID = 11
noChaptersID = 7
errChaptersID = 8
errFindByChapterID = 9
)
var (
@ -63,6 +66,19 @@ func createFullGallery(id int) models.Gallery {
}
}
func createEmptyGallery(id int) models.Gallery {
return models.Gallery{
ID: id,
Files: models.NewRelatedFiles([]file.File{
&file.BaseFile{
Path: path,
},
}),
CreatedAt: createTime,
UpdatedAt: updateTime,
}
}
func createFullJSONGallery() *jsonschema.Gallery {
return &jsonschema.Gallery{
Title: title,
@ -168,3 +184,109 @@ func TestGetStudioName(t *testing.T) {
mockStudioReader.AssertExpectations(t)
}
const (
validChapterID1 = 1
validChapterID2 = 2
chapterTitle1 = "chapterTitle1"
chapterTitle2 = "chapterTitle2"
chapterImageIndex1 = 10
chapterImageIndex2 = 50
)
type galleryChaptersTestScenario struct {
input models.Gallery
expected []jsonschema.GalleryChapter
err bool
}
var getGalleryChaptersJSONScenarios = []galleryChaptersTestScenario{
{
createEmptyGallery(galleryID),
[]jsonschema.GalleryChapter{
{
Title: chapterTitle1,
ImageIndex: chapterImageIndex1,
CreatedAt: json.JSONTime{
Time: createTime,
},
UpdatedAt: json.JSONTime{
Time: updateTime,
},
},
{
Title: chapterTitle2,
ImageIndex: chapterImageIndex2,
CreatedAt: json.JSONTime{
Time: createTime,
},
UpdatedAt: json.JSONTime{
Time: updateTime,
},
},
},
false,
},
{
createEmptyGallery(noChaptersID),
nil,
false,
},
{
createEmptyGallery(errChaptersID),
nil,
true,
},
}
var validChapters = []*models.GalleryChapter{
{
ID: validChapterID1,
Title: chapterTitle1,
ImageIndex: chapterImageIndex1,
CreatedAt: models.SQLiteTimestamp{
Timestamp: createTime,
},
UpdatedAt: models.SQLiteTimestamp{
Timestamp: updateTime,
},
},
{
ID: validChapterID2,
Title: chapterTitle2,
ImageIndex: chapterImageIndex2,
CreatedAt: models.SQLiteTimestamp{
Timestamp: createTime,
},
UpdatedAt: models.SQLiteTimestamp{
Timestamp: updateTime,
},
},
}
func TestGetGalleryChaptersJSON(t *testing.T) {
mockChapterReader := &mocks.GalleryChapterReaderWriter{}
chaptersErr := errors.New("error getting gallery chapters")
mockChapterReader.On("FindByGalleryID", testCtx, galleryID).Return(validChapters, nil).Once()
mockChapterReader.On("FindByGalleryID", testCtx, noChaptersID).Return(nil, nil).Once()
mockChapterReader.On("FindByGalleryID", testCtx, errChaptersID).Return(nil, chaptersErr).Once()
for i, s := range getGalleryChaptersJSONScenarios {
gallery := s.input
json, err := GetGalleryChaptersJSON(testCtx, mockChapterReader, &gallery)
switch {
case !s.err && err != nil:
t.Errorf("[%d] unexpected error: %s", i, err.Error())
case s.err && err == nil:
t.Errorf("[%d] expected error not returned", i)
default:
assert.Equal(t, s.expected, json, "[%d]", i)
}
}
}

View file

@ -24,6 +24,7 @@ type Importer struct {
Input jsonschema.Gallery
MissingRefBehaviour models.ImportMissingRefEnum
ID int
gallery models.Gallery
}

View file

@ -31,6 +31,13 @@ type ImageService interface {
DestroyZipImages(ctx context.Context, zipFile file.File, fileDeleter *image.FileDeleter, deleteGenerated bool) ([]*models.Image, error)
}
type ChapterRepository interface {
ChapterFinder
ChapterDestroyer
Update(ctx context.Context, updatedObject models.GalleryChapter) (*models.GalleryChapter, error)
}
type Service struct {
Repository Repository
ImageFinder ImageFinder

View file

@ -31,6 +31,8 @@ type GalleryFilterType struct {
Organized *bool `json:"organized"`
// Filter by average image resolution
AverageResolution *ResolutionCriterionInput `json:"average_resolution"`
// Filter to only include scenes which have chapters. `true` or `false`
HasChapters *string `json:"has_chapters"`
// Filter to only include galleries with this studio
Studios *HierarchicalMultiCriterionInput `json:"studios"`
// Filter to only include galleries with these tags

View file

@ -0,0 +1,20 @@
package models
import "context"
type GalleryChapterReader interface {
Find(ctx context.Context, id int) (*GalleryChapter, error)
FindMany(ctx context.Context, ids []int) ([]*GalleryChapter, error)
FindByGalleryID(ctx context.Context, galleryID int) ([]*GalleryChapter, error)
}
type GalleryChapterWriter interface {
Create(ctx context.Context, newGalleryChapter GalleryChapter) (*GalleryChapter, error)
Update(ctx context.Context, updatedGalleryChapter GalleryChapter) (*GalleryChapter, error)
Destroy(ctx context.Context, id int) error
}
type GalleryChapterReaderWriter interface {
GalleryChapterReader
GalleryChapterWriter
}

View file

@ -10,22 +10,30 @@ import (
"github.com/stashapp/stash/pkg/models/json"
)
type Gallery struct {
ZipFiles []string `json:"zip_files,omitempty"`
FolderPath string `json:"folder_path,omitempty"`
type GalleryChapter struct {
Title string `json:"title,omitempty"`
URL string `json:"url,omitempty"`
Date string `json:"date,omitempty"`
Details string `json:"details,omitempty"`
Rating int `json:"rating,omitempty"`
Organized bool `json:"organized,omitempty"`
Studio string `json:"studio,omitempty"`
Performers []string `json:"performers,omitempty"`
Tags []string `json:"tags,omitempty"`
ImageIndex int `json:"image_index,omitempty"`
CreatedAt json.JSONTime `json:"created_at,omitempty"`
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
}
type Gallery struct {
ZipFiles []string `json:"zip_files,omitempty"`
FolderPath string `json:"folder_path,omitempty"`
Title string `json:"title,omitempty"`
URL string `json:"url,omitempty"`
Date string `json:"date,omitempty"`
Details string `json:"details,omitempty"`
Rating int `json:"rating,omitempty"`
Organized bool `json:"organized,omitempty"`
Chapters []GalleryChapter `json:"chapters,omitempty"`
Studio string `json:"studio,omitempty"`
Performers []string `json:"performers,omitempty"`
Tags []string `json:"tags,omitempty"`
CreatedAt json.JSONTime `json:"created_at,omitempty"`
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
}
func (s Gallery) Filename(basename string, hash string) string {
ret := fsutil.SanitiseBasename(basename)

View file

@ -0,0 +1,144 @@
// Code generated by mockery v2.10.0. DO NOT EDIT.
package mocks
import (
context "context"
models "github.com/stashapp/stash/pkg/models"
mock "github.com/stretchr/testify/mock"
)
// GalleryChapterReaderWriter is an autogenerated mock type for the GalleryChapterReaderWriter type
type GalleryChapterReaderWriter struct {
mock.Mock
}
// Create provides a mock function with given fields: ctx, newGalleryChapter
func (_m *GalleryChapterReaderWriter) Create(ctx context.Context, newGalleryChapter models.GalleryChapter) (*models.GalleryChapter, error) {
ret := _m.Called(ctx, newGalleryChapter)
var r0 *models.GalleryChapter
if rf, ok := ret.Get(0).(func(context.Context, models.GalleryChapter) *models.GalleryChapter); ok {
r0 = rf(ctx, newGalleryChapter)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.GalleryChapter)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, models.GalleryChapter) error); ok {
r1 = rf(ctx, newGalleryChapter)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Destroy provides a mock function with given fields: ctx, id
func (_m *GalleryChapterReaderWriter) Destroy(ctx context.Context, id int) error {
ret := _m.Called(ctx, id)
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, int) error); ok {
r0 = rf(ctx, id)
} else {
r0 = ret.Error(0)
}
return r0
}
// Find provides a mock function with given fields: ctx, id
func (_m *GalleryChapterReaderWriter) Find(ctx context.Context, id int) (*models.GalleryChapter, error) {
ret := _m.Called(ctx, id)
var r0 *models.GalleryChapter
if rf, ok := ret.Get(0).(func(context.Context, int) *models.GalleryChapter); ok {
r0 = rf(ctx, id)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.GalleryChapter)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, id)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByGalleryID provides a mock function with given fields: ctx, galleryID
func (_m *GalleryChapterReaderWriter) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.GalleryChapter, error) {
ret := _m.Called(ctx, galleryID)
var r0 []*models.GalleryChapter
if rf, ok := ret.Get(0).(func(context.Context, int) []*models.GalleryChapter); ok {
r0 = rf(ctx, galleryID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.GalleryChapter)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, int) error); ok {
r1 = rf(ctx, galleryID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindMany provides a mock function with given fields: ctx, ids
func (_m *GalleryChapterReaderWriter) FindMany(ctx context.Context, ids []int) ([]*models.GalleryChapter, error) {
ret := _m.Called(ctx, ids)
var r0 []*models.GalleryChapter
if rf, ok := ret.Get(0).(func(context.Context, []int) []*models.GalleryChapter); ok {
r0 = rf(ctx, ids)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.GalleryChapter)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, []int) error); ok {
r1 = rf(ctx, ids)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Update provides a mock function with given fields: ctx, updatedGalleryChapter
func (_m *GalleryChapterReaderWriter) Update(ctx context.Context, updatedGalleryChapter models.GalleryChapter) (*models.GalleryChapter, error) {
ret := _m.Called(ctx, updatedGalleryChapter)
var r0 *models.GalleryChapter
if rf, ok := ret.Get(0).(func(context.Context, models.GalleryChapter) *models.GalleryChapter); ok {
r0 = rf(ctx, updatedGalleryChapter)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.GalleryChapter)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, models.GalleryChapter) error); ok {
r1 = rf(ctx, updatedGalleryChapter)
} else {
r1 = ret.Error(1)
}
return r0, r1
}

View file

@ -44,16 +44,17 @@ func (*TxnManager) Reset() error {
func NewTxnRepository() models.Repository {
return models.Repository{
TxnManager: &TxnManager{},
Gallery: &GalleryReaderWriter{},
Image: &ImageReaderWriter{},
Movie: &MovieReaderWriter{},
Performer: &PerformerReaderWriter{},
Scene: &SceneReaderWriter{},
SceneMarker: &SceneMarkerReaderWriter{},
ScrapedItem: &ScrapedItemReaderWriter{},
Studio: &StudioReaderWriter{},
Tag: &TagReaderWriter{},
SavedFilter: &SavedFilterReaderWriter{},
TxnManager: &TxnManager{},
Gallery: &GalleryReaderWriter{},
GalleryChapter: &GalleryChapterReaderWriter{},
Image: &ImageReaderWriter{},
Movie: &MovieReaderWriter{},
Performer: &PerformerReaderWriter{},
Scene: &SceneReaderWriter{},
SceneMarker: &SceneMarkerReaderWriter{},
ScrapedItem: &ScrapedItemReaderWriter{},
Studio: &StudioReaderWriter{},
Tag: &TagReaderWriter{},
SavedFilter: &SavedFilterReaderWriter{},
}
}

View file

@ -0,0 +1,24 @@
package models
import (
"database/sql"
)
type GalleryChapter struct {
ID int `db:"id" json:"id"`
Title string `db:"title" json:"title"`
ImageIndex int `db:"image_index" json:"image_index"`
GalleryID sql.NullInt64 `db:"gallery_id,omitempty" json:"gallery_id"`
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
}
type GalleryChapters []*GalleryChapter
func (m *GalleryChapters) Append(o interface{}) {
*m = append(*m, o.(*GalleryChapter))
}
func (m *GalleryChapters) New() interface{} {
return &GalleryChapter{}
}

View file

@ -14,16 +14,17 @@ type TxnManager interface {
type Repository struct {
TxnManager
File file.Store
Folder file.FolderStore
Gallery GalleryReaderWriter
Image ImageReaderWriter
Movie MovieReaderWriter
Performer PerformerReaderWriter
Scene SceneReaderWriter
SceneMarker SceneMarkerReaderWriter
ScrapedItem ScrapedItemReaderWriter
Studio StudioReaderWriter
Tag TagReaderWriter
SavedFilter SavedFilterReaderWriter
File file.Store
Folder file.FolderStore
Gallery GalleryReaderWriter
GalleryChapter GalleryChapterReaderWriter
Image ImageReaderWriter
Movie MovieReaderWriter
Performer PerformerReaderWriter
Scene SceneReaderWriter
SceneMarker SceneMarkerReaderWriter
ScrapedItem ScrapedItemReaderWriter
Studio StudioReaderWriter
Tag TagReaderWriter
SavedFilter SavedFilterReaderWriter
}

View file

@ -34,6 +34,10 @@ const (
GalleryUpdatePost HookTriggerEnum = "Gallery.Update.Post"
GalleryDestroyPost HookTriggerEnum = "Gallery.Destroy.Post"
GalleryChapterCreatePost HookTriggerEnum = "GalleryChapter.Create.Post"
GalleryChapterUpdatePost HookTriggerEnum = "GalleryChapter.Update.Post"
GalleryChapterDestroyPost HookTriggerEnum = "GalleryChapter.Destroy.Post"
MovieCreatePost HookTriggerEnum = "Movie.Create.Post"
MovieUpdatePost HookTriggerEnum = "Movie.Update.Post"
MovieDestroyPost HookTriggerEnum = "Movie.Destroy.Post"
@ -69,6 +73,10 @@ var AllHookTriggerEnum = []HookTriggerEnum{
GalleryUpdatePost,
GalleryDestroyPost,
GalleryChapterCreatePost,
GalleryChapterUpdatePost,
GalleryChapterDestroyPost,
MovieCreatePost,
MovieUpdatePost,
MovieDestroyPost,
@ -106,6 +114,10 @@ func (e HookTriggerEnum) IsValid() bool {
GalleryUpdatePost,
GalleryDestroyPost,
GalleryChapterCreatePost,
GalleryChapterUpdatePost,
GalleryChapterDestroyPost,
MovieCreatePost,
MovieUpdatePost,
MovieDestroyPost,

View file

@ -32,7 +32,7 @@ const (
dbConnTimeout = 30
)
var appSchemaVersion uint = 43
var appSchemaVersion uint = 44
//go:embed migrations/*.sql
var migrationsBox embed.FS

View file

@ -26,6 +26,7 @@ const (
galleriesTagsTable = "galleries_tags"
galleriesImagesTable = "galleries_images"
galleriesScenesTable = "scenes_galleries"
galleriesChaptersTable = "galleries_chapters"
galleryIDColumn = "gallery_id"
)
@ -668,6 +669,7 @@ func (qb *GalleryStore) makeFilter(ctx context.Context, galleryFilter *models.Ga
query.handleCriterion(ctx, galleryTagCountCriterionHandler(qb, galleryFilter.TagCount))
query.handleCriterion(ctx, galleryPerformersCriterionHandler(qb, galleryFilter.Performers))
query.handleCriterion(ctx, galleryPerformerCountCriterionHandler(qb, galleryFilter.PerformerCount))
query.handleCriterion(ctx, hasChaptersCriterionHandler(galleryFilter.HasChapters))
query.handleCriterion(ctx, galleryStudioCriterionHandler(qb, galleryFilter.Studios))
query.handleCriterion(ctx, galleryPerformerTagsCriterionHandler(qb, galleryFilter.PerformerTags))
query.handleCriterion(ctx, galleryAverageResolutionCriterionHandler(qb, galleryFilter.AverageResolution))
@ -729,11 +731,15 @@ func (qb *GalleryStore) makeQuery(ctx context.Context, galleryFilter *models.Gal
as: "gallery_folder",
onClause: "galleries.folder_id = gallery_folder.id",
},
join{
table: galleriesChaptersTable,
onClause: "galleries_chapters.gallery_id = galleries.id",
},
)
// add joins for files and checksum
filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename"
searchColumns := []string{"galleries.title", "gallery_folder.path", filepathColumn, "files_fingerprints.fingerprint"}
searchColumns := []string{"galleries.title", "gallery_folder.path", filepathColumn, "files_fingerprints.fingerprint", "galleries_chapters.title"}
query.parseQueryString(searchColumns, *q)
}
@ -949,6 +955,19 @@ func galleryImageCountCriterionHandler(qb *GalleryStore, imageCount *models.IntC
return h.handler(imageCount)
}
func hasChaptersCriterionHandler(hasChapters *string) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) {
if hasChapters != nil {
f.addLeftJoin("galleries_chapters", "", "galleries_chapters.gallery_id = galleries.id")
if *hasChapters == "true" {
f.addHaving("count(galleries_chapters.gallery_id) > 0")
} else {
f.addWhere("galleries_chapters.id IS NULL")
}
}
}
}
func galleryStudioCriterionHandler(qb *GalleryStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
h := hierarchicalMultiCriterionHandlerBuilder{
tx: qb.tx,

View file

@ -0,0 +1,94 @@
package sqlite
import (
"context"
"fmt"
"github.com/stashapp/stash/pkg/models"
)
type galleryChapterQueryBuilder struct {
repository
}
var GalleryChapterReaderWriter = &galleryChapterQueryBuilder{
repository{
tableName: galleriesChaptersTable,
idColumn: idColumn,
},
}
func (qb *galleryChapterQueryBuilder) Create(ctx context.Context, newObject models.GalleryChapter) (*models.GalleryChapter, error) {
var ret models.GalleryChapter
if err := qb.insertObject(ctx, newObject, &ret); err != nil {
return nil, err
}
return &ret, nil
}
func (qb *galleryChapterQueryBuilder) Update(ctx context.Context, updatedObject models.GalleryChapter) (*models.GalleryChapter, error) {
const partial = false
if err := qb.update(ctx, updatedObject.ID, updatedObject, partial); err != nil {
return nil, err
}
var ret models.GalleryChapter
if err := qb.getByID(ctx, updatedObject.ID, &ret); err != nil {
return nil, err
}
return &ret, nil
}
func (qb *galleryChapterQueryBuilder) Destroy(ctx context.Context, id int) error {
return qb.destroyExisting(ctx, []int{id})
}
func (qb *galleryChapterQueryBuilder) Find(ctx context.Context, id int) (*models.GalleryChapter, error) {
query := "SELECT * FROM galleries_chapters WHERE id = ? LIMIT 1"
args := []interface{}{id}
results, err := qb.queryGalleryChapters(ctx, query, args)
if err != nil || len(results) < 1 {
return nil, err
}
return results[0], nil
}
func (qb *galleryChapterQueryBuilder) FindMany(ctx context.Context, ids []int) ([]*models.GalleryChapter, error) {
var markers []*models.GalleryChapter
for _, id := range ids {
marker, err := qb.Find(ctx, id)
if err != nil {
return nil, err
}
if marker == nil {
return nil, fmt.Errorf("gallery chapter with id %d not found", id)
}
markers = append(markers, marker)
}
return markers, nil
}
func (qb *galleryChapterQueryBuilder) FindByGalleryID(ctx context.Context, galleryID int) ([]*models.GalleryChapter, error) {
query := `
SELECT galleries_chapters.* FROM galleries_chapters
WHERE galleries_chapters.gallery_id = ?
GROUP BY galleries_chapters.id
ORDER BY galleries_chapters.image_index ASC
`
args := []interface{}{galleryID}
return qb.queryGalleryChapters(ctx, query, args)
}
func (qb *galleryChapterQueryBuilder) queryGalleryChapters(ctx context.Context, query string, args []interface{}) ([]*models.GalleryChapter, error) {
var ret models.GalleryChapters
if err := qb.query(ctx, query, args, &ret); err != nil {
return nil, err
}
return []*models.GalleryChapter(ret), nil
}

View file

@ -0,0 +1,44 @@
//go:build integration
// +build integration
package sqlite_test
import (
"context"
"testing"
"github.com/stashapp/stash/pkg/sqlite"
"github.com/stretchr/testify/assert"
)
func TestChapterFindByGalleryID(t *testing.T) {
withTxn(func(ctx context.Context) error {
mqb := sqlite.GalleryChapterReaderWriter
galleryID := galleryIDs[galleryIdxWithChapters]
chapters, err := mqb.FindByGalleryID(ctx, galleryID)
if err != nil {
t.Errorf("Error finding chapters: %s", err.Error())
}
assert.Greater(t, len(chapters), 0)
for _, chapter := range chapters {
assert.Equal(t, galleryIDs[galleryIdxWithChapters], int(chapter.GalleryID.Int64))
}
chapters, err = mqb.FindByGalleryID(ctx, 0)
if err != nil {
t.Errorf("Error finding chapter: %s", err.Error())
}
assert.Len(t, chapters, 0)
return nil
})
}
// TODO Update
// TODO Destroy
// TODO Find

View file

@ -2616,6 +2616,37 @@ func TestGalleryStore_RemoveImages(t *testing.T) {
}
}
func TestGalleryQueryHasChapters(t *testing.T) {
withTxn(func(ctx context.Context) error {
sqb := db.Gallery
hasChapters := "true"
galleryFilter := models.GalleryFilterType{
HasChapters: &hasChapters,
}
q := getGalleryStringValue(galleryIdxWithChapters, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
galleries := queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)
assert.Len(t, galleries, 1)
assert.Equal(t, galleryIDs[galleryIdxWithChapters], galleries[0].ID)
hasChapters = "false"
galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)
assert.Len(t, galleries, 0)
findFilter.Q = nil
galleries = queryGallery(ctx, t, sqb, &galleryFilter, &findFilter)
assert.NotEqual(t, 0, len(galleries))
return nil
})
}
// TODO Count
// TODO All
// TODO Query

View file

@ -0,0 +1,10 @@
CREATE TABLE `galleries_chapters` (
`id` integer not null primary key autoincrement,
`title` varchar(255) not null,
`image_index` integer not null,
`gallery_id` integer not null,
`created_at` datetime not null,
`updated_at` datetime not null,
foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE
);
CREATE INDEX `index_galleries_chapters_on_gallery_id` on `galleries_chapters` (`gallery_id`);

View file

@ -146,6 +146,7 @@ const (
const (
galleryIdxWithScene = iota
galleryIdxWithChapters
galleryIdxWithImage
galleryIdx1WithImage
galleryIdx2WithImage
@ -236,6 +237,11 @@ const (
totalMarkers
)
const (
chapterIdxWithGallery = iota
totalChapters
)
const (
savedFilterIdxDefaultScene = iota
savedFilterIdxDefaultImage
@ -261,6 +267,7 @@ var (
sceneFileIDs []file.ID
imageFileIDs []file.ID
galleryFileIDs []file.ID
chapterIDs []int
sceneIDs []int
imageIDs []int
@ -372,6 +379,19 @@ var (
}
)
type chapterSpec struct {
galleryIdx int
title string
imageIndex int
}
var (
// indexed by chapter
chapterSpecs = []chapterSpec{
{galleryIdxWithChapters, "Test1", 10},
}
)
var (
imageGalleries = linkMap{
imageIdxWithGallery: {galleryIdxWithImage},
@ -599,6 +619,11 @@ func populateDB() error {
return fmt.Errorf("error creating scene marker: %s", err.Error())
}
}
for _, cs := range chapterSpecs {
if err := createChapter(ctx, sqlite.GalleryChapterReaderWriter, cs); err != nil {
return fmt.Errorf("error creating gallery chapter: %s", err.Error())
}
}
return nil
}); err != nil {
@ -1580,6 +1605,24 @@ func createMarker(ctx context.Context, mqb models.SceneMarkerReaderWriter, marke
return nil
}
func createChapter(ctx context.Context, mqb models.GalleryChapterReaderWriter, chapterSpec chapterSpec) error {
chapter := models.GalleryChapter{
GalleryID: sql.NullInt64{Int64: int64(sceneIDs[chapterSpec.galleryIdx]), Valid: true},
Title: chapterSpec.title,
ImageIndex: chapterSpec.imageIndex,
}
created, err := mqb.Create(ctx, chapter)
if err != nil {
return fmt.Errorf("error creating chapter %v+: %w", chapter, err)
}
chapterIDs = append(chapterIDs, created.ID)
return nil
}
func getSavedFilterMode(index int) models.FilterMode {
switch index {
case savedFilterIdxScene, savedFilterIdxDefaultScene:

View file

@ -125,18 +125,19 @@ func (db *Database) IsLocked(err error) bool {
func (db *Database) TxnRepository() models.Repository {
return models.Repository{
TxnManager: db,
File: db.File,
Folder: db.Folder,
Gallery: db.Gallery,
Image: db.Image,
Movie: MovieReaderWriter,
Performer: db.Performer,
Scene: db.Scene,
SceneMarker: SceneMarkerReaderWriter,
ScrapedItem: ScrapedItemReaderWriter,
Studio: StudioReaderWriter,
Tag: TagReaderWriter,
SavedFilter: SavedFilterReaderWriter,
TxnManager: db,
File: db.File,
Folder: db.Folder,
Gallery: db.Gallery,
GalleryChapter: GalleryChapterReaderWriter,
Image: db.Image,
Movie: MovieReaderWriter,
Performer: db.Performer,
Scene: db.Scene,
SceneMarker: SceneMarkerReaderWriter,
ScrapedItem: ScrapedItemReaderWriter,
Studio: StudioReaderWriter,
Tag: TagReaderWriter,
SavedFilter: SavedFilterReaderWriter,
}
}

View file

@ -2,6 +2,7 @@ database: generated.sqlite
scenes: 30000
images: 4000000
galleries: 1500
chapters: 3000
markers: 3000
performers: 10000
studios: 1500
@ -15,4 +16,4 @@ naming:
galleries: scene.txt
studios: studio.txt
tags: scene.txt
images: scene.txt
images: scene.txt

View file

@ -28,7 +28,7 @@ import (
const batchSize = 50000
// create an example database by generating a number of scenes, markers,
// performers, studios and tags, and associating between them all
// performers, studios, galleries, chapters and tags, and associating between them all
type config struct {
Database string `yaml:"database"`
@ -36,6 +36,7 @@ type config struct {
Markers int `yaml:"markers"`
Images int `yaml:"images"`
Galleries int `yaml:"galleries"`
Chapters int `yaml:"chapters"`
Performers int `yaml:"performers"`
Studios int `yaml:"studios"`
Tags int `yaml:"tags"`
@ -97,6 +98,7 @@ func populateDB() {
makeScenes(c.Scenes)
makeImages(c.Images)
makeGalleries(c.Galleries)
makeChapters(c.Chapters)
makeMarkers(c.Markers)
}
@ -496,6 +498,38 @@ func generateGallery(i int) models.Gallery {
}
}
func makeChapters(n int) {
logf("creating %d chapters...", n)
for i := 0; i < n; {
// do in batches of 1000
batch := i + batchSize
if err := withTxn(func(ctx context.Context) error {
for ; i < batch && i < n; i++ {
chapter := generateChapter(i)
chapter.GalleryID = models.NullInt64(int64(getRandomGallery()))
created, err := repo.GalleryChapter.Create(ctx, chapter)
if err != nil {
return err
}
}
logf("... created %d chapters", i)
return nil
}); err != nil {
panic(err)
}
}
}
func generateChapter(i int) models.GalleryChapter {
return models.GalleryChapter{
Title: names[c.Naming.Galleries].generateName(rand.Intn(7) + 1),
ImageIndex: rand.Intn(200),
}
}
func makeMarkers(n int) {
logf("creating %d markers...", n)
for i := 0; i < n; {
@ -617,6 +651,10 @@ func getRandomScene() int {
return rand.Intn(c.Scenes) + 1
}
func getRandomGallery() int {
return rand.Intn(c.Galleries) + 1
}
func getRandomTags(ctx context.Context, min, max int) []int {
var n int
if min == max {

View file

@ -0,0 +1,47 @@
import React from "react";
import { FormattedMessage } from "react-intl";
import * as GQL from "src/core/generated-graphql";
import { Button } from "react-bootstrap";
interface IChapterEntries {
galleryChapters: GQL.GalleryChapterDataFragment[];
onClickChapter: (image_index: number) => void;
onEdit: (chapter: GQL.GalleryChapterDataFragment) => void;
}
export const ChapterEntries: React.FC<IChapterEntries> = ({
galleryChapters,
onClickChapter,
onEdit,
}) => {
if (!galleryChapters?.length) return <div />;
const chapterCards = galleryChapters.map((chapter) => {
return (
<div key={chapter.id}>
<hr />
<div className="row">
<Button
variant="link"
onClick={() => onClickChapter(chapter.image_index)}
>
<div className="row">
{chapter.title}
{chapter.title.length > 0 ? " - #" : "#"}
{chapter.image_index}
</div>
</Button>
<Button
variant="link"
className="ml-auto"
onClick={() => onEdit(chapter)}
>
<FormattedMessage id="actions.edit" />
</Button>
</div>
</div>
);
});
return <div>{chapterCards}</div>;
};

View file

@ -14,6 +14,7 @@ import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { Icon } from "src/components/Shared/Icon";
import { Counter } from "src/components/Shared/Counter";
import Mousetrap from "mousetrap";
import { useGalleryLightbox } from "src/hooks/Lightbox/hooks";
import { useToast } from "src/hooks/Toast";
import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton";
import { GalleryEditPanel } from "./GalleryEditPanel";
@ -25,6 +26,7 @@ import { GalleryFileInfoPanel } from "./GalleryFileInfoPanel";
import { GalleryScenesPanel } from "./GalleryScenesPanel";
import { faEllipsisV } from "@fortawesome/free-solid-svg-icons";
import { galleryPath, galleryTitle } from "src/core/galleries";
import { GalleryChapterPanel } from "./GalleryChaptersPanel";
interface IProps {
gallery: GQL.GalleryDataFragment;
@ -39,6 +41,7 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
const history = useHistory();
const Toast = useToast();
const intl = useIntl();
const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters);
const [collapsed, setCollapsed] = useState(false);
@ -99,6 +102,10 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
});
}
async function onClickChapter(imageindex: number) {
showLightbox(imageindex - 1);
}
const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
function onDeleteDialogClosed(deleted: boolean) {
@ -189,6 +196,11 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
</Nav.Link>
</Nav.Item>
) : undefined}
<Nav.Item>
<Nav.Link eventKey="gallery-chapter-panel">
<FormattedMessage id="chapters" />
</Nav.Link>
</Nav.Item>
<Nav.Item>
<Nav.Link eventKey="gallery-edit-panel">
<FormattedMessage id="actions.edit" />
@ -215,6 +227,13 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
>
<GalleryFileInfoPanel gallery={gallery} />
</Tab.Pane>
<Tab.Pane eventKey="gallery-chapter-panel">
<GalleryChapterPanel
gallery={gallery}
onClickChapter={onClickChapter}
isVisible={activeTabKey === "gallery-chapter-panel"}
/>
</Tab.Pane>
<Tab.Pane eventKey="gallery-edit-panel">
<GalleryEditPanel
isVisible={activeTabKey === "gallery-edit-panel"}
@ -279,12 +298,14 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
// set up hotkeys
useEffect(() => {
Mousetrap.bind("a", () => setActiveTabKey("gallery-details-panel"));
Mousetrap.bind("c", () => setActiveTabKey("gallery-chapter-panel"));
Mousetrap.bind("e", () => setActiveTabKey("gallery-edit-panel"));
Mousetrap.bind("f", () => setActiveTabKey("gallery-file-info-panel"));
Mousetrap.bind(",", () => setCollapsed(!collapsed));
return () => {
Mousetrap.unbind("a");
Mousetrap.unbind("c");
Mousetrap.unbind("e");
Mousetrap.unbind("f");
Mousetrap.unbind(",");

View file

@ -0,0 +1,158 @@
import React from "react";
import { Button, Form } from "react-bootstrap";
import { FormattedMessage, useIntl } from "react-intl";
import { Form as FormikForm, Formik } from "formik";
import * as yup from "yup";
import * as GQL from "src/core/generated-graphql";
import {
useGalleryChapterCreate,
useGalleryChapterUpdate,
useGalleryChapterDestroy,
} from "src/core/StashService";
import { useToast } from "src/hooks/Toast";
import isEqual from "lodash-es/isEqual";
interface IFormFields {
title: string;
imageIndex: number;
}
interface IGalleryChapterForm {
galleryID: string;
editingChapter?: GQL.GalleryChapterDataFragment;
onClose: () => void;
}
export const GalleryChapterForm: React.FC<IGalleryChapterForm> = ({
galleryID,
editingChapter,
onClose,
}) => {
const intl = useIntl();
const [galleryChapterCreate] = useGalleryChapterCreate();
const [galleryChapterUpdate] = useGalleryChapterUpdate();
const [galleryChapterDestroy] = useGalleryChapterDestroy();
const Toast = useToast();
const schema = yup.object({
title: yup.string().ensure(),
imageIndex: yup
.number()
.required()
.label(intl.formatMessage({ id: "image_index" }))
.moreThan(0),
});
const onSubmit = (values: IFormFields) => {
const variables:
| GQL.GalleryChapterUpdateInput
| GQL.GalleryChapterCreateInput = {
title: values.title,
image_index: values.imageIndex,
gallery_id: galleryID,
};
if (!editingChapter) {
galleryChapterCreate({ variables })
.then(onClose)
.catch((err) => Toast.error(err));
} else {
const updateVariables = variables as GQL.GalleryChapterUpdateInput;
updateVariables.id = editingChapter!.id;
galleryChapterUpdate({ variables: updateVariables })
.then(onClose)
.catch((err) => Toast.error(err));
}
};
const onDelete = () => {
if (!editingChapter) return;
galleryChapterDestroy({ variables: { id: editingChapter.id } })
.then(onClose)
.catch((err) => Toast.error(err));
};
const values: IFormFields = {
title: editingChapter?.title ?? "",
imageIndex: editingChapter?.image_index ?? 1,
};
return (
<Formik
initialValues={values}
onSubmit={onSubmit}
validationSchema={schema}
>
{(formik) => (
<FormikForm>
<div>
<Form.Group>
<Form.Label>
<FormattedMessage id="title" />
</Form.Label>
<Form.Control
className="text-input"
placeholder={intl.formatMessage({ id: "title" })}
{...formik.getFieldProps("title")}
isInvalid={!!formik.getFieldMeta("title").error}
/>
<Form.Control.Feedback type="invalid">
{formik.getFieldMeta("title").error}
</Form.Control.Feedback>
</Form.Group>
<Form.Group>
<Form.Label>
<FormattedMessage id="image_index" />
</Form.Label>
<Form.Control
className="text-input"
placeholder={intl.formatMessage({ id: "image_index" })}
{...formik.getFieldProps("imageIndex")}
isInvalid={!!formik.getFieldMeta("imageIndex").error}
/>
<Form.Control.Feedback type="invalid">
{formik.getFieldMeta("imageIndex").error}
</Form.Control.Feedback>
</Form.Group>
</div>
<div className="buttons-container row">
<div className="col d-flex">
<Button
variant="primary"
disabled={
(editingChapter && !formik.dirty) ||
!isEqual(formik.errors, {})
}
onClick={() => formik.submitForm()}
>
<FormattedMessage id="actions.save" />
</Button>
<Button
variant="secondary"
type="button"
onClick={onClose}
className="ml-2"
>
<FormattedMessage id="actions.cancel" />
</Button>
{editingChapter && (
<Button
variant="danger"
className="ml-auto"
onClick={() => onDelete()}
>
<FormattedMessage id="actions.delete" />
</Button>
)}
</div>
</div>
</FormikForm>
)}
</Formik>
);
};

View file

@ -0,0 +1,72 @@
import React, { useState, useEffect } from "react";
import { Button } from "react-bootstrap";
import { FormattedMessage } from "react-intl";
import Mousetrap from "mousetrap";
import * as GQL from "src/core/generated-graphql";
import { ChapterEntries } from "./ChapterEntry";
import { GalleryChapterForm } from "./GalleryChapterForm";
interface IGalleryChapterPanelProps {
gallery: GQL.GalleryDataFragment;
isVisible: boolean;
onClickChapter: (index: number) => void;
}
export const GalleryChapterPanel: React.FC<IGalleryChapterPanelProps> = (
props: IGalleryChapterPanelProps
) => {
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
const [editingChapter, setEditingChapter] =
useState<GQL.GalleryChapterDataFragment>();
// set up hotkeys
useEffect(() => {
if (props.isVisible) {
Mousetrap.bind("n", () => onOpenEditor());
return () => {
Mousetrap.unbind("n");
};
}
});
function onOpenEditor(chapter?: GQL.GalleryChapterDataFragment) {
setIsEditorOpen(true);
setEditingChapter(chapter ?? undefined);
}
function onClickChapter(image_index: number) {
props.onClickChapter(image_index);
}
const closeEditor = () => {
setEditingChapter(undefined);
setIsEditorOpen(false);
};
if (isEditorOpen)
return (
<GalleryChapterForm
galleryID={props.gallery.id}
editingChapter={editingChapter}
onClose={closeEditor}
/>
);
return (
<div>
<Button onClick={() => onOpenEditor()}>
<FormattedMessage id="actions.create_chapters" />
</Button>
<div className="container">
<ChapterEntries
galleryChapters={props.gallery.chapters}
onClickChapter={onClickChapter}
onEdit={onOpenEditor}
/>
</div>
</div>
);
};
export default GalleryChapterPanel;

View file

@ -104,6 +104,7 @@ export const GalleryImagesPanel: React.FC<IGalleryDetailsProps> = ({
extraOperations={otherOperations}
persistState={PersistanceLevel.VIEW}
persistanceKey="galleryimages"
chapters={gallery.chapters}
/>
);
};

View file

@ -19,7 +19,7 @@ interface IProps {
const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
const intl = useIntl();
const showLightbox = useGalleryLightbox(gallery.id);
const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters);
const coverFile = gallery?.cover?.files.length
? gallery.cover.files[0]
@ -37,12 +37,16 @@ const GalleryWallCard: React.FC<IProps> = ({ gallery }) => {
? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")]
: performerNames;
async function showLightboxStart() {
showLightbox(0);
}
return (
<>
<section
className={`${CLASSNAME} ${CLASSNAME}-${orientation}`}
onClick={showLightbox}
onKeyPress={showLightbox}
onClick={showLightboxStart}
onKeyPress={showLightboxStart}
role="button"
tabIndex={0}
>

View file

@ -105,6 +105,7 @@ interface IImageListImages {
onSelectChange: (id: string, selected: boolean, shiftKey: boolean) => void;
slideshowRunning: boolean;
setSlideshowRunning: (running: boolean) => void;
chapters?: GQL.GalleryChapterDataFragment[];
}
const ImageListImages: React.FC<IImageListImages> = ({
@ -116,22 +117,29 @@ const ImageListImages: React.FC<IImageListImages> = ({
onSelectChange,
slideshowRunning,
setSlideshowRunning,
chapters = [],
}) => {
const handleLightBoxPage = useCallback(
(direction: number) => {
if (direction === -1) {
if (filter.currentPage === 1) {
onChangePage(pageCount);
} else {
onChangePage(filter.currentPage - 1);
}
} else if (direction === 1) {
if (filter.currentPage === pageCount) {
// return to the first page
onChangePage(1);
} else {
onChangePage(filter.currentPage + 1);
(props: { direction?: number; page?: number }) => {
const { direction, page: newPage } = props;
if (direction !== undefined) {
if (direction < 0) {
if (filter.currentPage === 1) {
onChangePage(pageCount);
} else {
onChangePage(filter.currentPage + direction);
}
} else if (direction > 0) {
if (filter.currentPage === pageCount) {
// return to the first page
onChangePage(1);
} else {
onChangePage(filter.currentPage + direction);
}
}
} else if (newPage !== undefined) {
onChangePage(newPage);
}
},
[onChangePage, filter.currentPage, pageCount]
@ -146,7 +154,9 @@ const ImageListImages: React.FC<IImageListImages> = ({
images,
showNavigation: false,
pageCallback: pageCount > 1 ? handleLightBoxPage : undefined,
pageHeader: `Page ${filter.currentPage} / ${pageCount}`,
page: filter.currentPage,
pages: pageCount,
pageSize: filter.itemsPerPage,
slideshowEnabled: slideshowRunning,
onClose: handleClose,
};
@ -154,12 +164,19 @@ const ImageListImages: React.FC<IImageListImages> = ({
images,
pageCount,
filter.currentPage,
filter.itemsPerPage,
slideshowRunning,
handleClose,
handleLightBoxPage,
]);
const showLightbox = useLightbox(lightboxState);
const showLightbox = useLightbox(
lightboxState,
filter.sortBy === "path" &&
filter.sortDirection === GQL.SortDirectionEnum.Asc
? chapters
: []
);
const handleImageOpen = useCallback(
(index) => {
@ -273,6 +290,7 @@ interface IImageList {
persistanceKey?: string;
alterQuery?: boolean;
extraOperations?: IItemListOperation<GQL.FindImagesQueryResult>[];
chapters?: GQL.GalleryChapterDataFragment[];
}
export const ImageList: React.FC<IImageList> = ({
@ -281,6 +299,7 @@ export const ImageList: React.FC<IImageList> = ({
persistanceKey,
alterQuery,
extraOperations,
chapters = [],
}) => {
const intl = useIntl();
const history = useHistory();
@ -386,6 +405,7 @@ export const ImageList: React.FC<IImageList> = ({
selectedIds={selectedIds}
slideshowRunning={slideshowRunning}
setSlideshowRunning={setSlideshowRunning}
chapters={chapters}
/>
);
}

View file

@ -752,6 +752,27 @@ export const mutateGallerySetPrimaryFile = (id: string, fileID: string) =>
update: deleteCache(galleryMutationImpactedQueries),
});
const galleryChapterMutationImpactedQueries = [
GQL.FindGalleryDocument,
GQL.FindGalleriesDocument,
];
export const useGalleryChapterCreate = () =>
GQL.useGalleryChapterCreateMutation({
refetchQueries: getQueryNames([GQL.FindGalleryDocument]),
update: deleteCache(galleryChapterMutationImpactedQueries),
});
export const useGalleryChapterUpdate = () =>
GQL.useGalleryChapterUpdateMutation({
refetchQueries: getQueryNames([GQL.FindGalleryDocument]),
update: deleteCache(galleryChapterMutationImpactedQueries),
});
export const useGalleryChapterDestroy = () =>
GQL.useGalleryChapterDestroyMutation({
refetchQueries: getQueryNames([GQL.FindGalleryDocument]),
update: deleteCache(galleryChapterMutationImpactedQueries),
});
export const studioMutationImpactedQueries = [
GQL.FindStudiosDocument,
GQL.FindSceneDocument,

View file

@ -1,6 +1,7 @@
##### 💥 Note: The cache directory is now required if using HLS/DASH streaming. Please set the cache directory in the System Settings page.
### ✨ New Features
* Added Chapters to Galleries. ([#3289](https://github.com/stashapp/stash/pull/3289))
* Added button to tagger scene cards to view scene sprite. ([#3536](https://github.com/stashapp/stash/pull/3536))
* Added hardware acceleration support (for a limited number of encoders) for transcoding. ([#3419](https://github.com/stashapp/stash/pull/3419))
* Added support for DASH streaming. ([#3275](https://github.com/stashapp/stash/pull/3275))

View file

@ -7,6 +7,7 @@ import {
Popover,
Form,
Row,
Dropdown,
} from "react-bootstrap";
import cx from "classnames";
import Mousetrap from "mousetrap";
@ -30,7 +31,7 @@ import {
import * as GQL from "src/core/generated-graphql";
import { useInterfaceLocalForage } from "../LocalForage";
import { imageLightboxDisplayModeIntlMap } from "src/core/enums";
import { ILightboxImage } from "./types";
import { ILightboxImage, IChapter } from "./types";
import {
faArrowLeft,
faArrowRight,
@ -42,6 +43,7 @@ import {
faPlay,
faSearchMinus,
faTimes,
faBars,
} from "@fortawesome/free-solid-svg-icons";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { useDebounce } from "../debounce";
@ -49,6 +51,8 @@ import { useDebounce } from "../debounce";
const CLASSNAME = "Lightbox";
const CLASSNAME_HEADER = `${CLASSNAME}-header`;
const CLASSNAME_LEFT_SPACER = `${CLASSNAME_HEADER}-left-spacer`;
const CLASSNAME_CHAPTERS = `${CLASSNAME_HEADER}-chapters`;
const CLASSNAME_CHAPTER_BUTTON = `${CLASSNAME_HEADER}-chapter-button`;
const CLASSNAME_INDICATOR = `${CLASSNAME_HEADER}-indicator`;
const CLASSNAME_OPTIONS = `${CLASSNAME_HEADER}-options`;
const CLASSNAME_OPTIONS_ICON = `${CLASSNAME_OPTIONS}-icon`;
@ -76,8 +80,11 @@ interface IProps {
initialIndex?: number;
showNavigation: boolean;
slideshowEnabled?: boolean;
pageHeader?: string;
pageCallback?: (direction: number) => void;
page?: number;
pages?: number;
pageSize?: number;
pageCallback?: (props: { direction?: number; page?: number }) => void;
chapters?: IChapter[];
hide: () => void;
}
@ -88,12 +95,16 @@ export const LightboxComponent: React.FC<IProps> = ({
initialIndex = 0,
showNavigation,
slideshowEnabled = false,
pageHeader,
page,
pages,
pageSize: pageSize = 40,
pageCallback,
chapters = [],
hide,
}) => {
const [updateImage] = useImageUpdate();
// zero-based
const [index, setIndex] = useState<number | null>(null);
const [movingLeft, setMovingLeft] = useState(false);
const oldIndex = useRef<number | null>(null);
@ -101,6 +112,7 @@ export const LightboxComponent: React.FC<IProps> = ({
const [isSwitchingPage, setIsSwitchingPage] = useState(true);
const [isFullscreen, setFullscreen] = useState(false);
const [showOptions, setShowOptions] = useState(false);
const [showChapters, setShowChapters] = useState(false);
const [imagesLoaded, setImagesLoaded] = useState(0);
const [navOffset, setNavOffset] = useState<React.CSSProperties | undefined>();
@ -310,12 +322,13 @@ export const LightboxComponent: React.FC<IProps> = ({
(isUserAction = true) => {
if (isSwitchingPage || index === -1) return;
setShowChapters(false);
setMovingLeft(true);
if (index === 0) {
// go to next page, or loop back if no callback is set
if (pageCallback) {
pageCallback(-1);
pageCallback({ direction: -1 });
setIndex(-1);
oldImages.current = images;
setIsSwitchingPage(true);
@ -334,11 +347,12 @@ export const LightboxComponent: React.FC<IProps> = ({
if (isSwitchingPage) return;
setMovingLeft(false);
setShowChapters(false);
if (index === images.length - 1) {
// go to preview page, or loop back if no callback is set
if (pageCallback) {
pageCallback(1);
pageCallback({ direction: 1 });
oldImages.current = images;
setIsSwitchingPage(true);
setIndex(0);
@ -449,6 +463,65 @@ export const LightboxComponent: React.FC<IProps> = ({
const currentIndex = index === null ? initialIndex : index;
function gotoPage(imageIndex: number) {
const indexInPage = (imageIndex - 1) % pageSize;
if (pageCallback) {
let jumppage = Math.floor(imageIndex / pageSize) + 1;
if (page !== jumppage) {
pageCallback({ page: jumppage });
oldImages.current = images;
setIsSwitchingPage(true);
}
}
setIndex(indexInPage);
setShowChapters(false);
}
function chapterHeader() {
const imageNumber = (index ?? 0) + 1;
const globalIndex = page
? (page - 1) * pageSize + imageNumber
: imageNumber;
let chapterTitle = "";
chapters.forEach(function (chapter) {
if (chapter.image_index > globalIndex) {
return;
}
chapterTitle = chapter.title;
});
return chapterTitle ?? "";
}
const renderChapterMenu = () => {
if (chapters.length <= 0) return;
const popoverContent = chapters.map(({ id, title, image_index }) => (
<Dropdown.Item key={id} onClick={() => gotoPage(image_index)}>
{" "}
{title}
{title.length > 0 ? " - #" : "#"}
{image_index}
</Dropdown.Item>
));
return (
<Dropdown
show={showChapters}
onToggle={() => setShowChapters(!showChapters)}
>
<Dropdown.Toggle className={`minimal ${CLASSNAME_CHAPTER_BUTTON}`}>
<Icon icon={showChapters ? faTimes : faBars} />
</Dropdown.Toggle>
<Dropdown.Menu className={`${CLASSNAME_CHAPTERS}`}>
{popoverContent}
</Dropdown.Menu>
</Dropdown>
);
};
// #2451: making OptionsForm an inline component means it
// get re-rendered each time. This makes the text
// field lose focus on input. Use function instead.
@ -634,6 +707,14 @@ export const LightboxComponent: React.FC<IProps> = ({
}
}
const pageHeader =
page && pages
? intl.formatMessage(
{ id: "dialogs.lightbox.page_header" },
{ page, total: pages }
)
: "";
return (
<div
className={CLASSNAME}
@ -642,9 +723,11 @@ export const LightboxComponent: React.FC<IProps> = ({
onClick={handleClose}
>
<div className={CLASSNAME_HEADER}>
<div className={CLASSNAME_LEFT_SPACER} />
<div className={CLASSNAME_LEFT_SPACER}>{renderChapterMenu()}</div>
<div className={CLASSNAME_INDICATOR}>
<span>{pageHeader}</span>
<span>
{chapterHeader()} {pageHeader}
</span>
{images.length > 1 ? (
<b ref={indicatorRef}>{`${currentIndex + 1} / ${images.length}`}</b>
) : undefined}

View file

@ -1,6 +1,6 @@
import React, { Suspense, useCallback, useState } from "react";
import { lazyComponent } from "src/utils/lazyComponent";
import { ILightboxImage } from "./types";
import { ILightboxImage, IChapter } from "./types";
const LightboxComponent = lazyComponent(() => import("./Lightbox"));
@ -10,8 +10,11 @@ export interface IState {
isLoading: boolean;
showNavigation: boolean;
initialIndex?: number;
pageCallback?: (direction: number) => void;
pageHeader?: string;
pageCallback?: (props: { direction?: number; page?: number }) => void;
chapters?: IChapter[];
page?: number;
pages?: number;
pageSize?: number;
slideshowEnabled: boolean;
onClose?: () => void;
}

View file

@ -1,8 +1,12 @@
import { useCallback, useContext, useEffect, useMemo, useState } from "react";
import * as GQL from "src/core/generated-graphql";
import { LightboxContext, IState } from "./context";
import { IChapter } from "./types";
export const useLightbox = (state: Partial<Omit<IState, "isVisible">>) => {
export const useLightbox = (
state: Partial<Omit<IState, "isVisible">>,
chapters: IChapter[] = []
) => {
const { setLightboxState } = useContext(LightboxContext);
useEffect(() => {
@ -10,7 +14,9 @@ export const useLightbox = (state: Partial<Omit<IState, "isVisible">>) => {
images: state.images,
showNavigation: state.showNavigation,
pageCallback: state.pageCallback,
pageHeader: state.pageHeader,
page: state.page,
pages: state.pages,
pageSize: state.pageSize,
slideshowEnabled: state.slideshowEnabled,
onClose: state.onClose,
});
@ -19,7 +25,9 @@ export const useLightbox = (state: Partial<Omit<IState, "isVisible">>) => {
state.images,
state.showNavigation,
state.pageCallback,
state.pageHeader,
state.page,
state.pages,
state.pageSize,
state.slideshowEnabled,
state.onClose,
]);
@ -30,14 +38,18 @@ export const useLightbox = (state: Partial<Omit<IState, "isVisible">>) => {
initialIndex: index,
isVisible: true,
slideshowEnabled,
page: state.page,
pages: state.pages,
pageSize: state.pageSize,
chapters: chapters,
});
},
[setLightboxState]
[setLightboxState, state.page, state.pages, state.pageSize, chapters]
);
return show;
};
export const useGalleryLightbox = (id: string) => {
export const useGalleryLightbox = (id: string, chapters: IChapter[] = []) => {
const { setLightboxState } = useContext(LightboxContext);
const pageSize = 40;
@ -69,20 +81,26 @@ export const useGalleryLightbox = (id: string) => {
}, [data?.findImages.count]);
const handleLightBoxPage = useCallback(
(direction: number) => {
if (direction === -1) {
if (page === 1) {
setPage(pages);
} else {
setPage(page - 1);
}
} else if (direction === 1) {
if (page === pages) {
// return to the first page
setPage(1);
} else {
setPage(page + 1);
(props: { direction?: number; page?: number }) => {
const { direction, page: newPage } = props;
if (direction !== undefined) {
if (direction < 0) {
if (page === 1) {
setPage(pages);
} else {
setPage(page + direction);
}
} else if (direction > 0) {
if (page === pages) {
// return to the first page
setPage(1);
} else {
setPage(page + direction);
}
}
} else if (newPage !== undefined) {
setPage(newPage);
}
},
[page, pages]
@ -95,25 +113,39 @@ export const useGalleryLightbox = (id: string) => {
isVisible: true,
images: data.findImages?.images ?? [],
pageCallback: pages > 1 ? handleLightBoxPage : undefined,
pageHeader: `Page ${page} / ${pages}`,
page,
pages,
});
}, [setLightboxState, data, handleLightBoxPage, page, pages]);
const show = () => {
const show = (index: number = 0) => {
if (index > pageSize) {
setPage(Math.floor(index / pageSize) + 1);
index = index % pageSize;
} else {
setPage(1);
}
if (data)
setLightboxState({
isLoading: false,
isVisible: true,
initialIndex: index,
images: data.findImages?.images ?? [],
pageCallback: pages > 1 ? handleLightBoxPage : undefined,
pageHeader: `Page ${page} / ${pages}`,
page,
pages,
pageSize,
chapters: chapters,
});
else {
setLightboxState({
isLoading: true,
isVisible: true,
initialIndex: index,
pageCallback: undefined,
pageHeader: undefined,
page: undefined,
pageSize,
chapters: chapters,
});
fetchGallery();
}

View file

@ -30,10 +30,11 @@
display: flex;
flex: 1;
justify-content: center;
}
@media (max-width: 575px) {
display: none;
}
&-chapters {
max-height: 90%;
overflow: auto;
}
&-indicator {

View file

@ -12,3 +12,9 @@ export interface ILightboxImage {
o_counter?: GQL.Maybe<number>;
paths: IImagePaths;
}
export interface IChapter {
id: string;
title: string;
image_index: number;
}

View file

@ -21,6 +21,7 @@
"confirm": "Confirm",
"continue": "Continue",
"create": "Create",
"create_chapters": "Create Chapter",
"create_entity": "Create {entityType}",
"create_marker": "Create Marker",
"created_entity": "Created {entity_type}: {entity_name}",
@ -132,6 +133,7 @@
"between_and": "and",
"captions": "Captions",
"career_length": "Career Length",
"chapters": "Chapters",
"component_tagger": {
"config": {
"active_instance": "Active stash-box instance:",
@ -732,6 +734,7 @@
"original": "Original"
},
"options": "Options",
"page_header": "Page {page} / {total}",
"reset_zoom_on_nav": "Reset zoom level when changing image",
"scale_up": {
"description": "Scale smaller images up to fill screen",
@ -857,6 +860,7 @@
},
"empty_server": "Add some scenes to your server to view recommendations on this page.",
"errors": {
"image_index_greater_than_zero": "Image index must be greater than 0",
"something_went_wrong": "Something went wrong.",
"lazy_component_error_help": "If you recently upgraded Stash, please reload the page or clear your browser cache."
},
@ -908,12 +912,14 @@
"uploading": "Uploading script"
},
"hasMarkers": "Has Markers",
"hasChapters": "Has Chapters",
"height": "Height",
"height_cm": "Height (cm)",
"help": "Help",
"ignore_auto_tag": "Ignore Auto Tag",
"image": "Image",
"image_count": "Image Count",
"image_index": "Image #",
"images": "Images",
"include_parent_tags": "Include parent tags",
"include_sub_studios": "Include subsidiary studios",
@ -1172,7 +1178,8 @@
"started_auto_tagging": "Started auto tagging",
"started_generating": "Started generating",
"started_importing": "Started importing",
"updated_entity": "Updated {entity}"
"updated_entity": "Updated {entity}",
"image_index_too_large": "Error: Image index is larger than the number of images in the Gallery"
},
"total": "Total",
"true": "True",

View file

@ -19,6 +19,7 @@ import {
import { OrganizedCriterion } from "./organized";
import { FavoriteCriterion, PerformerFavoriteCriterion } from "./favorite";
import { HasMarkersCriterion } from "./has-markers";
import { HasChaptersCriterion } from "./has-chapters";
import {
PerformerIsMissingCriterionOption,
ImageIsMissingCriterionOption,
@ -113,6 +114,8 @@ export function makeCriteria(
return new FavoriteCriterion();
case "hasMarkers":
return new HasMarkersCriterion();
case "hasChapters":
return new HasChaptersCriterion();
case "sceneIsMissing":
return new IsMissingCriterion(SceneIsMissingCriterionOption);
case "imageIsMissing":

View file

@ -0,0 +1,18 @@
import { CriterionOption, StringCriterion } from "./criterion";
export const HasChaptersCriterionOption = new CriterionOption({
messageID: "hasChapters",
type: "hasChapters",
parameterName: "has_chapters",
options: [true.toString(), false.toString()],
});
export class HasChaptersCriterion extends StringCriterion {
constructor() {
super(HasChaptersCriterionOption);
}
protected toCriterionInput(): string {
return this.value;
}
}

View file

@ -8,6 +8,7 @@ import {
import { PerformerFavoriteCriterionOption } from "./criteria/favorite";
import { GalleryIsMissingCriterionOption } from "./criteria/is-missing";
import { OrganizedCriterionOption } from "./criteria/organized";
import { HasChaptersCriterionOption } from "./criteria/has-chapters";
import { PerformersCriterionOption } from "./criteria/performers";
import { AverageResolutionCriterionOption } from "./criteria/resolution";
import { StudiosCriterionOption } from "./criteria/studios";
@ -53,6 +54,7 @@ const criterionOptions = [
AverageResolutionCriterionOption,
GalleryIsMissingCriterionOption,
TagsCriterionOption,
HasChaptersCriterionOption,
createStringCriterionOption("tag_count"),
PerformerTagsCriterionOption,
PerformersCriterionOption,

View file

@ -177,4 +177,5 @@ export type CriterionType =
| "scene_updated_at"
| "description"
| "scene_code"
| "disambiguation";
| "disambiguation"
| "hasChapters";