From 47dcdd439cea335d80025c56e2ccc9f415a41a2c Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Mon, 23 Feb 2026 07:39:28 +1100 Subject: [PATCH] Backend support for gallery custom fields (#6592) --- graphql/schema/types/filters.graphql | 2 + graphql/schema/types/gallery.graphql | 7 + internal/api/loaders/dataloaders.go | 22 +- internal/api/resolver_model_gallery.go | 13 + internal/api/resolver_mutation_gallery.go | 17 +- internal/autotag/integration_test.go | 5 +- internal/manager/task_export.go | 7 + pkg/gallery/import.go | 20 +- pkg/gallery/scan.go | 7 +- pkg/image/scan.go | 15 +- pkg/models/gallery.go | 5 + pkg/models/jsonschema/gallery.go | 33 +-- pkg/models/mocks/GalleryReaderWriter.go | 74 +++++- pkg/models/model_gallery.go | 16 ++ pkg/models/repository_gallery.go | 7 +- pkg/sqlite/anonymise.go | 4 + pkg/sqlite/custom_fields_test.go | 6 + pkg/sqlite/database.go | 2 +- pkg/sqlite/gallery.go | 33 ++- pkg/sqlite/gallery_filter.go | 7 + pkg/sqlite/gallery_test.go | 248 +++++++++++++++++- .../81_gallery_custom_fields.up.sql | 9 + pkg/sqlite/setup_test.go | 18 +- pkg/sqlite/tables.go | 1 + 24 files changed, 528 insertions(+), 50 deletions(-) create mode 100644 pkg/sqlite/migrations/81_gallery_custom_fields.up.sql diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 075e40372..d683329b6 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -596,6 +596,8 @@ input GalleryFilterType { files_filter: FileFilterType "Filter by related folders that meet this criteria" folders_filter: FolderFilterType + + custom_fields: [CustomFieldCriterionInput!] } input TagFilterType { diff --git a/graphql/schema/types/gallery.graphql b/graphql/schema/types/gallery.graphql index f456157a7..e28c3802b 100644 --- a/graphql/schema/types/gallery.graphql +++ b/graphql/schema/types/gallery.graphql @@ -32,6 +32,7 @@ type Gallery { cover: Image paths: GalleryPathsType! # Resolver + custom_fields: Map! image(index: Int!): Image! } @@ -50,6 +51,8 @@ input GalleryCreateInput { studio_id: ID tag_ids: [ID!] performer_ids: [ID!] + + custom_fields: Map } input GalleryUpdateInput { @@ -71,6 +74,8 @@ input GalleryUpdateInput { performer_ids: [ID!] primary_file_id: ID + + custom_fields: CustomFieldsInput } input BulkGalleryUpdateInput { @@ -89,6 +94,8 @@ input BulkGalleryUpdateInput { studio_id: ID tag_ids: BulkUpdateIds performer_ids: BulkUpdateIds + + custom_fields: CustomFieldsInput } input GalleryDestroyInput { diff --git a/internal/api/loaders/dataloaders.go b/internal/api/loaders/dataloaders.go index 520714432..e7293ad1c 100644 --- a/internal/api/loaders/dataloaders.go +++ b/internal/api/loaders/dataloaders.go @@ -54,8 +54,9 @@ type Loaders struct { ImageFiles *ImageFileIDsLoader GalleryFiles *GalleryFileIDsLoader - GalleryByID *GalleryLoader - ImageByID *ImageLoader + GalleryByID *GalleryLoader + GalleryCustomFields *CustomFieldsLoader + ImageByID *ImageLoader PerformerByID *PerformerLoader PerformerCustomFields *CustomFieldsLoader @@ -88,6 +89,11 @@ func (m Middleware) Middleware(next http.Handler) http.Handler { maxBatch: maxBatch, fetch: m.fetchGalleries(ctx), }, + GalleryCustomFields: &CustomFieldsLoader{ + wait: wait, + maxBatch: maxBatch, + fetch: m.fetchGalleryCustomFields(ctx), + }, ImageByID: &ImageLoader{ wait: wait, maxBatch: maxBatch, @@ -319,6 +325,18 @@ func (m Middleware) fetchTagCustomFields(ctx context.Context) func(keys []int) ( } } +func (m Middleware) fetchGalleryCustomFields(ctx context.Context) func(keys []int) ([]models.CustomFieldMap, []error) { + return func(keys []int) (ret []models.CustomFieldMap, errs []error) { + err := m.Repository.WithDB(ctx, func(ctx context.Context) error { + var err error + ret, err = m.Repository.Gallery.GetCustomFieldsBulk(ctx, keys) + return err + }) + + return ret, toErrorSlice(err) + } +} + func (m Middleware) fetchGroups(ctx context.Context) func(keys []int) ([]*models.Group, []error) { return func(keys []int) (ret []*models.Group, errs []error) { err := m.Repository.WithDB(ctx, func(ctx context.Context) error { diff --git a/internal/api/resolver_model_gallery.go b/internal/api/resolver_model_gallery.go index 9dc68b4c4..773a831d8 100644 --- a/internal/api/resolver_model_gallery.go +++ b/internal/api/resolver_model_gallery.go @@ -216,3 +216,16 @@ func (r *galleryResolver) Image(ctx context.Context, obj *models.Gallery, index return } + +func (r *galleryResolver) CustomFields(ctx context.Context, obj *models.Gallery) (map[string]interface{}, error) { + m, err := loaders.From(ctx).GalleryCustomFields.Load(obj.ID) + if err != nil { + return nil, err + } + + if m == nil { + return make(map[string]interface{}), nil + } + + return m, nil +} diff --git a/internal/api/resolver_mutation_gallery.go b/internal/api/resolver_mutation_gallery.go index e7f853922..2cd80b1ff 100644 --- a/internal/api/resolver_mutation_gallery.go +++ b/internal/api/resolver_mutation_gallery.go @@ -42,7 +42,10 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat } // Populate a new gallery from the input - newGallery := models.NewGallery() + newGallery := models.CreateGalleryInput{ + Gallery: &models.Gallery{}, + } + *newGallery.Gallery = models.NewGallery() newGallery.Title = strings.TrimSpace(input.Title) newGallery.Code = translator.string(input.Code) @@ -81,10 +84,12 @@ func (r *mutationResolver) GalleryCreate(ctx context.Context, input GalleryCreat newGallery.URLs = models.NewRelatedStrings([]string{strings.TrimSpace(*input.URL)}) } + newGallery.CustomFields = convertMapJSONNumbers(input.CustomFields) + // Start the transaction and save the gallery if err := r.withTxn(ctx, func(ctx context.Context) error { qb := r.repository.Gallery - if err := qb.Create(ctx, &newGallery, nil); err != nil { + if err := qb.Create(ctx, &newGallery); err != nil { return err } @@ -241,6 +246,10 @@ func (r *mutationResolver) galleryUpdate(ctx context.Context, input models.Galle return nil, fmt.Errorf("converting scene ids: %w", err) } + if input.CustomFields != nil { + updatedGallery.CustomFields = handleUpdateCustomFields(*input.CustomFields) + } + // gallery scene is set from the scene only gallery, err := qb.UpdatePartial(ctx, galleryID, updatedGallery) @@ -293,6 +302,10 @@ func (r *mutationResolver) BulkGalleryUpdate(ctx context.Context, input BulkGall return nil, fmt.Errorf("converting scene ids: %w", err) } + if input.CustomFields != nil { + updatedGallery.CustomFields = handleUpdateCustomFields(*input.CustomFields) + } + ret := []*models.Gallery{} // Start the transaction and save the galleries diff --git a/internal/autotag/integration_test.go b/internal/autotag/integration_test.go index 27cce014e..9745d623e 100644 --- a/internal/autotag/integration_test.go +++ b/internal/autotag/integration_test.go @@ -468,7 +468,10 @@ func makeGallery(expectedResult bool) *models.Gallery { } func createGallery(ctx context.Context, w models.GalleryWriter, o *models.Gallery, f *models.BaseFile) error { - err := w.Create(ctx, o, []models.FileID{f.ID}) + err := w.Create(ctx, &models.CreateGalleryInput{ + Gallery: o, + FileIDs: []models.FileID{f.ID}, + }) if err != nil { return fmt.Errorf("Failed to create gallery with path '%s': %s", f.Path, err.Error()) } diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 5f2897670..30adf626b 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -779,6 +779,7 @@ func (t *ExportTask) exportGallery(ctx context.Context, wg *sync.WaitGroup, jobC studioReader := r.Studio performerReader := r.Performer tagReader := r.Tag + galleryReader := r.Gallery galleryChapterReader := r.GalleryChapter for g := range jobChan { @@ -847,6 +848,12 @@ func (t *ExportTask) exportGallery(ctx context.Context, wg *sync.WaitGroup, jobC newGalleryJSON.Tags = tag.GetNames(tags) + newGalleryJSON.CustomFields, err = galleryReader.GetCustomFields(ctx, g.ID) + if err != nil { + logger.Errorf("[galleries] <%s> error getting gallery custom fields: %v", g.DisplayName(), err) + continue + } + if t.includeDependencies { if g.StudioID != nil { t.studios.IDs = sliceutil.AppendUnique(t.studios.IDs, *g.StudioID) diff --git a/pkg/gallery/import.go b/pkg/gallery/import.go index 22f3e6c44..e33297bdb 100644 --- a/pkg/gallery/import.go +++ b/pkg/gallery/import.go @@ -28,8 +28,9 @@ type Importer struct { Input jsonschema.Gallery MissingRefBehaviour models.ImportMissingRefEnum - ID int - gallery models.Gallery + ID int + gallery models.Gallery + customFields map[string]interface{} } func (i *Importer) PreImport(ctx context.Context) error { @@ -51,6 +52,8 @@ func (i *Importer) PreImport(ctx context.Context) error { return err } + i.customFields = i.Input.CustomFields + return nil } @@ -356,7 +359,11 @@ func (i *Importer) Create(ctx context.Context) (*int, error) { for _, f := range i.gallery.Files.List() { fileIDs = append(fileIDs, f.Base().ID) } - err := i.ReaderWriter.Create(ctx, &i.gallery, fileIDs) + err := i.ReaderWriter.Create(ctx, &models.CreateGalleryInput{ + Gallery: &i.gallery, + FileIDs: fileIDs, + CustomFields: i.customFields, + }) if err != nil { return nil, fmt.Errorf("error creating gallery: %v", err) } @@ -368,7 +375,12 @@ func (i *Importer) Create(ctx context.Context) (*int, error) { func (i *Importer) Update(ctx context.Context, id int) error { gallery := i.gallery gallery.ID = id - err := i.ReaderWriter.Update(ctx, &gallery) + err := i.ReaderWriter.Update(ctx, &models.UpdateGalleryInput{ + Gallery: &gallery, + CustomFields: models.CustomFieldsInput{ + Full: i.customFields, + }, + }) if err != nil { return fmt.Errorf("error updating existing gallery: %v", err) } diff --git a/pkg/gallery/scan.go b/pkg/gallery/scan.go index 9d0313b17..2064355cd 100644 --- a/pkg/gallery/scan.go +++ b/pkg/gallery/scan.go @@ -17,7 +17,7 @@ type ScanCreatorUpdater interface { FindByFingerprints(ctx context.Context, fp []models.Fingerprint) ([]*models.Gallery, error) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) - Create(ctx context.Context, newGallery *models.Gallery, fileIDs []models.FileID) error + models.GalleryCreator UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) AddFileID(ctx context.Context, id int, fileID models.FileID) error } @@ -80,7 +80,10 @@ func (h *ScanHandler) Handle(ctx context.Context, f models.File, oldFile models. logger.Infof("%s doesn't exist. Creating new gallery...", f.Base().Path) - if err := h.CreatorUpdater.Create(ctx, &newGallery, []models.FileID{baseFile.ID}); err != nil { + if err := h.CreatorUpdater.Create(ctx, &models.CreateGalleryInput{ + Gallery: &newGallery, + FileIDs: []models.FileID{baseFile.ID}, + }); err != nil { return fmt.Errorf("creating new gallery: %w", err) } diff --git a/pkg/image/scan.go b/pkg/image/scan.go index a6002057f..67f4b334c 100644 --- a/pkg/image/scan.go +++ b/pkg/image/scan.go @@ -35,7 +35,7 @@ type ScanCreatorUpdater interface { type GalleryFinderCreator interface { FindByFileID(ctx context.Context, fileID models.FileID) ([]*models.Gallery, error) FindByFolderID(ctx context.Context, folderID models.FolderID) ([]*models.Gallery, error) - Create(ctx context.Context, newObject *models.Gallery, fileIDs []models.FileID) error + models.GalleryCreator UpdatePartial(ctx context.Context, id int, updatedGallery models.GalleryPartial) (*models.Gallery, error) } @@ -252,9 +252,13 @@ func (h *ScanHandler) getOrCreateFolderBasedGallery(ctx context.Context, f model newGallery := models.NewGallery() newGallery.FolderID = &folderID + input := models.CreateGalleryInput{ + Gallery: &newGallery, + } + logger.Infof("Creating folder-based gallery for %s", filepath.Dir(f.Base().Path)) - if err := h.GalleryFinder.Create(ctx, &newGallery, nil); err != nil { + if err := h.GalleryFinder.Create(ctx, &input); err != nil { return nil, fmt.Errorf("creating folder based gallery: %w", err) } @@ -308,7 +312,12 @@ func (h *ScanHandler) getOrCreateZipBasedGallery(ctx context.Context, zipFile mo logger.Infof("%s doesn't exist. Creating new gallery...", zipFile.Base().Path) - if err := h.GalleryFinder.Create(ctx, &newGallery, []models.FileID{zipFile.Base().ID}); err != nil { + input := models.CreateGalleryInput{ + Gallery: &newGallery, + FileIDs: []models.FileID{zipFile.Base().ID}, + } + + if err := h.GalleryFinder.Create(ctx, &input); err != nil { return nil, fmt.Errorf("creating zip-based gallery: %w", err) } diff --git a/pkg/models/gallery.go b/pkg/models/gallery.go index dfc776afe..8f335020a 100644 --- a/pkg/models/gallery.go +++ b/pkg/models/gallery.go @@ -67,6 +67,9 @@ type GalleryFilterType struct { CreatedAt *TimestampCriterionInput `json:"created_at"` // Filter by updated at UpdatedAt *TimestampCriterionInput `json:"updated_at"` + + // Filter by custom fields + CustomFields []CustomFieldCriterionInput `json:"custom_fields"` } type GalleryUpdateInput struct { @@ -86,6 +89,8 @@ type GalleryUpdateInput struct { PerformerIds []string `json:"performer_ids"` PrimaryFileID *string `json:"primary_file_id"` + CustomFields *CustomFieldsInput `json:"custom_fields"` + // deprecated URL *string `json:"url"` } diff --git a/pkg/models/jsonschema/gallery.go b/pkg/models/jsonschema/gallery.go index 7323e37ba..5fb6e16ab 100644 --- a/pkg/models/jsonschema/gallery.go +++ b/pkg/models/jsonschema/gallery.go @@ -18,22 +18,23 @@ type GalleryChapter struct { } type Gallery struct { - ZipFiles []string `json:"zip_files,omitempty"` - FolderPath string `json:"folder_path,omitempty"` - Title string `json:"title,omitempty"` - Code string `json:"code,omitempty"` - URLs []string `json:"urls,omitempty"` - Date string `json:"date,omitempty"` - Details string `json:"details,omitempty"` - Photographer string `json:"photographer,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"` + ZipFiles []string `json:"zip_files,omitempty"` + FolderPath string `json:"folder_path,omitempty"` + Title string `json:"title,omitempty"` + Code string `json:"code,omitempty"` + URLs []string `json:"urls,omitempty"` + Date string `json:"date,omitempty"` + Details string `json:"details,omitempty"` + Photographer string `json:"photographer,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"` + CustomFields map[string]interface{} `json:"custom_fields,omitempty"` // deprecated - for import only URL string `json:"url,omitempty"` diff --git a/pkg/models/mocks/GalleryReaderWriter.go b/pkg/models/mocks/GalleryReaderWriter.go index f07f8a7d9..f20d9f76e 100644 --- a/pkg/models/mocks/GalleryReaderWriter.go +++ b/pkg/models/mocks/GalleryReaderWriter.go @@ -114,13 +114,13 @@ func (_m *GalleryReaderWriter) CountByFileID(ctx context.Context, fileID models. return r0, r1 } -// Create provides a mock function with given fields: ctx, newGallery, fileIDs -func (_m *GalleryReaderWriter) Create(ctx context.Context, newGallery *models.Gallery, fileIDs []models.FileID) error { - ret := _m.Called(ctx, newGallery, fileIDs) +// Create provides a mock function with given fields: ctx, newGallery +func (_m *GalleryReaderWriter) Create(ctx context.Context, newGallery *models.CreateGalleryInput) error { + ret := _m.Called(ctx, newGallery) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Gallery, []models.FileID) error); ok { - r0 = rf(ctx, newGallery, fileIDs) + if rf, ok := ret.Get(0).(func(context.Context, *models.CreateGalleryInput) error); ok { + r0 = rf(ctx, newGallery) } else { r0 = ret.Error(0) } @@ -395,6 +395,52 @@ func (_m *GalleryReaderWriter) FindUserGalleryByTitle(ctx context.Context, title return r0, r1 } +// GetCustomFields provides a mock function with given fields: ctx, id +func (_m *GalleryReaderWriter) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { + ret := _m.Called(ctx, id) + + var r0 map[string]interface{} + if rf, ok := ret.Get(0).(func(context.Context, int) map[string]interface{}); ok { + r0 = rf(ctx, id) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(map[string]interface{}) + } + } + + 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 +} + +// GetCustomFieldsBulk provides a mock function with given fields: ctx, ids +func (_m *GalleryReaderWriter) GetCustomFieldsBulk(ctx context.Context, ids []int) ([]models.CustomFieldMap, error) { + ret := _m.Called(ctx, ids) + + var r0 []models.CustomFieldMap + if rf, ok := ret.Get(0).(func(context.Context, []int) []models.CustomFieldMap); ok { + r0 = rf(ctx, ids) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.CustomFieldMap) + } + } + + 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 +} + // GetFiles provides a mock function with given fields: ctx, relatedID func (_m *GalleryReaderWriter) GetFiles(ctx context.Context, relatedID int) ([]models.File, error) { ret := _m.Called(ctx, relatedID) @@ -656,12 +702,26 @@ func (_m *GalleryReaderWriter) SetCover(ctx context.Context, galleryID int, cove return r0 } +// SetCustomFields provides a mock function with given fields: ctx, id, fields +func (_m *GalleryReaderWriter) SetCustomFields(ctx context.Context, id int, fields models.CustomFieldsInput) error { + ret := _m.Called(ctx, id, fields) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int, models.CustomFieldsInput) error); ok { + r0 = rf(ctx, id, fields) + } else { + r0 = ret.Error(0) + } + + return r0 +} + // Update provides a mock function with given fields: ctx, updatedGallery -func (_m *GalleryReaderWriter) Update(ctx context.Context, updatedGallery *models.Gallery) error { +func (_m *GalleryReaderWriter) Update(ctx context.Context, updatedGallery *models.UpdateGalleryInput) error { ret := _m.Called(ctx, updatedGallery) var r0 error - if rf, ok := ret.Get(0).(func(context.Context, *models.Gallery) error); ok { + if rf, ok := ret.Get(0).(func(context.Context, *models.UpdateGalleryInput) error); ok { r0 = rf(ctx, updatedGallery) } else { r0 = ret.Error(0) diff --git a/pkg/models/model_gallery.go b/pkg/models/model_gallery.go index 4b6a3183d..bbdba46a6 100644 --- a/pkg/models/model_gallery.go +++ b/pkg/models/model_gallery.go @@ -46,6 +46,20 @@ func NewGallery() Gallery { } } +type CreateGalleryInput struct { + *Gallery + + FileIDs []FileID + CustomFields map[string]interface{} `json:"custom_fields"` +} + +type UpdateGalleryInput struct { + *Gallery + + FileIDs []FileID + CustomFields CustomFieldsInput `json:"custom_fields"` +} + // GalleryPartial represents part of a Gallery object. It is used to update // the database entry. Only non-nil fields will be updated. type GalleryPartial struct { @@ -70,6 +84,8 @@ type GalleryPartial struct { TagIDs *UpdateIDs PerformerIDs *UpdateIDs PrimaryFileID *FileID + + CustomFields CustomFieldsInput } func NewGalleryPartial() GalleryPartial { diff --git a/pkg/models/repository_gallery.go b/pkg/models/repository_gallery.go index 0cfb9964f..b8f1452f3 100644 --- a/pkg/models/repository_gallery.go +++ b/pkg/models/repository_gallery.go @@ -37,12 +37,12 @@ type GalleryCounter interface { // GalleryCreator provides methods to create galleries. type GalleryCreator interface { - Create(ctx context.Context, newGallery *Gallery, fileIDs []FileID) error + Create(ctx context.Context, newGallery *CreateGalleryInput) error } // GalleryUpdater provides methods to update galleries. type GalleryUpdater interface { - Update(ctx context.Context, updatedGallery *Gallery) error + Update(ctx context.Context, updatedGallery *UpdateGalleryInput) error UpdatePartial(ctx context.Context, id int, updatedGallery GalleryPartial) (*Gallery, error) UpdateImages(ctx context.Context, galleryID int, imageIDs []int) error } @@ -70,6 +70,7 @@ type GalleryReader interface { PerformerIDLoader TagIDLoader FileLoader + CustomFieldsReader All(ctx context.Context) ([]*Gallery, error) } @@ -80,6 +81,8 @@ type GalleryWriter interface { GalleryUpdater GalleryDestroyer + CustomFieldsWriter + AddFileID(ctx context.Context, id int, fileID FileID) error AddImages(ctx context.Context, galleryID int, imageIDs ...int) error RemoveImages(ctx context.Context, galleryID int, imageIDs ...int) error diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index e0a354980..6a5cd4da5 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -522,6 +522,10 @@ func (db *Anonymiser) anonymiseGalleries(ctx context.Context) error { return err } + if err := db.anonymiseCustomFields(ctx, goqu.T(galleriesCustomFieldsTable.GetTable()), "gallery_id"); err != nil { + return err + } + return nil } diff --git a/pkg/sqlite/custom_fields_test.go b/pkg/sqlite/custom_fields_test.go index a2c045851..ae4a276f7 100644 --- a/pkg/sqlite/custom_fields_test.go +++ b/pkg/sqlite/custom_fields_test.go @@ -240,3 +240,9 @@ func TestSceneSetCustomFields(t *testing.T) { testSetCustomFields(t, "Scene", db.Scene, sceneIDs[sceneIdx], getSceneCustomFields(sceneIdx)) } + +func TestGallerySetCustomFields(t *testing.T) { + galleryIdx := galleryIdxWithScene + + testSetCustomFields(t, "Gallery", db.Gallery, galleryIDs[galleryIdx], getGalleryCustomFields(galleryIdx)) +} diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 5b67e5602..d95832836 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 80 +var appSchemaVersion uint = 81 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/gallery.go b/pkg/sqlite/gallery.go index 41729057b..305b1fe09 100644 --- a/pkg/sqlite/gallery.go +++ b/pkg/sqlite/gallery.go @@ -183,6 +183,8 @@ var ( ) type GalleryStore struct { + customFieldsStore + tableMgr *table fileStore *FileStore @@ -191,6 +193,10 @@ type GalleryStore struct { func NewGalleryStore(fileStore *FileStore, folderStore *FolderStore) *GalleryStore { return &GalleryStore{ + customFieldsStore: customFieldsStore{ + table: galleriesCustomFieldsTable, + fk: galleriesCustomFieldsTable.Col(galleryIDColumn), + }, tableMgr: galleryTableMgr, fileStore: fileStore, folderStore: folderStore, @@ -231,18 +237,18 @@ func (qb *GalleryStore) selectDataset() *goqu.SelectDataset { ) } -func (qb *GalleryStore) Create(ctx context.Context, newObject *models.Gallery, fileIDs []models.FileID) error { +func (qb *GalleryStore) Create(ctx context.Context, newObject *models.CreateGalleryInput) error { var r galleryRow - r.fromGallery(*newObject) + r.fromGallery(*newObject.Gallery) id, err := qb.tableMgr.insertID(ctx, r) if err != nil { return err } - if len(fileIDs) > 0 { + if len(newObject.FileIDs) > 0 { const firstPrimary = true - if err := galleriesFilesTableMgr.insertJoins(ctx, id, firstPrimary, fileIDs); err != nil { + if err := galleriesFilesTableMgr.insertJoins(ctx, id, firstPrimary, newObject.FileIDs); err != nil { return err } } @@ -269,19 +275,24 @@ func (qb *GalleryStore) Create(ctx context.Context, newObject *models.Gallery, f } } + const partial = false + if err := qb.setCustomFields(ctx, id, newObject.CustomFields, partial); err != nil { + return err + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) } - *newObject = *updated + *newObject.Gallery = *updated return nil } -func (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.Gallery) error { +func (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.UpdateGalleryInput) error { var r galleryRow - r.fromGallery(*updatedObject) + r.fromGallery(*updatedObject.Gallery) if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil { return err @@ -319,6 +330,10 @@ func (qb *GalleryStore) Update(ctx context.Context, updatedObject *models.Galler } } + if err := qb.SetCustomFields(ctx, updatedObject.ID, updatedObject.CustomFields); err != nil { + return err + } + return nil } @@ -364,6 +379,10 @@ func (qb *GalleryStore) UpdatePartial(ctx context.Context, id int, partial model } } + if err := qb.SetCustomFields(ctx, id, partial.CustomFields); err != nil { + return nil, err + } + return qb.find(ctx, id) } diff --git a/pkg/sqlite/gallery_filter.go b/pkg/sqlite/gallery_filter.go index f05ff7b81..f920e442a 100644 --- a/pkg/sqlite/gallery_filter.go +++ b/pkg/sqlite/gallery_filter.go @@ -105,6 +105,13 @@ func (qb *galleryFilterHandler) criterionHandler() criterionHandler { ×tampCriterionHandler{filter.CreatedAt, "galleries.created_at", nil}, ×tampCriterionHandler{filter.UpdatedAt, "galleries.updated_at", nil}, + &customFieldsFilterHandler{ + table: galleriesCustomFieldsTable.GetTable(), + fkCol: galleryIDColumn, + c: filter.CustomFields, + idCol: "galleries.id", + }, + &relatedFilterHandler{ relatedIDCol: "scenes_galleries.scene_id", relatedRepo: sceneRepository.repository, diff --git a/pkg/sqlite/gallery_test.go b/pkg/sqlite/gallery_test.go index 06d7daf17..4156f129c 100644 --- a/pkg/sqlite/gallery_test.go +++ b/pkg/sqlite/gallery_test.go @@ -160,7 +160,10 @@ func Test_galleryQueryBuilder_Create(t *testing.T) { fileIDs = []models.FileID{s.Files.List()[0].Base().ID} } - if err := qb.Create(ctx, &s, fileIDs); (err != nil) != tt.wantErr { + if err := qb.Create(ctx, &models.CreateGalleryInput{ + Gallery: &s, + FileIDs: fileIDs, + }); (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.Create() error = %v, wantErr = %v", err, tt.wantErr) } @@ -360,7 +363,9 @@ func Test_galleryQueryBuilder_Update(t *testing.T) { copy := *tt.updatedObject - if err := qb.Update(ctx, tt.updatedObject); (err != nil) != tt.wantErr { + if err := qb.Update(ctx, &models.UpdateGalleryInput{ + Gallery: tt.updatedObject, + }); (err != nil) != tt.wantErr { t.Errorf("galleryQueryBuilder.Update() error = %v, wantErr %v", err, tt.wantErr) } @@ -3001,6 +3006,245 @@ func TestGallerySetAndResetCover(t *testing.T) { }) } +func TestGalleryQueryCustomFields(t *testing.T) { + tests := []struct { + name string + filter *models.GalleryFilterType + includeIdxs []int + excludeIdxs []int + wantErr bool + }{ + { + "equals", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierEquals, + Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")}, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "not equals", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotEquals, + Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")}, + }, + }, + }, + nil, + []int{galleryIdxWithImage}, + false, + }, + { + "includes", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierIncludes, + Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")[9:]}, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "excludes", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierExcludes, + Value: []any{getGalleryStringValue(galleryIdxWithImage, "custom")[9:]}, + }, + }, + }, + nil, + []int{galleryIdxWithImage}, + false, + }, + { + "regex", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + []int{galleryIdxWithPerformerTag}, + nil, + false, + }, + { + "invalid regex", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "not matches regex", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithPerformerTag, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{".*17_custom"}, + }, + }, + }, + nil, + []int{galleryIdxWithPerformerTag}, + false, + }, + { + "invalid not matches regex", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotMatchesRegex, + Value: []any{"["}, + }, + }, + }, + nil, + nil, + true, + }, + { + "null", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "not existing", + Modifier: models.CriterionModifierIsNull, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "not null", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "string", + Modifier: models.CriterionModifierNotNull, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "between", + &models.GalleryFilterType{ + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + []int{galleryIdxWithImage}, + nil, + false, + }, + { + "not between", + &models.GalleryFilterType{ + Title: &models.StringCriterionInput{ + Value: getGalleryStringValue(galleryIdxWithImage, titleField), + Modifier: models.CriterionModifierEquals, + }, + CustomFields: []models.CustomFieldCriterionInput{ + { + Field: "real", + Modifier: models.CriterionModifierNotBetween, + Value: []any{0.15, 0.25}, + }, + }, + }, + nil, + []int{galleryIdxWithImage}, + false, + }, + } + + for _, tt := range tests { + runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + + galleries, _, err := db.Gallery.Query(ctx, tt.filter, nil) + if (err != nil) != tt.wantErr { + t.Errorf("GalleryStore.Query() error = %v, wantErr %v", err, tt.wantErr) + } + + if err != nil { + return + } + + ids := galleriesToIDs(galleries) + include := indexesToIDs(galleryIDs, tt.includeIdxs) + exclude := indexesToIDs(galleryIDs, tt.excludeIdxs) + + for _, i := range include { + assert.Contains(ids, i) + } + for _, e := range exclude { + assert.NotContains(ids, e) + } + }) + } +} + // TODO Count // TODO All // TODO Query diff --git a/pkg/sqlite/migrations/81_gallery_custom_fields.up.sql b/pkg/sqlite/migrations/81_gallery_custom_fields.up.sql new file mode 100644 index 000000000..89a6e4c05 --- /dev/null +++ b/pkg/sqlite/migrations/81_gallery_custom_fields.up.sql @@ -0,0 +1,9 @@ +CREATE TABLE `gallery_custom_fields` ( + `gallery_id` integer NOT NULL, + `field` varchar(64) NOT NULL, + `value` BLOB NOT NULL, + PRIMARY KEY (`gallery_id`, `field`), + foreign key(`gallery_id`) references `galleries`(`id`) on delete CASCADE +); + +CREATE INDEX `index_gallery_custom_fields_field_value` ON `gallery_custom_fields` (`field`, `value`); diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 91f9f127b..675b3f417 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1389,6 +1389,18 @@ func makeGallery(i int, includeScenes bool) *models.Gallery { return ret } +func getGalleryCustomFields(index int) map[string]interface{} { + if index%5 == 0 { + return nil + } + + return map[string]interface{}{ + "string": getGalleryStringValue(index, "custom"), + "int": int64(index % 5), + "real": float64(index) / 10, + } +} + func createGalleries(ctx context.Context, n int) error { gqb := db.Gallery fqb := db.File @@ -1410,7 +1422,11 @@ func createGalleries(ctx context.Context, n int) error { const includeScenes = false gallery := makeGallery(i, includeScenes) - err := gqb.Create(ctx, gallery, fileIDs) + err := gqb.Create(ctx, &models.CreateGalleryInput{ + Gallery: gallery, + FileIDs: fileIDs, + CustomFields: getGalleryCustomFields(i), + }) if err != nil { return fmt.Errorf("Error creating gallery %v+: %s", gallery, err.Error()) diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 53e62b166..7867054ba 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -20,6 +20,7 @@ var ( performersGalleriesJoinTable = goqu.T(performersGalleriesTable) galleriesScenesJoinTable = goqu.T(galleriesScenesTable) galleriesURLsJoinTable = goqu.T(galleriesURLsTable) + galleriesCustomFieldsTable = goqu.T("gallery_custom_fields") scenesFilesJoinTable = goqu.T(scenesFilesTable) scenesTagsJoinTable = goqu.T(scenesTagsTable)