Add Studio to movie and fix movie schema (#458)

* Add movie migration
* Update server and UI code for type changes
* Add studio to movies
* Movie blobs to end
* Document movie duration
* Add filtering on movie studio
This commit is contained in:
WithoutPants
2020-04-22 11:22:14 +10:00
committed by GitHub
parent f21e04dcbc
commit eee7adfb85
30 changed files with 531 additions and 144 deletions

2
go.sum
View File

@@ -321,8 +321,10 @@ github.com/gogo/protobuf v1.0.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4= github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
github.com/golang-migrate/migrate v3.5.4+incompatible h1:R7OzwvCJTCgwapPCiX6DyBiu2czIUMDCB118gFTKTUA=
github.com/golang-migrate/migrate/v4 v4.3.1 h1:3eR1NY+pplX+m6yJ1fQf5dFWX3fBgUtZfDiaS/kJVu4= github.com/golang-migrate/migrate/v4 v4.3.1 h1:3eR1NY+pplX+m6yJ1fQf5dFWX3fBgUtZfDiaS/kJVu4=
github.com/golang-migrate/migrate/v4 v4.3.1/go.mod h1:mJ89KBgbXmM3P49BqOxRL3riNF/ATlg5kMhm17GA0dE= github.com/golang-migrate/migrate/v4 v4.3.1/go.mod h1:mJ89KBgbXmM3P49BqOxRL3riNF/ATlg5kMhm17GA0dE=
github.com/golang-migrate/migrate/v4 v4.10.0 h1:76R6UL3BGnDTpYeittMtfpaNvGBH5zMZatO/fCzIjWo=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef h1:veQD95Isof8w9/WXiA+pa3tz3fJXkt5B7QaRBrM62gk= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef h1:veQD95Isof8w9/WXiA+pa3tz3fJXkt5B7QaRBrM62gk=
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=

View File

@@ -7,6 +7,11 @@ fragment MovieData on Movie {
date date
rating rating
director director
studio {
...StudioData
}
synopsis synopsis
url url
front_image_path front_image_path

View File

@@ -1,16 +1,17 @@
mutation MovieCreate( mutation MovieCreate(
$name: String!, $name: String!,
$aliases: String, $aliases: String,
$duration: String, $duration: Int,
$date: String, $date: String,
$rating: String, $rating: Int,
$studio_id: ID,
$director: String, $director: String,
$synopsis: String, $synopsis: String,
$url: String, $url: String,
$front_image: String, $front_image: String,
$back_image: String) { $back_image: String) {
movieCreate(input: { name: $name, aliases: $aliases, duration: $duration, date: $date, rating: $rating, director: $director, synopsis: $synopsis, url: $url, front_image: $front_image, back_image: $back_image }) { movieCreate(input: { name: $name, aliases: $aliases, duration: $duration, date: $date, rating: $rating, studio_id: $studio_id, director: $director, synopsis: $synopsis, url: $url, front_image: $front_image, back_image: $back_image }) {
...MovieData ...MovieData
} }
} }
@@ -19,16 +20,17 @@ mutation MovieUpdate(
$id: ID! $id: ID!
$name: String, $name: String,
$aliases: String, $aliases: String,
$duration: String, $duration: Int,
$date: String, $date: String,
$rating: String, $rating: Int,
$studio_id: ID,
$director: String, $director: String,
$synopsis: String, $synopsis: String,
$url: String, $url: String,
$front_image: String, $front_image: String,
$back_image: String) { $back_image: String) {
movieUpdate(input: { id: $id, name: $name, aliases: $aliases, duration: $duration, date: $date, rating: $rating, director: $director, synopsis: $synopsis, url: $url, front_image: $front_image, back_image: $back_image }) { movieUpdate(input: { id: $id, name: $name, aliases: $aliases, duration: $duration, date: $date, rating: $rating, studio_id: $studio_id, director: $director, synopsis: $synopsis, url: $url, front_image: $front_image, back_image: $back_image }) {
...MovieData ...MovieData
} }
} }

View File

@@ -1,5 +1,5 @@
query FindMovies($filter: FindFilterType) { query FindMovies($filter: FindFilterType, $movie_filter: MovieFilterType) {
findMovies(filter: $filter) { findMovies(filter: $filter, movie_filter: $movie_filter) {
count count
movies { movies {
...MovieData ...MovieData

View File

@@ -25,7 +25,7 @@ type Query {
"""Find a movie by ID""" """Find a movie by ID"""
findMovie(id: ID!): Movie findMovie(id: ID!): Movie
"""A function which queries Movie objects""" """A function which queries Movie objects"""
findMovies(filter: FindFilterType): FindMoviesResultType! findMovies(movie_filter: MovieFilterType, filter: FindFilterType): FindMoviesResultType!
findGallery(id: ID!): Gallery findGallery(id: ID!): Gallery
findGalleries(filter: FindFilterType): FindGalleriesResultType! findGalleries(filter: FindFilterType): FindGalleriesResultType!

View File

@@ -86,6 +86,11 @@ input SceneFilterType {
performers: MultiCriterionInput performers: MultiCriterionInput
} }
input MovieFilterType {
"""Filter to only include movies with this studio"""
studios: MultiCriterionInput
}
enum CriterionModifier { enum CriterionModifier {
"""=""" """="""
EQUALS, EQUALS,

View File

@@ -3,9 +3,11 @@ type Movie {
checksum: String! checksum: String!
name: String! name: String!
aliases: String aliases: String
duration: String """Duration in seconds"""
duration: Int
date: String date: String
rating: String rating: Int
studio: Studio
director: String director: String
synopsis: String synopsis: String
url: String url: String
@@ -18,9 +20,11 @@ type Movie {
input MovieCreateInput { input MovieCreateInput {
name: String! name: String!
aliases: String aliases: String
duration: String """Duration in seconds"""
duration: Int
date: String date: String
rating: String rating: Int
studio_id: ID
director: String director: String
synopsis: String synopsis: String
url: String url: String
@@ -33,9 +37,10 @@ input MovieUpdateInput {
id: ID! id: ID!
name: String name: String
aliases: String aliases: String
duration: String duration: Int
date: String date: String
rating: String rating: Int
studio_id: ID
director: String director: String
synopsis: String synopsis: String
url: String url: String

View File

@@ -20,7 +20,7 @@ type ScenePathsType {
type SceneMovie { type SceneMovie {
movie: Movie! movie: Movie!
scene_index: String scene_index: Int
} }
type Scene { type Scene {
@@ -48,7 +48,7 @@ type Scene {
input SceneMovieInput { input SceneMovieInput {
movie_id: ID! movie_id: ID!
scene_index: String scene_index: Int
} }
input SceneUpdateInput { input SceneUpdateInput {

View File

@@ -4,8 +4,10 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"net/http" "net/http"
"os"
"github.com/stashapp/stash/pkg/database" "github.com/stashapp/stash/pkg/database"
"github.com/stashapp/stash/pkg/logger"
) )
type migrateData struct { type migrateData struct {
@@ -47,20 +49,44 @@ func doMigrateHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, fmt.Sprintf("error: %s", err), 500) http.Error(w, fmt.Sprintf("error: %s", err), 500)
} }
backupPath := r.Form.Get("backuppath") formBackupPath := r.Form.Get("backuppath")
// always backup so that we can roll back to the previous version if
// migration fails
backupPath := formBackupPath
if formBackupPath == "" {
backupPath = database.DatabaseBackupPath()
}
// perform database backup // perform database backup
if backupPath != "" {
if err = database.Backup(backupPath); err != nil { if err = database.Backup(backupPath); err != nil {
http.Error(w, fmt.Sprintf("error backing up database: %s", err), 500) http.Error(w, fmt.Sprintf("error backing up database: %s", err), 500)
return return
} }
}
err = database.RunMigrations() err = database.RunMigrations()
if err != nil { if err != nil {
http.Error(w, fmt.Sprintf("error performing migration: %s", err), 500) errStr := fmt.Sprintf("error performing migration: %s", err)
// roll back to the backed up version
restoreErr := database.RestoreFromBackup(backupPath)
if restoreErr != nil {
errStr = fmt.Sprintf("ERROR: unable to restore database from backup after migration failure: %s\n%s", restoreErr.Error(), errStr)
} else {
errStr = "An error occurred migrating the database to the latest schema version. The backup database file was automatically renamed to restore the database.\n" + errStr
}
http.Error(w, errStr, 500)
return return
} }
// if no backup path was provided, then delete the created backup
if formBackupPath == "" {
err = os.Remove(backupPath)
if err != nil {
logger.Warnf("error removing unwanted database backup (%s): %s", backupPath, err.Error())
}
}
http.Redirect(w, r, "/", 301) http.Redirect(w, r, "/", 301)
} }

View File

@@ -29,9 +29,10 @@ func (r *movieResolver) Aliases(ctx context.Context, obj *models.Movie) (*string
return nil, nil return nil, nil
} }
func (r *movieResolver) Duration(ctx context.Context, obj *models.Movie) (*string, error) { func (r *movieResolver) Duration(ctx context.Context, obj *models.Movie) (*int, error) {
if obj.Duration.Valid { if obj.Duration.Valid {
return &obj.Duration.String, nil rating := int(obj.Duration.Int64)
return &rating, nil
} }
return nil, nil return nil, nil
} }
@@ -44,13 +45,23 @@ func (r *movieResolver) Date(ctx context.Context, obj *models.Movie) (*string, e
return nil, nil return nil, nil
} }
func (r *movieResolver) Rating(ctx context.Context, obj *models.Movie) (*string, error) { func (r *movieResolver) Rating(ctx context.Context, obj *models.Movie) (*int, error) {
if obj.Rating.Valid { if obj.Rating.Valid {
return &obj.Rating.String, nil rating := int(obj.Rating.Int64)
return &rating, nil
} }
return nil, nil return nil, nil
} }
func (r *movieResolver) Studio(ctx context.Context, obj *models.Movie) (*models.Studio, error) {
qb := models.NewStudioQueryBuilder()
if obj.StudioID.Valid {
return qb.Find(int(obj.StudioID.Int64), nil)
}
return nil, nil
}
func (r *movieResolver) Director(ctx context.Context, obj *models.Movie) (*string, error) { func (r *movieResolver) Director(ctx context.Context, obj *models.Movie) (*string, error) {
if obj.Director.Valid { if obj.Director.Valid {
return &obj.Director.String, nil return &obj.Director.String, nil

View File

@@ -119,10 +119,17 @@ func (r *sceneResolver) Movies(ctx context.Context, obj *models.Scene) ([]*model
} }
sceneIdx := sm.SceneIndex sceneIdx := sm.SceneIndex
ret = append(ret, &models.SceneMovie{ sceneMovie := &models.SceneMovie{
Movie: movie, Movie: movie,
SceneIndex: &sceneIdx, }
})
if sceneIdx.Valid {
var idx int
idx = int(sceneIdx.Int64)
sceneMovie.SceneIndex = &idx
}
ret = append(ret, sceneMovie)
} }
return ret, nil return ret, nil
} }

View File

@@ -51,7 +51,8 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input models.MovieCr
newMovie.Aliases = sql.NullString{String: *input.Aliases, Valid: true} newMovie.Aliases = sql.NullString{String: *input.Aliases, Valid: true}
} }
if input.Duration != nil { if input.Duration != nil {
newMovie.Duration = sql.NullString{String: *input.Duration, Valid: true} duration := int64(*input.Duration)
newMovie.Duration = sql.NullInt64{Int64: duration, Valid: true}
} }
if input.Date != nil { if input.Date != nil {
@@ -59,7 +60,8 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input models.MovieCr
} }
if input.Rating != nil { if input.Rating != nil {
newMovie.Rating = sql.NullString{String: *input.Rating, Valid: true} rating := int64(*input.Rating)
newMovie.Rating = sql.NullInt64{Int64: rating, Valid: true}
} }
if input.Director != nil { if input.Director != nil {
@@ -94,57 +96,71 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input models.MovieCr
func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUpdateInput) (*models.Movie, error) { func (r *mutationResolver) MovieUpdate(ctx context.Context, input models.MovieUpdateInput) (*models.Movie, error) {
// Populate movie from the input // Populate movie from the input
movieID, _ := strconv.Atoi(input.ID) movieID, _ := strconv.Atoi(input.ID)
updatedMovie := models.Movie{
updatedMovie := models.MoviePartial{
ID: movieID, ID: movieID,
UpdatedAt: models.SQLiteTimestamp{Timestamp: time.Now()}, UpdatedAt: &models.SQLiteTimestamp{Timestamp: time.Now()},
} }
if input.FrontImage != nil { if input.FrontImage != nil {
_, frontimageData, err := utils.ProcessBase64Image(*input.FrontImage) _, frontimageData, err := utils.ProcessBase64Image(*input.FrontImage)
if err != nil { if err != nil {
return nil, err return nil, err
} }
updatedMovie.FrontImage = frontimageData updatedMovie.FrontImage = &frontimageData
} }
if input.BackImage != nil { if input.BackImage != nil {
_, backimageData, err := utils.ProcessBase64Image(*input.BackImage) _, backimageData, err := utils.ProcessBase64Image(*input.BackImage)
if err != nil { if err != nil {
return nil, err return nil, err
} }
updatedMovie.BackImage = backimageData updatedMovie.BackImage = &backimageData
} }
if input.Name != nil { if input.Name != nil {
// generate checksum from movie name rather than image // generate checksum from movie name rather than image
checksum := utils.MD5FromString(*input.Name) checksum := utils.MD5FromString(*input.Name)
updatedMovie.Name = sql.NullString{String: *input.Name, Valid: true} updatedMovie.Name = &sql.NullString{String: *input.Name, Valid: true}
updatedMovie.Checksum = checksum updatedMovie.Checksum = &checksum
} }
if input.Aliases != nil { if input.Aliases != nil {
updatedMovie.Aliases = sql.NullString{String: *input.Aliases, Valid: true} updatedMovie.Aliases = &sql.NullString{String: *input.Aliases, Valid: true}
} }
if input.Duration != nil { if input.Duration != nil {
updatedMovie.Duration = sql.NullString{String: *input.Duration, Valid: true} duration := int64(*input.Duration)
updatedMovie.Duration = &sql.NullInt64{Int64: duration, Valid: true}
} }
if input.Date != nil { if input.Date != nil {
updatedMovie.Date = models.SQLiteDate{String: *input.Date, Valid: true} updatedMovie.Date = &models.SQLiteDate{String: *input.Date, Valid: true}
} }
if input.Rating != nil { if input.Rating != nil {
updatedMovie.Rating = sql.NullString{String: *input.Rating, Valid: true} rating := int64(*input.Rating)
updatedMovie.Rating = &sql.NullInt64{Int64: rating, Valid: true}
} else {
// rating must be nullable
updatedMovie.Rating = &sql.NullInt64{Valid: false}
}
if input.StudioID != nil {
studioID, _ := strconv.ParseInt(*input.StudioID, 10, 64)
updatedMovie.StudioID = &sql.NullInt64{Int64: studioID, Valid: true}
} else {
// studio must be nullable
updatedMovie.StudioID = &sql.NullInt64{Valid: false}
} }
if input.Director != nil { if input.Director != nil {
updatedMovie.Director = sql.NullString{String: *input.Director, Valid: true} updatedMovie.Director = &sql.NullString{String: *input.Director, Valid: true}
} }
if input.Synopsis != nil { if input.Synopsis != nil {
updatedMovie.Synopsis = sql.NullString{String: *input.Synopsis, Valid: true} updatedMovie.Synopsis = &sql.NullString{String: *input.Synopsis, Valid: true}
} }
if input.URL != nil { if input.URL != nil {
updatedMovie.URL = sql.NullString{String: *input.URL, Valid: true} updatedMovie.URL = &sql.NullString{String: *input.URL, Valid: true}
} }
// Start the transaction and save the movie // Start the transaction and save the movie

View File

@@ -153,16 +153,19 @@ func (r *mutationResolver) sceneUpdate(input models.SceneUpdateInput, tx *sqlx.T
for _, movie := range input.Movies { for _, movie := range input.Movies {
movieID, _ := strconv.Atoi(movie.MovieID) movieID, _ := strconv.Atoi(movie.MovieID)
sceneIdx := ""
if movie.SceneIndex != nil {
sceneIdx = *movie.SceneIndex
}
movieJoin := models.MoviesScenes{ movieJoin := models.MoviesScenes{
MovieID: movieID, MovieID: movieID,
SceneID: sceneID, SceneID: sceneID,
SceneIndex: sceneIdx,
} }
if movie.SceneIndex != nil {
movieJoin.SceneIndex = sql.NullInt64{
Int64: int64(*movie.SceneIndex),
Valid: true,
}
}
movieJoins = append(movieJoins, movieJoin) movieJoins = append(movieJoins, movieJoin)
} }
if err := jqb.UpdateMoviesScenes(sceneID, movieJoins, tx); err != nil { if err := jqb.UpdateMoviesScenes(sceneID, movieJoins, tx); err != nil {

View File

@@ -13,9 +13,9 @@ func (r *queryResolver) FindMovie(ctx context.Context, id string) (*models.Movie
return qb.Find(idInt, nil) return qb.Find(idInt, nil)
} }
func (r *queryResolver) FindMovies(ctx context.Context, filter *models.FindFilterType) (*models.FindMoviesResultType, error) { func (r *queryResolver) FindMovies(ctx context.Context, movieFilter *models.MovieFilterType, filter *models.FindFilterType) (*models.FindMoviesResultType, error) {
qb := models.NewMovieQueryBuilder() qb := models.NewMovieQueryBuilder()
movies, total := qb.Query(filter) movies, total := qb.Query(movieFilter, filter)
return &models.FindMoviesResultType{ return &models.FindMoviesResultType{
Count: total, Count: total,
Movies: movies, Movies: movies,

View File

@@ -5,11 +5,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"regexp"
"time" "time"
"github.com/gobuffalo/packr/v2" "github.com/gobuffalo/packr/v2"
"github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4"
sqlite3mig "github.com/golang-migrate/migrate/v4/database/sqlite3"
"github.com/golang-migrate/migrate/v4/source" "github.com/golang-migrate/migrate/v4/source"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
sqlite3 "github.com/mattn/go-sqlite3" sqlite3 "github.com/mattn/go-sqlite3"
@@ -19,14 +19,14 @@ import (
var DB *sqlx.DB var DB *sqlx.DB
var dbPath string var dbPath string
var appSchemaVersion uint = 7 var appSchemaVersion uint = 8
var databaseSchemaVersion uint var databaseSchemaVersion uint
const sqlite3Driver = "sqlite3_regexp" const sqlite3Driver = "sqlite3ex"
func init() { func init() {
// register custom driver with regexp function // register custom driver with regexp function
registerRegexpFunc() registerCustomDriver()
} }
func Initialize(databasePath string) { func Initialize(databasePath string) {
@@ -55,14 +55,25 @@ func Initialize(databasePath string) {
} }
} }
const disableForeignKeys = false
DB = open(databasePath, disableForeignKeys)
}
func open(databasePath string, disableForeignKeys bool) *sqlx.DB {
// https://github.com/mattn/go-sqlite3 // https://github.com/mattn/go-sqlite3
conn, err := sqlx.Open(sqlite3Driver, "file:"+databasePath+"?_fk=true") url := "file:" + databasePath
if !disableForeignKeys {
url += "?_fk=true"
}
conn, err := sqlx.Open(sqlite3Driver, url)
conn.SetMaxOpenConns(25) conn.SetMaxOpenConns(25)
conn.SetMaxIdleConns(4) conn.SetMaxIdleConns(4)
if err != nil { if err != nil {
logger.Fatalf("db.Open(): %q\n", err) logger.Fatalf("db.Open(): %q\n", err)
} }
DB = conn
return conn
} }
func Reset(databasePath string) error { func Reset(databasePath string) error {
@@ -97,6 +108,10 @@ func Backup(backupPath string) error {
return nil return nil
} }
func RestoreFromBackup(backupPath string) error {
return os.Rename(backupPath, dbPath)
}
// Migrate the database // Migrate the database
func NeedsMigration() bool { func NeedsMigration() bool {
return databaseSchemaVersion != appSchemaVersion return databaseSchemaVersion != appSchemaVersion
@@ -123,10 +138,21 @@ func getMigrate() (*migrate.Migrate, error) {
databasePath := utils.FixWindowsPath(dbPath) databasePath := utils.FixWindowsPath(dbPath)
s, _ := WithInstance(packrSource) s, _ := WithInstance(packrSource)
return migrate.NewWithSourceInstance(
const disableForeignKeys = true
conn := open(databasePath, disableForeignKeys)
driver, err := sqlite3mig.WithInstance(conn.DB, &sqlite3mig.Config{})
if err != nil {
return nil, err
}
// use sqlite3Driver so that migration has access to durationToTinyInt
return migrate.NewWithInstance(
"packr2", "packr2",
s, s,
fmt.Sprintf("sqlite3://%s", "file:"+databasePath), databasePath,
driver,
) )
} }
@@ -153,6 +179,8 @@ func RunMigrations() error {
if stepNumber != 0 { if stepNumber != 0 {
err = m.Steps(int(stepNumber)) err = m.Steps(int(stepNumber))
if err != nil { if err != nil {
// migration failed
m.Close()
return err return err
} }
} }
@@ -164,15 +192,23 @@ func RunMigrations() error {
return nil return nil
} }
func registerRegexpFunc() { func registerCustomDriver() {
regexFn := func(re, s string) (bool, error) {
return regexp.MatchString(re, s)
}
sql.Register(sqlite3Driver, sql.Register(sqlite3Driver,
&sqlite3.SQLiteDriver{ &sqlite3.SQLiteDriver{
ConnectHook: func(conn *sqlite3.SQLiteConn) error { ConnectHook: func(conn *sqlite3.SQLiteConn) error {
return conn.RegisterFunc("regexp", regexFn, true) funcs := map[string]interface{}{
}, "regexp": regexFn,
}) "durationToTinyInt": durationToTinyIntFn,
}
for name, fn := range funcs {
if err := conn.RegisterFunc(name, fn, true); err != nil {
return fmt.Errorf("Error registering function %s: %s", name, err.Error())
}
}
return nil
},
},
)
} }

37
pkg/database/functions.go Normal file
View File

@@ -0,0 +1,37 @@
package database
import (
"regexp"
"strconv"
"strings"
)
func regexFn(re, s string) (bool, error) {
return regexp.MatchString(re, s)
}
func durationToTinyIntFn(str string) (int64, error) {
splits := strings.Split(str, ":")
if len(splits) > 3 {
return 0, nil
}
seconds := 0
factor := 1
for len(splits) > 0 {
// pop the last split
var thisSplit string
thisSplit, splits = splits[len(splits)-1], splits[:len(splits)-1]
thisInt, err := strconv.Atoi(thisSplit)
if err != nil {
return 0, nil
}
seconds += factor * thisInt
factor *= 60
}
return int64(seconds), nil
}

View File

@@ -0,0 +1,106 @@
ALTER TABLE `movies` rename to `_movies_old`;
ALTER TABLE `movies_scenes` rename to `_movies_scenes_old`;
DROP INDEX IF EXISTS `movies_checksum_unique`;
DROP INDEX IF EXISTS `index_movie_id_scene_index_unique`;
DROP INDEX IF EXISTS `index_movies_scenes_on_movie_id`;
DROP INDEX IF EXISTS `index_movies_scenes_on_scene_id`;
-- recreate the movies table with fixed column types and constraints
CREATE TABLE `movies` (
`id` integer not null primary key autoincrement,
-- add not null
`name` varchar(255) not null,
`aliases` varchar(255),
-- varchar(6) -> integer
`duration` integer,
`date` date,
-- varchar(1) -> tinyint
`rating` tinyint,
`studio_id` integer,
`director` varchar(255),
`synopsis` text,
`checksum` varchar(255) not null,
`url` varchar(255),
`created_at` datetime not null,
`updated_at` datetime not null,
`front_image` blob not null,
`back_image` blob,
foreign key(`studio_id`) references `studios`(`id`) on delete set null
);
CREATE TABLE `movies_scenes` (
`movie_id` integer,
`scene_id` integer,
-- varchar(2) -> tinyint
`scene_index` tinyint,
foreign key(`movie_id`) references `movies`(`id`) on delete cascade,
foreign key(`scene_id`) references `scenes`(`id`) on delete cascade
);
-- add unique index on movie name
CREATE UNIQUE INDEX `movies_name_unique` on `movies` (`name`);
CREATE UNIQUE INDEX `movies_checksum_unique` on `movies` (`checksum`);
-- remove unique index on movies_scenes
CREATE INDEX `index_movies_scenes_on_movie_id` on `movies_scenes` (`movie_id`);
CREATE INDEX `index_movies_scenes_on_scene_id` on `movies_scenes` (`scene_id`);
CREATE INDEX `index_movies_on_studio_id` on `movies` (`studio_id`);
-- custom functions cannot accept NULL values, so massage the old data
UPDATE `_movies_old` set `duration` = 0 WHERE `duration` IS NULL;
-- now populate from the old tables
INSERT INTO `movies`
(
`id`,
`name`,
`aliases`,
`duration`,
`date`,
`rating`,
`director`,
`synopsis`,
`front_image`,
`back_image`,
`checksum`,
`url`,
`created_at`,
`updated_at`
)
SELECT
`id`,
`name`,
`aliases`,
durationToTinyInt(`duration`),
`date`,
CAST(`rating` as tinyint),
`director`,
`synopsis`,
`front_image`,
`back_image`,
`checksum`,
`url`,
`created_at`,
`updated_at`
FROM `_movies_old`
-- ignore null named movies
WHERE `name` is not null;
-- durationToTinyInt returns 0 if it cannot parse the string
-- set these values to null instead
UPDATE `movies` SET `duration` = NULL WHERE `duration` = 0;
INSERT INTO `movies_scenes`
(
`movie_id`,
`scene_id`,
`scene_index`
)
SELECT
`movie_id`,
`scene_id`,
CAST(`scene_index` as tinyint)
FROM `_movies_scenes_old`;
-- drop old tables
DROP TABLE `_movies_scenes_old`;
DROP TABLE `_movies_old`;

View File

@@ -11,9 +11,9 @@ import (
type Movie struct { type Movie struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
Aliases string `json:"aliases,omitempty"` Aliases string `json:"aliases,omitempty"`
Duration string `json:"duration,omitempty"` Duration int `json:"duration,omitempty"`
Date string `json:"date,omitempty"` Date string `json:"date,omitempty"`
Rating string `json:"rating,omitempty"` Rating int `json:"rating,omitempty"`
Director string `json:"director,omitempty"` Director string `json:"director,omitempty"`
Synopsis string `json:"sypnopsis,omitempty"` Synopsis string `json:"sypnopsis,omitempty"`
FrontImage string `json:"front_image,omitempty"` FrontImage string `json:"front_image,omitempty"`

View File

@@ -31,7 +31,7 @@ type SceneFile struct {
type SceneMovie struct { type SceneMovie struct {
MovieName string `json:"movieName,omitempty"` MovieName string `json:"movieName,omitempty"`
SceneIndex string `json:"scene_index,omitempty"` SceneIndex int `json:"scene_index,omitempty"`
} }
type Scene struct { type Scene struct {

View File

@@ -146,7 +146,7 @@ func (t *ExportTask) ExportScenes(ctx context.Context) {
if movie.Name.Valid { if movie.Name.Valid {
sceneMovieJSON := jsonschema.SceneMovie{ sceneMovieJSON := jsonschema.SceneMovie{
MovieName: movie.Name.String, MovieName: movie.Name.String,
SceneIndex: sceneMovie.SceneIndex, SceneIndex: int(sceneMovie.SceneIndex.Int64),
} }
newSceneJSON.Movies = append(newSceneJSON.Movies, sceneMovieJSON) newSceneJSON.Movies = append(newSceneJSON.Movies, sceneMovieJSON)
} }
@@ -381,10 +381,10 @@ func (t *ExportTask) ExportMovies(ctx context.Context) {
newMovieJSON.Date = utils.GetYMDFromDatabaseDate(movie.Date.String) newMovieJSON.Date = utils.GetYMDFromDatabaseDate(movie.Date.String)
} }
if movie.Rating.Valid { if movie.Rating.Valid {
newMovieJSON.Rating = movie.Rating.String newMovieJSON.Rating = int(movie.Rating.Int64)
} }
if movie.Duration.Valid { if movie.Duration.Valid {
newMovieJSON.Duration = movie.Duration.String newMovieJSON.Duration = int(movie.Duration.Int64)
} }
if movie.Director.Valid { if movie.Director.Valid {

View File

@@ -250,8 +250,6 @@ func (t *ImportTask) ImportMovies(ctx context.Context) {
Name: sql.NullString{String: movieJSON.Name, Valid: true}, Name: sql.NullString{String: movieJSON.Name, Valid: true},
Aliases: sql.NullString{String: movieJSON.Aliases, Valid: true}, Aliases: sql.NullString{String: movieJSON.Aliases, Valid: true},
Date: models.SQLiteDate{String: movieJSON.Date, Valid: true}, Date: models.SQLiteDate{String: movieJSON.Date, Valid: true},
Duration: sql.NullString{String: movieJSON.Duration, Valid: true},
Rating: sql.NullString{String: movieJSON.Rating, Valid: true},
Director: sql.NullString{String: movieJSON.Director, Valid: true}, Director: sql.NullString{String: movieJSON.Director, Valid: true},
Synopsis: sql.NullString{String: movieJSON.Synopsis, Valid: true}, Synopsis: sql.NullString{String: movieJSON.Synopsis, Valid: true},
URL: sql.NullString{String: movieJSON.URL, Valid: true}, URL: sql.NullString{String: movieJSON.URL, Valid: true},
@@ -259,6 +257,13 @@ func (t *ImportTask) ImportMovies(ctx context.Context) {
UpdatedAt: models.SQLiteTimestamp{Timestamp: t.getTimeFromJSONTime(movieJSON.UpdatedAt)}, UpdatedAt: models.SQLiteTimestamp{Timestamp: t.getTimeFromJSONTime(movieJSON.UpdatedAt)},
} }
if movieJSON.Rating != 0 {
newMovie.Rating = sql.NullInt64{Int64: int64(movieJSON.Rating), Valid: true}
}
if movieJSON.Duration != 0 {
newMovie.Duration = sql.NullInt64{Int64: int64(movieJSON.Duration), Valid: true}
}
_, err = qb.Create(newMovie, tx) _, err = qb.Create(newMovie, tx)
if err != nil { if err != nil {
_ = tx.Rollback() _ = tx.Rollback()
@@ -712,11 +717,19 @@ func (t *ImportTask) getMoviesScenes(input []jsonschema.SceneMovie, sceneID int,
if movie == nil { if movie == nil {
logger.Warnf("[scenes] movie %s does not exist", inputMovie.MovieName) logger.Warnf("[scenes] movie %s does not exist", inputMovie.MovieName)
} else { } else {
movies = append(movies, models.MoviesScenes{ toAdd := models.MoviesScenes{
MovieID: movie.ID, MovieID: movie.ID,
SceneID: sceneID, SceneID: sceneID,
SceneIndex: inputMovie.SceneIndex, }
})
if inputMovie.SceneIndex != 0 {
toAdd.SceneIndex = sql.NullInt64{
Int64: int64(inputMovie.SceneIndex),
Valid: true,
}
}
movies = append(movies, toAdd)
} }
} }

View File

@@ -1,5 +1,7 @@
package models package models
import "database/sql"
type PerformersScenes struct { type PerformersScenes struct {
PerformerID int `db:"performer_id" json:"performer_id"` PerformerID int `db:"performer_id" json:"performer_id"`
SceneID int `db:"scene_id" json:"scene_id"` SceneID int `db:"scene_id" json:"scene_id"`
@@ -8,7 +10,7 @@ type PerformersScenes struct {
type MoviesScenes struct { type MoviesScenes struct {
MovieID int `db:"movie_id" json:"movie_id"` MovieID int `db:"movie_id" json:"movie_id"`
SceneID int `db:"scene_id" json:"scene_id"` SceneID int `db:"scene_id" json:"scene_id"`
SceneIndex string `db:"scene_index" json:"scene_index"` SceneIndex sql.NullInt64 `db:"scene_index" json:"scene_index"`
} }
type ScenesTags struct { type ScenesTags struct {

View File

@@ -11,9 +11,10 @@ type Movie struct {
Checksum string `db:"checksum" json:"checksum"` Checksum string `db:"checksum" json:"checksum"`
Name sql.NullString `db:"name" json:"name"` Name sql.NullString `db:"name" json:"name"`
Aliases sql.NullString `db:"aliases" json:"aliases"` Aliases sql.NullString `db:"aliases" json:"aliases"`
Duration sql.NullString `db:"duration" json:"duration"` Duration sql.NullInt64 `db:"duration" json:"duration"`
Date SQLiteDate `db:"date" json:"date"` Date SQLiteDate `db:"date" json:"date"`
Rating sql.NullString `db:"rating" json:"rating"` Rating sql.NullInt64 `db:"rating" json:"rating"`
StudioID sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
Director sql.NullString `db:"director" json:"director"` Director sql.NullString `db:"director" json:"director"`
Synopsis sql.NullString `db:"synopsis" json:"synopsis"` Synopsis sql.NullString `db:"synopsis" json:"synopsis"`
URL sql.NullString `db:"url" json:"url"` URL sql.NullString `db:"url" json:"url"`
@@ -21,4 +22,22 @@ type Movie struct {
UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"` UpdatedAt SQLiteTimestamp `db:"updated_at" json:"updated_at"`
} }
type MoviePartial struct {
ID int `db:"id" json:"id"`
FrontImage *[]byte `db:"front_image" json:"front_image"`
BackImage *[]byte `db:"back_image" json:"back_image"`
Checksum *string `db:"checksum" json:"checksum"`
Name *sql.NullString `db:"name" json:"name"`
Aliases *sql.NullString `db:"aliases" json:"aliases"`
Duration *sql.NullInt64 `db:"duration" json:"duration"`
Date *SQLiteDate `db:"date" json:"date"`
Rating *sql.NullInt64 `db:"rating" json:"rating"`
StudioID *sql.NullInt64 `db:"studio_id,omitempty" json:"studio_id"`
Director *sql.NullString `db:"director" json:"director"`
Synopsis *sql.NullString `db:"synopsis" json:"synopsis"`
URL *sql.NullString `db:"url" json:"url"`
CreatedAt *SQLiteTimestamp `db:"created_at" json:"created_at"`
UpdatedAt *SQLiteTimestamp `db:"updated_at" json:"updated_at"`
}
var DefaultMovieImage string = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA3XAAAN1wFCKJt4AAAAB3RJTUUH4wgVBQsJl1CMZAAAASJJREFUeNrt3N0JwyAYhlEj3cj9R3Cm5rbkqtAP+qrnGaCYHPwJpLlaa++mmLpbAERAgAgIEAEBIiBABERAgAgIEAEBIiBABERAgAgIEAHZuVflj40x4i94zhk9vqsVvEq6AsQqMP1EjORx20OACAgQRRx7T+zzcFBxcjNDfoB4ntQqTm5Awo7MlqywZxcgYQ+RlqywJ3ozJAQCSBiEJSsQA0gYBpDAgAARECACAkRAgAgIEAERECACAmSjUv6eAOSB8m8YIGGzBUjYbAESBgMkbBkDEjZbgITBAClcxiqQvEoatreYIWEBASIgJ4Gkf11ntXH3nS9uxfGWfJ5J9hAgAgJEQAQEiIAAERAgAgJEQAQEiIAAERAgAgJEQAQEiL7qBuc6RKLHxr0CAAAAAElFTkSuQmCC" var DefaultMovieImage string = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAYAAABw4pVUAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAA3XAAAN1wFCKJt4AAAAB3RJTUUH4wgVBQsJl1CMZAAAASJJREFUeNrt3N0JwyAYhlEj3cj9R3Cm5rbkqtAP+qrnGaCYHPwJpLlaa++mmLpbAERAgAgIEAEBIiBABERAgAgIEAEBIiBABERAgAgIEAHZuVflj40x4i94zhk9vqsVvEq6AsQqMP1EjORx20OACAgQRRx7T+zzcFBxcjNDfoB4ntQqTm5Awo7MlqywZxcgYQ+RlqywJ3ozJAQCSBiEJSsQA0gYBpDAgAARECACAkRAgAgIEAERECACAmSjUv6eAOSB8m8YIGGzBUjYbAESBgMkbBkDEjZbgITBAClcxiqQvEoatreYIWEBASIgJ4Gkf11ntXH3nS9uxfGWfJ5J9hAgAgJEQAQEiIAAERAgAgJEQAQEiIAAERAgAgJEQAQEiL7qBuc6RKLHxr0CAAAAAElFTkSuQmCC"

View File

@@ -161,7 +161,7 @@ func (qb *JoinsQueryBuilder) CreateMoviesScenes(newJoins []MoviesScenes, tx *sql
// if the movie already exists on the scene. It returns true if scene // if the movie already exists on the scene. It returns true if scene
// movie was added. // movie was added.
func (qb *JoinsQueryBuilder) AddMoviesScene(sceneID int, movieID int, sceneIdx string, tx *sqlx.Tx) (bool, error) { func (qb *JoinsQueryBuilder) AddMoviesScene(sceneID int, movieID int, sceneIdx *int, tx *sqlx.Tx) (bool, error) {
ensureTx(tx) ensureTx(tx)
existingMovies, err := qb.GetSceneMovies(sceneID, tx) existingMovies, err := qb.GetSceneMovies(sceneID, tx)
@@ -180,7 +180,13 @@ func (qb *JoinsQueryBuilder) AddMoviesScene(sceneID int, movieID int, sceneIdx s
movieJoin := MoviesScenes{ movieJoin := MoviesScenes{
MovieID: movieID, MovieID: movieID,
SceneID: sceneID, SceneID: sceneID,
SceneIndex: sceneIdx, }
if sceneIdx != nil {
movieJoin.SceneIndex = sql.NullInt64{
Int64: int64(*sceneIdx),
Valid: true,
}
} }
movieJoins := append(existingMovies, movieJoin) movieJoins := append(existingMovies, movieJoin)

View File

@@ -2,6 +2,7 @@ package models
import ( import (
"database/sql" "database/sql"
"strconv"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
"github.com/stashapp/stash/pkg/database" "github.com/stashapp/stash/pkg/database"
@@ -16,8 +17,8 @@ func NewMovieQueryBuilder() MovieQueryBuilder {
func (qb *MovieQueryBuilder) Create(newMovie Movie, tx *sqlx.Tx) (*Movie, error) { func (qb *MovieQueryBuilder) Create(newMovie Movie, tx *sqlx.Tx) (*Movie, error) {
ensureTx(tx) ensureTx(tx)
result, err := tx.NamedExec( result, err := tx.NamedExec(
`INSERT INTO movies (front_image, back_image, checksum, name, aliases, duration, date, rating, director, synopsis, url, created_at, updated_at) `INSERT INTO movies (front_image, back_image, checksum, name, aliases, duration, date, rating, studio_id, director, synopsis, url, created_at, updated_at)
VALUES (:front_image, :back_image, :checksum, :name, :aliases, :duration, :date, :rating, :director, :synopsis, :url, :created_at, :updated_at) VALUES (:front_image, :back_image, :checksum, :name, :aliases, :duration, :date, :rating, :studio_id, :director, :synopsis, :url, :created_at, :updated_at)
`, `,
newMovie, newMovie,
) )
@@ -35,20 +36,17 @@ func (qb *MovieQueryBuilder) Create(newMovie Movie, tx *sqlx.Tx) (*Movie, error)
return &newMovie, nil return &newMovie, nil
} }
func (qb *MovieQueryBuilder) Update(updatedMovie Movie, tx *sqlx.Tx) (*Movie, error) { func (qb *MovieQueryBuilder) Update(updatedMovie MoviePartial, tx *sqlx.Tx) (*Movie, error) {
ensureTx(tx) ensureTx(tx)
_, err := tx.NamedExec( _, err := tx.NamedExec(
`UPDATE movies SET `+SQLGenKeys(updatedMovie)+` WHERE movies.id = :id`, `UPDATE movies SET `+SQLGenKeysPartial(updatedMovie)+` WHERE movies.id = :id`,
updatedMovie, updatedMovie,
) )
if err != nil { if err != nil {
return nil, err return nil, err
} }
if err := tx.Get(&updatedMovie, `SELECT * FROM movies WHERE id = ? LIMIT 1`, updatedMovie.ID); err != nil { return qb.Find(updatedMovie.ID, tx)
return nil, err
}
return &updatedMovie, nil
} }
func (qb *MovieQueryBuilder) Destroy(id string, tx *sqlx.Tx) error { func (qb *MovieQueryBuilder) Destroy(id string, tx *sqlx.Tx) error {
@@ -113,10 +111,13 @@ func (qb *MovieQueryBuilder) AllSlim() ([]*Movie, error) {
return qb.queryMovies("SELECT movies.id, movies.name FROM movies "+qb.getMovieSort(nil), nil, nil) return qb.queryMovies("SELECT movies.id, movies.name FROM movies "+qb.getMovieSort(nil), nil, nil)
} }
func (qb *MovieQueryBuilder) Query(findFilter *FindFilterType) ([]*Movie, int) { func (qb *MovieQueryBuilder) Query(movieFilter *MovieFilterType, findFilter *FindFilterType) ([]*Movie, int) {
if findFilter == nil { if findFilter == nil {
findFilter = &FindFilterType{} findFilter = &FindFilterType{}
} }
if movieFilter == nil {
movieFilter = &MovieFilterType{}
}
var whereClauses []string var whereClauses []string
var havingClauses []string var havingClauses []string
@@ -125,6 +126,7 @@ func (qb *MovieQueryBuilder) Query(findFilter *FindFilterType) ([]*Movie, int) {
body += ` body += `
left join movies_scenes as scenes_join on scenes_join.movie_id = movies.id left join movies_scenes as scenes_join on scenes_join.movie_id = movies.id
left join scenes on scenes_join.scene_id = scenes.id left join scenes on scenes_join.scene_id = scenes.id
left join studios as studio on studio.id = movies.studio_id
` `
if q := findFilter.Q; q != nil && *q != "" { if q := findFilter.Q; q != nil && *q != "" {
@@ -134,6 +136,16 @@ func (qb *MovieQueryBuilder) Query(findFilter *FindFilterType) ([]*Movie, int) {
args = append(args, thisArgs...) args = append(args, thisArgs...)
} }
if studiosFilter := movieFilter.Studios; studiosFilter != nil && len(studiosFilter.Value) > 0 {
for _, studioID := range studiosFilter.Value {
args = append(args, studioID)
}
whereClause, havingClause := qb.getMultiCriterionClause("studio", "", "studio_id", studiosFilter)
whereClauses = appendClause(whereClauses, whereClause)
havingClauses = appendClause(havingClauses, havingClause)
}
sortAndPagination := qb.getMovieSort(findFilter) + getPagination(findFilter) sortAndPagination := qb.getMovieSort(findFilter) + getPagination(findFilter)
idsResult, countResult := executeFindQuery("movies", body, args, sortAndPagination, whereClauses, havingClauses) idsResult, countResult := executeFindQuery("movies", body, args, sortAndPagination, whereClauses, havingClauses)
@@ -146,6 +158,29 @@ func (qb *MovieQueryBuilder) Query(findFilter *FindFilterType) ([]*Movie, int) {
return movies, countResult return movies, countResult
} }
// returns where clause and having clause
func (qb *MovieQueryBuilder) getMultiCriterionClause(table string, joinTable string, joinTableField string, criterion *MultiCriterionInput) (string, string) {
whereClause := ""
havingClause := ""
if criterion.Modifier == CriterionModifierIncludes {
// includes any of the provided ids
whereClause = table + ".id IN " + getInBinding(len(criterion.Value))
} else if criterion.Modifier == CriterionModifierIncludesAll {
// includes all of the provided ids
whereClause = table + ".id IN " + getInBinding(len(criterion.Value))
havingClause = "count(distinct " + table + ".id) IS " + strconv.Itoa(len(criterion.Value))
} else if criterion.Modifier == CriterionModifierExcludes {
// excludes all of the provided ids
if joinTable != "" {
whereClause = "not exists (select " + joinTable + ".movie_id from " + joinTable + " where " + joinTable + ".movie_id = movies.id and " + joinTable + "." + joinTableField + " in " + getInBinding(len(criterion.Value)) + ")"
} else {
whereClause = "not exists (select m.id from movies as m where m.id = movies.id and m." + joinTableField + " in " + getInBinding(len(criterion.Value)) + ")"
}
}
return whereClause, havingClause
}
func (qb *MovieQueryBuilder) getMovieSort(findFilter *FindFilterType) string { func (qb *MovieQueryBuilder) getMovieSort(findFilter *FindFilterType) string {
var sort string var sort string
var direction string var direction string

View File

@@ -5,7 +5,7 @@ import * as GQL from "src/core/generated-graphql";
interface IProps { interface IProps {
movie: GQL.MovieDataFragment; movie: GQL.MovieDataFragment;
sceneIndex?: string; sceneIndex?: number;
} }
export const MovieCard: FunctionComponent<IProps> = (props: IProps) => { export const MovieCard: FunctionComponent<IProps> = (props: IProps) => {

View File

@@ -8,10 +8,16 @@ import {
DetailsEditNavbar, DetailsEditNavbar,
LoadingIndicator, LoadingIndicator,
Modal, Modal,
StudioSelect,
} from "src/components/Shared"; } from "src/components/Shared";
import { useToast } from "src/hooks"; import { useToast } from "src/hooks";
import { Table, Form } from "react-bootstrap"; import { Table, Form } from "react-bootstrap";
import { TableUtils, ImageUtils, EditableTextUtils, TextUtils } from "src/utils"; import {
TableUtils,
ImageUtils,
EditableTextUtils,
TextUtils,
} from "src/utils";
import { MovieScenesPanel } from "./MovieScenesPanel"; import { MovieScenesPanel } from "./MovieScenesPanel";
export const Movie: React.FC = () => { export const Movie: React.FC = () => {
@@ -29,9 +35,10 @@ export const Movie: React.FC = () => {
const [backImage, setBackImage] = useState<string | undefined>(undefined); const [backImage, setBackImage] = useState<string | undefined>(undefined);
const [name, setName] = useState<string | undefined>(undefined); const [name, setName] = useState<string | undefined>(undefined);
const [aliases, setAliases] = useState<string | undefined>(undefined); const [aliases, setAliases] = useState<string | undefined>(undefined);
const [duration, setDuration] = useState<string | undefined>(undefined); const [duration, setDuration] = useState<number | undefined>(undefined);
const [date, setDate] = useState<string | undefined>(undefined); const [date, setDate] = useState<string | undefined>(undefined);
const [rating, setRating] = useState<string | undefined>(undefined); const [rating, setRating] = useState<number | undefined>(undefined);
const [studioId, setStudioId] = useState<string>();
const [director, setDirector] = useState<string | undefined>(undefined); const [director, setDirector] = useState<string | undefined>(undefined);
const [synopsis, setSynopsis] = useState<string | undefined>(undefined); const [synopsis, setSynopsis] = useState<string | undefined>(undefined);
const [url, setUrl] = useState<string | undefined>(undefined); const [url, setUrl] = useState<string | undefined>(undefined);
@@ -63,6 +70,7 @@ export const Movie: React.FC = () => {
setDuration(state.duration ?? undefined); setDuration(state.duration ?? undefined);
setDate(state.date ?? undefined); setDate(state.date ?? undefined);
setRating(state.rating ?? undefined); setRating(state.rating ?? undefined);
setStudioId(state?.studio?.id ?? undefined);
setDirector(state.director ?? undefined); setDirector(state.director ?? undefined);
setSynopsis(state.synopsis ?? undefined); setSynopsis(state.synopsis ?? undefined);
setUrl(state.url ?? undefined); setUrl(state.url ?? undefined);
@@ -113,6 +121,7 @@ export const Movie: React.FC = () => {
duration, duration,
date, date,
rating, rating,
studio_id: studioId,
director, director,
synopsis, synopsis,
url, url,
@@ -213,12 +222,10 @@ export const Movie: React.FC = () => {
})} })}
{TableUtils.renderDurationInput({ {TableUtils.renderDurationInput({
title: "Duration", title: "Duration",
value: duration, value: duration ? duration.toString() : "",
isEditing, isEditing,
onChange: (value: string | undefined) => { onChange: (value: string | undefined) =>
setDuration(value ?? ""); setDuration(value ? Number.parseInt(value, 10) : undefined),
},
asString: true,
})} })}
{TableUtils.renderInputGroup({ {TableUtils.renderInputGroup({
title: "Date (YYYY-MM-DD)", title: "Date (YYYY-MM-DD)",
@@ -226,6 +233,18 @@ export const Movie: React.FC = () => {
isEditing, isEditing,
onChange: setDate, onChange: setDate,
})} })}
<tr>
<td>Studio</td>
<td>
<StudioSelect
isDisabled={!isEditing}
onSelect={(items) =>
setStudioId(items.length > 0 ? items[0]?.id : undefined)
}
ids={studioId ? [studioId] : []}
/>
</td>
</tr>
{TableUtils.renderInputGroup({ {TableUtils.renderInputGroup({
title: "Director", title: "Director",
value: director, value: director,
@@ -234,9 +253,10 @@ export const Movie: React.FC = () => {
})} })}
{TableUtils.renderHtmlSelect({ {TableUtils.renderHtmlSelect({
title: "Rating", title: "Rating",
value: rating, value: rating ? rating : "",
isEditing, isEditing,
onChange: (value: string) => setRating(value), onChange: (value: string) =>
setRating(Number.parseInt(value, 10)),
selectOptions: ["", "1", "2", "3", "4", "5"], selectOptions: ["", "1", "2", "3", "4", "5"],
})} })}
</tbody> </tbody>

View File

@@ -5,7 +5,7 @@ import { Form } from "react-bootstrap";
type ValidTypes = GQL.SlimMovieDataFragment; type ValidTypes = GQL.SlimMovieDataFragment;
export type MovieSceneIndexMap = Map<string, string | undefined>; export type MovieSceneIndexMap = Map<string, number | undefined>;
export interface IProps { export interface IProps {
movieSceneIndexes: MovieSceneIndexMap; movieSceneIndexes: MovieSceneIndexMap;
@@ -30,7 +30,7 @@ export const SceneMovieTable: React.FunctionComponent<IProps> = (
return props.movieSceneIndexes.get(movie.id); return props.movieSceneIndexes.get(movie.id);
}); });
const updateFieldChanged = (movieId: string, value: string) => { const updateFieldChanged = (movieId: string, value: number) => {
const newMap = new Map(props.movieSceneIndexes); const newMap = new Map(props.movieSceneIndexes);
newMap.set(movieId, value); newMap.set(movieId, value);
props.onUpdate(newMap); props.onUpdate(newMap);
@@ -48,9 +48,15 @@ export const SceneMovieTable: React.FunctionComponent<IProps> = (
<Form.Control <Form.Control
as="select" as="select"
className="input-control" className="input-control"
value={storeIdx[index] ?? ""} value={storeIdx[index] ? storeIdx[index]?.toString() : ""}
onChange={(e: React.FormEvent<HTMLInputElement>) => onChange={(e: React.FormEvent<HTMLInputElement>) =>
updateFieldChanged(item.id, e.currentTarget.value) updateFieldChanged(
item.id,
Number.parseInt(
e.currentTarget.value ? e.currentTarget.value : "0",
10
)
)
} }
> >
{["", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"].map( {["", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10"].map(

View File

@@ -187,9 +187,12 @@ export class StashService {
} }
public static useFindMovies(filter: ListFilterModel) { public static useFindMovies(filter: ListFilterModel) {
const movieFilter = filter.makeMovieFilter();
return GQL.useFindMoviesQuery({ return GQL.useFindMoviesQuery({
variables: { variables: {
filter: filter.makeFindFilter(), filter: filter.makeFindFilter(),
movie_filter: movieFilter,
}, },
}); });
} }

View File

@@ -6,6 +6,7 @@ import {
SceneFilterType, SceneFilterType,
SceneMarkerFilterType, SceneMarkerFilterType,
SortDirectionEnum, SortDirectionEnum,
MovieFilterType,
} from "src/core/generated-graphql"; } from "src/core/generated-graphql";
import { StashService } from "src/core/StashService"; import { StashService } from "src/core/StashService";
import { import {
@@ -162,7 +163,10 @@ export class ListFilterModel {
this.sortBy = "name"; this.sortBy = "name";
this.sortByOptions = ["name", "scenes_count"]; this.sortByOptions = ["name", "scenes_count"];
this.displayModeOptions = [DisplayMode.Grid]; this.displayModeOptions = [DisplayMode.Grid];
this.criterionOptions = [new NoneCriterionOption()]; this.criterionOptions = [
new NoneCriterionOption(),
new StudiosCriterionOption(),
];
break; break;
case FilterMode.Galleries: case FilterMode.Galleries:
this.sortBy = "path"; this.sortBy = "path";
@@ -552,4 +556,22 @@ export class ListFilterModel {
}); });
return result; return result;
} }
public makeMovieFilter(): MovieFilterType {
const result: MovieFilterType = {};
this.criteria.forEach((criterion) => {
switch (criterion.type) {
case "studios": {
const studCrit = criterion as StudiosCriterion;
result.studios = {
value: studCrit.value.map((studio) => studio.id),
modifier: studCrit.modifier,
};
break;
}
// no default
}
});
return result;
}
} }