From 62bdff351dc94c4ae3777ccea5b2731a9412280e Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Tue, 11 Jun 2024 13:08:49 +1000 Subject: [PATCH] Movie URLs (#4900) * Fix exclude behaviour for stringListCriterionHandlerBuilder --- graphql/schema/types/movie.graphql | 10 ++- graphql/schema/types/scraped-movie.graphql | 6 +- internal/api/resolver_model_movie.go | 29 +++++++ internal/api/resolver_mutation_movie.go | 11 ++- internal/manager/task_export.go | 5 ++ pkg/models/jsonschema/movie.go | 5 +- pkg/models/mocks/MovieReaderWriter.go | 23 +++++ pkg/models/model_movie.go | 12 ++- pkg/models/model_scraped_item.go | 5 +- pkg/models/repository_movie.go | 1 + pkg/movie/export.go | 2 +- pkg/movie/export_test.go | 6 +- pkg/movie/import.go | 6 +- pkg/scraper/movie.go | 19 +++-- pkg/sqlite/anonymise.go | 8 +- pkg/sqlite/criterion_handlers.go | 37 ++++++++- pkg/sqlite/database.go | 2 +- pkg/sqlite/gallery_filter.go | 2 + pkg/sqlite/image_filter.go | 2 + pkg/sqlite/migrations/59_movie_urls.up.sql | 83 +++++++++++++++++++ pkg/sqlite/movies.go | 30 ++++++- pkg/sqlite/movies_filter.go | 16 +++- pkg/sqlite/movies_test.go | 75 ++++++++++++++++- pkg/sqlite/performer_filter.go | 2 + pkg/sqlite/scene_filter.go | 14 ++++ pkg/sqlite/setup_test.go | 13 ++- pkg/sqlite/studio_filter.go | 2 + pkg/sqlite/tables.go | 10 +++ pkg/sqlite/tag_filter.go | 2 + ui/v2.5/graphql/data/movie.graphql | 2 +- ui/v2.5/graphql/data/scrapers.graphql | 4 +- .../components/Movies/MovieDetails/Movie.tsx | 15 +--- .../Movies/MovieDetails/MovieEditPanel.tsx | 34 +++----- .../Movies/MovieDetails/MovieScrapeDialog.tsx | 23 +++-- .../components/Shared/ExternalLinksButton.tsx | 48 +++++++++++ ui/v2.5/src/components/Shared/styles.scss | 4 + 36 files changed, 484 insertions(+), 84 deletions(-) create mode 100644 pkg/sqlite/migrations/59_movie_urls.up.sql create mode 100644 ui/v2.5/src/components/Shared/ExternalLinksButton.tsx diff --git a/graphql/schema/types/movie.graphql b/graphql/schema/types/movie.graphql index 1a52c91ea27..8501d88334a 100644 --- a/graphql/schema/types/movie.graphql +++ b/graphql/schema/types/movie.graphql @@ -10,7 +10,8 @@ type Movie { studio: Studio director: String synopsis: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!]! created_at: Time! updated_at: Time! @@ -31,7 +32,8 @@ input MovieCreateInput { studio_id: ID director: String synopsis: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] "This should be a URL or a base64 encoded data URL" front_image: String "This should be a URL or a base64 encoded data URL" @@ -49,7 +51,8 @@ input MovieUpdateInput { studio_id: ID director: String synopsis: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] "This should be a URL or a base64 encoded data URL" front_image: String "This should be a URL or a base64 encoded data URL" @@ -63,6 +66,7 @@ input BulkMovieUpdateInput { rating100: Int studio_id: ID director: String + urls: BulkUpdateStrings } input MovieDestroyInput { diff --git a/graphql/schema/types/scraped-movie.graphql b/graphql/schema/types/scraped-movie.graphql index e3110b8e178..f45903ccef1 100644 --- a/graphql/schema/types/scraped-movie.graphql +++ b/graphql/schema/types/scraped-movie.graphql @@ -7,7 +7,8 @@ type ScrapedMovie { date: String rating: String director: String - url: String + url: String @deprecated(reason: "use urls") + urls: [String!] synopsis: String studio: ScrapedStudio @@ -24,6 +25,7 @@ input ScrapedMovieInput { date: String rating: String director: String - url: String + url: String @deprecated(reason: "use urls") + urls: [String!] synopsis: String } diff --git a/internal/api/resolver_model_movie.go b/internal/api/resolver_model_movie.go index e08d99471c3..630b7d2a0ea 100644 --- a/internal/api/resolver_model_movie.go +++ b/internal/api/resolver_model_movie.go @@ -20,6 +20,35 @@ func (r *movieResolver) Rating100(ctx context.Context, obj *models.Movie) (*int, return obj.Rating, nil } +func (r *movieResolver) URL(ctx context.Context, obj *models.Movie) (*string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Movie) + }); err != nil { + return nil, err + } + } + + urls := obj.URLs.List() + if len(urls) == 0 { + return nil, nil + } + + return &urls[0], nil +} + +func (r *movieResolver) Urls(ctx context.Context, obj *models.Movie) ([]string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Movie) + }); err != nil { + return nil, err + } + } + + return obj.URLs.List(), nil +} + func (r *movieResolver) Studio(ctx context.Context, obj *models.Movie) (ret *models.Studio, err error) { if obj.StudioID == nil { return nil, nil diff --git a/internal/api/resolver_mutation_movie.go b/internal/api/resolver_mutation_movie.go index cb447465419..82198c125d4 100644 --- a/internal/api/resolver_mutation_movie.go +++ b/internal/api/resolver_mutation_movie.go @@ -38,7 +38,6 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp newMovie.Rating = input.Rating100 newMovie.Director = translator.string(input.Director) newMovie.Synopsis = translator.string(input.Synopsis) - newMovie.URL = translator.string(input.URL) var err error @@ -51,6 +50,12 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp return nil, fmt.Errorf("converting studio id: %w", err) } + if input.Urls != nil { + newMovie.URLs = models.NewRelatedStrings(input.Urls) + } else if input.URL != nil { + newMovie.URLs = models.NewRelatedStrings([]string{*input.URL}) + } + // Process the base 64 encoded image string var frontimageData []byte if input.FrontImage != nil { @@ -125,7 +130,6 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp updatedMovie.Rating = translator.optionalInt(input.Rating100, "rating100") updatedMovie.Director = translator.optionalString(input.Director, "director") updatedMovie.Synopsis = translator.optionalString(input.Synopsis, "synopsis") - updatedMovie.URL = translator.optionalString(input.URL, "url") updatedMovie.Date, err = translator.optionalDate(input.Date, "date") if err != nil { @@ -136,6 +140,8 @@ func (r *mutationResolver) MovieUpdate(ctx context.Context, input MovieUpdateInp return nil, fmt.Errorf("converting studio id: %w", err) } + updatedMovie.URLs = translator.optionalURLs(input.Urls, input.URL) + var frontimageData []byte frontImageIncluded := translator.hasField("front_image") if input.FrontImage != nil { @@ -205,6 +211,7 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU if err != nil { return nil, fmt.Errorf("converting studio id: %w", err) } + updatedMovie.URLs = translator.optionalURLsBulk(input.Urls, nil) ret := []*models.Movie{} diff --git a/internal/manager/task_export.go b/internal/manager/task_export.go index 155f9feced8..555502dc5b0 100644 --- a/internal/manager/task_export.go +++ b/internal/manager/task_export.go @@ -1109,6 +1109,11 @@ func (t *ExportTask) exportMovie(ctx context.Context, wg *sync.WaitGroup, jobCha studioReader := r.Studio for m := range jobChan { + if err := m.LoadURLs(ctx, r.Movie); err != nil { + logger.Errorf("[movies] <%s> error getting movie urls: %v", m.Name, err) + continue + } + newMovieJSON, err := movie.ToJSON(ctx, movieReader, studioReader, m) if err != nil { diff --git a/pkg/models/jsonschema/movie.go b/pkg/models/jsonschema/movie.go index d787f8288af..33ce10c1d4a 100644 --- a/pkg/models/jsonschema/movie.go +++ b/pkg/models/jsonschema/movie.go @@ -21,10 +21,13 @@ type Movie struct { Synopsis string `json:"synopsis,omitempty"` FrontImage string `json:"front_image,omitempty"` BackImage string `json:"back_image,omitempty"` - URL string `json:"url,omitempty"` + URLs []string `json:"urls,omitempty"` Studio string `json:"studio,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + + // deprecated - for import only + URL string `json:"url,omitempty"` } func (s Movie) Filename() string { diff --git a/pkg/models/mocks/MovieReaderWriter.go b/pkg/models/mocks/MovieReaderWriter.go index edf355e142c..3f693be94ed 100644 --- a/pkg/models/mocks/MovieReaderWriter.go +++ b/pkg/models/mocks/MovieReaderWriter.go @@ -312,6 +312,29 @@ func (_m *MovieReaderWriter) GetFrontImage(ctx context.Context, movieID int) ([] return r0, r1 } +// GetURLs provides a mock function with given fields: ctx, relatedID +func (_m *MovieReaderWriter) GetURLs(ctx context.Context, relatedID int) ([]string, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []string + if rf, ok := ret.Get(0).(func(context.Context, int) []string); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]string) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, int) error); ok { + r1 = rf(ctx, relatedID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // HasBackImage provides a mock function with given fields: ctx, movieID func (_m *MovieReaderWriter) HasBackImage(ctx context.Context, movieID int) (bool, error) { ret := _m.Called(ctx, movieID) diff --git a/pkg/models/model_movie.go b/pkg/models/model_movie.go index 5880ff2d137..d1ce0d8dcbf 100644 --- a/pkg/models/model_movie.go +++ b/pkg/models/model_movie.go @@ -1,6 +1,7 @@ package models import ( + "context" "time" ) @@ -15,9 +16,10 @@ type Movie struct { StudioID *int `json:"studio_id"` Director string `json:"director"` Synopsis string `json:"synopsis"` - URL string `json:"url"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` + + URLs RelatedStrings `json:"urls"` } func NewMovie() Movie { @@ -28,6 +30,12 @@ func NewMovie() Movie { } } +func (g *Movie) LoadURLs(ctx context.Context, l URLLoader) error { + return g.URLs.load(func() ([]string, error) { + return l.GetURLs(ctx, g.ID) + }) +} + type MoviePartial struct { Name OptionalString Aliases OptionalString @@ -38,7 +46,7 @@ type MoviePartial struct { StudioID OptionalInt Director OptionalString Synopsis OptionalString - URL OptionalString + URLs *UpdateStrings CreatedAt OptionalTime UpdatedAt OptionalTime } diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index cb383c082e7..b3a7a2418b3 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -368,13 +368,16 @@ type ScrapedMovie struct { Date *string `json:"date"` Rating *string `json:"rating"` Director *string `json:"director"` - URL *string `json:"url"` + URLs []string `json:"urls"` Synopsis *string `json:"synopsis"` Studio *ScrapedStudio `json:"studio"` // This should be a base64 encoded data URL FrontImage *string `json:"front_image"` // This should be a base64 encoded data URL BackImage *string `json:"back_image"` + + // deprecated + URL *string `json:"url"` } func (ScrapedMovie) IsScrapedContent() {} diff --git a/pkg/models/repository_movie.go b/pkg/models/repository_movie.go index 9234ea7a5d1..2518e21b529 100644 --- a/pkg/models/repository_movie.go +++ b/pkg/models/repository_movie.go @@ -64,6 +64,7 @@ type MovieReader interface { MovieFinder MovieQueryer MovieCounter + URLLoader All(ctx context.Context) ([]*Movie, error) GetFrontImage(ctx context.Context, movieID int) ([]byte, error) diff --git a/pkg/movie/export.go b/pkg/movie/export.go index 5a6c49aa364..55e157168e7 100644 --- a/pkg/movie/export.go +++ b/pkg/movie/export.go @@ -23,7 +23,7 @@ func ToJSON(ctx context.Context, reader ImageGetter, studioReader models.StudioG Aliases: movie.Aliases, Director: movie.Director, Synopsis: movie.Synopsis, - URL: movie.URL, + URLs: movie.URLs.List(), CreatedAt: json.JSONTime{Time: movie.CreatedAt}, UpdatedAt: json.JSONTime{Time: movie.UpdatedAt}, } diff --git a/pkg/movie/export_test.go b/pkg/movie/export_test.go index 51d57e2b6e8..dd6c9f27409 100644 --- a/pkg/movie/export_test.go +++ b/pkg/movie/export_test.go @@ -72,7 +72,7 @@ func createFullMovie(id int, studioID int) models.Movie { Duration: &duration, Director: director, Synopsis: synopsis, - URL: url, + URLs: models.NewRelatedStrings([]string{url}), StudioID: &studioID, CreatedAt: createTime, UpdatedAt: updateTime, @@ -82,6 +82,7 @@ func createFullMovie(id int, studioID int) models.Movie { func createEmptyMovie(id int) models.Movie { return models.Movie{ ID: id, + URLs: models.NewRelatedStrings([]string{}), CreatedAt: createTime, UpdatedAt: updateTime, } @@ -96,7 +97,7 @@ func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Movie Duration: duration, Director: director, Synopsis: synopsis, - URL: url, + URLs: []string{url}, Studio: studio, FrontImage: frontImage, BackImage: backImage, @@ -111,6 +112,7 @@ func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Movie func createEmptyJSONMovie() *jsonschema.Movie { return &jsonschema.Movie{ + URLs: []string{}, CreatedAt: json.JSONTime{ Time: createTime, }, diff --git a/pkg/movie/import.go b/pkg/movie/import.go index 8004798ae53..00e56d4e137 100644 --- a/pkg/movie/import.go +++ b/pkg/movie/import.go @@ -55,11 +55,15 @@ func (i *Importer) movieJSONToMovie(movieJSON jsonschema.Movie) models.Movie { Aliases: movieJSON.Aliases, Director: movieJSON.Director, Synopsis: movieJSON.Synopsis, - URL: movieJSON.URL, CreatedAt: movieJSON.CreatedAt.GetTime(), UpdatedAt: movieJSON.UpdatedAt.GetTime(), } + if len(movieJSON.URLs) > 0 { + newMovie.URLs = models.NewRelatedStrings(movieJSON.URLs) + } else if movieJSON.URL != "" { + newMovie.URLs = models.NewRelatedStrings([]string{movieJSON.URL}) + } if movieJSON.Date != "" { d, err := models.ParseDate(movieJSON.Date) if err == nil { diff --git a/pkg/scraper/movie.go b/pkg/scraper/movie.go index 4416b6199cb..00c89ad9c45 100644 --- a/pkg/scraper/movie.go +++ b/pkg/scraper/movie.go @@ -1,12 +1,15 @@ package scraper type ScrapedMovieInput struct { - Name *string `json:"name"` - Aliases *string `json:"aliases"` - Duration *string `json:"duration"` - Date *string `json:"date"` - Rating *string `json:"rating"` - Director *string `json:"director"` - URL *string `json:"url"` - Synopsis *string `json:"synopsis"` + Name *string `json:"name"` + Aliases *string `json:"aliases"` + Duration *string `json:"duration"` + Date *string `json:"date"` + Rating *string `json:"rating"` + Director *string `json:"director"` + URLs []string `json:"urls"` + Synopsis *string `json:"synopsis"` + + // deprecated + URL *string `json:"url"` } diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index 7e4efd70299..44381c0700e 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -847,7 +847,6 @@ func (db *Anonymiser) anonymiseMovies(ctx context.Context) error { table.Col("name"), table.Col("aliases"), table.Col("synopsis"), - table.Col("url"), table.Col("director"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) @@ -860,7 +859,6 @@ func (db *Anonymiser) anonymiseMovies(ctx context.Context) error { name sql.NullString aliases sql.NullString synopsis sql.NullString - url sql.NullString director sql.NullString ) @@ -869,7 +867,6 @@ func (db *Anonymiser) anonymiseMovies(ctx context.Context) error { &name, &aliases, &synopsis, - &url, &director, ); err != nil { return err @@ -879,7 +876,6 @@ func (db *Anonymiser) anonymiseMovies(ctx context.Context) error { db.obfuscateNullString(set, "name", name) db.obfuscateNullString(set, "aliases", aliases) db.obfuscateNullString(set, "synopsis", synopsis) - db.obfuscateNullString(set, "url", url) db.obfuscateNullString(set, "director", director) if len(set) > 0 { @@ -905,6 +901,10 @@ func (db *Anonymiser) anonymiseMovies(ctx context.Context) error { } } + if err := db.anonymiseURLs(ctx, goqu.T(movieURLsTable), "movie_id"); err != nil { + return err + } + return nil } diff --git a/pkg/sqlite/criterion_handlers.go b/pkg/sqlite/criterion_handlers.go index 243f1f54e13..e021bd1759b 100644 --- a/pkg/sqlite/criterion_handlers.go +++ b/pkg/sqlite/criterion_handlers.go @@ -517,20 +517,51 @@ func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInp // handler for StringCriterion for string list fields type stringListCriterionHandlerBuilder struct { + primaryTable string + // foreign key of the primary object on the join table + primaryFK string // table joining primary and foreign objects joinTable string // string field on the join table stringColumn string - addJoinTable func(f *filterBuilder) + addJoinTable func(f *filterBuilder) + excludeHandler func(f *filterBuilder, criterion *models.StringCriterionInput) } func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if criterion != nil { - m.addJoinTable(f) + if criterion.Modifier == models.CriterionModifierExcludes { + // special handling for excludes + if m.excludeHandler != nil { + m.excludeHandler(f, criterion) + return + } - stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(ctx, f) + // excludes all of the provided values + // need to use actual join table name for this + // .id NOT IN (select . from where . in ) + whereClause := utils.StrFormat("{primaryTable}.id NOT IN (SELECT {joinTable}.{primaryFK} from {joinTable} where {joinTable}.{stringColumn} LIKE ?)", + utils.StrFormatMap{ + "primaryTable": m.primaryTable, + "joinTable": m.joinTable, + "primaryFK": m.primaryFK, + "stringColumn": m.stringColumn, + }, + ) + + f.addWhere(whereClause, "%"+criterion.Value+"%") + + // TODO - should we also exclude null values? + // m.addJoinTable(f) + // stringCriterionHandler(&models.StringCriterionInput{ + // Modifier: models.CriterionModifierNotNull, + // }, m.joinTable+"."+m.stringColumn)(ctx, f) + } else { + m.addJoinTable(f) + stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(ctx, f) + } } } } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 4da53c3528e..3475e955a72 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -30,7 +30,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 58 +var appSchemaVersion uint = 59 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/gallery_filter.go b/pkg/sqlite/gallery_filter.go index abca78b105c..ad5ac592ada 100644 --- a/pkg/sqlite/gallery_filter.go +++ b/pkg/sqlite/gallery_filter.go @@ -151,6 +151,8 @@ func (qb *galleryFilterHandler) criterionHandler() criterionHandler { func (qb *galleryFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ + primaryTable: galleryTable, + primaryFK: galleryIDColumn, joinTable: galleriesURLsTable, stringColumn: galleriesURLColumn, addJoinTable: func(f *filterBuilder) { diff --git a/pkg/sqlite/image_filter.go b/pkg/sqlite/image_filter.go index 4fef482714f..8f2d5d6b90a 100644 --- a/pkg/sqlite/image_filter.go +++ b/pkg/sqlite/image_filter.go @@ -160,6 +160,8 @@ func (qb *imageFilterHandler) missingCriterionHandler(isMissing *string) criteri func (qb *imageFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ + primaryTable: imageTable, + primaryFK: imageIDColumn, joinTable: imagesURLsTable, stringColumn: imageURLColumn, addJoinTable: func(f *filterBuilder) { diff --git a/pkg/sqlite/migrations/59_movie_urls.up.sql b/pkg/sqlite/migrations/59_movie_urls.up.sql new file mode 100644 index 00000000000..3ea860e3020 --- /dev/null +++ b/pkg/sqlite/migrations/59_movie_urls.up.sql @@ -0,0 +1,83 @@ +PRAGMA foreign_keys=OFF; + +CREATE TABLE `movie_urls` ( + `movie_id` integer NOT NULL, + `position` integer NOT NULL, + `url` varchar(255) NOT NULL, + foreign key(`movie_id`) references `movies`(`id`) on delete CASCADE, + PRIMARY KEY(`movie_id`, `position`, `url`) +); + +CREATE INDEX `movie_urls_url` on `movie_urls` (`url`); + +-- drop url +CREATE TABLE `movies_new` ( + `id` integer not null primary key autoincrement, + `name` varchar(255) not null, + `aliases` varchar(255), + `duration` integer, + `date` date, + `rating` tinyint, + `studio_id` integer REFERENCES `studios`(`id`) ON DELETE SET NULL, + `director` varchar(255), + `synopsis` text, + `created_at` datetime not null, + `updated_at` datetime not null, + `front_image_blob` varchar(255) REFERENCES `blobs`(`checksum`), + `back_image_blob` varchar(255) REFERENCES `blobs`(`checksum`) +); + +INSERT INTO `movies_new` + ( + `id`, + `name`, + `aliases`, + `duration`, + `date`, + `rating`, + `studio_id`, + `director`, + `synopsis`, + `created_at`, + `updated_at`, + `front_image_blob`, + `back_image_blob` + ) + SELECT + `id`, + `name`, + `aliases`, + `duration`, + `date`, + `rating`, + `studio_id`, + `director`, + `synopsis`, + `created_at`, + `updated_at`, + `front_image_blob`, + `back_image_blob` + FROM `movies`; + +INSERT INTO `movie_urls` + ( + `movie_id`, + `position`, + `url` + ) + SELECT + `id`, + '0', + `url` + FROM `movies` + WHERE `movies`.`url` IS NOT NULL AND `movies`.`url` != ''; + +DROP INDEX `index_movies_on_name_unique`; +DROP INDEX `index_movies_on_studio_id`; +DROP TABLE `movies`; +ALTER TABLE `movies_new` rename to `movies`; + +CREATE INDEX `index_movies_on_name` ON `movies`(`name`); +CREATE INDEX `index_movies_on_studio_id` on `movies` (`studio_id`); + +PRAGMA foreign_keys=ON; diff --git a/pkg/sqlite/movies.go b/pkg/sqlite/movies.go index acbf036f2bb..6fc4ce5f09e 100644 --- a/pkg/sqlite/movies.go +++ b/pkg/sqlite/movies.go @@ -22,6 +22,9 @@ const ( movieFrontImageBlobColumn = "front_image_blob" movieBackImageBlobColumn = "back_image_blob" + + movieURLsTable = "movie_urls" + movieURLColumn = "url" ) type movieRow struct { @@ -35,7 +38,6 @@ type movieRow struct { 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"` @@ -54,7 +56,6 @@ func (r *movieRow) fromMovie(o models.Movie) { 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} } @@ -70,7 +71,6 @@ func (r *movieRow) resolve() *models.Movie { StudioID: nullIntPtr(r.StudioID), Director: r.Director.String, Synopsis: r.Synopsis.String, - URL: r.URL.String, CreatedAt: r.CreatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp, } @@ -91,7 +91,6 @@ func (r *movieRowRecord) fromPartial(o models.MoviePartial) { 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) } @@ -148,6 +147,13 @@ func (qb *MovieStore) Create(ctx context.Context, newObject *models.Movie) error return err } + if newObject.URLs.Loaded() { + const startPos = 0 + if err := moviesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + return err + } + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) @@ -173,6 +179,12 @@ func (qb *MovieStore) UpdatePartial(ctx context.Context, id int, partial models. } } + if partial.URLs != nil { + if err := moviesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { + return nil, err + } + } + return qb.find(ctx, id) } @@ -184,6 +196,12 @@ func (qb *MovieStore) Update(ctx context.Context, updatedObject *models.Movie) e return err } + if updatedObject.URLs.Loaded() { + if err := moviesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + return err + } + } + return nil } @@ -537,3 +555,7 @@ WHERE movies.studio_id = ? args := []interface{}{studioID} return movieRepository.runCountQuery(ctx, query, args) } + +func (qb *MovieStore) GetURLs(ctx context.Context, movieID int) ([]string, error) { + return moviesURLsTableMgr.get(ctx, movieID) +} diff --git a/pkg/sqlite/movies_filter.go b/pkg/sqlite/movies_filter.go index 78d5abf5d22..8ef939592c7 100644 --- a/pkg/sqlite/movies_filter.go +++ b/pkg/sqlite/movies_filter.go @@ -60,7 +60,7 @@ func (qb *movieFilterHandler) criterionHandler() criterionHandler { intCriterionHandler(movieFilter.Rating100, "movies.rating", nil), floatIntCriterionHandler(movieFilter.Duration, "movies.duration", nil), qb.missingCriterionHandler(movieFilter.IsMissing), - stringCriterionHandler(movieFilter.URL, "movies.url"), + qb.urlsCriterionHandler(movieFilter.URL), studioCriterionHandler(movieTable, movieFilter.Studios), qb.performersCriterionHandler(movieFilter.Performers), &dateCriterionHandler{movieFilter.Date, "movies.date", nil}, @@ -102,6 +102,20 @@ func (qb *movieFilterHandler) missingCriterionHandler(isMissing *string) criteri } } +func (qb *movieFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + primaryTable: movieTable, + primaryFK: movieIDColumn, + joinTable: movieURLsTable, + stringColumn: movieURLColumn, + addJoinTable: func(f *filterBuilder) { + moviesURLsTableMgr.join(f, "", "movies.id") + }, + } + + return h.handler(url) +} + func (qb *movieFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc { return func(ctx context.Context, f *filterBuilder) { if performers != nil { diff --git a/pkg/sqlite/movies_test.go b/pkg/sqlite/movies_test.go index 9b9615fbd90..9c4e0135fa1 100644 --- a/pkg/sqlite/movies_test.go +++ b/pkg/sqlite/movies_test.go @@ -15,6 +15,16 @@ import ( "github.com/stashapp/stash/pkg/models" ) +func loadMovieRelationships(ctx context.Context, expected models.Movie, actual *models.Movie) error { + if expected.URLs.Loaded() { + if err := actual.LoadURLs(ctx, db.Gallery); err != nil { + return err + } + } + + return nil +} + func TestMovieFindByName(t *testing.T) { withTxn(func(ctx context.Context) error { mqb := db.Movie @@ -205,7 +215,14 @@ func TestMovieQueryURL(t *testing.T) { verifyFn := func(n *models.Movie) { t.Helper() - verifyString(t, n.URL, urlCriterion) + + urls := n.URLs.List() + var url string + if len(urls) > 0 { + url = urls[0] + } + + verifyString(t, url, urlCriterion) } verifyMovieQuery(t, filter, verifyFn) @@ -228,6 +245,56 @@ func TestMovieQueryURL(t *testing.T) { verifyMovieQuery(t, filter, verifyFn) } +func TestMovieQueryURLExcludes(t *testing.T) { + withRollbackTxn(func(ctx context.Context) error { + mqb := db.Movie + + // create movie with two URLs + movie := models.Movie{ + Name: "TestMovieQueryURLExcludes", + URLs: models.NewRelatedStrings([]string{ + "aaa", + "bbb", + }), + } + + err := mqb.Create(ctx, &movie) + + if err != nil { + return fmt.Errorf("Error creating movie: %w", err) + } + + // query for movies that exclude the URL "aaa" + urlCriterion := models.StringCriterionInput{ + Value: "aaa", + Modifier: models.CriterionModifierExcludes, + } + + nameCriterion := models.StringCriterionInput{ + Value: movie.Name, + Modifier: models.CriterionModifierEquals, + } + + filter := models.MovieFilterType{ + URL: &urlCriterion, + Name: &nameCriterion, + } + + movies := queryMovie(ctx, t, mqb, &filter, nil) + assert.Len(t, movies, 0, "Expected no movies to be found") + + // query for movies that exclude the URL "ccc" + urlCriterion.Value = "ccc" + movies = queryMovie(ctx, t, mqb, &filter, nil) + + if assert.Len(t, movies, 1, "Expected one movie to be found") { + assert.Equal(t, movie.Name, movies[0].Name) + } + + return nil + }) +} + func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func(s *models.Movie)) { withTxn(func(ctx context.Context) error { t.Helper() @@ -235,6 +302,12 @@ func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func movies := queryMovie(ctx, t, sqb, &filter, nil) + for _, movie := range movies { + if err := movie.LoadURLs(ctx, sqb); err != nil { + t.Errorf("Error loading movie relationships: %v", err) + } + } + // assume it should find at least one assert.Greater(t, len(movies), 0) diff --git a/pkg/sqlite/performer_filter.go b/pkg/sqlite/performer_filter.go index 100da424488..13c2ec5a248 100644 --- a/pkg/sqlite/performer_filter.go +++ b/pkg/sqlite/performer_filter.go @@ -243,6 +243,8 @@ func (qb *performerFilterHandler) performerAgeFilterCriterionHandler(age *models func (qb *performerFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ + primaryTable: performerTable, + primaryFK: performerIDColumn, joinTable: performersAliasesTable, stringColumn: performerAliasColumn, addJoinTable: func(f *filterBuilder) { diff --git a/pkg/sqlite/scene_filter.go b/pkg/sqlite/scene_filter.go index 2ce329a9626..b9c219695d0 100644 --- a/pkg/sqlite/scene_filter.go +++ b/pkg/sqlite/scene_filter.go @@ -345,6 +345,8 @@ func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) crite func (qb *sceneFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ + primaryTable: sceneTable, + primaryFK: sceneIDColumn, joinTable: scenesURLsTable, stringColumn: sceneURLColumn, addJoinTable: func(f *filterBuilder) { @@ -368,12 +370,24 @@ func (qb *sceneFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, join func (qb *sceneFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ + primaryTable: sceneTable, + primaryFK: sceneIDColumn, joinTable: videoCaptionsTable, stringColumn: captionCodeColumn, addJoinTable: func(f *filterBuilder) { qb.addSceneFilesTable(f) f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = scenes_files.file_id") }, + excludeHandler: func(f *filterBuilder, criterion *models.StringCriterionInput) { + excludeClause := `scenes.id NOT IN ( + SELECT scenes_files.scene_id from scenes_files + INNER JOIN video_captions on video_captions.file_id = scenes_files.file_id + WHERE video_captions.language_code LIKE ? + )` + f.addWhere(excludeClause, criterion.Value) + + // TODO - should we also exclude null values? + }, } return h.handler(captions) diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index 91b2b49fb7d..1ccab4574f7 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1303,6 +1303,15 @@ func getMovieNullStringValue(index int, field string) string { return ret.String } +func getMovieEmptyString(index int, field string) string { + v := getPrefixedNullStringValue("movie", index, field) + if !v.Valid { + return "" + } + + return v.String +} + // createMoviees creates n movies with plain Name and o movies with camel cased NaMe included func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o int) error { const namePlain = "Name" @@ -1321,7 +1330,9 @@ func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o in name = getMovieStringValue(index, name) movie := models.Movie{ Name: name, - URL: getMovieNullStringValue(index, urlField), + URLs: models.NewRelatedStrings([]string{ + getMovieEmptyString(i, urlField), + }), } err := mqb.Create(ctx, &movie) diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index 1a3aa2131f0..45745c4717d 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -178,6 +178,8 @@ func (qb *studioFilterHandler) parentCriterionHandler(parents *models.MultiCrite func (qb *studioFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ + primaryTable: studioTable, + primaryFK: studioIDColumn, joinTable: studioAliasesTable, stringColumn: studioAliasColumn, addJoinTable: func(f *filterBuilder) { diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 2eebf033f56..64d1e4eb236 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -34,6 +34,8 @@ var ( studiosAliasesJoinTable = goqu.T(studioAliasesTable) studiosStashIDsJoinTable = goqu.T("studio_stash_ids") + + moviesURLsJoinTable = goqu.T(movieURLsTable) ) var ( @@ -299,6 +301,14 @@ var ( table: goqu.T(movieTable), idColumn: goqu.T(movieTable).Col(idColumn), } + + moviesURLsTableMgr = &orderedValueTable[string]{ + table: table{ + table: moviesURLsJoinTable, + idColumn: moviesURLsJoinTable.Col(movieIDColumn), + }, + valueColumn: moviesURLsJoinTable.Col(movieURLColumn), + } ) var ( diff --git a/pkg/sqlite/tag_filter.go b/pkg/sqlite/tag_filter.go index a628a073503..55321dbbabf 100644 --- a/pkg/sqlite/tag_filter.go +++ b/pkg/sqlite/tag_filter.go @@ -105,6 +105,8 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler { func (qb *tagFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { h := stringListCriterionHandlerBuilder{ + primaryTable: tagTable, + primaryFK: tagIDColumn, joinTable: tagAliasesTable, stringColumn: tagAliasColumn, addJoinTable: func(f *filterBuilder) { diff --git a/ui/v2.5/graphql/data/movie.graphql b/ui/v2.5/graphql/data/movie.graphql index 3fd4273d28f..a0ed1f67f32 100644 --- a/ui/v2.5/graphql/data/movie.graphql +++ b/ui/v2.5/graphql/data/movie.graphql @@ -12,7 +12,7 @@ fragment MovieData on Movie { } synopsis - url + urls front_image_path back_image_path scene_count diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index 94b6434b164..a59d74b096e 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -90,7 +90,7 @@ fragment ScrapedMovieData on ScrapedMovie { date rating director - url + urls synopsis front_image back_image @@ -108,7 +108,7 @@ fragment ScrapedSceneMovieData on ScrapedMovie { date rating director - url + urls synopsis front_image back_image diff --git a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx index d61d9a61d77..723b1a7ac58 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/Movie.tsx @@ -26,10 +26,8 @@ import { MovieEditPanel } from "./MovieEditPanel"; import { faChevronDown, faChevronUp, - faLink, faTrashAlt, } from "@fortawesome/free-solid-svg-icons"; -import TextUtils from "src/utils/text"; import { Icon } from "src/components/Shared/Icon"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { ConfigurationContext } from "src/hooks/Config"; @@ -37,7 +35,7 @@ import { DetailImage } from "src/components/Shared/DetailImage"; import { useRatingKeybinds } from "src/hooks/keybinds"; import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; -import { ExternalLink } from "src/components/Shared/ExternalLink"; +import { ExternalLinksButton } from "src/components/Shared/ExternalLinksButton"; interface IProps { movie: GQL.MovieDataFragment; @@ -273,16 +271,7 @@ const MoviePage: React.FC = ({ movie }) => { const renderClickableIcons = () => ( - {movie.url && ( - - )} + {movie.urls.length > 0 && } ); diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx index 80217eff504..5b9bac5f8d9 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieEditPanel.tsx @@ -9,7 +9,6 @@ import { } from "src/core/StashService"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; -import { URLField } from "src/components/Shared/URLField"; import { useToast } from "src/hooks/Toast"; import { Modal as BSModal, Form, Button } from "react-bootstrap"; import TextUtils from "src/utils/text"; @@ -20,7 +19,11 @@ import { MovieScrapeDialog } from "./MovieScrapeDialog"; import isEqual from "lodash-es/isEqual"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; -import { yupDateString, yupFormikValidate } from "src/utils/yup"; +import { + yupDateString, + yupFormikValidate, + yupUniqueStringList, +} from "src/utils/yup"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; interface IMovieEditPanel { @@ -64,7 +67,7 @@ export const MovieEditPanel: React.FC = ({ date: yupDateString(intl), studio_id: yup.string().required().nullable(), director: yup.string().ensure(), - url: yup.string().ensure(), + urls: yupUniqueStringList(intl), synopsis: yup.string().ensure(), front_image: yup.string().nullable().optional(), back_image: yup.string().nullable().optional(), @@ -77,7 +80,7 @@ export const MovieEditPanel: React.FC = ({ date: movie?.date ?? "", studio_id: movie?.studio?.id ?? null, director: movie?.director ?? "", - url: movie?.url ?? "", + urls: movie?.urls ?? [], synopsis: movie?.synopsis ?? "", }; @@ -153,8 +156,8 @@ export const MovieEditPanel: React.FC = ({ if (state.synopsis) { formik.setFieldValue("synopsis", state.synopsis); } - if (state.url) { - formik.setFieldValue("url", state.url); + if (state.urls) { + formik.setFieldValue("urls", state.urls); } if (state.front_image) { @@ -178,8 +181,7 @@ export const MovieEditPanel: React.FC = ({ setIsLoading(false); } - async function onScrapeMovieURL() { - const { url } = formik.values; + async function onScrapeMovieURL(url: string) { if (!url) return; setIsLoading(true); @@ -334,6 +336,7 @@ export const MovieEditPanel: React.FC = ({ renderInputField, renderDateField, renderDurationField, + renderURLListField, } = formikUtils(intl, formik); function renderStudioField() { @@ -348,19 +351,6 @@ export const MovieEditPanel: React.FC = ({ return renderField("studio_id", title, control); } - function renderUrlField() { - const title = intl.formatMessage({ id: "url" }); - const control = ( - - ); - - return renderField("url", title, control); - } - // TODO: CSS class return (
@@ -391,7 +381,7 @@ export const MovieEditPanel: React.FC = ({ {renderDateField("date")} {renderStudioField()} {renderInputField("director")} - {renderUrlField()} + {renderURLListField("urls", onScrapeMovieURL, urlScrapable)} {renderInputField("synopsis", "textarea")} diff --git a/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx b/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx index 3ae243ab5aa..eff3c8b284c 100644 --- a/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx +++ b/ui/v2.5/src/components/Movies/MovieDetails/MovieScrapeDialog.tsx @@ -6,6 +6,7 @@ import { ScrapedInputGroupRow, ScrapedImageRow, ScrapedTextAreaRow, + ScrapedStringListRow, } from "src/components/Shared/ScrapeDialog/ScrapeDialog"; import TextUtils from "src/utils/text"; import { @@ -15,6 +16,7 @@ import { import { Studio } from "src/components/Studios/StudioSelect"; import { useCreateScrapedStudio } from "src/components/Shared/ScrapeDialog/createObjects"; import { ScrapedStudioRow } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; +import { uniq } from "lodash-es"; interface IMovieScrapeDialogProps { movie: Partial; @@ -64,8 +66,13 @@ export const MovieScrapeDialog: React.FC = ( props.scraped.studio?.stored_id ? props.scraped.studio : undefined ) ); - const [url, setURL] = useState>( - new ScrapeResult(props.movie.url, props.scraped.url) + const [urls, setURLs] = useState>( + new ScrapeResult( + props.movie.urls, + props.scraped.urls + ? uniq((props.movie.urls ?? []).concat(props.scraped.urls ?? [])) + : undefined + ) ); const [frontImage, setFrontImage] = useState>( new ScrapeResult(props.movie.front_image, props.scraped.front_image) @@ -94,7 +101,7 @@ export const MovieScrapeDialog: React.FC = ( director, synopsis, studio, - url, + urls, frontImage, backImage, ]; @@ -117,7 +124,7 @@ export const MovieScrapeDialog: React.FC = ( director: director.getNewValue(), synopsis: synopsis.getNewValue(), studio: newStudioValue, - url: url.getNewValue(), + urls: urls.getNewValue(), front_image: frontImage.getNewValue(), back_image: backImage.getNewValue(), }; @@ -164,10 +171,10 @@ export const MovieScrapeDialog: React.FC = ( newStudio={newStudio} onCreateNew={createNewStudio} /> - setURL(value)} + setURLs(value)} /> = ({ urls, icon = faLink }) => { + if (!urls.length) { + return null; + } + + if (urls.length === 1) { + return ( + + ); + } + + return ( + + + + + + + {urls.map((url) => ( + + {url} + + ))} + + + ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 3736ad52432..983e517834b 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -592,3 +592,7 @@ button.btn.favorite-button { } } } + +.external-links-button { + display: inline-block; +}