mirror of
https://github.com/stashapp/stash.git
synced 2025-12-12 11:22:37 +01:00
* Genericise sliceutil.SliceSame * Genericise intslice functions * Genericise stringutil functions
585 lines
16 KiB
Go
585 lines
16 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
|
|
"github.com/doug-martin/goqu/v9"
|
|
"github.com/doug-martin/goqu/v9/exp"
|
|
"github.com/jmoiron/sqlx"
|
|
"gopkg.in/guregu/null.v4"
|
|
"gopkg.in/guregu/null.v4/zero"
|
|
|
|
"github.com/stashapp/stash/pkg/models"
|
|
"github.com/stashapp/stash/pkg/sliceutil"
|
|
)
|
|
|
|
const (
|
|
movieTable = "movies"
|
|
movieIDColumn = "movie_id"
|
|
|
|
movieFrontImageBlobColumn = "front_image_blob"
|
|
movieBackImageBlobColumn = "back_image_blob"
|
|
)
|
|
|
|
type movieRow struct {
|
|
ID int `db:"id" goqu:"skipinsert"`
|
|
Name zero.String `db:"name"`
|
|
Aliases zero.String `db:"aliases"`
|
|
Duration null.Int `db:"duration"`
|
|
Date NullDate `db:"date"`
|
|
// expressed as 1-100
|
|
Rating null.Int `db:"rating"`
|
|
StudioID null.Int `db:"studio_id,omitempty"`
|
|
Director zero.String `db:"director"`
|
|
Synopsis zero.String `db:"synopsis"`
|
|
URL zero.String `db:"url"`
|
|
CreatedAt Timestamp `db:"created_at"`
|
|
UpdatedAt Timestamp `db:"updated_at"`
|
|
|
|
// not used in resolutions or updates
|
|
FrontImageBlob zero.String `db:"front_image_blob"`
|
|
BackImageBlob zero.String `db:"back_image_blob"`
|
|
}
|
|
|
|
func (r *movieRow) fromMovie(o models.Movie) {
|
|
r.ID = o.ID
|
|
r.Name = zero.StringFrom(o.Name)
|
|
r.Aliases = zero.StringFrom(o.Aliases)
|
|
r.Duration = intFromPtr(o.Duration)
|
|
r.Date = NullDateFromDatePtr(o.Date)
|
|
r.Rating = intFromPtr(o.Rating)
|
|
r.StudioID = intFromPtr(o.StudioID)
|
|
r.Director = zero.StringFrom(o.Director)
|
|
r.Synopsis = zero.StringFrom(o.Synopsis)
|
|
r.URL = zero.StringFrom(o.URL)
|
|
r.CreatedAt = Timestamp{Timestamp: o.CreatedAt}
|
|
r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}
|
|
}
|
|
|
|
func (r *movieRow) resolve() *models.Movie {
|
|
ret := &models.Movie{
|
|
ID: r.ID,
|
|
Name: r.Name.String,
|
|
Aliases: r.Aliases.String,
|
|
Duration: nullIntPtr(r.Duration),
|
|
Date: r.Date.DatePtr(),
|
|
Rating: nullIntPtr(r.Rating),
|
|
StudioID: nullIntPtr(r.StudioID),
|
|
Director: r.Director.String,
|
|
Synopsis: r.Synopsis.String,
|
|
URL: r.URL.String,
|
|
CreatedAt: r.CreatedAt.Timestamp,
|
|
UpdatedAt: r.UpdatedAt.Timestamp,
|
|
}
|
|
|
|
return ret
|
|
}
|
|
|
|
type movieRowRecord struct {
|
|
updateRecord
|
|
}
|
|
|
|
func (r *movieRowRecord) fromPartial(o models.MoviePartial) {
|
|
r.setNullString("name", o.Name)
|
|
r.setNullString("aliases", o.Aliases)
|
|
r.setNullInt("duration", o.Duration)
|
|
r.setNullDate("date", o.Date)
|
|
r.setNullInt("rating", o.Rating)
|
|
r.setNullInt("studio_id", o.StudioID)
|
|
r.setNullString("director", o.Director)
|
|
r.setNullString("synopsis", o.Synopsis)
|
|
r.setNullString("url", o.URL)
|
|
r.setTimestamp("created_at", o.CreatedAt)
|
|
r.setTimestamp("updated_at", o.UpdatedAt)
|
|
}
|
|
|
|
type MovieStore struct {
|
|
repository
|
|
blobJoinQueryBuilder
|
|
|
|
tableMgr *table
|
|
}
|
|
|
|
func NewMovieStore(blobStore *BlobStore) *MovieStore {
|
|
return &MovieStore{
|
|
repository: repository{
|
|
tableName: movieTable,
|
|
idColumn: idColumn,
|
|
},
|
|
blobJoinQueryBuilder: blobJoinQueryBuilder{
|
|
blobStore: blobStore,
|
|
joinTable: movieTable,
|
|
},
|
|
|
|
tableMgr: movieTableMgr,
|
|
}
|
|
}
|
|
|
|
func (qb *MovieStore) table() exp.IdentifierExpression {
|
|
return qb.tableMgr.table
|
|
}
|
|
|
|
func (qb *MovieStore) selectDataset() *goqu.SelectDataset {
|
|
return dialect.From(qb.table()).Select(qb.table().All())
|
|
}
|
|
|
|
func (qb *MovieStore) Create(ctx context.Context, newObject *models.Movie) error {
|
|
var r movieRow
|
|
r.fromMovie(*newObject)
|
|
|
|
id, err := qb.tableMgr.insertID(ctx, r)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
updated, err := qb.find(ctx, id)
|
|
if err != nil {
|
|
return fmt.Errorf("finding after create: %w", err)
|
|
}
|
|
|
|
*newObject = *updated
|
|
|
|
return nil
|
|
}
|
|
|
|
func (qb *MovieStore) UpdatePartial(ctx context.Context, id int, partial models.MoviePartial) (*models.Movie, error) {
|
|
r := movieRowRecord{
|
|
updateRecord{
|
|
Record: make(exp.Record),
|
|
},
|
|
}
|
|
|
|
r.fromPartial(partial)
|
|
|
|
if len(r.Record) > 0 {
|
|
if err := qb.tableMgr.updateByID(ctx, id, r.Record); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return qb.find(ctx, id)
|
|
}
|
|
|
|
func (qb *MovieStore) Update(ctx context.Context, updatedObject *models.Movie) error {
|
|
var r movieRow
|
|
r.fromMovie(*updatedObject)
|
|
|
|
if err := qb.tableMgr.updateByID(ctx, updatedObject.ID, r); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (qb *MovieStore) Destroy(ctx context.Context, id int) error {
|
|
// must handle image checksums manually
|
|
if err := qb.destroyImages(ctx, id); err != nil {
|
|
return err
|
|
}
|
|
|
|
return qb.destroyExisting(ctx, []int{id})
|
|
}
|
|
|
|
// returns nil, nil if not found
|
|
func (qb *MovieStore) Find(ctx context.Context, id int) (*models.Movie, error) {
|
|
ret, err := qb.find(ctx, id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return nil, nil
|
|
}
|
|
return ret, err
|
|
}
|
|
|
|
func (qb *MovieStore) FindMany(ctx context.Context, ids []int) ([]*models.Movie, error) {
|
|
ret := make([]*models.Movie, len(ids))
|
|
|
|
table := qb.table()
|
|
if err := batchExec(ids, defaultBatchSize, func(batch []int) error {
|
|
q := qb.selectDataset().Prepared(true).Where(table.Col(idColumn).In(batch))
|
|
unsorted, err := qb.getMany(ctx, q)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, s := range unsorted {
|
|
i := sliceutil.Index(ids, s.ID)
|
|
ret[i] = s
|
|
}
|
|
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for i := range ret {
|
|
if ret[i] == nil {
|
|
return nil, fmt.Errorf("movie with id %d not found", ids[i])
|
|
}
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
// returns nil, sql.ErrNoRows if not found
|
|
func (qb *MovieStore) find(ctx context.Context, id int) (*models.Movie, error) {
|
|
q := qb.selectDataset().Where(qb.tableMgr.byID(id))
|
|
|
|
ret, err := qb.get(ctx, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
// returns nil, sql.ErrNoRows if not found
|
|
func (qb *MovieStore) get(ctx context.Context, q *goqu.SelectDataset) (*models.Movie, error) {
|
|
ret, err := qb.getMany(ctx, q)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if len(ret) == 0 {
|
|
return nil, sql.ErrNoRows
|
|
}
|
|
|
|
return ret[0], nil
|
|
}
|
|
|
|
func (qb *MovieStore) getMany(ctx context.Context, q *goqu.SelectDataset) ([]*models.Movie, error) {
|
|
const single = false
|
|
var ret []*models.Movie
|
|
if err := queryFunc(ctx, q, single, func(r *sqlx.Rows) error {
|
|
var f movieRow
|
|
if err := r.StructScan(&f); err != nil {
|
|
return err
|
|
}
|
|
|
|
s := f.resolve()
|
|
|
|
ret = append(ret, s)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
func (qb *MovieStore) FindByName(ctx context.Context, name string, nocase bool) (*models.Movie, error) {
|
|
// query := "SELECT * FROM movies WHERE name = ?"
|
|
// if nocase {
|
|
// query += " COLLATE NOCASE"
|
|
// }
|
|
// query += " LIMIT 1"
|
|
where := "name = ?"
|
|
if nocase {
|
|
where += " COLLATE NOCASE"
|
|
}
|
|
sq := qb.selectDataset().Prepared(true).Where(goqu.L(where, name)).Limit(1)
|
|
ret, err := qb.get(ctx, sq)
|
|
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return nil, err
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
func (qb *MovieStore) FindByNames(ctx context.Context, names []string, nocase bool) ([]*models.Movie, error) {
|
|
// query := "SELECT * FROM movies WHERE name"
|
|
// if nocase {
|
|
// query += " COLLATE NOCASE"
|
|
// }
|
|
// query += " IN " + getInBinding(len(names))
|
|
where := "name"
|
|
if nocase {
|
|
where += " COLLATE NOCASE"
|
|
}
|
|
where += " IN " + getInBinding(len(names))
|
|
var args []interface{}
|
|
for _, name := range names {
|
|
args = append(args, name)
|
|
}
|
|
sq := qb.selectDataset().Prepared(true).Where(goqu.L(where, args...))
|
|
ret, err := qb.getMany(ctx, sq)
|
|
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
func (qb *MovieStore) Count(ctx context.Context) (int, error) {
|
|
q := dialect.Select(goqu.COUNT("*")).From(qb.table())
|
|
return count(ctx, q)
|
|
}
|
|
|
|
func (qb *MovieStore) All(ctx context.Context) ([]*models.Movie, error) {
|
|
table := qb.table()
|
|
|
|
return qb.getMany(ctx, qb.selectDataset().Order(
|
|
table.Col("name").Asc(),
|
|
table.Col(idColumn).Asc(),
|
|
))
|
|
}
|
|
|
|
func (qb *MovieStore) makeFilter(ctx context.Context, movieFilter *models.MovieFilterType) *filterBuilder {
|
|
query := &filterBuilder{}
|
|
|
|
query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Name, "movies.name"))
|
|
query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Director, "movies.director"))
|
|
query.handleCriterion(ctx, stringCriterionHandler(movieFilter.Synopsis, "movies.synopsis"))
|
|
query.handleCriterion(ctx, intCriterionHandler(movieFilter.Rating100, "movies.rating", nil))
|
|
query.handleCriterion(ctx, floatIntCriterionHandler(movieFilter.Duration, "movies.duration", nil))
|
|
query.handleCriterion(ctx, movieIsMissingCriterionHandler(qb, movieFilter.IsMissing))
|
|
query.handleCriterion(ctx, stringCriterionHandler(movieFilter.URL, "movies.url"))
|
|
query.handleCriterion(ctx, studioCriterionHandler(movieTable, movieFilter.Studios))
|
|
query.handleCriterion(ctx, moviePerformersCriterionHandler(qb, movieFilter.Performers))
|
|
query.handleCriterion(ctx, dateCriterionHandler(movieFilter.Date, "movies.date"))
|
|
query.handleCriterion(ctx, timestampCriterionHandler(movieFilter.CreatedAt, "movies.created_at"))
|
|
query.handleCriterion(ctx, timestampCriterionHandler(movieFilter.UpdatedAt, "movies.updated_at"))
|
|
|
|
return query
|
|
}
|
|
|
|
func (qb *MovieStore) makeQuery(ctx context.Context, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) (*queryBuilder, error) {
|
|
if findFilter == nil {
|
|
findFilter = &models.FindFilterType{}
|
|
}
|
|
if movieFilter == nil {
|
|
movieFilter = &models.MovieFilterType{}
|
|
}
|
|
|
|
query := qb.newQuery()
|
|
distinctIDs(&query, movieTable)
|
|
|
|
if q := findFilter.Q; q != nil && *q != "" {
|
|
searchColumns := []string{"movies.name"}
|
|
query.parseQueryString(searchColumns, *q)
|
|
}
|
|
|
|
filter := qb.makeFilter(ctx, movieFilter)
|
|
|
|
if err := query.addFilter(filter); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
query.sortAndPagination = qb.getMovieSort(findFilter) + getPagination(findFilter)
|
|
|
|
return &query, nil
|
|
}
|
|
|
|
func (qb *MovieStore) Query(ctx context.Context, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) ([]*models.Movie, int, error) {
|
|
query, err := qb.makeQuery(ctx, movieFilter, findFilter)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
idsResult, countResult, err := query.executeFind(ctx)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
movies, err := qb.FindMany(ctx, idsResult)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
|
|
return movies, countResult, nil
|
|
}
|
|
|
|
func (qb *MovieStore) QueryCount(ctx context.Context, movieFilter *models.MovieFilterType, findFilter *models.FindFilterType) (int, error) {
|
|
query, err := qb.makeQuery(ctx, movieFilter, findFilter)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return query.executeCount(ctx)
|
|
}
|
|
|
|
func movieIsMissingCriterionHandler(qb *MovieStore, isMissing *string) criterionHandlerFunc {
|
|
return func(ctx context.Context, f *filterBuilder) {
|
|
if isMissing != nil && *isMissing != "" {
|
|
switch *isMissing {
|
|
case "front_image":
|
|
f.addWhere("movies.front_image_blob IS NULL")
|
|
case "back_image":
|
|
f.addWhere("movies.back_image_blob IS NULL")
|
|
case "scenes":
|
|
f.addLeftJoin("movies_scenes", "", "movies_scenes.movie_id = movies.id")
|
|
f.addWhere("movies_scenes.scene_id IS NULL")
|
|
default:
|
|
f.addWhere("(movies." + *isMissing + " IS NULL OR TRIM(movies." + *isMissing + ") = '')")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func moviePerformersCriterionHandler(qb *MovieStore, performers *models.MultiCriterionInput) criterionHandlerFunc {
|
|
return func(ctx context.Context, f *filterBuilder) {
|
|
if performers != nil {
|
|
if performers.Modifier == models.CriterionModifierIsNull || performers.Modifier == models.CriterionModifierNotNull {
|
|
var notClause string
|
|
if performers.Modifier == models.CriterionModifierNotNull {
|
|
notClause = "NOT"
|
|
}
|
|
|
|
f.addLeftJoin("movies_scenes", "", "movies.id = movies_scenes.movie_id")
|
|
f.addLeftJoin("performers_scenes", "", "movies_scenes.scene_id = performers_scenes.scene_id")
|
|
|
|
f.addWhere(fmt.Sprintf("performers_scenes.performer_id IS %s NULL", notClause))
|
|
return
|
|
}
|
|
|
|
if len(performers.Value) == 0 {
|
|
return
|
|
}
|
|
|
|
var args []interface{}
|
|
for _, arg := range performers.Value {
|
|
args = append(args, arg)
|
|
}
|
|
|
|
// Hack, can't apply args to join, nor inner join on a left join, so use CTE instead
|
|
f.addWith(`movies_performers AS (
|
|
SELECT movies_scenes.movie_id, performers_scenes.performer_id
|
|
FROM movies_scenes
|
|
INNER JOIN performers_scenes ON movies_scenes.scene_id = performers_scenes.scene_id
|
|
WHERE performers_scenes.performer_id IN`+getInBinding(len(performers.Value))+`
|
|
)`, args...)
|
|
f.addLeftJoin("movies_performers", "", "movies.id = movies_performers.movie_id")
|
|
|
|
switch performers.Modifier {
|
|
case models.CriterionModifierIncludes:
|
|
f.addWhere("movies_performers.performer_id IS NOT NULL")
|
|
case models.CriterionModifierIncludesAll:
|
|
f.addWhere("movies_performers.performer_id IS NOT NULL")
|
|
f.addHaving("COUNT(DISTINCT movies_performers.performer_id) = ?", len(performers.Value))
|
|
case models.CriterionModifierExcludes:
|
|
f.addWhere("movies_performers.performer_id IS NULL")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (qb *MovieStore) getMovieSort(findFilter *models.FindFilterType) string {
|
|
var sort string
|
|
var direction string
|
|
if findFilter == nil {
|
|
sort = "name"
|
|
direction = "ASC"
|
|
} else {
|
|
sort = findFilter.GetSort("name")
|
|
direction = findFilter.GetDirection()
|
|
}
|
|
|
|
sortQuery := ""
|
|
switch sort {
|
|
case "scenes_count": // generic getSort won't work for this
|
|
sortQuery += getCountSort(movieTable, moviesScenesTable, movieIDColumn, direction)
|
|
default:
|
|
sortQuery += getSort(sort, direction, "movies")
|
|
}
|
|
|
|
// Whatever the sorting, always use name/id as a final sort
|
|
sortQuery += ", COALESCE(movies.name, movies.id) COLLATE NATURAL_CI ASC"
|
|
return sortQuery
|
|
}
|
|
|
|
func (qb *MovieStore) queryMovies(ctx context.Context, query string, args []interface{}) ([]*models.Movie, error) {
|
|
const single = false
|
|
var ret []*models.Movie
|
|
if err := qb.queryFunc(ctx, query, args, single, func(r *sqlx.Rows) error {
|
|
var f movieRow
|
|
if err := r.StructScan(&f); err != nil {
|
|
return err
|
|
}
|
|
|
|
s := f.resolve()
|
|
|
|
ret = append(ret, s)
|
|
return nil
|
|
}); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return ret, nil
|
|
}
|
|
|
|
func (qb *MovieStore) UpdateFrontImage(ctx context.Context, movieID int, frontImage []byte) error {
|
|
return qb.UpdateImage(ctx, movieID, movieFrontImageBlobColumn, frontImage)
|
|
}
|
|
|
|
func (qb *MovieStore) UpdateBackImage(ctx context.Context, movieID int, backImage []byte) error {
|
|
return qb.UpdateImage(ctx, movieID, movieBackImageBlobColumn, backImage)
|
|
}
|
|
|
|
func (qb *MovieStore) destroyImages(ctx context.Context, movieID int) error {
|
|
if err := qb.DestroyImage(ctx, movieID, movieFrontImageBlobColumn); err != nil {
|
|
return err
|
|
}
|
|
if err := qb.DestroyImage(ctx, movieID, movieBackImageBlobColumn); err != nil {
|
|
return err
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (qb *MovieStore) GetFrontImage(ctx context.Context, movieID int) ([]byte, error) {
|
|
return qb.GetImage(ctx, movieID, movieFrontImageBlobColumn)
|
|
}
|
|
|
|
func (qb *MovieStore) HasFrontImage(ctx context.Context, movieID int) (bool, error) {
|
|
return qb.HasImage(ctx, movieID, movieFrontImageBlobColumn)
|
|
}
|
|
|
|
func (qb *MovieStore) GetBackImage(ctx context.Context, movieID int) ([]byte, error) {
|
|
return qb.GetImage(ctx, movieID, movieBackImageBlobColumn)
|
|
}
|
|
|
|
func (qb *MovieStore) HasBackImage(ctx context.Context, movieID int) (bool, error) {
|
|
return qb.HasImage(ctx, movieID, movieBackImageBlobColumn)
|
|
}
|
|
|
|
func (qb *MovieStore) FindByPerformerID(ctx context.Context, performerID int) ([]*models.Movie, error) {
|
|
query := `SELECT DISTINCT movies.*
|
|
FROM movies
|
|
INNER JOIN movies_scenes ON movies.id = movies_scenes.movie_id
|
|
INNER JOIN performers_scenes ON performers_scenes.scene_id = movies_scenes.scene_id
|
|
WHERE performers_scenes.performer_id = ?
|
|
`
|
|
args := []interface{}{performerID}
|
|
return qb.queryMovies(ctx, query, args)
|
|
}
|
|
|
|
func (qb *MovieStore) CountByPerformerID(ctx context.Context, performerID int) (int, error) {
|
|
query := `SELECT COUNT(DISTINCT movies_scenes.movie_id) AS count
|
|
FROM movies_scenes
|
|
INNER JOIN performers_scenes ON performers_scenes.scene_id = movies_scenes.scene_id
|
|
WHERE performers_scenes.performer_id = ?
|
|
`
|
|
args := []interface{}{performerID}
|
|
return qb.runCountQuery(ctx, query, args)
|
|
}
|
|
|
|
func (qb *MovieStore) FindByStudioID(ctx context.Context, studioID int) ([]*models.Movie, error) {
|
|
query := `SELECT movies.*
|
|
FROM movies
|
|
WHERE movies.studio_id = ?
|
|
`
|
|
args := []interface{}{studioID}
|
|
return qb.queryMovies(ctx, query, args)
|
|
}
|
|
|
|
func (qb *MovieStore) CountByStudioID(ctx context.Context, studioID int) (int, error) {
|
|
query := `SELECT COUNT(1) AS count
|
|
FROM movies
|
|
WHERE movies.studio_id = ?
|
|
`
|
|
args := []interface{}{studioID}
|
|
return qb.runCountQuery(ctx, query, args)
|
|
}
|