mirror of
https://github.com/stashapp/stash.git
synced 2025-12-17 04:14:39 +03:00
Movie URLs (#4900)
* Fix exclude behaviour for stringListCriterionHandlerBuilder
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
83
pkg/sqlite/migrations/59_movie_urls.up.sql
Normal file
83
pkg/sqlite/migrations/59_movie_urls.up.sql
Normal 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;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
48
ui/v2.5/src/components/Shared/ExternalLinksButton.tsx
Normal file
48
ui/v2.5/src/components/Shared/ExternalLinksButton.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -592,3 +592,7 @@ button.btn.favorite-button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.external-links-button {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user