Movie URLs (#4900)

* Fix exclude behaviour for stringListCriterionHandlerBuilder
This commit is contained in:
WithoutPants
2024-06-11 13:08:49 +10:00
committed by GitHub
parent bf25759a57
commit 62bdff351d
36 changed files with 484 additions and 84 deletions

View File

@@ -10,7 +10,8 @@ type Movie {
studio: Studio studio: Studio
director: String director: String
synopsis: String synopsis: String
url: String url: String @deprecated(reason: "Use urls")
urls: [String!]!
created_at: Time! created_at: Time!
updated_at: Time! updated_at: Time!
@@ -31,7 +32,8 @@ input MovieCreateInput {
studio_id: ID studio_id: ID
director: String director: String
synopsis: String synopsis: String
url: String url: String @deprecated(reason: "Use urls")
urls: [String!]
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
front_image: String front_image: String
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
@@ -49,7 +51,8 @@ input MovieUpdateInput {
studio_id: ID studio_id: ID
director: String director: String
synopsis: String synopsis: String
url: String url: String @deprecated(reason: "Use urls")
urls: [String!]
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
front_image: String front_image: String
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
@@ -63,6 +66,7 @@ input BulkMovieUpdateInput {
rating100: Int rating100: Int
studio_id: ID studio_id: ID
director: String director: String
urls: BulkUpdateStrings
} }
input MovieDestroyInput { input MovieDestroyInput {

View File

@@ -7,7 +7,8 @@ type ScrapedMovie {
date: String date: String
rating: String rating: String
director: String director: String
url: String url: String @deprecated(reason: "use urls")
urls: [String!]
synopsis: String synopsis: String
studio: ScrapedStudio studio: ScrapedStudio
@@ -24,6 +25,7 @@ input ScrapedMovieInput {
date: String date: String
rating: String rating: String
director: String director: String
url: String url: String @deprecated(reason: "use urls")
urls: [String!]
synopsis: String synopsis: String
} }

View File

@@ -20,6 +20,35 @@ func (r *movieResolver) Rating100(ctx context.Context, obj *models.Movie) (*int,
return obj.Rating, nil 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) { func (r *movieResolver) Studio(ctx context.Context, obj *models.Movie) (ret *models.Studio, err error) {
if obj.StudioID == nil { if obj.StudioID == nil {
return nil, nil return nil, nil

View File

@@ -38,7 +38,6 @@ func (r *mutationResolver) MovieCreate(ctx context.Context, input MovieCreateInp
newMovie.Rating = input.Rating100 newMovie.Rating = input.Rating100
newMovie.Director = translator.string(input.Director) newMovie.Director = translator.string(input.Director)
newMovie.Synopsis = translator.string(input.Synopsis) newMovie.Synopsis = translator.string(input.Synopsis)
newMovie.URL = translator.string(input.URL)
var err error 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) 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 // Process the base 64 encoded image string
var frontimageData []byte var frontimageData []byte
if input.FrontImage != nil { 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.Rating = translator.optionalInt(input.Rating100, "rating100")
updatedMovie.Director = translator.optionalString(input.Director, "director") updatedMovie.Director = translator.optionalString(input.Director, "director")
updatedMovie.Synopsis = translator.optionalString(input.Synopsis, "synopsis") updatedMovie.Synopsis = translator.optionalString(input.Synopsis, "synopsis")
updatedMovie.URL = translator.optionalString(input.URL, "url")
updatedMovie.Date, err = translator.optionalDate(input.Date, "date") updatedMovie.Date, err = translator.optionalDate(input.Date, "date")
if err != nil { 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) return nil, fmt.Errorf("converting studio id: %w", err)
} }
updatedMovie.URLs = translator.optionalURLs(input.Urls, input.URL)
var frontimageData []byte var frontimageData []byte
frontImageIncluded := translator.hasField("front_image") frontImageIncluded := translator.hasField("front_image")
if input.FrontImage != nil { if input.FrontImage != nil {
@@ -205,6 +211,7 @@ func (r *mutationResolver) BulkMovieUpdate(ctx context.Context, input BulkMovieU
if err != nil { if err != nil {
return nil, fmt.Errorf("converting studio id: %w", err) return nil, fmt.Errorf("converting studio id: %w", err)
} }
updatedMovie.URLs = translator.optionalURLsBulk(input.Urls, nil)
ret := []*models.Movie{} ret := []*models.Movie{}

View File

@@ -1109,6 +1109,11 @@ func (t *ExportTask) exportMovie(ctx context.Context, wg *sync.WaitGroup, jobCha
studioReader := r.Studio studioReader := r.Studio
for m := range jobChan { 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) newMovieJSON, err := movie.ToJSON(ctx, movieReader, studioReader, m)
if err != nil { if err != nil {

View File

@@ -21,10 +21,13 @@ type Movie struct {
Synopsis string `json:"synopsis,omitempty"` Synopsis string `json:"synopsis,omitempty"`
FrontImage string `json:"front_image,omitempty"` FrontImage string `json:"front_image,omitempty"`
BackImage string `json:"back_image,omitempty"` BackImage string `json:"back_image,omitempty"`
URL string `json:"url,omitempty"` URLs []string `json:"urls,omitempty"`
Studio string `json:"studio,omitempty"` Studio string `json:"studio,omitempty"`
CreatedAt json.JSONTime `json:"created_at,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"`
UpdatedAt json.JSONTime `json:"updated_at,omitempty"` UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
// deprecated - for import only
URL string `json:"url,omitempty"`
} }
func (s Movie) Filename() string { func (s Movie) Filename() string {

View File

@@ -312,6 +312,29 @@ func (_m *MovieReaderWriter) GetFrontImage(ctx context.Context, movieID int) ([]
return r0, r1 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 // HasBackImage provides a mock function with given fields: ctx, movieID
func (_m *MovieReaderWriter) HasBackImage(ctx context.Context, movieID int) (bool, error) { func (_m *MovieReaderWriter) HasBackImage(ctx context.Context, movieID int) (bool, error) {
ret := _m.Called(ctx, movieID) ret := _m.Called(ctx, movieID)

View File

@@ -1,6 +1,7 @@
package models package models
import ( import (
"context"
"time" "time"
) )
@@ -15,9 +16,10 @@ type Movie struct {
StudioID *int `json:"studio_id"` StudioID *int `json:"studio_id"`
Director string `json:"director"` Director string `json:"director"`
Synopsis string `json:"synopsis"` Synopsis string `json:"synopsis"`
URL string `json:"url"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
URLs RelatedStrings `json:"urls"`
} }
func NewMovie() Movie { 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 { type MoviePartial struct {
Name OptionalString Name OptionalString
Aliases OptionalString Aliases OptionalString
@@ -38,7 +46,7 @@ type MoviePartial struct {
StudioID OptionalInt StudioID OptionalInt
Director OptionalString Director OptionalString
Synopsis OptionalString Synopsis OptionalString
URL OptionalString URLs *UpdateStrings
CreatedAt OptionalTime CreatedAt OptionalTime
UpdatedAt OptionalTime UpdatedAt OptionalTime
} }

View File

@@ -368,13 +368,16 @@ type ScrapedMovie struct {
Date *string `json:"date"` Date *string `json:"date"`
Rating *string `json:"rating"` Rating *string `json:"rating"`
Director *string `json:"director"` Director *string `json:"director"`
URL *string `json:"url"` URLs []string `json:"urls"`
Synopsis *string `json:"synopsis"` Synopsis *string `json:"synopsis"`
Studio *ScrapedStudio `json:"studio"` Studio *ScrapedStudio `json:"studio"`
// This should be a base64 encoded data URL // This should be a base64 encoded data URL
FrontImage *string `json:"front_image"` FrontImage *string `json:"front_image"`
// This should be a base64 encoded data URL // This should be a base64 encoded data URL
BackImage *string `json:"back_image"` BackImage *string `json:"back_image"`
// deprecated
URL *string `json:"url"`
} }
func (ScrapedMovie) IsScrapedContent() {} func (ScrapedMovie) IsScrapedContent() {}

View File

@@ -64,6 +64,7 @@ type MovieReader interface {
MovieFinder MovieFinder
MovieQueryer MovieQueryer
MovieCounter MovieCounter
URLLoader
All(ctx context.Context) ([]*Movie, error) All(ctx context.Context) ([]*Movie, error)
GetFrontImage(ctx context.Context, movieID int) ([]byte, error) GetFrontImage(ctx context.Context, movieID int) ([]byte, error)

View File

@@ -23,7 +23,7 @@ func ToJSON(ctx context.Context, reader ImageGetter, studioReader models.StudioG
Aliases: movie.Aliases, Aliases: movie.Aliases,
Director: movie.Director, Director: movie.Director,
Synopsis: movie.Synopsis, Synopsis: movie.Synopsis,
URL: movie.URL, URLs: movie.URLs.List(),
CreatedAt: json.JSONTime{Time: movie.CreatedAt}, CreatedAt: json.JSONTime{Time: movie.CreatedAt},
UpdatedAt: json.JSONTime{Time: movie.UpdatedAt}, UpdatedAt: json.JSONTime{Time: movie.UpdatedAt},
} }

View File

@@ -72,7 +72,7 @@ func createFullMovie(id int, studioID int) models.Movie {
Duration: &duration, Duration: &duration,
Director: director, Director: director,
Synopsis: synopsis, Synopsis: synopsis,
URL: url, URLs: models.NewRelatedStrings([]string{url}),
StudioID: &studioID, StudioID: &studioID,
CreatedAt: createTime, CreatedAt: createTime,
UpdatedAt: updateTime, UpdatedAt: updateTime,
@@ -82,6 +82,7 @@ func createFullMovie(id int, studioID int) models.Movie {
func createEmptyMovie(id int) models.Movie { func createEmptyMovie(id int) models.Movie {
return models.Movie{ return models.Movie{
ID: id, ID: id,
URLs: models.NewRelatedStrings([]string{}),
CreatedAt: createTime, CreatedAt: createTime,
UpdatedAt: updateTime, UpdatedAt: updateTime,
} }
@@ -96,7 +97,7 @@ func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Movie
Duration: duration, Duration: duration,
Director: director, Director: director,
Synopsis: synopsis, Synopsis: synopsis,
URL: url, URLs: []string{url},
Studio: studio, Studio: studio,
FrontImage: frontImage, FrontImage: frontImage,
BackImage: backImage, BackImage: backImage,
@@ -111,6 +112,7 @@ func createFullJSONMovie(studio, frontImage, backImage string) *jsonschema.Movie
func createEmptyJSONMovie() *jsonschema.Movie { func createEmptyJSONMovie() *jsonschema.Movie {
return &jsonschema.Movie{ return &jsonschema.Movie{
URLs: []string{},
CreatedAt: json.JSONTime{ CreatedAt: json.JSONTime{
Time: createTime, Time: createTime,
}, },

View File

@@ -55,11 +55,15 @@ func (i *Importer) movieJSONToMovie(movieJSON jsonschema.Movie) models.Movie {
Aliases: movieJSON.Aliases, Aliases: movieJSON.Aliases,
Director: movieJSON.Director, Director: movieJSON.Director,
Synopsis: movieJSON.Synopsis, Synopsis: movieJSON.Synopsis,
URL: movieJSON.URL,
CreatedAt: movieJSON.CreatedAt.GetTime(), CreatedAt: movieJSON.CreatedAt.GetTime(),
UpdatedAt: movieJSON.UpdatedAt.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 != "" { if movieJSON.Date != "" {
d, err := models.ParseDate(movieJSON.Date) d, err := models.ParseDate(movieJSON.Date)
if err == nil { if err == nil {

View File

@@ -7,6 +7,9 @@ type ScrapedMovieInput struct {
Date *string `json:"date"` Date *string `json:"date"`
Rating *string `json:"rating"` Rating *string `json:"rating"`
Director *string `json:"director"` Director *string `json:"director"`
URL *string `json:"url"` URLs []string `json:"urls"`
Synopsis *string `json:"synopsis"` Synopsis *string `json:"synopsis"`
// deprecated
URL *string `json:"url"`
} }

View File

@@ -847,7 +847,6 @@ func (db *Anonymiser) anonymiseMovies(ctx context.Context) error {
table.Col("name"), table.Col("name"),
table.Col("aliases"), table.Col("aliases"),
table.Col("synopsis"), table.Col("synopsis"),
table.Col("url"),
table.Col("director"), table.Col("director"),
).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000)
@@ -860,7 +859,6 @@ func (db *Anonymiser) anonymiseMovies(ctx context.Context) error {
name sql.NullString name sql.NullString
aliases sql.NullString aliases sql.NullString
synopsis sql.NullString synopsis sql.NullString
url sql.NullString
director sql.NullString director sql.NullString
) )
@@ -869,7 +867,6 @@ func (db *Anonymiser) anonymiseMovies(ctx context.Context) error {
&name, &name,
&aliases, &aliases,
&synopsis, &synopsis,
&url,
&director, &director,
); err != nil { ); err != nil {
return err return err
@@ -879,7 +876,6 @@ func (db *Anonymiser) anonymiseMovies(ctx context.Context) error {
db.obfuscateNullString(set, "name", name) db.obfuscateNullString(set, "name", name)
db.obfuscateNullString(set, "aliases", aliases) db.obfuscateNullString(set, "aliases", aliases)
db.obfuscateNullString(set, "synopsis", synopsis) db.obfuscateNullString(set, "synopsis", synopsis)
db.obfuscateNullString(set, "url", url)
db.obfuscateNullString(set, "director", director) db.obfuscateNullString(set, "director", director)
if len(set) > 0 { 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 return nil
} }

View File

@@ -517,23 +517,54 @@ func (m *countCriterionHandlerBuilder) handler(criterion *models.IntCriterionInp
// handler for StringCriterion for string list fields // handler for StringCriterion for string list fields
type stringListCriterionHandlerBuilder struct { type stringListCriterionHandlerBuilder struct {
primaryTable string
// foreign key of the primary object on the join table
primaryFK string
// table joining primary and foreign objects // table joining primary and foreign objects
joinTable string joinTable string
// string field on the join table // string field on the join table
stringColumn string 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 { func (m *stringListCriterionHandlerBuilder) handler(criterion *models.StringCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if criterion != nil { if criterion != nil {
m.addJoinTable(f) if criterion.Modifier == models.CriterionModifierExcludes {
// special handling for excludes
if m.excludeHandler != nil {
m.excludeHandler(f, criterion)
return
}
// excludes all of the provided values
// need to use actual join table name for this
// <primaryTable>.id NOT IN (select <joinTable>.<primaryFK> from <joinTable> where <joinTable>.<foreignFK> in <values>)
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) stringCriterionHandler(criterion, m.joinTable+"."+m.stringColumn)(ctx, f)
} }
} }
} }
}
func studioCriterionHandler(primaryTable string, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc { func studioCriterionHandler(primaryTable string, studios *models.HierarchicalMultiCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {

View File

@@ -30,7 +30,7 @@ const (
dbConnTimeout = 30 dbConnTimeout = 30
) )
var appSchemaVersion uint = 58 var appSchemaVersion uint = 59
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsBox embed.FS var migrationsBox embed.FS

View File

@@ -151,6 +151,8 @@ func (qb *galleryFilterHandler) criterionHandler() criterionHandler {
func (qb *galleryFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { func (qb *galleryFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc {
h := stringListCriterionHandlerBuilder{ h := stringListCriterionHandlerBuilder{
primaryTable: galleryTable,
primaryFK: galleryIDColumn,
joinTable: galleriesURLsTable, joinTable: galleriesURLsTable,
stringColumn: galleriesURLColumn, stringColumn: galleriesURLColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder) {

View File

@@ -160,6 +160,8 @@ func (qb *imageFilterHandler) missingCriterionHandler(isMissing *string) criteri
func (qb *imageFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { func (qb *imageFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc {
h := stringListCriterionHandlerBuilder{ h := stringListCriterionHandlerBuilder{
primaryTable: imageTable,
primaryFK: imageIDColumn,
joinTable: imagesURLsTable, joinTable: imagesURLsTable,
stringColumn: imageURLColumn, stringColumn: imageURLColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder) {

View File

@@ -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;

View File

@@ -22,6 +22,9 @@ const (
movieFrontImageBlobColumn = "front_image_blob" movieFrontImageBlobColumn = "front_image_blob"
movieBackImageBlobColumn = "back_image_blob" movieBackImageBlobColumn = "back_image_blob"
movieURLsTable = "movie_urls"
movieURLColumn = "url"
) )
type movieRow struct { type movieRow struct {
@@ -35,7 +38,6 @@ type movieRow struct {
StudioID null.Int `db:"studio_id,omitempty"` StudioID null.Int `db:"studio_id,omitempty"`
Director zero.String `db:"director"` Director zero.String `db:"director"`
Synopsis zero.String `db:"synopsis"` Synopsis zero.String `db:"synopsis"`
URL zero.String `db:"url"`
CreatedAt Timestamp `db:"created_at"` CreatedAt Timestamp `db:"created_at"`
UpdatedAt Timestamp `db:"updated_at"` UpdatedAt Timestamp `db:"updated_at"`
@@ -54,7 +56,6 @@ func (r *movieRow) fromMovie(o models.Movie) {
r.StudioID = intFromPtr(o.StudioID) r.StudioID = intFromPtr(o.StudioID)
r.Director = zero.StringFrom(o.Director) r.Director = zero.StringFrom(o.Director)
r.Synopsis = zero.StringFrom(o.Synopsis) r.Synopsis = zero.StringFrom(o.Synopsis)
r.URL = zero.StringFrom(o.URL)
r.CreatedAt = Timestamp{Timestamp: o.CreatedAt} r.CreatedAt = Timestamp{Timestamp: o.CreatedAt}
r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt} r.UpdatedAt = Timestamp{Timestamp: o.UpdatedAt}
} }
@@ -70,7 +71,6 @@ func (r *movieRow) resolve() *models.Movie {
StudioID: nullIntPtr(r.StudioID), StudioID: nullIntPtr(r.StudioID),
Director: r.Director.String, Director: r.Director.String,
Synopsis: r.Synopsis.String, Synopsis: r.Synopsis.String,
URL: r.URL.String,
CreatedAt: r.CreatedAt.Timestamp, CreatedAt: r.CreatedAt.Timestamp,
UpdatedAt: r.UpdatedAt.Timestamp, UpdatedAt: r.UpdatedAt.Timestamp,
} }
@@ -91,7 +91,6 @@ func (r *movieRowRecord) fromPartial(o models.MoviePartial) {
r.setNullInt("studio_id", o.StudioID) r.setNullInt("studio_id", o.StudioID)
r.setNullString("director", o.Director) r.setNullString("director", o.Director)
r.setNullString("synopsis", o.Synopsis) r.setNullString("synopsis", o.Synopsis)
r.setNullString("url", o.URL)
r.setTimestamp("created_at", o.CreatedAt) r.setTimestamp("created_at", o.CreatedAt)
r.setTimestamp("updated_at", o.UpdatedAt) r.setTimestamp("updated_at", o.UpdatedAt)
} }
@@ -148,6 +147,13 @@ func (qb *MovieStore) Create(ctx context.Context, newObject *models.Movie) error
return err 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) updated, err := qb.find(ctx, id)
if err != nil { if err != nil {
return fmt.Errorf("finding after create: %w", err) 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) return qb.find(ctx, id)
} }
@@ -184,6 +196,12 @@ func (qb *MovieStore) Update(ctx context.Context, updatedObject *models.Movie) e
return err return err
} }
if updatedObject.URLs.Loaded() {
if err := moviesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil {
return err
}
}
return nil return nil
} }
@@ -537,3 +555,7 @@ WHERE movies.studio_id = ?
args := []interface{}{studioID} args := []interface{}{studioID}
return movieRepository.runCountQuery(ctx, query, args) return movieRepository.runCountQuery(ctx, query, args)
} }
func (qb *MovieStore) GetURLs(ctx context.Context, movieID int) ([]string, error) {
return moviesURLsTableMgr.get(ctx, movieID)
}

View File

@@ -60,7 +60,7 @@ func (qb *movieFilterHandler) criterionHandler() criterionHandler {
intCriterionHandler(movieFilter.Rating100, "movies.rating", nil), intCriterionHandler(movieFilter.Rating100, "movies.rating", nil),
floatIntCriterionHandler(movieFilter.Duration, "movies.duration", nil), floatIntCriterionHandler(movieFilter.Duration, "movies.duration", nil),
qb.missingCriterionHandler(movieFilter.IsMissing), qb.missingCriterionHandler(movieFilter.IsMissing),
stringCriterionHandler(movieFilter.URL, "movies.url"), qb.urlsCriterionHandler(movieFilter.URL),
studioCriterionHandler(movieTable, movieFilter.Studios), studioCriterionHandler(movieTable, movieFilter.Studios),
qb.performersCriterionHandler(movieFilter.Performers), qb.performersCriterionHandler(movieFilter.Performers),
&dateCriterionHandler{movieFilter.Date, "movies.date", nil}, &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 { func (qb *movieFilterHandler) performersCriterionHandler(performers *models.MultiCriterionInput) criterionHandlerFunc {
return func(ctx context.Context, f *filterBuilder) { return func(ctx context.Context, f *filterBuilder) {
if performers != nil { if performers != nil {

View File

@@ -15,6 +15,16 @@ import (
"github.com/stashapp/stash/pkg/models" "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) { func TestMovieFindByName(t *testing.T) {
withTxn(func(ctx context.Context) error { withTxn(func(ctx context.Context) error {
mqb := db.Movie mqb := db.Movie
@@ -205,7 +215,14 @@ func TestMovieQueryURL(t *testing.T) {
verifyFn := func(n *models.Movie) { verifyFn := func(n *models.Movie) {
t.Helper() 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) verifyMovieQuery(t, filter, verifyFn)
@@ -228,6 +245,56 @@ func TestMovieQueryURL(t *testing.T) {
verifyMovieQuery(t, filter, verifyFn) 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)) { func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func(s *models.Movie)) {
withTxn(func(ctx context.Context) error { withTxn(func(ctx context.Context) error {
t.Helper() t.Helper()
@@ -235,6 +302,12 @@ func verifyMovieQuery(t *testing.T, filter models.MovieFilterType, verifyFn func
movies := queryMovie(ctx, t, sqb, &filter, nil) 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 // assume it should find at least one
assert.Greater(t, len(movies), 0) assert.Greater(t, len(movies), 0)

View File

@@ -243,6 +243,8 @@ func (qb *performerFilterHandler) performerAgeFilterCriterionHandler(age *models
func (qb *performerFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { func (qb *performerFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc {
h := stringListCriterionHandlerBuilder{ h := stringListCriterionHandlerBuilder{
primaryTable: performerTable,
primaryFK: performerIDColumn,
joinTable: performersAliasesTable, joinTable: performersAliasesTable,
stringColumn: performerAliasColumn, stringColumn: performerAliasColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder) {

View File

@@ -345,6 +345,8 @@ func (qb *sceneFilterHandler) isMissingCriterionHandler(isMissing *string) crite
func (qb *sceneFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { func (qb *sceneFilterHandler) urlsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc {
h := stringListCriterionHandlerBuilder{ h := stringListCriterionHandlerBuilder{
primaryTable: sceneTable,
primaryFK: sceneIDColumn,
joinTable: scenesURLsTable, joinTable: scenesURLsTable,
stringColumn: sceneURLColumn, stringColumn: sceneURLColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder) {
@@ -368,12 +370,24 @@ func (qb *sceneFilterHandler) getMultiCriterionHandlerBuilder(foreignTable, join
func (qb *sceneFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc { func (qb *sceneFilterHandler) captionCriterionHandler(captions *models.StringCriterionInput) criterionHandlerFunc {
h := stringListCriterionHandlerBuilder{ h := stringListCriterionHandlerBuilder{
primaryTable: sceneTable,
primaryFK: sceneIDColumn,
joinTable: videoCaptionsTable, joinTable: videoCaptionsTable,
stringColumn: captionCodeColumn, stringColumn: captionCodeColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder) {
qb.addSceneFilesTable(f) qb.addSceneFilesTable(f)
f.addLeftJoin(videoCaptionsTable, "", "video_captions.file_id = scenes_files.file_id") 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) return h.handler(captions)

View File

@@ -1303,6 +1303,15 @@ func getMovieNullStringValue(index int, field string) string {
return ret.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 // 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 { func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o int) error {
const namePlain = "Name" const namePlain = "Name"
@@ -1321,7 +1330,9 @@ func createMovies(ctx context.Context, mqb models.MovieReaderWriter, n int, o in
name = getMovieStringValue(index, name) name = getMovieStringValue(index, name)
movie := models.Movie{ movie := models.Movie{
Name: name, Name: name,
URL: getMovieNullStringValue(index, urlField), URLs: models.NewRelatedStrings([]string{
getMovieEmptyString(i, urlField),
}),
} }
err := mqb.Create(ctx, &movie) err := mqb.Create(ctx, &movie)

View File

@@ -178,6 +178,8 @@ func (qb *studioFilterHandler) parentCriterionHandler(parents *models.MultiCrite
func (qb *studioFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { func (qb *studioFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc {
h := stringListCriterionHandlerBuilder{ h := stringListCriterionHandlerBuilder{
primaryTable: studioTable,
primaryFK: studioIDColumn,
joinTable: studioAliasesTable, joinTable: studioAliasesTable,
stringColumn: studioAliasColumn, stringColumn: studioAliasColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder) {

View File

@@ -34,6 +34,8 @@ var (
studiosAliasesJoinTable = goqu.T(studioAliasesTable) studiosAliasesJoinTable = goqu.T(studioAliasesTable)
studiosStashIDsJoinTable = goqu.T("studio_stash_ids") studiosStashIDsJoinTable = goqu.T("studio_stash_ids")
moviesURLsJoinTable = goqu.T(movieURLsTable)
) )
var ( var (
@@ -299,6 +301,14 @@ var (
table: goqu.T(movieTable), table: goqu.T(movieTable),
idColumn: goqu.T(movieTable).Col(idColumn), idColumn: goqu.T(movieTable).Col(idColumn),
} }
moviesURLsTableMgr = &orderedValueTable[string]{
table: table{
table: moviesURLsJoinTable,
idColumn: moviesURLsJoinTable.Col(movieIDColumn),
},
valueColumn: moviesURLsJoinTable.Col(movieURLColumn),
}
) )
var ( var (

View File

@@ -105,6 +105,8 @@ func (qb *tagFilterHandler) criterionHandler() criterionHandler {
func (qb *tagFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc { func (qb *tagFilterHandler) aliasCriterionHandler(alias *models.StringCriterionInput) criterionHandlerFunc {
h := stringListCriterionHandlerBuilder{ h := stringListCriterionHandlerBuilder{
primaryTable: tagTable,
primaryFK: tagIDColumn,
joinTable: tagAliasesTable, joinTable: tagAliasesTable,
stringColumn: tagAliasColumn, stringColumn: tagAliasColumn,
addJoinTable: func(f *filterBuilder) { addJoinTable: func(f *filterBuilder) {

View File

@@ -12,7 +12,7 @@ fragment MovieData on Movie {
} }
synopsis synopsis
url urls
front_image_path front_image_path
back_image_path back_image_path
scene_count scene_count

View File

@@ -90,7 +90,7 @@ fragment ScrapedMovieData on ScrapedMovie {
date date
rating rating
director director
url urls
synopsis synopsis
front_image front_image
back_image back_image
@@ -108,7 +108,7 @@ fragment ScrapedSceneMovieData on ScrapedMovie {
date date
rating rating
director director
url urls
synopsis synopsis
front_image front_image
back_image back_image

View File

@@ -26,10 +26,8 @@ import { MovieEditPanel } from "./MovieEditPanel";
import { import {
faChevronDown, faChevronDown,
faChevronUp, faChevronUp,
faLink,
faTrashAlt, faTrashAlt,
} from "@fortawesome/free-solid-svg-icons"; } from "@fortawesome/free-solid-svg-icons";
import TextUtils from "src/utils/text";
import { Icon } from "src/components/Shared/Icon"; import { Icon } from "src/components/Shared/Icon";
import { RatingSystem } from "src/components/Shared/Rating/RatingSystem"; import { RatingSystem } from "src/components/Shared/Rating/RatingSystem";
import { ConfigurationContext } from "src/hooks/Config"; import { ConfigurationContext } from "src/hooks/Config";
@@ -37,7 +35,7 @@ import { DetailImage } from "src/components/Shared/DetailImage";
import { useRatingKeybinds } from "src/hooks/keybinds"; import { useRatingKeybinds } from "src/hooks/keybinds";
import { useLoadStickyHeader } from "src/hooks/detailsPanel"; import { useLoadStickyHeader } from "src/hooks/detailsPanel";
import { useScrollToTopOnMount } from "src/hooks/scrollToTop"; import { useScrollToTopOnMount } from "src/hooks/scrollToTop";
import { ExternalLink } from "src/components/Shared/ExternalLink"; import { ExternalLinksButton } from "src/components/Shared/ExternalLinksButton";
interface IProps { interface IProps {
movie: GQL.MovieDataFragment; movie: GQL.MovieDataFragment;
@@ -273,16 +271,7 @@ const MoviePage: React.FC<IProps> = ({ movie }) => {
const renderClickableIcons = () => ( const renderClickableIcons = () => (
<span className="name-icons"> <span className="name-icons">
{movie.url && ( {movie.urls.length > 0 && <ExternalLinksButton urls={movie.urls} />}
<Button
as={ExternalLink}
href={TextUtils.sanitiseURL(movie.url)}
className="minimal link"
title={movie.url}
>
<Icon icon={faLink} />
</Button>
)}
</span> </span>
); );

View File

@@ -9,7 +9,6 @@ import {
} from "src/core/StashService"; } from "src/core/StashService";
import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator";
import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar";
import { URLField } from "src/components/Shared/URLField";
import { useToast } from "src/hooks/Toast"; import { useToast } from "src/hooks/Toast";
import { Modal as BSModal, Form, Button } from "react-bootstrap"; import { Modal as BSModal, Form, Button } from "react-bootstrap";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
@@ -20,7 +19,11 @@ import { MovieScrapeDialog } from "./MovieScrapeDialog";
import isEqual from "lodash-es/isEqual"; import isEqual from "lodash-es/isEqual";
import { handleUnsavedChanges } from "src/utils/navigation"; import { handleUnsavedChanges } from "src/utils/navigation";
import { formikUtils } from "src/utils/form"; 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"; import { Studio, StudioSelect } from "src/components/Studios/StudioSelect";
interface IMovieEditPanel { interface IMovieEditPanel {
@@ -64,7 +67,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
date: yupDateString(intl), date: yupDateString(intl),
studio_id: yup.string().required().nullable(), studio_id: yup.string().required().nullable(),
director: yup.string().ensure(), director: yup.string().ensure(),
url: yup.string().ensure(), urls: yupUniqueStringList(intl),
synopsis: yup.string().ensure(), synopsis: yup.string().ensure(),
front_image: yup.string().nullable().optional(), front_image: yup.string().nullable().optional(),
back_image: yup.string().nullable().optional(), back_image: yup.string().nullable().optional(),
@@ -77,7 +80,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
date: movie?.date ?? "", date: movie?.date ?? "",
studio_id: movie?.studio?.id ?? null, studio_id: movie?.studio?.id ?? null,
director: movie?.director ?? "", director: movie?.director ?? "",
url: movie?.url ?? "", urls: movie?.urls ?? [],
synopsis: movie?.synopsis ?? "", synopsis: movie?.synopsis ?? "",
}; };
@@ -153,8 +156,8 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
if (state.synopsis) { if (state.synopsis) {
formik.setFieldValue("synopsis", state.synopsis); formik.setFieldValue("synopsis", state.synopsis);
} }
if (state.url) { if (state.urls) {
formik.setFieldValue("url", state.url); formik.setFieldValue("urls", state.urls);
} }
if (state.front_image) { if (state.front_image) {
@@ -178,8 +181,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
setIsLoading(false); setIsLoading(false);
} }
async function onScrapeMovieURL() { async function onScrapeMovieURL(url: string) {
const { url } = formik.values;
if (!url) return; if (!url) return;
setIsLoading(true); setIsLoading(true);
@@ -334,6 +336,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
renderInputField, renderInputField,
renderDateField, renderDateField,
renderDurationField, renderDurationField,
renderURLListField,
} = formikUtils(intl, formik); } = formikUtils(intl, formik);
function renderStudioField() { function renderStudioField() {
@@ -348,19 +351,6 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
return renderField("studio_id", title, control); return renderField("studio_id", title, control);
} }
function renderUrlField() {
const title = intl.formatMessage({ id: "url" });
const control = (
<URLField
{...formik.getFieldProps("url")}
onScrapeClick={onScrapeMovieURL}
urlScrapable={urlScrapable}
/>
);
return renderField("url", title, control);
}
// TODO: CSS class // TODO: CSS class
return ( return (
<div> <div>
@@ -391,7 +381,7 @@ export const MovieEditPanel: React.FC<IMovieEditPanel> = ({
{renderDateField("date")} {renderDateField("date")}
{renderStudioField()} {renderStudioField()}
{renderInputField("director")} {renderInputField("director")}
{renderUrlField()} {renderURLListField("urls", onScrapeMovieURL, urlScrapable)}
{renderInputField("synopsis", "textarea")} {renderInputField("synopsis", "textarea")}
</Form> </Form>

View File

@@ -6,6 +6,7 @@ import {
ScrapedInputGroupRow, ScrapedInputGroupRow,
ScrapedImageRow, ScrapedImageRow,
ScrapedTextAreaRow, ScrapedTextAreaRow,
ScrapedStringListRow,
} from "src/components/Shared/ScrapeDialog/ScrapeDialog"; } from "src/components/Shared/ScrapeDialog/ScrapeDialog";
import TextUtils from "src/utils/text"; import TextUtils from "src/utils/text";
import { import {
@@ -15,6 +16,7 @@ import {
import { Studio } from "src/components/Studios/StudioSelect"; import { Studio } from "src/components/Studios/StudioSelect";
import { useCreateScrapedStudio } from "src/components/Shared/ScrapeDialog/createObjects"; import { useCreateScrapedStudio } from "src/components/Shared/ScrapeDialog/createObjects";
import { ScrapedStudioRow } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow"; import { ScrapedStudioRow } from "src/components/Shared/ScrapeDialog/ScrapedObjectsRow";
import { uniq } from "lodash-es";
interface IMovieScrapeDialogProps { interface IMovieScrapeDialogProps {
movie: Partial<GQL.MovieUpdateInput>; movie: Partial<GQL.MovieUpdateInput>;
@@ -64,8 +66,13 @@ export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = (
props.scraped.studio?.stored_id ? props.scraped.studio : undefined props.scraped.studio?.stored_id ? props.scraped.studio : undefined
) )
); );
const [url, setURL] = useState<ScrapeResult<string>>( const [urls, setURLs] = useState<ScrapeResult<string[]>>(
new ScrapeResult<string>(props.movie.url, props.scraped.url) new ScrapeResult<string[]>(
props.movie.urls,
props.scraped.urls
? uniq((props.movie.urls ?? []).concat(props.scraped.urls ?? []))
: undefined
)
); );
const [frontImage, setFrontImage] = useState<ScrapeResult<string>>( const [frontImage, setFrontImage] = useState<ScrapeResult<string>>(
new ScrapeResult<string>(props.movie.front_image, props.scraped.front_image) new ScrapeResult<string>(props.movie.front_image, props.scraped.front_image)
@@ -94,7 +101,7 @@ export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = (
director, director,
synopsis, synopsis,
studio, studio,
url, urls,
frontImage, frontImage,
backImage, backImage,
]; ];
@@ -117,7 +124,7 @@ export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = (
director: director.getNewValue(), director: director.getNewValue(),
synopsis: synopsis.getNewValue(), synopsis: synopsis.getNewValue(),
studio: newStudioValue, studio: newStudioValue,
url: url.getNewValue(), urls: urls.getNewValue(),
front_image: frontImage.getNewValue(), front_image: frontImage.getNewValue(),
back_image: backImage.getNewValue(), back_image: backImage.getNewValue(),
}; };
@@ -164,10 +171,10 @@ export const MovieScrapeDialog: React.FC<IMovieScrapeDialogProps> = (
newStudio={newStudio} newStudio={newStudio}
onCreateNew={createNewStudio} onCreateNew={createNewStudio}
/> />
<ScrapedInputGroupRow <ScrapedStringListRow
title="URL" title={intl.formatMessage({ id: "urls" })}
result={url} result={urls}
onChange={(value) => setURL(value)} onChange={(value) => setURLs(value)}
/> />
<ScrapedImageRow <ScrapedImageRow
title="Front Image" title="Front Image"

View File

@@ -0,0 +1,48 @@
import { Button, Dropdown } from "react-bootstrap";
import { ExternalLink } from "./ExternalLink";
import TextUtils from "src/utils/text";
import { Icon } from "./Icon";
import { IconDefinition, faLink } from "@fortawesome/free-solid-svg-icons";
export const ExternalLinksButton: React.FC<{
icon?: IconDefinition;
urls: string[];
}> = ({ urls, icon = faLink }) => {
if (!urls.length) {
return null;
}
if (urls.length === 1) {
return (
<Button
as={ExternalLink}
href={TextUtils.sanitiseURL(urls[0])}
className="minimal link external-links-button"
title={urls[0]}
>
<Icon icon={icon} />
</Button>
);
}
return (
<Dropdown className="external-links-button">
<Dropdown.Toggle as={Button} className="minimal link">
<Icon icon={icon} />
</Dropdown.Toggle>
<Dropdown.Menu>
{urls.map((url) => (
<Dropdown.Item
key={url}
as={ExternalLink}
href={TextUtils.sanitiseURL(url)}
title={url}
>
{url}
</Dropdown.Item>
))}
</Dropdown.Menu>
</Dropdown>
);
};

View File

@@ -592,3 +592,7 @@ button.btn.favorite-button {
} }
} }
} }
.external-links-button {
display: inline-block;
}