Images section (#813)

* Add new configuration options
* Refactor scan/clean
* Schema changes
* Add details to galleries
* Remove redundant code
* Refine thumbnail generation
* Gallery overhaul
* Don't allow modifying zip gallery images
* Show gallery card overlays
* Hide zoom slider when not in grid mode
This commit is contained in:
WithoutPants
2020-10-13 10:12:46 +11:00
committed by GitHub
parent df3252e24f
commit aca2c7c5f4
147 changed files with 12483 additions and 946 deletions

View File

@@ -10,6 +10,7 @@ type GalleryReader interface {
FindByChecksum(checksum string) (*Gallery, error)
FindByPath(path string) (*Gallery, error)
FindBySceneID(sceneID int) (*Gallery, error)
FindByImageID(imageID int) ([]*Gallery, error)
// ValidGalleriesForScenePath(scenePath string) ([]*Gallery, error)
// Count() (int, error)
All() ([]*Gallery, error)
@@ -60,6 +61,10 @@ func (t *galleryReaderWriter) FindBySceneID(sceneID int) (*Gallery, error) {
return t.qb.FindBySceneID(sceneID, t.tx)
}
func (t *galleryReaderWriter) FindByImageID(imageID int) ([]*Gallery, error) {
return t.qb.FindByImageID(imageID, t.tx)
}
func (t *galleryReaderWriter) Create(newGallery Gallery) (*Gallery, error) {
return t.qb.Create(newGallery, t.tx)
}

72
pkg/models/image.go Normal file
View File

@@ -0,0 +1,72 @@
package models
import (
"github.com/jmoiron/sqlx"
)
type ImageReader interface {
// Find(id int) (*Image, error)
FindMany(ids []int) ([]*Image, error)
FindByChecksum(checksum string) (*Image, error)
// FindByPath(path string) (*Image, error)
// FindByPerformerID(performerID int) ([]*Image, error)
// CountByPerformerID(performerID int) (int, error)
// FindByStudioID(studioID int) ([]*Image, error)
// Count() (int, error)
// SizeCount() (string, error)
// CountByStudioID(studioID int) (int, error)
// CountByTagID(tagID int) (int, error)
All() ([]*Image, error)
// Query(imageFilter *ImageFilterType, findFilter *FindFilterType) ([]*Image, int)
}
type ImageWriter interface {
Create(newImage Image) (*Image, error)
Update(updatedImage ImagePartial) (*Image, error)
UpdateFull(updatedImage Image) (*Image, error)
// IncrementOCounter(id int) (int, error)
// DecrementOCounter(id int) (int, error)
// ResetOCounter(id int) (int, error)
// Destroy(id string) error
}
type ImageReaderWriter interface {
ImageReader
ImageWriter
}
func NewImageReaderWriter(tx *sqlx.Tx) ImageReaderWriter {
return &imageReaderWriter{
tx: tx,
qb: NewImageQueryBuilder(),
}
}
type imageReaderWriter struct {
tx *sqlx.Tx
qb ImageQueryBuilder
}
func (t *imageReaderWriter) FindMany(ids []int) ([]*Image, error) {
return t.qb.FindMany(ids)
}
func (t *imageReaderWriter) FindByChecksum(checksum string) (*Image, error) {
return t.qb.FindByChecksum(checksum)
}
func (t *imageReaderWriter) All() ([]*Image, error) {
return t.qb.All()
}
func (t *imageReaderWriter) Create(newImage Image) (*Image, error) {
return t.qb.Create(newImage, t.tx)
}
func (t *imageReaderWriter) Update(updatedImage ImagePartial) (*Image, error) {
return t.qb.Update(updatedImage, t.tx)
}
func (t *imageReaderWriter) UpdateFull(updatedImage Image) (*Image, error) {
return t.qb.UpdateFull(updatedImage, t.tx)
}

View File

@@ -28,6 +28,11 @@ type JoinWriter interface {
// DestroySceneMarkersTags(sceneMarkerID int, updatedJoins []SceneMarkersTags) error
// DestroyScenesGalleries(sceneID int) error
// DestroyScenesMarkers(sceneID int) error
UpdatePerformersGalleries(galleryID int, updatedJoins []PerformersGalleries) error
UpdateGalleriesTags(galleryID int, updatedJoins []GalleriesTags) error
UpdateGalleriesImages(imageID int, updatedJoins []GalleriesImages) error
UpdatePerformersImages(imageID int, updatedJoins []PerformersImages) error
UpdateImagesTags(imageID int, updatedJoins []ImagesTags) error
}
type JoinReaderWriter interface {
@@ -74,3 +79,23 @@ func (t *joinReaderWriter) UpdateScenesTags(sceneID int, updatedJoins []ScenesTa
func (t *joinReaderWriter) UpdateSceneMarkersTags(sceneMarkerID int, updatedJoins []SceneMarkersTags) error {
return t.qb.UpdateSceneMarkersTags(sceneMarkerID, updatedJoins, t.tx)
}
func (t *joinReaderWriter) UpdatePerformersGalleries(galleryID int, updatedJoins []PerformersGalleries) error {
return t.qb.UpdatePerformersGalleries(galleryID, updatedJoins, t.tx)
}
func (t *joinReaderWriter) UpdateGalleriesTags(galleryID int, updatedJoins []GalleriesTags) error {
return t.qb.UpdateGalleriesTags(galleryID, updatedJoins, t.tx)
}
func (t *joinReaderWriter) UpdateGalleriesImages(imageID int, updatedJoins []GalleriesImages) error {
return t.qb.UpdateGalleriesImages(imageID, updatedJoins, t.tx)
}
func (t *joinReaderWriter) UpdatePerformersImages(imageID int, updatedJoins []PerformersImages) error {
return t.qb.UpdatePerformersImages(imageID, updatedJoins, t.tx)
}
func (t *joinReaderWriter) UpdateImagesTags(imageID int, updatedJoins []ImagesTags) error {
return t.qb.UpdateImagesTags(imageID, updatedJoins, t.tx)
}

View File

@@ -81,6 +81,29 @@ func (_m *GalleryReaderWriter) FindByChecksum(checksum string) (*models.Gallery,
return r0, r1
}
// FindByImageID provides a mock function with given fields: imageID
func (_m *GalleryReaderWriter) FindByImageID(imageID int) ([]*models.Gallery, error) {
ret := _m.Called(imageID)
var r0 []*models.Gallery
if rf, ok := ret.Get(0).(func(int) []*models.Gallery); ok {
r0 = rf(imageID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Gallery)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(imageID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByPath provides a mock function with given fields: path
func (_m *GalleryReaderWriter) FindByPath(path string) (*models.Gallery, error) {
ret := _m.Called(path)

View File

@@ -0,0 +1,151 @@
// Code generated by mockery v0.0.0-dev. DO NOT EDIT.
package mocks
import (
models "github.com/stashapp/stash/pkg/models"
mock "github.com/stretchr/testify/mock"
)
// ImageReaderWriter is an autogenerated mock type for the ImageReaderWriter type
type ImageReaderWriter struct {
mock.Mock
}
// All provides a mock function with given fields:
func (_m *ImageReaderWriter) All() ([]*models.Image, error) {
ret := _m.Called()
var r0 []*models.Image
if rf, ok := ret.Get(0).(func() []*models.Image); ok {
r0 = rf()
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Image)
}
}
var r1 error
if rf, ok := ret.Get(1).(func() error); ok {
r1 = rf()
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Create provides a mock function with given fields: newImage
func (_m *ImageReaderWriter) Create(newImage models.Image) (*models.Image, error) {
ret := _m.Called(newImage)
var r0 *models.Image
if rf, ok := ret.Get(0).(func(models.Image) *models.Image); ok {
r0 = rf(newImage)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.Image)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(models.Image) error); ok {
r1 = rf(newImage)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByChecksum provides a mock function with given fields: checksum
func (_m *ImageReaderWriter) FindByChecksum(checksum string) (*models.Image, error) {
ret := _m.Called(checksum)
var r0 *models.Image
if rf, ok := ret.Get(0).(func(string) *models.Image); ok {
r0 = rf(checksum)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.Image)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(checksum)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindMany provides a mock function with given fields: ids
func (_m *ImageReaderWriter) FindMany(ids []int) ([]*models.Image, error) {
ret := _m.Called(ids)
var r0 []*models.Image
if rf, ok := ret.Get(0).(func([]int) []*models.Image); ok {
r0 = rf(ids)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Image)
}
}
var r1 error
if rf, ok := ret.Get(1).(func([]int) error); ok {
r1 = rf(ids)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Update provides a mock function with given fields: updatedImage
func (_m *ImageReaderWriter) Update(updatedImage models.ImagePartial) (*models.Image, error) {
ret := _m.Called(updatedImage)
var r0 *models.Image
if rf, ok := ret.Get(0).(func(models.ImagePartial) *models.Image); ok {
r0 = rf(updatedImage)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.Image)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(models.ImagePartial) error); ok {
r1 = rf(updatedImage)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// UpdateFull provides a mock function with given fields: updatedImage
func (_m *ImageReaderWriter) UpdateFull(updatedImage models.Image) (*models.Image, error) {
ret := _m.Called(updatedImage)
var r0 *models.Image
if rf, ok := ret.Get(0).(func(models.Image) *models.Image); ok {
r0 = rf(updatedImage)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*models.Image)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(models.Image) error); ok {
r1 = rf(updatedImage)
} else {
r1 = ret.Error(1)
}
return r0, r1
}

View File

@@ -63,6 +63,48 @@ func (_m *JoinReaderWriter) GetSceneMovies(sceneID int) ([]models.MoviesScenes,
return r0, r1
}
// UpdateGalleriesImages provides a mock function with given fields: imageID, updatedJoins
func (_m *JoinReaderWriter) UpdateGalleriesImages(imageID int, updatedJoins []models.GalleriesImages) error {
ret := _m.Called(imageID, updatedJoins)
var r0 error
if rf, ok := ret.Get(0).(func(int, []models.GalleriesImages) error); ok {
r0 = rf(imageID, updatedJoins)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateGalleriesTags provides a mock function with given fields: galleryID, updatedJoins
func (_m *JoinReaderWriter) UpdateGalleriesTags(galleryID int, updatedJoins []models.GalleriesTags) error {
ret := _m.Called(galleryID, updatedJoins)
var r0 error
if rf, ok := ret.Get(0).(func(int, []models.GalleriesTags) error); ok {
r0 = rf(galleryID, updatedJoins)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateImagesTags provides a mock function with given fields: imageID, updatedJoins
func (_m *JoinReaderWriter) UpdateImagesTags(imageID int, updatedJoins []models.ImagesTags) error {
ret := _m.Called(imageID, updatedJoins)
var r0 error
if rf, ok := ret.Get(0).(func(int, []models.ImagesTags) error); ok {
r0 = rf(imageID, updatedJoins)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdateMoviesScenes provides a mock function with given fields: sceneID, updatedJoins
func (_m *JoinReaderWriter) UpdateMoviesScenes(sceneID int, updatedJoins []models.MoviesScenes) error {
ret := _m.Called(sceneID, updatedJoins)
@@ -77,6 +119,34 @@ func (_m *JoinReaderWriter) UpdateMoviesScenes(sceneID int, updatedJoins []model
return r0
}
// UpdatePerformersGalleries provides a mock function with given fields: galleryID, updatedJoins
func (_m *JoinReaderWriter) UpdatePerformersGalleries(galleryID int, updatedJoins []models.PerformersGalleries) error {
ret := _m.Called(galleryID, updatedJoins)
var r0 error
if rf, ok := ret.Get(0).(func(int, []models.PerformersGalleries) error); ok {
r0 = rf(galleryID, updatedJoins)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdatePerformersImages provides a mock function with given fields: imageID, updatedJoins
func (_m *JoinReaderWriter) UpdatePerformersImages(imageID int, updatedJoins []models.PerformersImages) error {
ret := _m.Called(imageID, updatedJoins)
var r0 error
if rf, ok := ret.Get(0).(func(int, []models.PerformersImages) error); ok {
r0 = rf(imageID, updatedJoins)
} else {
r0 = ret.Error(0)
}
return r0
}
// UpdatePerformersScenes provides a mock function with given fields: sceneID, updatedJoins
func (_m *JoinReaderWriter) UpdatePerformersScenes(sceneID int, updatedJoins []models.PerformersScenes) error {
ret := _m.Called(sceneID, updatedJoins)

View File

@@ -58,6 +58,52 @@ func (_m *PerformerReaderWriter) Create(newPerformer models.Performer) (*models.
return r0, r1
}
// FindByGalleryID provides a mock function with given fields: galleryID
func (_m *PerformerReaderWriter) FindByGalleryID(galleryID int) ([]*models.Performer, error) {
ret := _m.Called(galleryID)
var r0 []*models.Performer
if rf, ok := ret.Get(0).(func(int) []*models.Performer); ok {
r0 = rf(galleryID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Performer)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(galleryID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByImageID provides a mock function with given fields: imageID
func (_m *PerformerReaderWriter) FindByImageID(imageID int) ([]*models.Performer, error) {
ret := _m.Called(imageID)
var r0 []*models.Performer
if rf, ok := ret.Get(0).(func(int) []*models.Performer); ok {
r0 = rf(imageID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Performer)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(imageID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByNames provides a mock function with given fields: names, nocase
func (_m *PerformerReaderWriter) FindByNames(names []string, nocase bool) ([]*models.Performer, error) {
ret := _m.Called(names, nocase)

View File

@@ -81,6 +81,52 @@ func (_m *TagReaderWriter) Find(id int) (*models.Tag, error) {
return r0, r1
}
// FindByGalleryID provides a mock function with given fields: galleryID
func (_m *TagReaderWriter) FindByGalleryID(galleryID int) ([]*models.Tag, error) {
ret := _m.Called(galleryID)
var r0 []*models.Tag
if rf, ok := ret.Get(0).(func(int) []*models.Tag); ok {
r0 = rf(galleryID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Tag)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(galleryID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByImageID provides a mock function with given fields: imageID
func (_m *TagReaderWriter) FindByImageID(imageID int) ([]*models.Tag, error) {
ret := _m.Called(imageID)
var r0 []*models.Tag
if rf, ok := ret.Get(0).(func(int) []*models.Tag); ok {
r0 = rf(imageID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*models.Tag)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(int) error); ok {
r1 = rf(imageID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// FindByName provides a mock function with given fields: name, nocase
func (_m *TagReaderWriter) FindByName(name string, nocase bool) (*models.Tag, error) {
ret := _m.Called(name, nocase)

View File

@@ -1,175 +1,40 @@
package models
import (
"archive/zip"
"bytes"
"database/sql"
"image"
"image/jpeg"
"io/ioutil"
"path/filepath"
"sort"
"strings"
"github.com/disintegration/imaging"
"github.com/stashapp/stash/pkg/api/urlbuilders"
"github.com/stashapp/stash/pkg/logger"
"github.com/stashapp/stash/pkg/utils"
_ "golang.org/x/image/webp"
)
type Gallery struct {
ID int `db:"id" json:"id"`
Path string `db:"path" json:"path"`
Path sql.NullString `db:"path" json:"path"`
Checksum string `db:"checksum" json:"checksum"`
Zip bool `db:"zip" json:"zip"`
Title sql.NullString `db:"title" json:"title"`
URL sql.NullString `db:"url" json:"url"`
Date SQLiteDate `db:"date" json:"date"`
Details sql.NullString `db:"details" json:"details"`
Rating sql.NullInt64 `db:"rating" json:"rating"`
StudioID sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
SceneID sql.NullInt64 `db:"scene_id,omitempty" json:"scene_id"`
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
}
const DefaultGthumbWidth int = 200
func (g *Gallery) CountFiles() int {
filteredFiles, readCloser, err := g.listZipContents()
if err != nil {
return 0
}
defer readCloser.Close()
return len(filteredFiles)
// 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 {
ID int `db:"id" json:"id"`
Path *sql.NullString `db:"path" json:"path"`
Checksum *string `db:"checksum" json:"checksum"`
Title *sql.NullString `db:"title" json:"title"`
URL *sql.NullString `db:"url" json:"url"`
Date *SQLiteDate `db:"date" json:"date"`
Details *sql.NullString `db:"details" json:"details"`
Rating *sql.NullInt64 `db:"rating" json:"rating"`
StudioID *sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
SceneID *sql.NullInt64 `db:"scene_id,omitempty" json:"scene_id"`
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"`
}
func (g *Gallery) GetFiles(baseURL string) []*GalleryFilesType {
var galleryFiles []*GalleryFilesType
filteredFiles, readCloser, err := g.listZipContents()
if err != nil {
return nil
}
defer readCloser.Close()
builder := urlbuilders.NewGalleryURLBuilder(baseURL, g.ID)
for i, file := range filteredFiles {
galleryURL := builder.GetGalleryImageURL(i)
galleryFile := GalleryFilesType{
Index: i,
Name: &file.Name,
Path: &galleryURL,
}
galleryFiles = append(galleryFiles, &galleryFile)
}
return galleryFiles
}
func (g *Gallery) GetImage(index int) []byte {
data, _ := g.readZipFile(index)
return data
}
func (g *Gallery) GetThumbnail(index int, width int) []byte {
data, _ := g.readZipFile(index)
srcImage, _, err := image.Decode(bytes.NewReader(data))
if err != nil {
return data
}
resizedImage := imaging.Resize(srcImage, width, 0, imaging.Box)
buf := new(bytes.Buffer)
err = jpeg.Encode(buf, resizedImage, nil)
if err != nil {
return data
}
return buf.Bytes()
}
func (g *Gallery) readZipFile(index int) ([]byte, error) {
filteredFiles, readCloser, err := g.listZipContents()
if err != nil {
return nil, err
}
defer readCloser.Close()
zipFile := filteredFiles[index]
zipFileReadCloser, err := zipFile.Open()
if err != nil {
logger.Warn("failed to read file inside zip file")
return nil, err
}
defer zipFileReadCloser.Close()
return ioutil.ReadAll(zipFileReadCloser)
}
func (g *Gallery) listZipContents() ([]*zip.File, *zip.ReadCloser, error) {
readCloser, err := zip.OpenReader(g.Path)
if err != nil {
logger.Warnf("failed to read zip file %s", g.Path)
return nil, nil, err
}
filteredFiles := make([]*zip.File, 0)
for _, file := range readCloser.File {
if file.FileInfo().IsDir() {
continue
}
ext := filepath.Ext(file.Name)
ext = strings.ToLower(ext)
if ext != ".jpg" && ext != ".jpeg" && ext != ".png" && ext != ".gif" && ext != ".webp" {
continue
}
if strings.Contains(file.Name, "__MACOSX") {
continue
}
filteredFiles = append(filteredFiles, file)
}
sort.Slice(filteredFiles, func(i, j int) bool {
a := filteredFiles[i]
b := filteredFiles[j]
return utils.NaturalCompare(a.Name, b.Name)
})
cover := contains(filteredFiles, "cover.jpg") // first image with cover.jpg in the name
if cover >= 0 { // will be moved to the start
reorderedFiles := reorder(filteredFiles, cover)
if reorderedFiles != nil {
return reorderedFiles, readCloser, nil
}
}
return filteredFiles, readCloser, nil
}
// return index of first occurrenece of string x ( case insensitive ) in name of zip contents, -1 otherwise
func contains(a []*zip.File, x string) int {
for i, n := range a {
if strings.Contains(strings.ToLower(n.Name), strings.ToLower(x)) {
return i
}
}
return -1
}
// reorder slice so that element with position toFirst gets at the start
func reorder(a []*zip.File, toFirst int) []*zip.File {
var first *zip.File
switch {
case toFirst < 0 || toFirst >= len(a):
return nil
case toFirst == 0:
return a
default:
first = a[toFirst]
copy(a[toFirst:], a[toFirst+1:]) // Shift a[toFirst+1:] left one index removing a[toFirst] element
a[len(a)-1] = nil // Nil now unused element for garbage collection
a = a[:len(a)-1] // Truncate slice
a = append([]*zip.File{first}, a...) // Push first to the start of the slice
}
return a
}
func (g *Gallery) ImageCount() int {
images, _, _ := g.listZipContents()
if images == nil {
return 0
}
return len(images)
}
const DefaultGthumbWidth int = 640

44
pkg/models/model_image.go Normal file
View File

@@ -0,0 +1,44 @@
package models
import (
"database/sql"
)
// Image stores the metadata for a single image.
type Image struct {
ID int `db:"id" json:"id"`
Checksum string `db:"checksum" json:"checksum"`
Path string `db:"path" json:"path"`
Title sql.NullString `db:"title" json:"title"`
Rating sql.NullInt64 `db:"rating" json:"rating"`
OCounter int `db:"o_counter" json:"o_counter"`
Size sql.NullInt64 `db:"size" json:"size"`
Width sql.NullInt64 `db:"width" json:"width"`
Height sql.NullInt64 `db:"height" json:"height"`
StudioID sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
CreatedAt SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
}
// ImagePartial represents part of a Image object. It is used to update
// the database entry. Only non-nil fields will be updated.
type ImagePartial struct {
ID int `db:"id" json:"id"`
Checksum *string `db:"checksum" json:"checksum"`
Path *string `db:"path" json:"path"`
Title *sql.NullString `db:"title" json:"title"`
Rating *sql.NullInt64 `db:"rating" json:"rating"`
Size *sql.NullInt64 `db:"size" json:"size"`
Width *sql.NullInt64 `db:"width" json:"width"`
Height *sql.NullInt64 `db:"height" json:"height"`
StudioID *sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"`
}
// ImageFileType represents the file metadata for an image.
type ImageFileType struct {
Size *int `graphql:"size" json:"size"`
Width *int `graphql:"width" json:"width"`
Height *int `graphql:"height" json:"height"`
}

View File

@@ -22,3 +22,28 @@ type SceneMarkersTags struct {
SceneMarkerID int `db:"scene_marker_id" json:"scene_marker_id"`
TagID int `db:"tag_id" json:"tag_id"`
}
type PerformersImages struct {
PerformerID int `db:"performer_id" json:"performer_id"`
ImageID int `db:"image_id" json:"image_id"`
}
type ImagesTags struct {
ImageID int `db:"image_id" json:"image_id"`
TagID int `db:"tag_id" json:"tag_id"`
}
type GalleriesImages struct {
GalleryID int `db:"gallery_id" json:"gallery_id"`
ImageID int `db:"image_id" json:"image_id"`
}
type PerformersGalleries struct {
PerformerID int `db:"performer_id" json:"performer_id"`
GalleryID int `db:"gallery_id" json:"gallery_id"`
}
type GalleriesTags struct {
TagID int `db:"tag_id" json:"tag_id"`
GalleryID int `db:"gallery_id" json:"gallery_id"`
}

View File

@@ -9,6 +9,8 @@ type PerformerReader interface {
FindMany(ids []int) ([]*Performer, error)
FindBySceneID(sceneID int) ([]*Performer, error)
FindNamesBySceneID(sceneID int) ([]*Performer, error)
FindByImageID(imageID int) ([]*Performer, error)
FindByGalleryID(galleryID int) ([]*Performer, error)
FindByNames(names []string, nocase bool) ([]*Performer, error)
// Count() (int, error)
All() ([]*Performer, error)
@@ -66,6 +68,14 @@ func (t *performerReaderWriter) FindNamesBySceneID(sceneID int) ([]*Performer, e
return t.qb.FindNameBySceneID(sceneID, t.tx)
}
func (t *performerReaderWriter) FindByImageID(id int) ([]*Performer, error) {
return t.qb.FindByImageID(id, t.tx)
}
func (t *performerReaderWriter) FindByGalleryID(id int) ([]*Performer, error) {
return t.qb.FindByGalleryID(id, t.tx)
}
func (t *performerReaderWriter) Create(newPerformer Performer) (*Performer, error) {
return t.qb.Create(newPerformer, t.tx)
}

View File

@@ -21,8 +21,8 @@ func NewGalleryQueryBuilder() GalleryQueryBuilder {
func (qb *GalleryQueryBuilder) Create(newGallery Gallery, tx *sqlx.Tx) (*Gallery, error) {
ensureTx(tx)
result, err := tx.NamedExec(
`INSERT INTO galleries (path, checksum, scene_id, created_at, updated_at)
VALUES (:path, :checksum, :scene_id, :created_at, :updated_at)
`INSERT INTO galleries (path, checksum, zip, title, date, details, url, studio_id, rating, scene_id, created_at, updated_at)
VALUES (:path, :checksum, :zip, :title, :date, :details, :url, :studio_id, :rating, :scene_id, :created_at, :updated_at)
`,
newGallery,
)
@@ -55,6 +55,19 @@ func (qb *GalleryQueryBuilder) Update(updatedGallery Gallery, tx *sqlx.Tx) (*Gal
return &updatedGallery, nil
}
func (qb *GalleryQueryBuilder) UpdatePartial(updatedGallery GalleryPartial, tx *sqlx.Tx) (*Gallery, error) {
ensureTx(tx)
_, err := tx.NamedExec(
`UPDATE galleries SET `+SQLGenKeysPartial(updatedGallery)+` WHERE galleries.id = :id`,
updatedGallery,
)
if err != nil {
return nil, err
}
return qb.Find(updatedGallery.ID, tx)
}
func (qb *GalleryQueryBuilder) Destroy(id int, tx *sqlx.Tx) error {
return executeDeleteQuery("galleries", strconv.Itoa(id), tx)
}
@@ -77,16 +90,16 @@ func (qb *GalleryQueryBuilder) ClearGalleryId(sceneID int, tx *sqlx.Tx) error {
return err
}
func (qb *GalleryQueryBuilder) Find(id int) (*Gallery, error) {
func (qb *GalleryQueryBuilder) Find(id int, tx *sqlx.Tx) (*Gallery, error) {
query := "SELECT * FROM galleries WHERE id = ? LIMIT 1"
args := []interface{}{id}
return qb.queryGallery(query, args, nil)
return qb.queryGallery(query, args, tx)
}
func (qb *GalleryQueryBuilder) FindMany(ids []int) ([]*Gallery, error) {
var galleries []*Gallery
for _, id := range ids {
gallery, err := qb.Find(id)
gallery, err := qb.Find(id, nil)
if err != nil {
return nil, err
}
@@ -125,6 +138,24 @@ func (qb *GalleryQueryBuilder) ValidGalleriesForScenePath(scenePath string) ([]*
return qb.queryGalleries(query, nil, nil)
}
func (qb *GalleryQueryBuilder) FindByImageID(imageID int, tx *sqlx.Tx) ([]*Gallery, error) {
query := selectAll(galleryTable) + `
LEFT JOIN galleries_images as images_join on images_join.gallery_id = galleries.id
WHERE images_join.image_id = ?
GROUP BY galleries.id
`
args := []interface{}{imageID}
return qb.queryGalleries(query, args, tx)
}
func (qb *GalleryQueryBuilder) CountByImageID(imageID int) (int, error) {
query := `SELECT image_id FROM galleries_images
WHERE image_id = ?
GROUP BY gallery_id`
args := []interface{}{imageID}
return runCountQuery(buildCountQuery(query), args)
}
func (qb *GalleryQueryBuilder) Count() (int, error) {
return runCountQuery(buildCountQuery("SELECT galleries.id FROM galleries"), nil)
}
@@ -146,6 +177,11 @@ func (qb *GalleryQueryBuilder) Query(galleryFilter *GalleryFilterType, findFilte
}
query.body = selectDistinctIDs("galleries")
query.body += `
left join performers_galleries as performers_join on performers_join.gallery_id = galleries.id
left join studios as studio on studio.id = galleries.studio_id
left join galleries_tags as tags_join on tags_join.gallery_id = galleries.id
`
if q := findFilter.Q; q != nil && *q != "" {
searchColumns := []string{"galleries.path", "galleries.checksum"}
@@ -154,21 +190,73 @@ func (qb *GalleryQueryBuilder) Query(galleryFilter *GalleryFilterType, findFilte
query.addArg(thisArgs...)
}
if zipFilter := galleryFilter.IsZip; zipFilter != nil {
var favStr string
if *zipFilter == true {
favStr = "1"
} else {
favStr = "0"
}
query.addWhere("galleries.zip = " + favStr)
}
query.handleStringCriterionInput(galleryFilter.Path, "galleries.path")
if isMissingFilter := galleryFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
switch *isMissingFilter {
case "scene":
query.addWhere("galleries.scene_id IS NULL")
case "studio":
query.addWhere("galleries.studio_id IS NULL")
case "performers":
query.addWhere("performers_join.gallery_id IS NULL")
case "date":
query.addWhere("galleries.date IS \"\" OR galleries.date IS \"0001-01-01\"")
case "tags":
query.addWhere("tags_join.gallery_id IS NULL")
default:
query.addWhere("galleries." + *isMissingFilter + " IS NULL")
}
}
if tagsFilter := galleryFilter.Tags; tagsFilter != nil && len(tagsFilter.Value) > 0 {
for _, tagID := range tagsFilter.Value {
query.addArg(tagID)
}
query.body += " LEFT JOIN tags on tags_join.tag_id = tags.id"
whereClause, havingClause := getMultiCriterionClause("galleries", "tags", "tags_join", "gallery_id", "tag_id", tagsFilter)
query.addWhere(whereClause)
query.addHaving(havingClause)
}
if performersFilter := galleryFilter.Performers; performersFilter != nil && len(performersFilter.Value) > 0 {
for _, performerID := range performersFilter.Value {
query.addArg(performerID)
}
query.body += " LEFT JOIN performers ON performers_join.performer_id = performers.id"
whereClause, havingClause := getMultiCriterionClause("galleries", "performers", "performers_join", "gallery_id", "performer_id", performersFilter)
query.addWhere(whereClause)
query.addHaving(havingClause)
}
if studiosFilter := galleryFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 {
for _, studioID := range studiosFilter.Value {
query.addArg(studioID)
}
whereClause, havingClause := getMultiCriterionClause("galleries", "studio", "", "", "studio_id", studiosFilter)
query.addWhere(whereClause)
query.addHaving(havingClause)
}
query.sortAndPagination = qb.getGallerySort(findFilter) + getPagination(findFilter)
idsResult, countResult := query.executeFind()
var galleries []*Gallery
for _, id := range idsResult {
gallery, _ := qb.Find(id)
gallery, _ := qb.Find(id, nil)
galleries = append(galleries, gallery)
}

View File

@@ -14,15 +14,15 @@ func TestGalleryFind(t *testing.T) {
gqb := models.NewGalleryQueryBuilder()
const galleryIdx = 0
gallery, err := gqb.Find(galleryIDs[galleryIdx])
gallery, err := gqb.Find(galleryIDs[galleryIdx], nil)
if err != nil {
t.Fatalf("Error finding gallery: %s", err.Error())
}
assert.Equal(t, getGalleryStringValue(galleryIdx, "Path"), gallery.Path)
assert.Equal(t, getGalleryStringValue(galleryIdx, "Path"), gallery.Path.String)
gallery, err = gqb.Find(0)
gallery, err = gqb.Find(0, nil)
if err != nil {
t.Fatalf("Error finding gallery: %s", err.Error())
@@ -42,7 +42,7 @@ func TestGalleryFindByChecksum(t *testing.T) {
t.Fatalf("Error finding gallery: %s", err.Error())
}
assert.Equal(t, getGalleryStringValue(galleryIdx, "Path"), gallery.Path)
assert.Equal(t, getGalleryStringValue(galleryIdx, "Path"), gallery.Path.String)
galleryChecksum = "not exist"
gallery, err = gqb.FindByChecksum(galleryChecksum, nil)
@@ -65,7 +65,7 @@ func TestGalleryFindByPath(t *testing.T) {
t.Fatalf("Error finding gallery: %s", err.Error())
}
assert.Equal(t, galleryPath, gallery.Path)
assert.Equal(t, galleryPath, gallery.Path.String)
galleryPath = "not exist"
gallery, err = gqb.FindByPath(galleryPath)
@@ -87,7 +87,7 @@ func TestGalleryFindBySceneID(t *testing.T) {
t.Fatalf("Error finding gallery: %s", err.Error())
}
assert.Equal(t, getGalleryStringValue(galleryIdxWithScene, "Path"), gallery.Path)
assert.Equal(t, getGalleryStringValue(galleryIdxWithScene, "Path"), gallery.Path.String)
gallery, err = gqb.FindBySceneID(0, nil)
@@ -149,7 +149,7 @@ func verifyGalleriesPath(t *testing.T, pathCriterion models.StringCriterionInput
galleries, _ := sqb.Query(&galleryFilter, nil)
for _, gallery := range galleries {
verifyString(t, gallery.Path, pathCriterion)
verifyNullString(t, gallery.Path, pathCriterion)
}
}

View File

@@ -0,0 +1,434 @@
package models
import (
"database/sql"
"fmt"
"strconv"
"github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/utils"
)
const imageTable = "images"
var imagesForPerformerQuery = selectAll(imageTable) + `
LEFT JOIN performers_images as performers_join on performers_join.image_id = images.id
WHERE performers_join.performer_id = ?
GROUP BY images.id
`
var countImagesForPerformerQuery = `
SELECT performer_id FROM performers_images as performers_join
WHERE performer_id = ?
GROUP BY image_id
`
var imagesForStudioQuery = selectAll(imageTable) + `
JOIN studios ON studios.id = images.studio_id
WHERE studios.id = ?
GROUP BY images.id
`
var imagesForMovieQuery = selectAll(imageTable) + `
LEFT JOIN movies_images as movies_join on movies_join.image_id = images.id
WHERE movies_join.movie_id = ?
GROUP BY images.id
`
var countImagesForTagQuery = `
SELECT tag_id AS id FROM images_tags
WHERE images_tags.tag_id = ?
GROUP BY images_tags.image_id
`
var imagesForGalleryQuery = selectAll(imageTable) + `
LEFT JOIN galleries_images as galleries_join on galleries_join.image_id = images.id
WHERE galleries_join.gallery_id = ?
GROUP BY images.id
`
var countImagesForGalleryQuery = `
SELECT gallery_id FROM galleries_images
WHERE gallery_id = ?
GROUP BY image_id
`
type ImageQueryBuilder struct{}
func NewImageQueryBuilder() ImageQueryBuilder {
return ImageQueryBuilder{}
}
func (qb *ImageQueryBuilder) Create(newImage Image, tx *sqlx.Tx) (*Image, error) {
ensureTx(tx)
result, err := tx.NamedExec(
`INSERT INTO images (checksum, path, title, rating, o_counter, size,
width, height, studio_id, created_at, updated_at)
VALUES (:checksum, :path, :title, :rating, :o_counter, :size,
:width, :height, :studio_id, :created_at, :updated_at)
`,
newImage,
)
if err != nil {
return nil, err
}
imageID, err := result.LastInsertId()
if err != nil {
return nil, err
}
if err := tx.Get(&newImage, `SELECT * FROM images WHERE id = ? LIMIT 1`, imageID); err != nil {
return nil, err
}
return &newImage, nil
}
func (qb *ImageQueryBuilder) Update(updatedImage ImagePartial, tx *sqlx.Tx) (*Image, error) {
ensureTx(tx)
_, err := tx.NamedExec(
`UPDATE images SET `+SQLGenKeysPartial(updatedImage)+` WHERE images.id = :id`,
updatedImage,
)
if err != nil {
return nil, err
}
return qb.find(updatedImage.ID, tx)
}
func (qb *ImageQueryBuilder) UpdateFull(updatedImage Image, tx *sqlx.Tx) (*Image, error) {
ensureTx(tx)
_, err := tx.NamedExec(
`UPDATE images SET `+SQLGenKeys(updatedImage)+` WHERE images.id = :id`,
updatedImage,
)
if err != nil {
return nil, err
}
return qb.find(updatedImage.ID, tx)
}
func (qb *ImageQueryBuilder) IncrementOCounter(id int, tx *sqlx.Tx) (int, error) {
ensureTx(tx)
_, err := tx.Exec(
`UPDATE images SET o_counter = o_counter + 1 WHERE images.id = ?`,
id,
)
if err != nil {
return 0, err
}
image, err := qb.find(id, tx)
if err != nil {
return 0, err
}
return image.OCounter, nil
}
func (qb *ImageQueryBuilder) DecrementOCounter(id int, tx *sqlx.Tx) (int, error) {
ensureTx(tx)
_, err := tx.Exec(
`UPDATE images SET o_counter = o_counter - 1 WHERE images.id = ? and images.o_counter > 0`,
id,
)
if err != nil {
return 0, err
}
image, err := qb.find(id, tx)
if err != nil {
return 0, err
}
return image.OCounter, nil
}
func (qb *ImageQueryBuilder) ResetOCounter(id int, tx *sqlx.Tx) (int, error) {
ensureTx(tx)
_, err := tx.Exec(
`UPDATE images SET o_counter = 0 WHERE images.id = ?`,
id,
)
if err != nil {
return 0, err
}
image, err := qb.find(id, tx)
if err != nil {
return 0, err
}
return image.OCounter, nil
}
func (qb *ImageQueryBuilder) Destroy(id int, tx *sqlx.Tx) error {
return executeDeleteQuery("images", strconv.Itoa(id), tx)
}
func (qb *ImageQueryBuilder) Find(id int) (*Image, error) {
return qb.find(id, nil)
}
func (qb *ImageQueryBuilder) FindMany(ids []int) ([]*Image, error) {
var images []*Image
for _, id := range ids {
image, err := qb.Find(id)
if err != nil {
return nil, err
}
if image == nil {
return nil, fmt.Errorf("image with id %d not found", id)
}
images = append(images, image)
}
return images, nil
}
func (qb *ImageQueryBuilder) find(id int, tx *sqlx.Tx) (*Image, error) {
query := selectAll(imageTable) + "WHERE id = ? LIMIT 1"
args := []interface{}{id}
return qb.queryImage(query, args, tx)
}
func (qb *ImageQueryBuilder) FindByChecksum(checksum string) (*Image, error) {
query := "SELECT * FROM images WHERE checksum = ? LIMIT 1"
args := []interface{}{checksum}
return qb.queryImage(query, args, nil)
}
func (qb *ImageQueryBuilder) FindByPath(path string) (*Image, error) {
query := selectAll(imageTable) + "WHERE path = ? LIMIT 1"
args := []interface{}{path}
return qb.queryImage(query, args, nil)
}
func (qb *ImageQueryBuilder) FindByPerformerID(performerID int) ([]*Image, error) {
args := []interface{}{performerID}
return qb.queryImages(imagesForPerformerQuery, args, nil)
}
func (qb *ImageQueryBuilder) CountByPerformerID(performerID int) (int, error) {
args := []interface{}{performerID}
return runCountQuery(buildCountQuery(countImagesForPerformerQuery), args)
}
func (qb *ImageQueryBuilder) FindByStudioID(studioID int) ([]*Image, error) {
args := []interface{}{studioID}
return qb.queryImages(imagesForStudioQuery, args, nil)
}
func (qb *ImageQueryBuilder) FindByGalleryID(galleryID int) ([]*Image, error) {
args := []interface{}{galleryID}
return qb.queryImages(imagesForGalleryQuery, args, nil)
}
func (qb *ImageQueryBuilder) CountByGalleryID(galleryID int) (int, error) {
args := []interface{}{galleryID}
return runCountQuery(buildCountQuery(countImagesForGalleryQuery), args)
}
func (qb *ImageQueryBuilder) Count() (int, error) {
return runCountQuery(buildCountQuery("SELECT images.id FROM images"), nil)
}
func (qb *ImageQueryBuilder) SizeCount() (string, error) {
sum, err := runSumQuery("SELECT SUM(size) as sum FROM images", nil)
if err != nil {
return "0 B", err
}
return utils.HumanizeBytes(sum), err
}
func (qb *ImageQueryBuilder) CountByStudioID(studioID int) (int, error) {
args := []interface{}{studioID}
return runCountQuery(buildCountQuery(imagesForStudioQuery), args)
}
func (qb *ImageQueryBuilder) CountByTagID(tagID int) (int, error) {
args := []interface{}{tagID}
return runCountQuery(buildCountQuery(countImagesForTagQuery), args)
}
func (qb *ImageQueryBuilder) All() ([]*Image, error) {
return qb.queryImages(selectAll(imageTable)+qb.getImageSort(nil), nil, nil)
}
func (qb *ImageQueryBuilder) Query(imageFilter *ImageFilterType, findFilter *FindFilterType) ([]*Image, int) {
if imageFilter == nil {
imageFilter = &ImageFilterType{}
}
if findFilter == nil {
findFilter = &FindFilterType{}
}
query := queryBuilder{
tableName: imageTable,
}
query.body = selectDistinctIDs(imageTable)
query.body += `
left join performers_images as performers_join on performers_join.image_id = images.id
left join studios as studio on studio.id = images.studio_id
left join images_tags as tags_join on tags_join.image_id = images.id
left join galleries_images as galleries_join on galleries_join.image_id = images.id
`
if q := findFilter.Q; q != nil && *q != "" {
searchColumns := []string{"images.title", "images.path", "images.checksum"}
clause, thisArgs := getSearchBinding(searchColumns, *q, false)
query.addWhere(clause)
query.addArg(thisArgs...)
}
if rating := imageFilter.Rating; rating != nil {
clause, count := getIntCriterionWhereClause("images.rating", *imageFilter.Rating)
query.addWhere(clause)
if count == 1 {
query.addArg(imageFilter.Rating.Value)
}
}
if oCounter := imageFilter.OCounter; oCounter != nil {
clause, count := getIntCriterionWhereClause("images.o_counter", *imageFilter.OCounter)
query.addWhere(clause)
if count == 1 {
query.addArg(imageFilter.OCounter.Value)
}
}
if resolutionFilter := imageFilter.Resolution; resolutionFilter != nil {
if resolution := resolutionFilter.String(); resolutionFilter.IsValid() {
switch resolution {
case "LOW":
query.addWhere("images.height < 480")
case "STANDARD":
query.addWhere("(images.height >= 480 AND images.height < 720)")
case "STANDARD_HD":
query.addWhere("(images.height >= 720 AND images.height < 1080)")
case "FULL_HD":
query.addWhere("(images.height >= 1080 AND images.height < 2160)")
case "FOUR_K":
query.addWhere("images.height >= 2160")
}
}
}
if isMissingFilter := imageFilter.IsMissing; isMissingFilter != nil && *isMissingFilter != "" {
switch *isMissingFilter {
case "studio":
query.addWhere("images.studio_id IS NULL")
case "performers":
query.addWhere("performers_join.image_id IS NULL")
case "galleries":
query.addWhere("galleries_join.image_id IS NULL")
case "tags":
query.addWhere("tags_join.image_id IS NULL")
default:
query.addWhere("images." + *isMissingFilter + " IS NULL")
}
}
if tagsFilter := imageFilter.Tags; tagsFilter != nil && len(tagsFilter.Value) > 0 {
for _, tagID := range tagsFilter.Value {
query.addArg(tagID)
}
query.body += " LEFT JOIN tags on tags_join.tag_id = tags.id"
whereClause, havingClause := getMultiCriterionClause("images", "tags", "images_tags", "image_id", "tag_id", tagsFilter)
query.addWhere(whereClause)
query.addHaving(havingClause)
}
if galleriesFilter := imageFilter.Galleries; galleriesFilter != nil && len(galleriesFilter.Value) > 0 {
for _, galleryID := range galleriesFilter.Value {
query.addArg(galleryID)
}
query.body += " LEFT JOIN galleries ON galleries_join.gallery_id = galleries.id"
whereClause, havingClause := getMultiCriterionClause("images", "galleries", "galleries_images", "image_id", "gallery_id", galleriesFilter)
query.addWhere(whereClause)
query.addHaving(havingClause)
}
if performersFilter := imageFilter.Performers; performersFilter != nil && len(performersFilter.Value) > 0 {
for _, performerID := range performersFilter.Value {
query.addArg(performerID)
}
query.body += " LEFT JOIN performers ON performers_join.performer_id = performers.id"
whereClause, havingClause := getMultiCriterionClause("images", "performers", "performers_images", "image_id", "performer_id", performersFilter)
query.addWhere(whereClause)
query.addHaving(havingClause)
}
if studiosFilter := imageFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 {
for _, studioID := range studiosFilter.Value {
query.addArg(studioID)
}
whereClause, havingClause := getMultiCriterionClause("images", "studio", "", "", "studio_id", studiosFilter)
query.addWhere(whereClause)
query.addHaving(havingClause)
}
query.sortAndPagination = qb.getImageSort(findFilter) + getPagination(findFilter)
idsResult, countResult := query.executeFind()
var images []*Image
for _, id := range idsResult {
image, _ := qb.Find(id)
images = append(images, image)
}
return images, countResult
}
func (qb *ImageQueryBuilder) getImageSort(findFilter *FindFilterType) string {
if findFilter == nil {
return " ORDER BY images.path ASC "
}
sort := findFilter.GetSort("title")
direction := findFilter.GetDirection()
return getSort(sort, direction, "images")
}
func (qb *ImageQueryBuilder) queryImage(query string, args []interface{}, tx *sqlx.Tx) (*Image, error) {
results, err := qb.queryImages(query, args, tx)
if err != nil || len(results) < 1 {
return nil, err
}
return results[0], nil
}
func (qb *ImageQueryBuilder) queryImages(query string, args []interface{}, tx *sqlx.Tx) ([]*Image, error) {
var rows *sqlx.Rows
var err error
if tx != nil {
rows, err = tx.Queryx(query, args...)
} else {
rows, err = database.DB.Queryx(query, args...)
}
if err != nil && err != sql.ErrNoRows {
return nil, err
}
defer rows.Close()
images := make([]*Image, 0)
for rows.Next() {
image := Image{}
if err := rows.StructScan(&image); err != nil {
return nil, err
}
images = append(images, &image)
}
if err := rows.Err(); err != nil {
return nil, err
}
return images, nil
}

View File

@@ -0,0 +1,624 @@
// +build integration
package models_test
import (
"database/sql"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stashapp/stash/pkg/models"
)
func TestImageFind(t *testing.T) {
// assume that the first image is imageWithGalleryPath
sqb := models.NewImageQueryBuilder()
const imageIdx = 0
imageID := imageIDs[imageIdx]
image, err := sqb.Find(imageID)
if err != nil {
t.Fatalf("Error finding image: %s", err.Error())
}
assert.Equal(t, getImageStringValue(imageIdx, "Path"), image.Path)
imageID = 0
image, err = sqb.Find(imageID)
if err != nil {
t.Fatalf("Error finding image: %s", err.Error())
}
assert.Nil(t, image)
}
func TestImageFindByPath(t *testing.T) {
sqb := models.NewImageQueryBuilder()
const imageIdx = 1
imagePath := getImageStringValue(imageIdx, "Path")
image, err := sqb.FindByPath(imagePath)
if err != nil {
t.Fatalf("Error finding image: %s", err.Error())
}
assert.Equal(t, imageIDs[imageIdx], image.ID)
assert.Equal(t, imagePath, image.Path)
imagePath = "not exist"
image, err = sqb.FindByPath(imagePath)
if err != nil {
t.Fatalf("Error finding image: %s", err.Error())
}
assert.Nil(t, image)
}
func TestImageCountByPerformerID(t *testing.T) {
sqb := models.NewImageQueryBuilder()
count, err := sqb.CountByPerformerID(performerIDs[performerIdxWithImage])
if err != nil {
t.Fatalf("Error counting images: %s", err.Error())
}
assert.Equal(t, 1, count)
count, err = sqb.CountByPerformerID(0)
if err != nil {
t.Fatalf("Error counting images: %s", err.Error())
}
assert.Equal(t, 0, count)
}
func TestImageQueryQ(t *testing.T) {
const imageIdx = 2
q := getImageStringValue(imageIdx, titleField)
sqb := models.NewImageQueryBuilder()
imageQueryQ(t, sqb, q, imageIdx)
}
func imageQueryQ(t *testing.T, sqb models.ImageQueryBuilder, q string, expectedImageIdx int) {
filter := models.FindFilterType{
Q: &q,
}
images, _ := sqb.Query(nil, &filter)
assert.Len(t, images, 1)
image := images[0]
assert.Equal(t, imageIDs[expectedImageIdx], image.ID)
// no Q should return all results
filter.Q = nil
images, _ = sqb.Query(nil, &filter)
assert.Len(t, images, totalImages)
}
func TestImageQueryRating(t *testing.T) {
const rating = 3
ratingCriterion := models.IntCriterionInput{
Value: rating,
Modifier: models.CriterionModifierEquals,
}
verifyImagesRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotEquals
verifyImagesRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierGreaterThan
verifyImagesRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierLessThan
verifyImagesRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierIsNull
verifyImagesRating(t, ratingCriterion)
ratingCriterion.Modifier = models.CriterionModifierNotNull
verifyImagesRating(t, ratingCriterion)
}
func verifyImagesRating(t *testing.T, ratingCriterion models.IntCriterionInput) {
sqb := models.NewImageQueryBuilder()
imageFilter := models.ImageFilterType{
Rating: &ratingCriterion,
}
images, _ := sqb.Query(&imageFilter, nil)
for _, image := range images {
verifyInt64(t, image.Rating, ratingCriterion)
}
}
func TestImageQueryOCounter(t *testing.T) {
const oCounter = 1
oCounterCriterion := models.IntCriterionInput{
Value: oCounter,
Modifier: models.CriterionModifierEquals,
}
verifyImagesOCounter(t, oCounterCriterion)
oCounterCriterion.Modifier = models.CriterionModifierNotEquals
verifyImagesOCounter(t, oCounterCriterion)
oCounterCriterion.Modifier = models.CriterionModifierGreaterThan
verifyImagesOCounter(t, oCounterCriterion)
oCounterCriterion.Modifier = models.CriterionModifierLessThan
verifyImagesOCounter(t, oCounterCriterion)
}
func verifyImagesOCounter(t *testing.T, oCounterCriterion models.IntCriterionInput) {
sqb := models.NewImageQueryBuilder()
imageFilter := models.ImageFilterType{
OCounter: &oCounterCriterion,
}
images, _ := sqb.Query(&imageFilter, nil)
for _, image := range images {
verifyInt(t, image.OCounter, oCounterCriterion)
}
}
func TestImageQueryResolution(t *testing.T) {
verifyImagesResolution(t, models.ResolutionEnumLow)
verifyImagesResolution(t, models.ResolutionEnumStandard)
verifyImagesResolution(t, models.ResolutionEnumStandardHd)
verifyImagesResolution(t, models.ResolutionEnumFullHd)
verifyImagesResolution(t, models.ResolutionEnumFourK)
verifyImagesResolution(t, models.ResolutionEnum("unknown"))
}
func verifyImagesResolution(t *testing.T, resolution models.ResolutionEnum) {
sqb := models.NewImageQueryBuilder()
imageFilter := models.ImageFilterType{
Resolution: &resolution,
}
images, _ := sqb.Query(&imageFilter, nil)
for _, image := range images {
verifyImageResolution(t, image.Height, resolution)
}
}
func verifyImageResolution(t *testing.T, height sql.NullInt64, resolution models.ResolutionEnum) {
assert := assert.New(t)
h := height.Int64
switch resolution {
case models.ResolutionEnumLow:
assert.True(h < 480)
case models.ResolutionEnumStandard:
assert.True(h >= 480 && h < 720)
case models.ResolutionEnumStandardHd:
assert.True(h >= 720 && h < 1080)
case models.ResolutionEnumFullHd:
assert.True(h >= 1080 && h < 2160)
case models.ResolutionEnumFourK:
assert.True(h >= 2160)
}
}
func TestImageQueryIsMissingGalleries(t *testing.T) {
sqb := models.NewImageQueryBuilder()
isMissing := "galleries"
imageFilter := models.ImageFilterType{
IsMissing: &isMissing,
}
q := getImageStringValue(imageIdxWithGallery, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
images, _ := sqb.Query(&imageFilter, &findFilter)
assert.Len(t, images, 0)
findFilter.Q = nil
images, _ = sqb.Query(&imageFilter, &findFilter)
// ensure non of the ids equal the one with gallery
for _, image := range images {
assert.NotEqual(t, imageIDs[imageIdxWithGallery], image.ID)
}
}
func TestImageQueryIsMissingStudio(t *testing.T) {
sqb := models.NewImageQueryBuilder()
isMissing := "studio"
imageFilter := models.ImageFilterType{
IsMissing: &isMissing,
}
q := getImageStringValue(imageIdxWithStudio, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
images, _ := sqb.Query(&imageFilter, &findFilter)
assert.Len(t, images, 0)
findFilter.Q = nil
images, _ = sqb.Query(&imageFilter, &findFilter)
// ensure non of the ids equal the one with studio
for _, image := range images {
assert.NotEqual(t, imageIDs[imageIdxWithStudio], image.ID)
}
}
func TestImageQueryIsMissingPerformers(t *testing.T) {
sqb := models.NewImageQueryBuilder()
isMissing := "performers"
imageFilter := models.ImageFilterType{
IsMissing: &isMissing,
}
q := getImageStringValue(imageIdxWithPerformer, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
images, _ := sqb.Query(&imageFilter, &findFilter)
assert.Len(t, images, 0)
findFilter.Q = nil
images, _ = sqb.Query(&imageFilter, &findFilter)
assert.True(t, len(images) > 0)
// ensure non of the ids equal the one with movies
for _, image := range images {
assert.NotEqual(t, imageIDs[imageIdxWithPerformer], image.ID)
}
}
func TestImageQueryIsMissingTags(t *testing.T) {
sqb := models.NewImageQueryBuilder()
isMissing := "tags"
imageFilter := models.ImageFilterType{
IsMissing: &isMissing,
}
q := getImageStringValue(imageIdxWithTwoTags, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
images, _ := sqb.Query(&imageFilter, &findFilter)
assert.Len(t, images, 0)
findFilter.Q = nil
images, _ = sqb.Query(&imageFilter, &findFilter)
assert.True(t, len(images) > 0)
}
func TestImageQueryIsMissingRating(t *testing.T) {
sqb := models.NewImageQueryBuilder()
isMissing := "rating"
imageFilter := models.ImageFilterType{
IsMissing: &isMissing,
}
images, _ := sqb.Query(&imageFilter, nil)
assert.True(t, len(images) > 0)
// ensure date is null, empty or "0001-01-01"
for _, image := range images {
assert.True(t, !image.Rating.Valid)
}
}
func TestImageQueryPerformers(t *testing.T) {
sqb := models.NewImageQueryBuilder()
performerCriterion := models.MultiCriterionInput{
Value: []string{
strconv.Itoa(performerIDs[performerIdxWithImage]),
strconv.Itoa(performerIDs[performerIdx1WithImage]),
},
Modifier: models.CriterionModifierIncludes,
}
imageFilter := models.ImageFilterType{
Performers: &performerCriterion,
}
images, _ := sqb.Query(&imageFilter, nil)
assert.Len(t, images, 2)
// ensure ids are correct
for _, image := range images {
assert.True(t, image.ID == imageIDs[imageIdxWithPerformer] || image.ID == imageIDs[imageIdxWithTwoPerformers])
}
performerCriterion = models.MultiCriterionInput{
Value: []string{
strconv.Itoa(performerIDs[performerIdx1WithImage]),
strconv.Itoa(performerIDs[performerIdx2WithImage]),
},
Modifier: models.CriterionModifierIncludesAll,
}
images, _ = sqb.Query(&imageFilter, nil)
assert.Len(t, images, 1)
assert.Equal(t, imageIDs[imageIdxWithTwoPerformers], images[0].ID)
performerCriterion = models.MultiCriterionInput{
Value: []string{
strconv.Itoa(performerIDs[performerIdx1WithImage]),
},
Modifier: models.CriterionModifierExcludes,
}
q := getImageStringValue(imageIdxWithTwoPerformers, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
images, _ = sqb.Query(&imageFilter, &findFilter)
assert.Len(t, images, 0)
}
func TestImageQueryTags(t *testing.T) {
sqb := models.NewImageQueryBuilder()
tagCriterion := models.MultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdxWithImage]),
strconv.Itoa(tagIDs[tagIdx1WithImage]),
},
Modifier: models.CriterionModifierIncludes,
}
imageFilter := models.ImageFilterType{
Tags: &tagCriterion,
}
images, _ := sqb.Query(&imageFilter, nil)
assert.Len(t, images, 2)
// ensure ids are correct
for _, image := range images {
assert.True(t, image.ID == imageIDs[imageIdxWithTag] || image.ID == imageIDs[imageIdxWithTwoTags])
}
tagCriterion = models.MultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdx1WithImage]),
strconv.Itoa(tagIDs[tagIdx2WithImage]),
},
Modifier: models.CriterionModifierIncludesAll,
}
images, _ = sqb.Query(&imageFilter, nil)
assert.Len(t, images, 1)
assert.Equal(t, imageIDs[imageIdxWithTwoTags], images[0].ID)
tagCriterion = models.MultiCriterionInput{
Value: []string{
strconv.Itoa(tagIDs[tagIdx1WithImage]),
},
Modifier: models.CriterionModifierExcludes,
}
q := getImageStringValue(imageIdxWithTwoTags, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
images, _ = sqb.Query(&imageFilter, &findFilter)
assert.Len(t, images, 0)
}
func TestImageQueryStudio(t *testing.T) {
sqb := models.NewImageQueryBuilder()
studioCriterion := models.MultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithImage]),
},
Modifier: models.CriterionModifierIncludes,
}
imageFilter := models.ImageFilterType{
Studios: &studioCriterion,
}
images, _ := sqb.Query(&imageFilter, nil)
assert.Len(t, images, 1)
// ensure id is correct
assert.Equal(t, imageIDs[imageIdxWithStudio], images[0].ID)
studioCriterion = models.MultiCriterionInput{
Value: []string{
strconv.Itoa(studioIDs[studioIdxWithImage]),
},
Modifier: models.CriterionModifierExcludes,
}
q := getImageStringValue(imageIdxWithStudio, titleField)
findFilter := models.FindFilterType{
Q: &q,
}
images, _ = sqb.Query(&imageFilter, &findFilter)
assert.Len(t, images, 0)
}
func TestImageQuerySorting(t *testing.T) {
sort := titleField
direction := models.SortDirectionEnumAsc
findFilter := models.FindFilterType{
Sort: &sort,
Direction: &direction,
}
sqb := models.NewImageQueryBuilder()
images, _ := sqb.Query(nil, &findFilter)
// images should be in same order as indexes
firstImage := images[0]
lastImage := images[len(images)-1]
assert.Equal(t, imageIDs[0], firstImage.ID)
assert.Equal(t, imageIDs[len(imageIDs)-1], lastImage.ID)
// sort in descending order
direction = models.SortDirectionEnumDesc
images, _ = sqb.Query(nil, &findFilter)
firstImage = images[0]
lastImage = images[len(images)-1]
assert.Equal(t, imageIDs[len(imageIDs)-1], firstImage.ID)
assert.Equal(t, imageIDs[0], lastImage.ID)
}
func TestImageQueryPagination(t *testing.T) {
perPage := 1
findFilter := models.FindFilterType{
PerPage: &perPage,
}
sqb := models.NewImageQueryBuilder()
images, _ := sqb.Query(nil, &findFilter)
assert.Len(t, images, 1)
firstID := images[0].ID
page := 2
findFilter.Page = &page
images, _ = sqb.Query(nil, &findFilter)
assert.Len(t, images, 1)
secondID := images[0].ID
assert.NotEqual(t, firstID, secondID)
perPage = 2
page = 1
images, _ = sqb.Query(nil, &findFilter)
assert.Len(t, images, 2)
assert.Equal(t, firstID, images[0].ID)
assert.Equal(t, secondID, images[1].ID)
}
func TestImageCountByTagID(t *testing.T) {
sqb := models.NewImageQueryBuilder()
imageCount, err := sqb.CountByTagID(tagIDs[tagIdxWithImage])
if err != nil {
t.Fatalf("error calling CountByTagID: %s", err.Error())
}
assert.Equal(t, 1, imageCount)
imageCount, err = sqb.CountByTagID(0)
if err != nil {
t.Fatalf("error calling CountByTagID: %s", err.Error())
}
assert.Equal(t, 0, imageCount)
}
func TestImageCountByStudioID(t *testing.T) {
sqb := models.NewImageQueryBuilder()
imageCount, err := sqb.CountByStudioID(studioIDs[studioIdxWithImage])
if err != nil {
t.Fatalf("error calling CountByStudioID: %s", err.Error())
}
assert.Equal(t, 1, imageCount)
imageCount, err = sqb.CountByStudioID(0)
if err != nil {
t.Fatalf("error calling CountByStudioID: %s", err.Error())
}
assert.Equal(t, 0, imageCount)
}
func TestImageFindByPerformerID(t *testing.T) {
sqb := models.NewImageQueryBuilder()
images, err := sqb.FindByPerformerID(performerIDs[performerIdxWithImage])
if err != nil {
t.Fatalf("error calling FindByPerformerID: %s", err.Error())
}
assert.Len(t, images, 1)
assert.Equal(t, imageIDs[imageIdxWithPerformer], images[0].ID)
images, err = sqb.FindByPerformerID(0)
if err != nil {
t.Fatalf("error calling FindByPerformerID: %s", err.Error())
}
assert.Len(t, images, 0)
}
func TestImageFindByStudioID(t *testing.T) {
sqb := models.NewImageQueryBuilder()
images, err := sqb.FindByStudioID(performerIDs[studioIdxWithImage])
if err != nil {
t.Fatalf("error calling FindByStudioID: %s", err.Error())
}
assert.Len(t, images, 1)
assert.Equal(t, imageIDs[imageIdxWithStudio], images[0].ID)
images, err = sqb.FindByStudioID(0)
if err != nil {
t.Fatalf("error calling FindByStudioID: %s", err.Error())
}
assert.Len(t, images, 0)
}
// TODO Update
// TODO IncrementOCounter
// TODO DecrementOCounter
// TODO ResetOCounter
// TODO Destroy
// TODO FindByChecksum
// TODO Count
// TODO SizeCount
// TODO All

View File

@@ -365,3 +365,523 @@ func (qb *JoinsQueryBuilder) DestroyScenesMarkers(sceneID int, tx *sqlx.Tx) erro
return err
}
func (qb *JoinsQueryBuilder) GetImagePerformers(imageID int, tx *sqlx.Tx) ([]PerformersImages, error) {
ensureTx(tx)
// Delete the existing joins and then create new ones
query := `SELECT * from performers_images WHERE image_id = ?`
var rows *sqlx.Rows
var err error
if tx != nil {
rows, err = tx.Queryx(query, imageID)
} else {
rows, err = database.DB.Queryx(query, imageID)
}
if err != nil && err != sql.ErrNoRows {
return nil, err
}
defer rows.Close()
performerImages := make([]PerformersImages, 0)
for rows.Next() {
performerImage := PerformersImages{}
if err := rows.StructScan(&performerImage); err != nil {
return nil, err
}
performerImages = append(performerImages, performerImage)
}
if err := rows.Err(); err != nil {
return nil, err
}
return performerImages, nil
}
func (qb *JoinsQueryBuilder) CreatePerformersImages(newJoins []PerformersImages, tx *sqlx.Tx) error {
ensureTx(tx)
for _, join := range newJoins {
_, err := tx.NamedExec(
`INSERT INTO performers_images (performer_id, image_id) VALUES (:performer_id, :image_id)`,
join,
)
if err != nil {
return err
}
}
return nil
}
// AddPerformerImage adds a performer to a image. It does not make any change
// if the performer already exists on the image. It returns true if image
// performer was added.
func (qb *JoinsQueryBuilder) AddPerformerImage(imageID int, performerID int, tx *sqlx.Tx) (bool, error) {
ensureTx(tx)
existingPerformers, err := qb.GetImagePerformers(imageID, tx)
if err != nil {
return false, err
}
// ensure not already present
for _, p := range existingPerformers {
if p.PerformerID == performerID && p.ImageID == imageID {
return false, nil
}
}
performerJoin := PerformersImages{
PerformerID: performerID,
ImageID: imageID,
}
performerJoins := append(existingPerformers, performerJoin)
err = qb.UpdatePerformersImages(imageID, performerJoins, tx)
return err == nil, err
}
func (qb *JoinsQueryBuilder) UpdatePerformersImages(imageID int, updatedJoins []PerformersImages, tx *sqlx.Tx) error {
ensureTx(tx)
// Delete the existing joins and then create new ones
_, err := tx.Exec("DELETE FROM performers_images WHERE image_id = ?", imageID)
if err != nil {
return err
}
return qb.CreatePerformersImages(updatedJoins, tx)
}
func (qb *JoinsQueryBuilder) DestroyPerformersImages(imageID int, tx *sqlx.Tx) error {
ensureTx(tx)
// Delete the existing joins
_, err := tx.Exec("DELETE FROM performers_images WHERE image_id = ?", imageID)
return err
}
func (qb *JoinsQueryBuilder) GetImageTags(imageID int, tx *sqlx.Tx) ([]ImagesTags, error) {
ensureTx(tx)
// Delete the existing joins and then create new ones
query := `SELECT * from images_tags WHERE image_id = ?`
var rows *sqlx.Rows
var err error
if tx != nil {
rows, err = tx.Queryx(query, imageID)
} else {
rows, err = database.DB.Queryx(query, imageID)
}
if err != nil && err != sql.ErrNoRows {
return nil, err
}
defer rows.Close()
imageTags := make([]ImagesTags, 0)
for rows.Next() {
imageTag := ImagesTags{}
if err := rows.StructScan(&imageTag); err != nil {
return nil, err
}
imageTags = append(imageTags, imageTag)
}
if err := rows.Err(); err != nil {
return nil, err
}
return imageTags, nil
}
func (qb *JoinsQueryBuilder) CreateImagesTags(newJoins []ImagesTags, tx *sqlx.Tx) error {
ensureTx(tx)
for _, join := range newJoins {
_, err := tx.NamedExec(
`INSERT INTO images_tags (image_id, tag_id) VALUES (:image_id, :tag_id)`,
join,
)
if err != nil {
return err
}
}
return nil
}
func (qb *JoinsQueryBuilder) UpdateImagesTags(imageID int, updatedJoins []ImagesTags, tx *sqlx.Tx) error {
ensureTx(tx)
// Delete the existing joins and then create new ones
_, err := tx.Exec("DELETE FROM images_tags WHERE image_id = ?", imageID)
if err != nil {
return err
}
return qb.CreateImagesTags(updatedJoins, tx)
}
// AddImageTag adds a tag to a image. It does not make any change if the tag
// already exists on the image. It returns true if image tag was added.
func (qb *JoinsQueryBuilder) AddImageTag(imageID int, tagID int, tx *sqlx.Tx) (bool, error) {
ensureTx(tx)
existingTags, err := qb.GetImageTags(imageID, tx)
if err != nil {
return false, err
}
// ensure not already present
for _, p := range existingTags {
if p.TagID == tagID && p.ImageID == imageID {
return false, nil
}
}
tagJoin := ImagesTags{
TagID: tagID,
ImageID: imageID,
}
tagJoins := append(existingTags, tagJoin)
err = qb.UpdateImagesTags(imageID, tagJoins, tx)
return err == nil, err
}
func (qb *JoinsQueryBuilder) DestroyImagesTags(imageID int, tx *sqlx.Tx) error {
ensureTx(tx)
// Delete the existing joins
_, err := tx.Exec("DELETE FROM images_tags WHERE image_id = ?", imageID)
return err
}
func (qb *JoinsQueryBuilder) GetImageGalleries(imageID int, tx *sqlx.Tx) ([]GalleriesImages, error) {
ensureTx(tx)
// Delete the existing joins and then create new ones
query := `SELECT * from galleries_images WHERE image_id = ?`
var rows *sqlx.Rows
var err error
if tx != nil {
rows, err = tx.Queryx(query, imageID)
} else {
rows, err = database.DB.Queryx(query, imageID)
}
if err != nil && err != sql.ErrNoRows {
return nil, err
}
defer rows.Close()
galleryImages := make([]GalleriesImages, 0)
for rows.Next() {
galleriesImages := GalleriesImages{}
if err := rows.StructScan(&galleriesImages); err != nil {
return nil, err
}
galleryImages = append(galleryImages, galleriesImages)
}
if err := rows.Err(); err != nil {
return nil, err
}
return galleryImages, nil
}
func (qb *JoinsQueryBuilder) CreateGalleriesImages(newJoins []GalleriesImages, tx *sqlx.Tx) error {
ensureTx(tx)
for _, join := range newJoins {
_, err := tx.NamedExec(
`INSERT INTO galleries_images (gallery_id, image_id) VALUES (:gallery_id, :image_id)`,
join,
)
if err != nil {
return err
}
}
return nil
}
func (qb *JoinsQueryBuilder) UpdateGalleriesImages(imageID int, updatedJoins []GalleriesImages, tx *sqlx.Tx) error {
ensureTx(tx)
// Delete the existing joins and then create new ones
_, err := tx.Exec("DELETE FROM galleries_images WHERE image_id = ?", imageID)
if err != nil {
return err
}
return qb.CreateGalleriesImages(updatedJoins, tx)
}
// AddGalleryImage adds a gallery to an image. It does not make any change if the tag
// already exists on the image. It returns true if image tag was added.
func (qb *JoinsQueryBuilder) AddImageGallery(imageID int, galleryID int, tx *sqlx.Tx) (bool, error) {
ensureTx(tx)
existingGalleries, err := qb.GetImageGalleries(imageID, tx)
if err != nil {
return false, err
}
// ensure not already present
for _, p := range existingGalleries {
if p.GalleryID == galleryID && p.ImageID == imageID {
return false, nil
}
}
galleryJoin := GalleriesImages{
GalleryID: galleryID,
ImageID: imageID,
}
galleryJoins := append(existingGalleries, galleryJoin)
err = qb.UpdateGalleriesImages(imageID, galleryJoins, tx)
return err == nil, err
}
// RemoveImageGallery removes a gallery from an image. Returns true if the join
// was removed.
func (qb *JoinsQueryBuilder) RemoveImageGallery(imageID int, galleryID int, tx *sqlx.Tx) (bool, error) {
ensureTx(tx)
existingGalleries, err := qb.GetImageGalleries(imageID, tx)
if err != nil {
return false, err
}
// remove the join
var updatedJoins []GalleriesImages
found := false
for _, p := range existingGalleries {
if p.GalleryID == galleryID && p.ImageID == imageID {
found = true
continue
}
updatedJoins = append(updatedJoins, p)
}
if found {
err = qb.UpdateGalleriesImages(imageID, updatedJoins, tx)
}
return found && err == nil, err
}
func (qb *JoinsQueryBuilder) DestroyImageGalleries(imageID int, tx *sqlx.Tx) error {
ensureTx(tx)
// Delete the existing joins
_, err := tx.Exec("DELETE FROM galleries_images WHERE image_id = ?", imageID)
return err
}
func (qb *JoinsQueryBuilder) GetGalleryPerformers(galleryID int, tx *sqlx.Tx) ([]PerformersGalleries, error) {
ensureTx(tx)
// Delete the existing joins and then create new ones
query := `SELECT * from performers_galleries WHERE gallery_id = ?`
var rows *sqlx.Rows
var err error
if tx != nil {
rows, err = tx.Queryx(query, galleryID)
} else {
rows, err = database.DB.Queryx(query, galleryID)
}
if err != nil && err != sql.ErrNoRows {
return nil, err
}
defer rows.Close()
performerGalleries := make([]PerformersGalleries, 0)
for rows.Next() {
performerGallery := PerformersGalleries{}
if err := rows.StructScan(&performerGallery); err != nil {
return nil, err
}
performerGalleries = append(performerGalleries, performerGallery)
}
if err := rows.Err(); err != nil {
return nil, err
}
return performerGalleries, nil
}
func (qb *JoinsQueryBuilder) CreatePerformersGalleries(newJoins []PerformersGalleries, tx *sqlx.Tx) error {
ensureTx(tx)
for _, join := range newJoins {
_, err := tx.NamedExec(
`INSERT INTO performers_galleries (performer_id, gallery_id) VALUES (:performer_id, :gallery_id)`,
join,
)
if err != nil {
return err
}
}
return nil
}
// AddPerformerGallery adds a performer to a gallery. It does not make any change
// if the performer already exists on the gallery. It returns true if gallery
// performer was added.
func (qb *JoinsQueryBuilder) AddPerformerGallery(galleryID int, performerID int, tx *sqlx.Tx) (bool, error) {
ensureTx(tx)
existingPerformers, err := qb.GetGalleryPerformers(galleryID, tx)
if err != nil {
return false, err
}
// ensure not already present
for _, p := range existingPerformers {
if p.PerformerID == performerID && p.GalleryID == galleryID {
return false, nil
}
}
performerJoin := PerformersGalleries{
PerformerID: performerID,
GalleryID: galleryID,
}
performerJoins := append(existingPerformers, performerJoin)
err = qb.UpdatePerformersGalleries(galleryID, performerJoins, tx)
return err == nil, err
}
func (qb *JoinsQueryBuilder) UpdatePerformersGalleries(galleryID int, updatedJoins []PerformersGalleries, tx *sqlx.Tx) error {
ensureTx(tx)
// Delete the existing joins and then create new ones
_, err := tx.Exec("DELETE FROM performers_galleries WHERE gallery_id = ?", galleryID)
if err != nil {
return err
}
return qb.CreatePerformersGalleries(updatedJoins, tx)
}
func (qb *JoinsQueryBuilder) DestroyPerformersGalleries(galleryID int, tx *sqlx.Tx) error {
ensureTx(tx)
// Delete the existing joins
_, err := tx.Exec("DELETE FROM performers_galleries WHERE gallery_id = ?", galleryID)
return err
}
func (qb *JoinsQueryBuilder) GetGalleryTags(galleryID int, tx *sqlx.Tx) ([]GalleriesTags, error) {
ensureTx(tx)
// Delete the existing joins and then create new ones
query := `SELECT * from galleries_tags WHERE gallery_id = ?`
var rows *sqlx.Rows
var err error
if tx != nil {
rows, err = tx.Queryx(query, galleryID)
} else {
rows, err = database.DB.Queryx(query, galleryID)
}
if err != nil && err != sql.ErrNoRows {
return nil, err
}
defer rows.Close()
galleryTags := make([]GalleriesTags, 0)
for rows.Next() {
galleryTag := GalleriesTags{}
if err := rows.StructScan(&galleryTag); err != nil {
return nil, err
}
galleryTags = append(galleryTags, galleryTag)
}
if err := rows.Err(); err != nil {
return nil, err
}
return galleryTags, nil
}
func (qb *JoinsQueryBuilder) CreateGalleriesTags(newJoins []GalleriesTags, tx *sqlx.Tx) error {
ensureTx(tx)
for _, join := range newJoins {
_, err := tx.NamedExec(
`INSERT INTO galleries_tags (gallery_id, tag_id) VALUES (:gallery_id, :tag_id)`,
join,
)
if err != nil {
return err
}
}
return nil
}
func (qb *JoinsQueryBuilder) UpdateGalleriesTags(galleryID int, updatedJoins []GalleriesTags, tx *sqlx.Tx) error {
ensureTx(tx)
// Delete the existing joins and then create new ones
_, err := tx.Exec("DELETE FROM galleries_tags WHERE gallery_id = ?", galleryID)
if err != nil {
return err
}
return qb.CreateGalleriesTags(updatedJoins, tx)
}
// AddGalleryTag adds a tag to a gallery. It does not make any change if the tag
// already exists on the gallery. It returns true if gallery tag was added.
func (qb *JoinsQueryBuilder) AddGalleryTag(galleryID int, tagID int, tx *sqlx.Tx) (bool, error) {
ensureTx(tx)
existingTags, err := qb.GetGalleryTags(galleryID, tx)
if err != nil {
return false, err
}
// ensure not already present
for _, p := range existingTags {
if p.TagID == tagID && p.GalleryID == galleryID {
return false, nil
}
}
tagJoin := GalleriesTags{
TagID: tagID,
GalleryID: galleryID,
}
tagJoins := append(existingTags, tagJoin)
err = qb.UpdateGalleriesTags(galleryID, tagJoins, tx)
return err == nil, err
}
func (qb *JoinsQueryBuilder) DestroyGalleriesTags(galleryID int, tx *sqlx.Tx) error {
ensureTx(tx)
// Delete the existing joins
_, err := tx.Exec("DELETE FROM galleries_tags WHERE gallery_id = ?", galleryID)
return err
}

View File

@@ -104,6 +104,24 @@ func (qb *PerformerQueryBuilder) FindBySceneID(sceneID int, tx *sqlx.Tx) ([]*Per
return qb.queryPerformers(query, args, tx)
}
func (qb *PerformerQueryBuilder) FindByImageID(imageID int, tx *sqlx.Tx) ([]*Performer, error) {
query := selectAll("performers") + `
LEFT JOIN performers_images as images_join on images_join.performer_id = performers.id
WHERE images_join.image_id = ?
`
args := []interface{}{imageID}
return qb.queryPerformers(query, args, tx)
}
func (qb *PerformerQueryBuilder) FindByGalleryID(galleryID int, tx *sqlx.Tx) ([]*Performer, error) {
query := selectAll("performers") + `
LEFT JOIN performers_galleries as galleries_join on galleries_join.performer_id = performers.id
WHERE galleries_join.gallery_id = ?
`
args := []interface{}{galleryID}
return qb.queryPerformers(query, args, tx)
}
func (qb *PerformerQueryBuilder) FindNameBySceneID(sceneID int, tx *sqlx.Tx) ([]*Performer, error) {
query := `
SELECT performers.name FROM performers

View File

@@ -418,6 +418,8 @@ func sqlGenKeys(i interface{}, partial bool) string {
if partial || t != 0 {
query = append(query, fmt.Sprintf("%s=:%s", key, key))
}
case bool:
query = append(query, fmt.Sprintf("%s=:%s", key, key))
case SQLiteTimestamp:
if partial || !t.Timestamp.IsZero() {
query = append(query, fmt.Sprintf("%s=:%s", key, key))

View File

@@ -120,6 +120,30 @@ func (qb *TagQueryBuilder) FindBySceneID(sceneID int, tx *sqlx.Tx) ([]*Tag, erro
return qb.queryTags(query, args, tx)
}
func (qb *TagQueryBuilder) FindByImageID(imageID int, tx *sqlx.Tx) ([]*Tag, error) {
query := `
SELECT tags.* FROM tags
LEFT JOIN images_tags as images_join on images_join.tag_id = tags.id
WHERE images_join.image_id = ?
GROUP BY tags.id
`
query += qb.getTagSort(nil)
args := []interface{}{imageID}
return qb.queryTags(query, args, tx)
}
func (qb *TagQueryBuilder) FindByGalleryID(galleryID int, tx *sqlx.Tx) ([]*Tag, error) {
query := `
SELECT tags.* FROM tags
LEFT JOIN galleries_tags as galleries_join on galleries_join.tag_id = tags.id
WHERE galleries_join.gallery_id = ?
GROUP BY tags.id
`
query += qb.getTagSort(nil)
args := []interface{}{galleryID}
return qb.queryTags(query, args, tx)
}
func (qb *TagQueryBuilder) FindBySceneMarkerID(sceneMarkerID int, tx *sqlx.Tx) ([]*Tag, error) {
query := `
SELECT tags.* FROM tags

View File

@@ -116,7 +116,7 @@ func TestTagQueryIsMissingImage(t *testing.T) {
IsMissing: &isMissing,
}
q := getTagStringValue(tagIdxWithImage, "name")
q := getTagStringValue(tagIdxWithCoverImage, "name")
findFilter := models.FindFilterType{
Q: &q,
}
@@ -130,7 +130,7 @@ func TestTagQueryIsMissingImage(t *testing.T) {
// ensure non of the ids equal the one with image
for _, tag := range tags {
assert.NotEqual(t, tagIDs[tagIdxWithImage], tag.ID)
assert.NotEqual(t, tagIDs[tagIdxWithCoverImage], tag.ID)
}
}

View File

@@ -17,21 +17,24 @@ import (
"github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/modelstest"
"github.com/stashapp/stash/pkg/utils"
)
const totalScenes = 12
const performersNameCase = 3
const totalImages = 6
const performersNameCase = 6
const performersNameNoCase = 2
const moviesNameCase = 2
const moviesNameNoCase = 1
const totalGalleries = 2
const totalGalleries = 3
const tagsNameNoCase = 2
const tagsNameCase = 6
const studiosNameCase = 4
const tagsNameCase = 9
const studiosNameCase = 5
const studiosNameNoCase = 1
var sceneIDs []int
var imageIDs []int
var performerIDs []int
var movieIDs []int
var galleryIDs []int
@@ -53,13 +56,23 @@ const sceneIdxWithTwoTags = 5
const sceneIdxWithStudio = 6
const sceneIdxWithMarker = 7
const imageIdxWithGallery = 0
const imageIdxWithPerformer = 1
const imageIdxWithTwoPerformers = 2
const imageIdxWithTag = 3
const imageIdxWithTwoTags = 4
const imageIdxWithStudio = 5
const performerIdxWithScene = 0
const performerIdx1WithScene = 1
const performerIdx2WithScene = 2
const performerIdxWithImage = 3
const performerIdx1WithImage = 4
const performerIdx2WithImage = 5
// performers with dup names start from the end
const performerIdx1WithDupName = 3
const performerIdxWithDupName = 4
const performerIdx1WithDupName = 6
const performerIdxWithDupName = 7
const movieIdxWithScene = 0
const movieIdxWithStudio = 1
@@ -68,25 +81,30 @@ const movieIdxWithStudio = 1
const movieIdxWithDupName = 2
const galleryIdxWithScene = 0
const galleryIdxWithImage = 1
const tagIdxWithScene = 0
const tagIdx1WithScene = 1
const tagIdx2WithScene = 2
const tagIdxWithPrimaryMarker = 3
const tagIdxWithMarker = 4
const tagIdxWithImage = 5
const tagIdxWithCoverImage = 5
const tagIdxWithImage = 6
const tagIdx1WithImage = 7
const tagIdx2WithImage = 8
// tags with dup names start from the end
const tagIdx1WithDupName = 6
const tagIdxWithDupName = 7
const tagIdx1WithDupName = 9
const tagIdxWithDupName = 10
const studioIdxWithScene = 0
const studioIdxWithMovie = 1
const studioIdxWithChildStudio = 2
const studioIdxWithParentStudio = 3
const studioIdxWithImage = 4
// studios with dup names start from the end
const studioIdxWithDupName = 4
const studioIdxWithDupName = 5
const markerIdxWithScene = 0
@@ -144,6 +162,11 @@ func populateDB() error {
return err
}
if err := createImages(tx, totalImages); err != nil {
tx.Rollback()
return err
}
if err := createGalleries(tx, totalGalleries); err != nil {
tx.Rollback()
return err
@@ -164,7 +187,7 @@ func populateDB() error {
return err
}
if err := addTagImage(tx, tagIdxWithImage); err != nil {
if err := addTagImage(tx, tagIdxWithCoverImage); err != nil {
tx.Rollback()
return err
}
@@ -207,6 +230,26 @@ func populateDB() error {
return err
}
if err := linkImageGallery(tx, imageIdxWithGallery, galleryIdxWithImage); err != nil {
tx.Rollback()
return err
}
if err := linkImagePerformers(tx); err != nil {
tx.Rollback()
return err
}
if err := linkImageTags(tx); err != nil {
tx.Rollback()
return err
}
if err := linkImageStudio(tx, imageIdxWithStudio, studioIdxWithImage); err != nil {
tx.Rollback()
return err
}
if err := linkMovieStudio(tx, movieIdxWithStudio, studioIdxWithMovie); err != nil {
tx.Rollback()
return err
@@ -233,12 +276,12 @@ func getSceneStringValue(index int, field string) string {
return fmt.Sprintf("scene_%04d_%s", index, field)
}
func getSceneRating(index int) sql.NullInt64 {
func getRating(index int) sql.NullInt64 {
rating := index % 6
return sql.NullInt64{Int64: int64(rating), Valid: rating > 0}
}
func getSceneOCounter(index int) int {
func getOCounter(index int) int {
return index % 3
}
@@ -252,7 +295,7 @@ func getSceneDuration(index int) sql.NullFloat64 {
}
}
func getSceneHeight(index int) sql.NullInt64 {
func getHeight(index int) sql.NullInt64 {
heights := []int64{0, 200, 240, 300, 480, 700, 720, 800, 1080, 1500, 2160, 3000}
height := heights[index%len(heights)]
return sql.NullInt64{
@@ -279,10 +322,10 @@ func createScenes(tx *sqlx.Tx, n int) error {
Title: sql.NullString{String: getSceneStringValue(i, titleField), Valid: true},
Checksum: sql.NullString{String: getSceneStringValue(i, checksumField), Valid: true},
Details: sql.NullString{String: getSceneStringValue(i, "Details"), Valid: true},
Rating: getSceneRating(i),
OCounter: getSceneOCounter(i),
Rating: getRating(i),
OCounter: getOCounter(i),
Duration: getSceneDuration(i),
Height: getSceneHeight(i),
Height: getHeight(i),
Date: getSceneDate(i),
}
@@ -298,6 +341,35 @@ func createScenes(tx *sqlx.Tx, n int) error {
return nil
}
func getImageStringValue(index int, field string) string {
return fmt.Sprintf("image_%04d_%s", index, field)
}
func createImages(tx *sqlx.Tx, n int) error {
qb := models.NewImageQueryBuilder()
for i := 0; i < n; i++ {
image := models.Image{
Path: getImageStringValue(i, pathField),
Title: sql.NullString{String: getImageStringValue(i, titleField), Valid: true},
Checksum: getImageStringValue(i, checksumField),
Rating: getRating(i),
OCounter: getOCounter(i),
Height: getHeight(i),
}
created, err := qb.Create(image, tx)
if err != nil {
return fmt.Errorf("Error creating image %v+: %s", image, err.Error())
}
imageIDs = append(imageIDs, created.ID)
}
return nil
}
func getGalleryStringValue(index int, field string) string {
return "gallery_" + strconv.FormatInt(int64(index), 10) + "_" + field
}
@@ -307,7 +379,7 @@ func createGalleries(tx *sqlx.Tx, n int) error {
for i := 0; i < n; i++ {
gallery := models.Gallery{
Path: getGalleryStringValue(i, pathField),
Path: modelstest.NullString(getGalleryStringValue(i, pathField)),
Checksum: getGalleryStringValue(i, checksumField),
}
@@ -591,7 +663,7 @@ func linkScenePerformer(tx *sqlx.Tx, sceneIndex, performerIndex int) error {
func linkSceneGallery(tx *sqlx.Tx, sceneIndex, galleryIndex int) error {
gqb := models.NewGalleryQueryBuilder()
gallery, err := gqb.Find(galleryIDs[galleryIndex])
gallery, err := gqb.Find(galleryIDs[galleryIndex], nil)
if err != nil {
return fmt.Errorf("error finding gallery: %s", err.Error())
@@ -640,6 +712,68 @@ func linkSceneStudio(tx *sqlx.Tx, sceneIndex, studioIndex int) error {
return err
}
func linkImageGallery(tx *sqlx.Tx, imageIndex, galleryIndex int) error {
jqb := models.NewJoinsQueryBuilder()
_, err := jqb.AddImageGallery(imageIDs[imageIndex], galleryIDs[galleryIndex], tx)
return err
}
func linkImageTags(tx *sqlx.Tx) error {
if err := linkImageTag(tx, imageIdxWithTag, tagIdxWithImage); err != nil {
return err
}
if err := linkImageTag(tx, imageIdxWithTwoTags, tagIdx1WithImage); err != nil {
return err
}
if err := linkImageTag(tx, imageIdxWithTwoTags, tagIdx2WithImage); err != nil {
return err
}
return nil
}
func linkImageTag(tx *sqlx.Tx, imageIndex, tagIndex int) error {
jqb := models.NewJoinsQueryBuilder()
_, err := jqb.AddImageTag(imageIDs[imageIndex], tagIDs[tagIndex], tx)
return err
}
func linkImageStudio(tx *sqlx.Tx, imageIndex, studioIndex int) error {
sqb := models.NewImageQueryBuilder()
image := models.ImagePartial{
ID: imageIDs[imageIndex],
StudioID: &sql.NullInt64{Int64: int64(studioIDs[studioIndex]), Valid: true},
}
_, err := sqb.Update(image, tx)
return err
}
func linkImagePerformers(tx *sqlx.Tx) error {
if err := linkImagePerformer(tx, imageIdxWithPerformer, performerIdxWithImage); err != nil {
return err
}
if err := linkImagePerformer(tx, imageIdxWithTwoPerformers, performerIdx1WithImage); err != nil {
return err
}
if err := linkImagePerformer(tx, imageIdxWithTwoPerformers, performerIdx2WithImage); err != nil {
return err
}
return nil
}
func linkImagePerformer(tx *sqlx.Tx, imageIndex, performerIndex int) error {
jqb := models.NewJoinsQueryBuilder()
_, err := jqb.AddPerformerImage(imageIDs[imageIndex], performerIDs[performerIndex], tx)
return err
}
func linkMovieStudio(tx *sqlx.Tx, movieIndex, studioIndex int) error {
mqb := models.NewMovieQueryBuilder()

View File

@@ -9,6 +9,8 @@ type TagReader interface {
FindMany(ids []int) ([]*Tag, error)
FindBySceneID(sceneID int) ([]*Tag, error)
FindBySceneMarkerID(sceneMarkerID int) ([]*Tag, error)
FindByImageID(imageID int) ([]*Tag, error)
FindByGalleryID(galleryID int) ([]*Tag, error)
FindByName(name string, nocase bool) (*Tag, error)
FindByNames(names []string, nocase bool) ([]*Tag, error)
// Count() (int, error)
@@ -75,6 +77,14 @@ func (t *tagReaderWriter) FindBySceneID(sceneID int) ([]*Tag, error) {
return t.qb.FindBySceneID(sceneID, t.tx)
}
func (t *tagReaderWriter) FindByImageID(imageID int) ([]*Tag, error) {
return t.qb.FindByImageID(imageID, t.tx)
}
func (t *tagReaderWriter) FindByGalleryID(imageID int) ([]*Tag, error) {
return t.qb.FindByGalleryID(imageID, t.tx)
}
func (t *tagReaderWriter) Create(newTag Tag) (*Tag, error) {
return t.qb.Create(newTag, t.tx)
}