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
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 thumbnail
} }
} }
chapters {
id
title
image_index
}
studio { studio {
id id
name name

View File

@@ -16,6 +16,9 @@ fragment GalleryData on Gallery {
...FolderData ...FolderData
} }
chapters {
...GalleryChapterData
}
cover { cover {
...SlimImageData ...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! addGalleryImages(input: GalleryAddInput!): Boolean!
removeGalleryImages(input: GalleryRemoveInput!): Boolean! removeGalleryImages(input: GalleryRemoveInput!): Boolean!
galleryChapterCreate(input: GalleryChapterCreateInput!): GalleryChapter
galleryChapterUpdate(input: GalleryChapterUpdateInput!): GalleryChapter
galleryChapterDestroy(id: ID!): Boolean!
performerCreate(input: PerformerCreateInput!): Performer performerCreate(input: PerformerCreateInput!): Performer
performerUpdate(input: PerformerUpdateInput!): Performer performerUpdate(input: PerformerUpdateInput!): Performer
performerDestroy(input: PerformerDestroyInput!): Boolean! performerDestroy(input: PerformerDestroyInput!): Boolean!

View File

@@ -324,6 +324,8 @@ input GalleryFilterType {
organized: Boolean organized: Boolean
"""Filter by average image resolution""" """Filter by average image resolution"""
average_resolution: ResolutionCriterionInput 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""" """Filter to only include galleries with this studio"""
studios: HierarchicalMultiCriterionInput studios: HierarchicalMultiCriterionInput
"""Filter to only include galleries with these tags""" """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!]! files: [GalleryFile!]!
folder: Folder folder: Folder
chapters: [GalleryChapter!]!
scenes: [Scene!]! scenes: [Scene!]!
studio: Studio studio: Studio
image_count: Int! image_count: Int!

View File

@@ -47,6 +47,9 @@ func (r *Resolver) scraperCache() *scraper.Cache {
func (r *Resolver) Gallery() GalleryResolver { func (r *Resolver) Gallery() GalleryResolver {
return &galleryResolver{r} return &galleryResolver{r}
} }
func (r *Resolver) GalleryChapter() GalleryChapterResolver {
return &galleryChapterResolver{r}
}
func (r *Resolver) Mutation() MutationResolver { func (r *Resolver) Mutation() MutationResolver {
return &mutationResolver{r} return &mutationResolver{r}
} }
@@ -83,6 +86,7 @@ type queryResolver struct{ *Resolver }
type subscriptionResolver struct{ *Resolver } type subscriptionResolver struct{ *Resolver }
type galleryResolver struct{ *Resolver } type galleryResolver struct{ *Resolver }
type galleryChapterResolver struct{ *Resolver }
type performerResolver struct{ *Resolver } type performerResolver struct{ *Resolver }
type sceneResolver struct{ *Resolver } type sceneResolver struct{ *Resolver }
type sceneMarkerResolver 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 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 ( import (
"context" "context"
"database/sql"
"errors" "errors"
"fmt" "fmt"
"os" "os"
@@ -10,6 +11,7 @@ import (
"github.com/stashapp/stash/internal/manager" "github.com/stashapp/stash/internal/manager"
"github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/file"
"github.com/stashapp/stash/pkg/gallery"
"github.com/stashapp/stash/pkg/image" "github.com/stashapp/stash/pkg/image"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/plugin" "github.com/stashapp/stash/pkg/plugin"
@@ -489,3 +491,150 @@ func (r *mutationResolver) RemoveGalleryImages(ctx context.Context, input Galler
return true, nil 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 { type Repository struct {
models.TxnManager models.TxnManager
File FileReaderWriter File FileReaderWriter
Folder FolderReaderWriter Folder FolderReaderWriter
Gallery GalleryReaderWriter Gallery GalleryReaderWriter
Image ImageReaderWriter GalleryChapter models.GalleryChapterReaderWriter
Movie models.MovieReaderWriter Image ImageReaderWriter
Performer models.PerformerReaderWriter Movie models.MovieReaderWriter
Scene SceneReaderWriter Performer models.PerformerReaderWriter
SceneMarker models.SceneMarkerReaderWriter Scene SceneReaderWriter
ScrapedItem models.ScrapedItemReaderWriter SceneMarker models.SceneMarkerReaderWriter
Studio models.StudioReaderWriter ScrapedItem models.ScrapedItemReaderWriter
Tag models.TagReaderWriter Studio models.StudioReaderWriter
SavedFilter models.SavedFilterReaderWriter Tag models.TagReaderWriter
SavedFilter models.SavedFilterReaderWriter
} }
func (r *Repository) WithTxn(ctx context.Context, fn txn.TxnFunc) error { func (r *Repository) WithTxn(ctx context.Context, fn txn.TxnFunc) error {
@@ -79,19 +80,20 @@ func sqliteRepository(d *sqlite.Database) Repository {
txnRepo := d.TxnRepository() txnRepo := d.TxnRepository()
return Repository{ return Repository{
TxnManager: txnRepo, TxnManager: txnRepo,
File: d.File, File: d.File,
Folder: d.Folder, Folder: d.Folder,
Gallery: d.Gallery, Gallery: d.Gallery,
Image: d.Image, GalleryChapter: txnRepo.GalleryChapter,
Movie: txnRepo.Movie, Image: d.Image,
Performer: txnRepo.Performer, Movie: txnRepo.Movie,
Scene: d.Scene, Performer: txnRepo.Performer,
SceneMarker: txnRepo.SceneMarker, Scene: d.Scene,
ScrapedItem: txnRepo.ScrapedItem, SceneMarker: txnRepo.SceneMarker,
Studio: txnRepo.Studio, ScrapedItem: txnRepo.ScrapedItem,
Tag: txnRepo.Tag, Studio: txnRepo.Studio,
SavedFilter: txnRepo.SavedFilter, 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 studioReader := repo.Studio
performerReader := repo.Performer performerReader := repo.Performer
tagReader := repo.Tag tagReader := repo.Tag
galleryChapterReader := repo.GalleryChapter
for g := range jobChan { for g := range jobChan {
if err := g.LoadFiles(ctx, repo.Gallery); err != nil { 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 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) newGalleryJSON.Tags = tag.GetNames(tags)
if t.includeDependencies { if t.includeDependencies {

View File

@@ -487,6 +487,7 @@ func (t *ImportTask) ImportGalleries(ctx context.Context) {
tagWriter := r.Tag tagWriter := r.Tag
performerWriter := r.Performer performerWriter := r.Performer
studioWriter := r.Studio studioWriter := r.Studio
chapterWriter := r.GalleryChapter
galleryImporter := &gallery.Importer{ galleryImporter := &gallery.Importer{
ReaderWriter: readerWriter, ReaderWriter: readerWriter,
@@ -499,7 +500,25 @@ func (t *ImportTask) ImportGalleries(ctx context.Context) {
MissingRefBehaviour: t.MissingRefBehaviour, 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 { }); err != nil {
logger.Errorf("[galleries] <%s> import failed to commit: %s", fi.Name(), err.Error()) logger.Errorf("[galleries] <%s> import failed to commit: %s", fi.Name(), err.Error())
continue 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) { func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *image.FileDeleter, deleteGenerated, deleteFile bool) ([]*models.Image, error) {
var imgsDestroyed []*models.Image 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 // if this is a zip-based gallery, delete the images as well first
zipImgsDestroyed, err := s.destroyZipFileImages(ctx, i, fileDeleter, deleteGenerated, deleteFile) zipImgsDestroyed, err := s.destroyZipFileImages(ctx, i, fileDeleter, deleteGenerated, deleteFile)
if err != nil { if err != nil {
@@ -39,6 +41,15 @@ func (s *Service) Destroy(ctx context.Context, i *models.Gallery, fileDeleter *i
return imgsDestroyed, nil 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) { 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 { if err := i.LoadFiles(ctx, s.Repository); err != nil {
return nil, err return nil, err

View File

@@ -2,6 +2,7 @@ package gallery
import ( import (
"context" "context"
"fmt"
"github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/json"
@@ -9,6 +10,10 @@ import (
"github.com/stashapp/stash/pkg/studio" "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 // ToBasicJSON converts a gallery object into its JSON object equivalent. It
// does not convert the relationships to other objects. // does not convert the relationships to other objects.
func ToBasicJSON(gallery *models.Gallery) (*jsonschema.Gallery, error) { 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 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 { func GetIDs(galleries []*models.Gallery) []int {
var results []int var results []int
for _, gallery := range galleries { for _, gallery := range galleries {

View File

@@ -22,6 +22,9 @@ const (
errStudioID = 6 errStudioID = 6
// noTagsID = 11 // noTagsID = 11
noChaptersID = 7
errChaptersID = 8
errFindByChapterID = 9
) )
var ( 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 { func createFullJSONGallery() *jsonschema.Gallery {
return &jsonschema.Gallery{ return &jsonschema.Gallery{
Title: title, Title: title,
@@ -168,3 +184,109 @@ func TestGetStudioName(t *testing.T) {
mockStudioReader.AssertExpectations(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 Input jsonschema.Gallery
MissingRefBehaviour models.ImportMissingRefEnum MissingRefBehaviour models.ImportMissingRefEnum
ID int
gallery models.Gallery 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) 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 { type Service struct {
Repository Repository Repository Repository
ImageFinder ImageFinder ImageFinder ImageFinder

View File

@@ -31,6 +31,8 @@ type GalleryFilterType struct {
Organized *bool `json:"organized"` Organized *bool `json:"organized"`
// Filter by average image resolution // Filter by average image resolution
AverageResolution *ResolutionCriterionInput `json:"average_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 // Filter to only include galleries with this studio
Studios *HierarchicalMultiCriterionInput `json:"studios"` Studios *HierarchicalMultiCriterionInput `json:"studios"`
// Filter to only include galleries with these tags // 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" "github.com/stashapp/stash/pkg/models/json"
) )
type Gallery struct { type GalleryChapter struct {
ZipFiles []string `json:"zip_files,omitempty"`
FolderPath string `json:"folder_path,omitempty"`
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
URL string `json:"url,omitempty"` ImageIndex int `json:"image_index,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"`
CreatedAt json.JSONTime `json:"created_at,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"`
UpdatedAt json.JSONTime `json:"updated_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 { func (s Gallery) Filename(basename string, hash string) string {
ret := fsutil.SanitiseBasename(basename) 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 { func NewTxnRepository() models.Repository {
return models.Repository{ return models.Repository{
TxnManager: &TxnManager{}, TxnManager: &TxnManager{},
Gallery: &GalleryReaderWriter{}, Gallery: &GalleryReaderWriter{},
Image: &ImageReaderWriter{}, GalleryChapter: &GalleryChapterReaderWriter{},
Movie: &MovieReaderWriter{}, Image: &ImageReaderWriter{},
Performer: &PerformerReaderWriter{}, Movie: &MovieReaderWriter{},
Scene: &SceneReaderWriter{}, Performer: &PerformerReaderWriter{},
SceneMarker: &SceneMarkerReaderWriter{}, Scene: &SceneReaderWriter{},
ScrapedItem: &ScrapedItemReaderWriter{}, SceneMarker: &SceneMarkerReaderWriter{},
Studio: &StudioReaderWriter{}, ScrapedItem: &ScrapedItemReaderWriter{},
Tag: &TagReaderWriter{}, Studio: &StudioReaderWriter{},
SavedFilter: &SavedFilterReaderWriter{}, 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 { type Repository struct {
TxnManager TxnManager
File file.Store File file.Store
Folder file.FolderStore Folder file.FolderStore
Gallery GalleryReaderWriter Gallery GalleryReaderWriter
Image ImageReaderWriter GalleryChapter GalleryChapterReaderWriter
Movie MovieReaderWriter Image ImageReaderWriter
Performer PerformerReaderWriter Movie MovieReaderWriter
Scene SceneReaderWriter Performer PerformerReaderWriter
SceneMarker SceneMarkerReaderWriter Scene SceneReaderWriter
ScrapedItem ScrapedItemReaderWriter SceneMarker SceneMarkerReaderWriter
Studio StudioReaderWriter ScrapedItem ScrapedItemReaderWriter
Tag TagReaderWriter Studio StudioReaderWriter
SavedFilter SavedFilterReaderWriter Tag TagReaderWriter
SavedFilter SavedFilterReaderWriter
} }

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ const (
galleriesTagsTable = "galleries_tags" galleriesTagsTable = "galleries_tags"
galleriesImagesTable = "galleries_images" galleriesImagesTable = "galleries_images"
galleriesScenesTable = "scenes_galleries" galleriesScenesTable = "scenes_galleries"
galleriesChaptersTable = "galleries_chapters"
galleryIDColumn = "gallery_id" 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, galleryTagCountCriterionHandler(qb, galleryFilter.TagCount))
query.handleCriterion(ctx, galleryPerformersCriterionHandler(qb, galleryFilter.Performers)) query.handleCriterion(ctx, galleryPerformersCriterionHandler(qb, galleryFilter.Performers))
query.handleCriterion(ctx, galleryPerformerCountCriterionHandler(qb, galleryFilter.PerformerCount)) query.handleCriterion(ctx, galleryPerformerCountCriterionHandler(qb, galleryFilter.PerformerCount))
query.handleCriterion(ctx, hasChaptersCriterionHandler(galleryFilter.HasChapters))
query.handleCriterion(ctx, galleryStudioCriterionHandler(qb, galleryFilter.Studios)) query.handleCriterion(ctx, galleryStudioCriterionHandler(qb, galleryFilter.Studios))
query.handleCriterion(ctx, galleryPerformerTagsCriterionHandler(qb, galleryFilter.PerformerTags)) query.handleCriterion(ctx, galleryPerformerTagsCriterionHandler(qb, galleryFilter.PerformerTags))
query.handleCriterion(ctx, galleryAverageResolutionCriterionHandler(qb, galleryFilter.AverageResolution)) query.handleCriterion(ctx, galleryAverageResolutionCriterionHandler(qb, galleryFilter.AverageResolution))
@@ -729,11 +731,15 @@ func (qb *GalleryStore) makeQuery(ctx context.Context, galleryFilter *models.Gal
as: "gallery_folder", as: "gallery_folder",
onClause: "galleries.folder_id = gallery_folder.id", onClause: "galleries.folder_id = gallery_folder.id",
}, },
join{
table: galleriesChaptersTable,
onClause: "galleries_chapters.gallery_id = galleries.id",
},
) )
// add joins for files and checksum // add joins for files and checksum
filepathColumn := "folders.path || '" + string(filepath.Separator) + "' || files.basename" 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) query.parseQueryString(searchColumns, *q)
} }
@@ -949,6 +955,19 @@ func galleryImageCountCriterionHandler(qb *GalleryStore, imageCount *models.IntC
return h.handler(imageCount) 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 { func galleryStudioCriterionHandler(qb *GalleryStore, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
h := hierarchicalMultiCriterionHandlerBuilder{ h := hierarchicalMultiCriterionHandlerBuilder{
tx: qb.tx, 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 Count
// TODO All // TODO All
// TODO Query // 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 ( const (
galleryIdxWithScene = iota galleryIdxWithScene = iota
galleryIdxWithChapters
galleryIdxWithImage galleryIdxWithImage
galleryIdx1WithImage galleryIdx1WithImage
galleryIdx2WithImage galleryIdx2WithImage
@@ -236,6 +237,11 @@ const (
totalMarkers totalMarkers
) )
const (
chapterIdxWithGallery = iota
totalChapters
)
const ( const (
savedFilterIdxDefaultScene = iota savedFilterIdxDefaultScene = iota
savedFilterIdxDefaultImage savedFilterIdxDefaultImage
@@ -261,6 +267,7 @@ var (
sceneFileIDs []file.ID sceneFileIDs []file.ID
imageFileIDs []file.ID imageFileIDs []file.ID
galleryFileIDs []file.ID galleryFileIDs []file.ID
chapterIDs []int
sceneIDs []int sceneIDs []int
imageIDs []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 ( var (
imageGalleries = linkMap{ imageGalleries = linkMap{
imageIdxWithGallery: {galleryIdxWithImage}, imageIdxWithGallery: {galleryIdxWithImage},
@@ -599,6 +619,11 @@ func populateDB() error {
return fmt.Errorf("error creating scene marker: %s", err.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 return nil
}); err != nil { }); err != nil {
@@ -1580,6 +1605,24 @@ func createMarker(ctx context.Context, mqb models.SceneMarkerReaderWriter, marke
return nil 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 { func getSavedFilterMode(index int) models.FilterMode {
switch index { switch index {
case savedFilterIdxScene, savedFilterIdxDefaultScene: case savedFilterIdxScene, savedFilterIdxDefaultScene:

View File

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

View File

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

View File

@@ -28,7 +28,7 @@ import (
const batchSize = 50000 const batchSize = 50000
// create an example database by generating a number of scenes, markers, // 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 { type config struct {
Database string `yaml:"database"` Database string `yaml:"database"`
@@ -36,6 +36,7 @@ type config struct {
Markers int `yaml:"markers"` Markers int `yaml:"markers"`
Images int `yaml:"images"` Images int `yaml:"images"`
Galleries int `yaml:"galleries"` Galleries int `yaml:"galleries"`
Chapters int `yaml:"chapters"`
Performers int `yaml:"performers"` Performers int `yaml:"performers"`
Studios int `yaml:"studios"` Studios int `yaml:"studios"`
Tags int `yaml:"tags"` Tags int `yaml:"tags"`
@@ -97,6 +98,7 @@ func populateDB() {
makeScenes(c.Scenes) makeScenes(c.Scenes)
makeImages(c.Images) makeImages(c.Images)
makeGalleries(c.Galleries) makeGalleries(c.Galleries)
makeChapters(c.Chapters)
makeMarkers(c.Markers) 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) { func makeMarkers(n int) {
logf("creating %d markers...", n) logf("creating %d markers...", n)
for i := 0; i < n; { for i := 0; i < n; {
@@ -617,6 +651,10 @@ func getRandomScene() int {
return rand.Intn(c.Scenes) + 1 return rand.Intn(c.Scenes) + 1
} }
func getRandomGallery() int {
return rand.Intn(c.Galleries) + 1
}
func getRandomTags(ctx context.Context, min, max int) []int { func getRandomTags(ctx context.Context, min, max int) []int {
var n int var n int
if min == max { 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 { Icon } from "src/components/Shared/Icon";
import { Counter } from "src/components/Shared/Counter"; import { Counter } from "src/components/Shared/Counter";
import Mousetrap from "mousetrap"; import Mousetrap from "mousetrap";
import { useGalleryLightbox } from "src/hooks/Lightbox/hooks";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton"; import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton";
import { GalleryEditPanel } from "./GalleryEditPanel"; import { GalleryEditPanel } from "./GalleryEditPanel";
@@ -25,6 +26,7 @@ import { GalleryFileInfoPanel } from "./GalleryFileInfoPanel";
import { GalleryScenesPanel } from "./GalleryScenesPanel"; import { GalleryScenesPanel } from "./GalleryScenesPanel";
import { faEllipsisV } from "@fortawesome/free-solid-svg-icons"; import { faEllipsisV } from "@fortawesome/free-solid-svg-icons";
import { galleryPath, galleryTitle } from "src/core/galleries"; import { galleryPath, galleryTitle } from "src/core/galleries";
import { GalleryChapterPanel } from "./GalleryChaptersPanel";
interface IProps { interface IProps {
gallery: GQL.GalleryDataFragment; gallery: GQL.GalleryDataFragment;
@@ -39,6 +41,7 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
const history = useHistory(); const history = useHistory();
const Toast = useToast(); const Toast = useToast();
const intl = useIntl(); const intl = useIntl();
const showLightbox = useGalleryLightbox(gallery.id, gallery.chapters);
const [collapsed, setCollapsed] = useState(false); 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); const [isDeleteAlertOpen, setIsDeleteAlertOpen] = useState<boolean>(false);
function onDeleteDialogClosed(deleted: boolean) { function onDeleteDialogClosed(deleted: boolean) {
@@ -189,6 +196,11 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
</Nav.Link> </Nav.Link>
</Nav.Item> </Nav.Item>
) : undefined} ) : undefined}
<Nav.Item>
<Nav.Link eventKey="gallery-chapter-panel">
<FormattedMessage id="chapters" />
</Nav.Link>
</Nav.Item>
<Nav.Item> <Nav.Item>
<Nav.Link eventKey="gallery-edit-panel"> <Nav.Link eventKey="gallery-edit-panel">
<FormattedMessage id="actions.edit" /> <FormattedMessage id="actions.edit" />
@@ -215,6 +227,13 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
> >
<GalleryFileInfoPanel gallery={gallery} /> <GalleryFileInfoPanel gallery={gallery} />
</Tab.Pane> </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"> <Tab.Pane eventKey="gallery-edit-panel">
<GalleryEditPanel <GalleryEditPanel
isVisible={activeTabKey === "gallery-edit-panel"} isVisible={activeTabKey === "gallery-edit-panel"}
@@ -279,12 +298,14 @@ export const GalleryPage: React.FC<IProps> = ({ gallery }) => {
// set up hotkeys // set up hotkeys
useEffect(() => { useEffect(() => {
Mousetrap.bind("a", () => setActiveTabKey("gallery-details-panel")); Mousetrap.bind("a", () => setActiveTabKey("gallery-details-panel"));
Mousetrap.bind("c", () => setActiveTabKey("gallery-chapter-panel"));
Mousetrap.bind("e", () => setActiveTabKey("gallery-edit-panel")); Mousetrap.bind("e", () => setActiveTabKey("gallery-edit-panel"));
Mousetrap.bind("f", () => setActiveTabKey("gallery-file-info-panel")); Mousetrap.bind("f", () => setActiveTabKey("gallery-file-info-panel"));
Mousetrap.bind(",", () => setCollapsed(!collapsed)); Mousetrap.bind(",", () => setCollapsed(!collapsed));
return () => { return () => {
Mousetrap.unbind("a"); Mousetrap.unbind("a");
Mousetrap.unbind("c");
Mousetrap.unbind("e"); Mousetrap.unbind("e");
Mousetrap.unbind("f"); Mousetrap.unbind("f");
Mousetrap.unbind(","); 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} extraOperations={otherOperations}
persistState={PersistanceLevel.VIEW} persistState={PersistanceLevel.VIEW}
persistanceKey="galleryimages" persistanceKey="galleryimages"
chapters={gallery.chapters}
/> />
); );
}; };

View File

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

View File

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

View File

@@ -752,6 +752,27 @@ export const mutateGallerySetPrimaryFile = (id: string, fileID: string) =>
update: deleteCache(galleryMutationImpactedQueries), 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 = [ export const studioMutationImpactedQueries = [
GQL.FindStudiosDocument, GQL.FindStudiosDocument,
GQL.FindSceneDocument, 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. ##### 💥 Note: The cache directory is now required if using HLS/DASH streaming. Please set the cache directory in the System Settings page.
### ✨ New Features ### ✨ 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 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 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)) * Added support for DASH streaming. ([#3275](https://github.com/stashapp/stash/pull/3275))

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,7 @@ import {
import { OrganizedCriterion } from "./organized"; import { OrganizedCriterion } from "./organized";
import { FavoriteCriterion, PerformerFavoriteCriterion } from "./favorite"; import { FavoriteCriterion, PerformerFavoriteCriterion } from "./favorite";
import { HasMarkersCriterion } from "./has-markers"; import { HasMarkersCriterion } from "./has-markers";
import { HasChaptersCriterion } from "./has-chapters";
import { import {
PerformerIsMissingCriterionOption, PerformerIsMissingCriterionOption,
ImageIsMissingCriterionOption, ImageIsMissingCriterionOption,
@@ -113,6 +114,8 @@ export function makeCriteria(
return new FavoriteCriterion(); return new FavoriteCriterion();
case "hasMarkers": case "hasMarkers":
return new HasMarkersCriterion(); return new HasMarkersCriterion();
case "hasChapters":
return new HasChaptersCriterion();
case "sceneIsMissing": case "sceneIsMissing":
return new IsMissingCriterion(SceneIsMissingCriterionOption); return new IsMissingCriterion(SceneIsMissingCriterionOption);
case "imageIsMissing": 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 { PerformerFavoriteCriterionOption } from "./criteria/favorite";
import { GalleryIsMissingCriterionOption } from "./criteria/is-missing"; import { GalleryIsMissingCriterionOption } from "./criteria/is-missing";
import { OrganizedCriterionOption } from "./criteria/organized"; import { OrganizedCriterionOption } from "./criteria/organized";
import { HasChaptersCriterionOption } from "./criteria/has-chapters";
import { PerformersCriterionOption } from "./criteria/performers"; import { PerformersCriterionOption } from "./criteria/performers";
import { AverageResolutionCriterionOption } from "./criteria/resolution"; import { AverageResolutionCriterionOption } from "./criteria/resolution";
import { StudiosCriterionOption } from "./criteria/studios"; import { StudiosCriterionOption } from "./criteria/studios";
@@ -53,6 +54,7 @@ const criterionOptions = [
AverageResolutionCriterionOption, AverageResolutionCriterionOption,
GalleryIsMissingCriterionOption, GalleryIsMissingCriterionOption,
TagsCriterionOption, TagsCriterionOption,
HasChaptersCriterionOption,
createStringCriterionOption("tag_count"), createStringCriterionOption("tag_count"),
PerformerTagsCriterionOption, PerformerTagsCriterionOption,
PerformersCriterionOption, PerformersCriterionOption,

View File

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