mirror of
https://github.com/stashapp/stash.git
synced 2026-05-08 01:29:46 -05:00
Add Chapters for Galleries (#3289)
Co-authored-by: WithoutPants <53250216+WithoutPants@users.noreply.github.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type Importer struct {
|
||||
Input jsonschema.Gallery
|
||||
MissingRefBehaviour models.ImportMissingRefEnum
|
||||
|
||||
ID int
|
||||
gallery models.Gallery
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -10,22 +10,30 @@ import (
|
||||
"github.com/stashapp/stash/pkg/models/json"
|
||||
)
|
||||
|
||||
type Gallery struct {
|
||||
ZipFiles []string `json:"zip_files,omitempty"`
|
||||
FolderPath string `json:"folder_path,omitempty"`
|
||||
type GalleryChapter struct {
|
||||
Title string `json:"title,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Details string `json:"details,omitempty"`
|
||||
Rating int `json:"rating,omitempty"`
|
||||
Organized bool `json:"organized,omitempty"`
|
||||
Studio string `json:"studio,omitempty"`
|
||||
Performers []string `json:"performers,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
ImageIndex int `json:"image_index,omitempty"`
|
||||
CreatedAt json.JSONTime `json:"created_at,omitempty"`
|
||||
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
type Gallery struct {
|
||||
ZipFiles []string `json:"zip_files,omitempty"`
|
||||
FolderPath string `json:"folder_path,omitempty"`
|
||||
Title string `json:"title,omitempty"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Date string `json:"date,omitempty"`
|
||||
Details string `json:"details,omitempty"`
|
||||
Rating int `json:"rating,omitempty"`
|
||||
Organized bool `json:"organized,omitempty"`
|
||||
Chapters []GalleryChapter `json:"chapters,omitempty"`
|
||||
Studio string `json:"studio,omitempty"`
|
||||
Performers []string `json:"performers,omitempty"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
CreatedAt json.JSONTime `json:"created_at,omitempty"`
|
||||
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
func (s Gallery) Filename(basename string, hash string) string {
|
||||
ret := fsutil.SanitiseBasename(basename)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -44,16 +44,17 @@ func (*TxnManager) Reset() error {
|
||||
|
||||
func NewTxnRepository() models.Repository {
|
||||
return models.Repository{
|
||||
TxnManager: &TxnManager{},
|
||||
Gallery: &GalleryReaderWriter{},
|
||||
Image: &ImageReaderWriter{},
|
||||
Movie: &MovieReaderWriter{},
|
||||
Performer: &PerformerReaderWriter{},
|
||||
Scene: &SceneReaderWriter{},
|
||||
SceneMarker: &SceneMarkerReaderWriter{},
|
||||
ScrapedItem: &ScrapedItemReaderWriter{},
|
||||
Studio: &StudioReaderWriter{},
|
||||
Tag: &TagReaderWriter{},
|
||||
SavedFilter: &SavedFilterReaderWriter{},
|
||||
TxnManager: &TxnManager{},
|
||||
Gallery: &GalleryReaderWriter{},
|
||||
GalleryChapter: &GalleryChapterReaderWriter{},
|
||||
Image: &ImageReaderWriter{},
|
||||
Movie: &MovieReaderWriter{},
|
||||
Performer: &PerformerReaderWriter{},
|
||||
Scene: &SceneReaderWriter{},
|
||||
SceneMarker: &SceneMarkerReaderWriter{},
|
||||
ScrapedItem: &ScrapedItemReaderWriter{},
|
||||
Studio: &StudioReaderWriter{},
|
||||
Tag: &TagReaderWriter{},
|
||||
SavedFilter: &SavedFilterReaderWriter{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
}
|
||||
+13
-12
@@ -14,16 +14,17 @@ type TxnManager interface {
|
||||
type Repository struct {
|
||||
TxnManager
|
||||
|
||||
File file.Store
|
||||
Folder file.FolderStore
|
||||
Gallery GalleryReaderWriter
|
||||
Image ImageReaderWriter
|
||||
Movie MovieReaderWriter
|
||||
Performer PerformerReaderWriter
|
||||
Scene SceneReaderWriter
|
||||
SceneMarker SceneMarkerReaderWriter
|
||||
ScrapedItem ScrapedItemReaderWriter
|
||||
Studio StudioReaderWriter
|
||||
Tag TagReaderWriter
|
||||
SavedFilter SavedFilterReaderWriter
|
||||
File file.Store
|
||||
Folder file.FolderStore
|
||||
Gallery GalleryReaderWriter
|
||||
GalleryChapter GalleryChapterReaderWriter
|
||||
Image ImageReaderWriter
|
||||
Movie MovieReaderWriter
|
||||
Performer PerformerReaderWriter
|
||||
Scene SceneReaderWriter
|
||||
SceneMarker SceneMarkerReaderWriter
|
||||
ScrapedItem ScrapedItemReaderWriter
|
||||
Studio StudioReaderWriter
|
||||
Tag TagReaderWriter
|
||||
SavedFilter SavedFilterReaderWriter
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -32,7 +32,7 @@ const (
|
||||
dbConnTimeout = 30
|
||||
)
|
||||
|
||||
var appSchemaVersion uint = 43
|
||||
var appSchemaVersion uint = 44
|
||||
|
||||
//go:embed migrations/*.sql
|
||||
var migrationsBox embed.FS
|
||||
|
||||
+20
-1
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
@@ -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:
|
||||
|
||||
+14
-13
@@ -125,18 +125,19 @@ func (db *Database) IsLocked(err error) bool {
|
||||
|
||||
func (db *Database) TxnRepository() models.Repository {
|
||||
return models.Repository{
|
||||
TxnManager: db,
|
||||
File: db.File,
|
||||
Folder: db.Folder,
|
||||
Gallery: db.Gallery,
|
||||
Image: db.Image,
|
||||
Movie: MovieReaderWriter,
|
||||
Performer: db.Performer,
|
||||
Scene: db.Scene,
|
||||
SceneMarker: SceneMarkerReaderWriter,
|
||||
ScrapedItem: ScrapedItemReaderWriter,
|
||||
Studio: StudioReaderWriter,
|
||||
Tag: TagReaderWriter,
|
||||
SavedFilter: SavedFilterReaderWriter,
|
||||
TxnManager: db,
|
||||
File: db.File,
|
||||
Folder: db.Folder,
|
||||
Gallery: db.Gallery,
|
||||
GalleryChapter: GalleryChapterReaderWriter,
|
||||
Image: db.Image,
|
||||
Movie: MovieReaderWriter,
|
||||
Performer: db.Performer,
|
||||
Scene: db.Scene,
|
||||
SceneMarker: SceneMarkerReaderWriter,
|
||||
ScrapedItem: ScrapedItemReaderWriter,
|
||||
Studio: StudioReaderWriter,
|
||||
Tag: TagReaderWriter,
|
||||
SavedFilter: SavedFilterReaderWriter,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user