diff --git a/graphql/documents/data/gallery-chapter.graphql b/graphql/documents/data/gallery-chapter.graphql new file mode 100644 index 000000000..674991356 --- /dev/null +++ b/graphql/documents/data/gallery-chapter.graphql @@ -0,0 +1,9 @@ +fragment GalleryChapterData on GalleryChapter { + id + title + image_index + + gallery { + id + } +} diff --git a/graphql/documents/data/gallery-slim.graphql b/graphql/documents/data/gallery-slim.graphql index c49ef2c11..9469c8486 100644 --- a/graphql/documents/data/gallery-slim.graphql +++ b/graphql/documents/data/gallery-slim.graphql @@ -22,6 +22,11 @@ fragment SlimGalleryData on Gallery { thumbnail } } + chapters { + id + title + image_index + } studio { id name diff --git a/graphql/documents/data/gallery.graphql b/graphql/documents/data/gallery.graphql index bb804047e..b4df25896 100644 --- a/graphql/documents/data/gallery.graphql +++ b/graphql/documents/data/gallery.graphql @@ -16,6 +16,9 @@ fragment GalleryData on Gallery { ...FolderData } + chapters { + ...GalleryChapterData + } cover { ...SlimImageData } diff --git a/graphql/documents/mutations/gallery-chapter.graphql b/graphql/documents/mutations/gallery-chapter.graphql new file mode 100644 index 000000000..520aac8d3 --- /dev/null +++ b/graphql/documents/mutations/gallery-chapter.graphql @@ -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) +} diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 9964b7d5d..85de2d826 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -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! diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 2a0a7f860..9e124e49e 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -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""" diff --git a/graphql/schema/types/gallery-chapter.graphql b/graphql/schema/types/gallery-chapter.graphql new file mode 100644 index 000000000..0db36f91d --- /dev/null +++ b/graphql/schema/types/gallery-chapter.graphql @@ -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!]! +} diff --git a/graphql/schema/types/gallery.graphql b/graphql/schema/types/gallery.graphql index 3716b9478..1f62ddd51 100644 --- a/graphql/schema/types/gallery.graphql +++ b/graphql/schema/types/gallery.graphql @@ -19,6 +19,7 @@ type Gallery { files: [GalleryFile!]! folder: Folder + chapters: [GalleryChapter!]! scenes: [Scene!]! studio: Studio image_count: Int! diff --git a/internal/api/resolver.go b/internal/api/resolver.go index 69a3a83d4..8d2ccc744 100644 --- a/internal/api/resolver.go +++ b/internal/api/resolver.go @@ -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 } diff --git a/internal/api/resolver_model_gallery.go b/internal/api/resolver_model_gallery.go index e7fd70f9d..8157404dc 100644 --- a/internal/api/resolver_model_gallery.go +++ b/internal/api/resolver_model_gallery.go @@ -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 +} diff --git a/internal/api/resolver_model_gallery_chapter.go b/internal/api/resolver_model_gallery_chapter.go new file mode 100644 index 000000000..216336e12 --- /dev/null +++ b/internal/api/resolver_model_gallery_chapter.go @@ -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 +} diff --git a/internal/api/resolver_mutation_gallery.go b/internal/api/resolver_mutation_gallery.go index 51ff989a3..aad2efe5d 100644 --- a/internal/api/resolver_mutation_gallery.go +++ b/internal/api/resolver_mutation_gallery.go @@ -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 +} diff --git a/internal/manager/repository.go b/internal/manager/repository.go index 766914fd8..59428b146 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -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, } } diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 65157e3e5..4c4a2dd05 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -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 { diff --git a/internal/manager/task_import.go b/internal/manager/task_import.go index 33d862c2e..7cefc8af0 100644 --- a/internal/manager/task_import.go +++ b/internal/manager/task_import.go @@ -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 diff --git a/pkg/gallery/chapter_import.go b/pkg/gallery/chapter_import.go new file mode 100644 index 000000000..e9b195ac5 --- /dev/null +++ b/pkg/gallery/chapter_import.go @@ -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 +} diff --git a/pkg/gallery/delete.go b/pkg/gallery/delete.go index b6c1333ba..60aee0d28 100644 --- a/pkg/gallery/delete.go +++ b/pkg/gallery/delete.go @@ -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 diff --git a/pkg/gallery/export.go b/pkg/gallery/export.go index ebd8a8604..4797d4135 100644 --- a/pkg/gallery/export.go +++ b/pkg/gallery/export.go @@ -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 { diff --git a/pkg/gallery/export_test.go b/pkg/gallery/export_test.go index 13f8227f4..a424e09b0 100644 --- a/pkg/gallery/export_test.go +++ b/pkg/gallery/export_test.go @@ -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) + } + } + +} diff --git a/pkg/gallery/import.go b/pkg/gallery/import.go index c3bd83527..753717d65 100644 --- a/pkg/gallery/import.go +++ b/pkg/gallery/import.go @@ -24,6 +24,7 @@ type Importer struct { Input jsonschema.Gallery MissingRefBehaviour models.ImportMissingRefEnum + ID int gallery models.Gallery } diff --git a/pkg/gallery/service.go b/pkg/gallery/service.go index 4698277b7..acf70763f 100644 --- a/pkg/gallery/service.go +++ b/pkg/gallery/service.go @@ -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 diff --git a/pkg/models/gallery.go b/pkg/models/gallery.go index fa95cc559..61ee2a72d 100644 --- a/pkg/models/gallery.go +++ b/pkg/models/gallery.go @@ -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 diff --git a/pkg/models/gallery_chapter.go b/pkg/models/gallery_chapter.go new file mode 100644 index 000000000..b0c2d2b8d --- /dev/null +++ b/pkg/models/gallery_chapter.go @@ -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 +} diff --git a/pkg/models/jsonschema/gallery.go b/pkg/models/jsonschema/gallery.go index 596e7c610..ca399624e 100644 --- a/pkg/models/jsonschema/gallery.go +++ b/pkg/models/jsonschema/gallery.go @@ -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) diff --git a/pkg/models/mocks/GalleryChapterReaderWriter.go b/pkg/models/mocks/GalleryChapterReaderWriter.go new file mode 100644 index 000000000..8541d5b41 --- /dev/null +++ b/pkg/models/mocks/GalleryChapterReaderWriter.go @@ -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 +} diff --git a/pkg/models/mocks/transaction.go b/pkg/models/mocks/transaction.go index f2c4c9c49..8ce7176b2 100644 --- a/pkg/models/mocks/transaction.go +++ b/pkg/models/mocks/transaction.go @@ -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{}, } } diff --git a/pkg/models/model_gallery_chapter.go b/pkg/models/model_gallery_chapter.go new file mode 100644 index 000000000..308fdbe6c --- /dev/null +++ b/pkg/models/model_gallery_chapter.go @@ -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{} +} diff --git a/pkg/models/repository.go b/pkg/models/repository.go index 7a9e14af5..898f7f25f 100644 --- a/pkg/models/repository.go +++ b/pkg/models/repository.go @@ -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 } diff --git a/pkg/plugin/hooks.go b/pkg/plugin/hooks.go index a60e44e6c..fc91765b8 100644 --- a/pkg/plugin/hooks.go +++ b/pkg/plugin/hooks.go @@ -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, diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index f82f4f551..c107986c1 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -32,7 +32,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 43 +var appSchemaVersion uint = 44 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index b5e3a7a09..590586b94 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -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, diff --git a/pkg/sqlite/gallery_chapter.go b/pkg/sqlite/gallery_chapter.go new file mode 100644 index 000000000..694a70655 --- /dev/null +++ b/pkg/sqlite/gallery_chapter.go @@ -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 +} diff --git a/pkg/sqlite/gallery_chapter_test.go b/pkg/sqlite/gallery_chapter_test.go new file mode 100644 index 000000000..3464b462a --- /dev/null +++ b/pkg/sqlite/gallery_chapter_test.go @@ -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 diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 291e02e08..6d145cb1b 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -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 diff --git a/pkg/sqlite/migrations/44_gallery_chapters.up.sql b/pkg/sqlite/migrations/44_gallery_chapters.up.sql new file mode 100644 index 000000000..de38e23e9 --- /dev/null +++ b/pkg/sqlite/migrations/44_gallery_chapters.up.sql @@ -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`); diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 72ed733c5..2d034dd50 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -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: diff --git a/pkg/sqlite/transaction.go b/pkg/sqlite/transaction.go index c56b2fcd9..f67a2d6e7 100644 --- a/pkg/sqlite/transaction.go +++ b/pkg/sqlite/transaction.go @@ -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, } } diff --git a/scripts/test_db_generator/config.yml b/scripts/test_db_generator/config.yml index ac072c459..a5d94b98f 100644 --- a/scripts/test_db_generator/config.yml +++ b/scripts/test_db_generator/config.yml @@ -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 \ No newline at end of file + images: scene.txt diff --git a/scripts/test_db_generator/makeTestDB.go b/scripts/test_db_generator/makeTestDB.go index 347e85873..bfdb042df 100644 --- a/scripts/test_db_generator/makeTestDB.go +++ b/scripts/test_db_generator/makeTestDB.go @@ -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 { diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/ChapterEntry.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/ChapterEntry.tsx new file mode 100644 index 000000000..632244a5b --- /dev/null +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/ChapterEntry.tsx @@ -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 = ({ + galleryChapters, + onClickChapter, + onEdit, +}) => { + if (!galleryChapters?.length) return
; + + const chapterCards = galleryChapters.map((chapter) => { + return ( +
+
+
+ + +
+
+ ); + }); + + return
{chapterCards}
; +}; diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx index 125471961..1adedc799 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/Gallery.tsx @@ -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 = ({ 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 = ({ gallery }) => { }); } + async function onClickChapter(imageindex: number) { + showLightbox(imageindex - 1); + } + const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState(false); function onDeleteDialogClosed(deleted: boolean) { @@ -189,6 +196,11 @@ export const GalleryPage: React.FC = ({ gallery }) => { ) : undefined} + + + + + @@ -215,6 +227,13 @@ export const GalleryPage: React.FC = ({ gallery }) => { > + + + = ({ 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(","); diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryChapterForm.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryChapterForm.tsx new file mode 100644 index 000000000..a8c7133d7 --- /dev/null +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryChapterForm.tsx @@ -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 = ({ + 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) => ( + +
+ + + + + + + + {formik.getFieldMeta("title").error} + + + + + + + + + + + {formik.getFieldMeta("imageIndex").error} + + +
+
+
+ + + {editingChapter && ( + + )} +
+
+
+ )} +
+ ); +}; diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryChaptersPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryChaptersPanel.tsx new file mode 100644 index 000000000..ae5021c39 --- /dev/null +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryChaptersPanel.tsx @@ -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 = ( + props: IGalleryChapterPanelProps +) => { + const [isEditorOpen, setIsEditorOpen] = useState(false); + const [editingChapter, setEditingChapter] = + useState(); + + // 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 ( + + ); + + return ( +
+ +
+ +
+
+ ); +}; + +export default GalleryChapterPanel; diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx index af393a0a5..187411020 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryImagesPanel.tsx @@ -104,6 +104,7 @@ export const GalleryImagesPanel: React.FC = ({ extraOperations={otherOperations} persistState={PersistanceLevel.VIEW} persistanceKey="galleryimages" + chapters={gallery.chapters} /> ); }; diff --git a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx index b5e1ff7ec..d103938ce 100644 --- a/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryWallCard.tsx @@ -19,7 +19,7 @@ interface IProps { const GalleryWallCard: React.FC = ({ 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 = ({ gallery }) => { ? [...performerNames.slice(0, -2), performerNames.slice(-2).join(" & ")] : performerNames; + async function showLightboxStart() { + showLightbox(0); + } + return ( <>
diff --git a/ui/v2.5/src/components/Images/ImageList.tsx b/ui/v2.5/src/components/Images/ImageList.tsx index b49975444..2b3cc8c46 100644 --- a/ui/v2.5/src/components/Images/ImageList.tsx +++ b/ui/v2.5/src/components/Images/ImageList.tsx @@ -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 = ({ @@ -116,22 +117,29 @@ const ImageListImages: React.FC = ({ 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 = ({ 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 = ({ 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[]; + chapters?: GQL.GalleryChapterDataFragment[]; } export const ImageList: React.FC = ({ @@ -281,6 +299,7 @@ export const ImageList: React.FC = ({ persistanceKey, alterQuery, extraOperations, + chapters = [], }) => { const intl = useIntl(); const history = useHistory(); @@ -386,6 +405,7 @@ export const ImageList: React.FC = ({ selectedIds={selectedIds} slideshowRunning={slideshowRunning} setSlideshowRunning={setSlideshowRunning} + chapters={chapters} /> ); } diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index d5aaf80ea..421f9fd98 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -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, diff --git a/ui/v2.5/src/docs/en/Changelog/v0200.md b/ui/v2.5/src/docs/en/Changelog/v0200.md index cb178b025..294e31549 100644 --- a/ui/v2.5/src/docs/en/Changelog/v0200.md +++ b/ui/v2.5/src/docs/en/Changelog/v0200.md @@ -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)) diff --git a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx index 06721cf58..ca79805d7 100644 --- a/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx +++ b/ui/v2.5/src/hooks/Lightbox/Lightbox.tsx @@ -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 = ({ initialIndex = 0, showNavigation, slideshowEnabled = false, - pageHeader, + page, + pages, + pageSize: pageSize = 40, pageCallback, + chapters = [], hide, }) => { const [updateImage] = useImageUpdate(); + // zero-based const [index, setIndex] = useState(null); const [movingLeft, setMovingLeft] = useState(false); const oldIndex = useRef(null); @@ -101,6 +112,7 @@ export const LightboxComponent: React.FC = ({ 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(); @@ -310,12 +322,13 @@ export const LightboxComponent: React.FC = ({ (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 = ({ 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 = ({ 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 }) => ( + gotoPage(image_index)}> + {" "} + {title} + {title.length > 0 ? " - #" : "#"} + {image_index} + + )); + + return ( + setShowChapters(!showChapters)} + > + + + + + {popoverContent} + + + ); + }; + // #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 = ({ } } + const pageHeader = + page && pages + ? intl.formatMessage( + { id: "dialogs.lightbox.page_header" }, + { page, total: pages } + ) + : ""; + return (
= ({ onClick={handleClose} >
-
+
{renderChapterMenu()}
- {pageHeader} + + {chapterHeader()} {pageHeader} + {images.length > 1 ? ( {`${currentIndex + 1} / ${images.length}`} ) : undefined} diff --git a/ui/v2.5/src/hooks/Lightbox/context.tsx b/ui/v2.5/src/hooks/Lightbox/context.tsx index 55df6559c..b44a1127d 100644 --- a/ui/v2.5/src/hooks/Lightbox/context.tsx +++ b/ui/v2.5/src/hooks/Lightbox/context.tsx @@ -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; } diff --git a/ui/v2.5/src/hooks/Lightbox/hooks.ts b/ui/v2.5/src/hooks/Lightbox/hooks.ts index dffa0fb5c..8ec511f52 100644 --- a/ui/v2.5/src/hooks/Lightbox/hooks.ts +++ b/ui/v2.5/src/hooks/Lightbox/hooks.ts @@ -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>) => { +export const useLightbox = ( + state: Partial>, + chapters: IChapter[] = [] +) => { const { setLightboxState } = useContext(LightboxContext); useEffect(() => { @@ -10,7 +14,9 @@ export const useLightbox = (state: Partial>) => { 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>) => { 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>) => { 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(); } diff --git a/ui/v2.5/src/hooks/Lightbox/lightbox.scss b/ui/v2.5/src/hooks/Lightbox/lightbox.scss index 5a91b17e1..a4c82d639 100644 --- a/ui/v2.5/src/hooks/Lightbox/lightbox.scss +++ b/ui/v2.5/src/hooks/Lightbox/lightbox.scss @@ -30,10 +30,11 @@ display: flex; flex: 1; justify-content: center; + } - @media (max-width: 575px) { - display: none; - } + &-chapters { + max-height: 90%; + overflow: auto; } &-indicator { diff --git a/ui/v2.5/src/hooks/Lightbox/types.ts b/ui/v2.5/src/hooks/Lightbox/types.ts index ce630c812..6b60422fd 100644 --- a/ui/v2.5/src/hooks/Lightbox/types.ts +++ b/ui/v2.5/src/hooks/Lightbox/types.ts @@ -12,3 +12,9 @@ export interface ILightboxImage { o_counter?: GQL.Maybe; paths: IImagePaths; } + +export interface IChapter { + id: string; + title: string; + image_index: number; +} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index b90170f1a..f284b2305 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -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", diff --git a/ui/v2.5/src/models/list-filter/criteria/factory.ts b/ui/v2.5/src/models/list-filter/criteria/factory.ts index f5f14e0fe..d3f57e12d 100644 --- a/ui/v2.5/src/models/list-filter/criteria/factory.ts +++ b/ui/v2.5/src/models/list-filter/criteria/factory.ts @@ -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": diff --git a/ui/v2.5/src/models/list-filter/criteria/has-chapters.ts b/ui/v2.5/src/models/list-filter/criteria/has-chapters.ts new file mode 100644 index 000000000..12d74cbbb --- /dev/null +++ b/ui/v2.5/src/models/list-filter/criteria/has-chapters.ts @@ -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; + } +} diff --git a/ui/v2.5/src/models/list-filter/galleries.ts b/ui/v2.5/src/models/list-filter/galleries.ts index 3f1e7aa7e..1a0ab8688 100644 --- a/ui/v2.5/src/models/list-filter/galleries.ts +++ b/ui/v2.5/src/models/list-filter/galleries.ts @@ -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, diff --git a/ui/v2.5/src/models/list-filter/types.ts b/ui/v2.5/src/models/list-filter/types.ts index db5b30194..3aa192f3a 100644 --- a/ui/v2.5/src/models/list-filter/types.ts +++ b/ui/v2.5/src/models/list-filter/types.ts @@ -177,4 +177,5 @@ export type CriterionType = | "scene_updated_at" | "description" | "scene_code" - | "disambiguation"; + | "disambiguation" + | "hasChapters";