stash/pkg/sqlite/movies.go
DingDongSoLong4 9621213424
Genericise sliceutil functions (#4253)
* Genericise sliceutil.SliceSame
* Genericise intslice functions
* Genericise stringutil functions
2023-11-02 08:58:32 +11:00

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)
}