Add Chapters for Galleries (#3289)

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -52,6 +52,7 @@ type Repository struct {
File FileReaderWriter
Folder FolderReaderWriter
Gallery GalleryReaderWriter
GalleryChapter models.GalleryChapterReaderWriter
Image ImageReaderWriter
Movie models.MovieReaderWriter
Performer models.PerformerReaderWriter
@ -83,6 +84,7 @@ func sqliteRepository(d *sqlite.Database) Repository {
File: d.File,
Folder: d.Folder,
Gallery: d.Gallery,
GalleryChapter: txnRepo.GalleryChapter,
Image: d.Image,
Movie: txnRepo.Movie,
Performer: txnRepo.Performer,

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,6 +10,13 @@ import (
"github.com/stashapp/stash/pkg/models/json"
)
type GalleryChapter struct {
Title string `json:"title,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"`
@ -19,6 +26,7 @@ type Gallery struct {
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"`

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

@ -46,6 +46,7 @@ func NewTxnRepository() models.Repository {
return models.Repository{
TxnManager: &TxnManager{},
Gallery: &GalleryReaderWriter{},
GalleryChapter: &GalleryChapterReaderWriter{},
Image: &ImageReaderWriter{},
Movie: &MovieReaderWriter{},
Performer: &PerformerReaderWriter{},

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

@ -17,6 +17,7 @@ type Repository struct {
File file.Store
Folder file.FolderStore
Gallery GalleryReaderWriter
GalleryChapter GalleryChapterReaderWriter
Image ImageReaderWriter
Movie MovieReaderWriter
Performer PerformerReaderWriter

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -129,6 +129,7 @@ func (db *Database) TxnRepository() models.Repository {
File: db.File,
Folder: db.Folder,
Gallery: db.Gallery,
GalleryChapter: GalleryChapterReaderWriter,
Image: db.Image,
Movie: MovieReaderWriter,
Performer: db.Performer,

View file

@ -2,6 +2,7 @@ database: generated.sqlite
scenes: 30000
images: 4000000
galleries: 1500
chapters: 3000
markers: 3000
performers: 10000
studios: 1500

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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