diff --git a/graphql/documents/data/scene-slim.graphql b/graphql/documents/data/scene-slim.graphql index b6ed326e0..09db76bb7 100644 --- a/graphql/documents/data/scene-slim.graphql +++ b/graphql/documents/data/scene-slim.graphql @@ -4,7 +4,7 @@ fragment SlimSceneData on Scene { code details director - url + urls date rating100 o_counter diff --git a/graphql/documents/data/scene.graphql b/graphql/documents/data/scene.graphql index 8b0a664d5..3f26856a3 100644 --- a/graphql/documents/data/scene.graphql +++ b/graphql/documents/data/scene.graphql @@ -4,7 +4,7 @@ fragment SceneData on Scene { code details director - url + urls date rating100 o_counter diff --git a/graphql/documents/data/scrapers.graphql b/graphql/documents/data/scrapers.graphql index 1d4553a97..3af1d2868 100644 --- a/graphql/documents/data/scrapers.graphql +++ b/graphql/documents/data/scrapers.graphql @@ -114,7 +114,7 @@ fragment ScrapedSceneData on ScrapedScene { code details director - url + urls date image remote_site_id diff --git a/graphql/schema/types/scene.graphql b/graphql/schema/types/scene.graphql index 7ec2134c9..ef538b22f 100644 --- a/graphql/schema/types/scene.graphql +++ b/graphql/schema/types/scene.graphql @@ -40,7 +40,8 @@ type Scene { code: String details: String director: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] date: String # rating expressed as 1-5 rating: Int @deprecated(reason: "Use 1-100 range with rating100") @@ -91,7 +92,8 @@ input SceneCreateInput { code: String details: String director: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] date: String # rating expressed as 1-5 rating: Int @deprecated(reason: "Use 1-100 range with rating100") @@ -119,7 +121,8 @@ input SceneUpdateInput { code: String details: String director: String - url: String + url: String @deprecated(reason: "Use urls") + urls: [String!] date: String # rating expressed as 1-5 rating: Int @deprecated(reason: "Use 1-100 range with rating100") @@ -164,7 +167,8 @@ input BulkSceneUpdateInput { code: String details: String director: String - url: String + url: String @deprecated(reason: "Use urls") + urls: BulkUpdateStrings date: String # rating expressed as 1-5 rating: Int @deprecated(reason: "Use 1-100 range with rating100") diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 1230fde32..f04eb2b37 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -64,7 +64,8 @@ type ScrapedScene { code: String details: String director: String - url: String + url: String @deprecated(reason: "use urls") + urls: [String!] date: String """This should be a base64 encoded data URL""" @@ -87,7 +88,8 @@ input ScrapedSceneInput { code: String details: String director: String - url: String + url: String @deprecated(reason: "use urls") + urls: [String!] date: String # no image, file, duration or relationships diff --git a/internal/api/resolver_model_scene.go b/internal/api/resolver_model_scene.go index cd6f16a57..9d5b41725 100644 --- a/internal/api/resolver_model_scene.go +++ b/internal/api/resolver_model_scene.go @@ -405,3 +405,32 @@ func (r *sceneResolver) InteractiveSpeed(ctx context.Context, obj *models.Scene) return primaryFile.InteractiveSpeed, nil } + +func (r *sceneResolver) URL(ctx context.Context, obj *models.Scene) (*string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Scene) + }); err != nil { + return nil, err + } + } + + urls := obj.URLs.List() + if len(urls) == 0 { + return nil, nil + } + + return &urls[0], nil +} + +func (r *sceneResolver) Urls(ctx context.Context, obj *models.Scene) ([]string, error) { + if !obj.URLs.Loaded() { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadURLs(ctx, r.repository.Scene) + }); err != nil { + return nil, err + } + } + + return obj.URLs.List(), nil +} diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index c9608f0c2..3b96b9f07 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -67,7 +67,6 @@ func (r *mutationResolver) SceneCreate(ctx context.Context, input SceneCreateInp Code: translator.string(input.Code, "code"), Details: translator.string(input.Details, "details"), Director: translator.string(input.Director, "director"), - URL: translator.string(input.URL, "url"), Date: translator.datePtr(input.Date, "date"), Rating: translator.ratingConversionInt(input.Rating, input.Rating100), Organized: translator.bool(input.Organized, "organized"), @@ -83,6 +82,12 @@ func (r *mutationResolver) SceneCreate(ctx context.Context, input SceneCreateInp return nil, fmt.Errorf("converting studio id: %w", err) } + if input.Urls != nil { + newScene.URLs = models.NewRelatedStrings(input.Urls) + } else if input.URL != nil { + newScene.URLs = models.NewRelatedStrings([]string{*input.URL}) + } + var coverImageData []byte if input.CoverImage != nil && *input.CoverImage != "" { var err error @@ -168,7 +173,6 @@ func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTr updatedScene.Code = translator.optionalString(input.Code, "code") updatedScene.Details = translator.optionalString(input.Details, "details") updatedScene.Director = translator.optionalString(input.Director, "director") - updatedScene.URL = translator.optionalString(input.URL, "url") updatedScene.Date = translator.optionalDate(input.Date, "date") updatedScene.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100) updatedScene.OCounter = translator.optionalInt(input.OCounter, "o_counter") @@ -182,6 +186,18 @@ func scenePartialFromInput(input models.SceneUpdateInput, translator changesetTr updatedScene.Organized = translator.optionalBool(input.Organized, "organized") + if translator.hasField("urls") { + updatedScene.URLs = &models.UpdateStrings{ + Values: input.Urls, + Mode: models.RelationshipUpdateModeSet, + } + } else if translator.hasField("url") { + updatedScene.URLs = &models.UpdateStrings{ + Values: []string{*input.URL}, + Mode: models.RelationshipUpdateModeSet, + } + } + if input.PrimaryFileID != nil { primaryFileID, err := strconv.Atoi(*input.PrimaryFileID) if err != nil { @@ -339,7 +355,6 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU updatedScene.Code = translator.optionalString(input.Code, "code") updatedScene.Details = translator.optionalString(input.Details, "details") updatedScene.Director = translator.optionalString(input.Director, "director") - updatedScene.URL = translator.optionalString(input.URL, "url") updatedScene.Date = translator.optionalDate(input.Date, "date") updatedScene.Rating = translator.ratingConversionOptional(input.Rating, input.Rating100) updatedScene.StudioID, err = translator.optionalIntFromString(input.StudioID, "studio_id") @@ -349,6 +364,18 @@ func (r *mutationResolver) BulkSceneUpdate(ctx context.Context, input BulkSceneU updatedScene.Organized = translator.optionalBool(input.Organized, "organized") + if translator.hasField("urls") { + updatedScene.URLs = &models.UpdateStrings{ + Values: input.Urls.Values, + Mode: input.Urls.Mode, + } + } else if translator.hasField("url") { + updatedScene.URLs = &models.UpdateStrings{ + Values: []string{*input.URL}, + Mode: models.RelationshipUpdateModeSet, + } + } + if translator.hasField("performer_ids") { updatedScene.PerformerIDs, err = translateUpdateIDs(input.PerformerIds.Ids, input.PerformerIds.Mode) if err != nil { diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index 8f6753f5b..ccd57dd09 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -68,6 +68,10 @@ func (r *mutationResolver) SubmitStashBoxSceneDraft(ctx context.Context, input S logger.Errorf("Error getting scene cover: %v", err) } + if err := scene.LoadURLs(ctx, r.repository.Scene); err != nil { + return fmt.Errorf("loading scene URLs: %w", err) + } + res, err = client.SubmitSceneDraft(ctx, scene, boxes[input.StashBoxIndex].Endpoint, cover) return err }) diff --git a/internal/autotag/integration_test.go b/internal/autotag/integration_test.go index cb7aa08b6..b31d65666 100644 --- a/internal/autotag/integration_test.go +++ b/internal/autotag/integration_test.go @@ -176,7 +176,7 @@ func createScenes(ctx context.Context, sqb models.SceneReaderWriter, folderStore s := &models.Scene{ Title: expectedMatchTitle, - URL: existingStudioSceneName, + Code: existingStudioSceneName, StudioID: &existingStudioID, } if err := createScene(ctx, sqb, s, f); err != nil { @@ -625,7 +625,7 @@ func TestParseStudioScenes(t *testing.T) { for _, scene := range scenes { // check for existing studio id scene first - if scene.URL == existingStudioSceneName { + if scene.Code == existingStudioSceneName { if scene.StudioID == nil || *scene.StudioID != existingStudioID { t.Error("Incorrectly overwrote studio ID for scene with existing studio ID") } diff --git a/internal/identify/identify.go b/internal/identify/identify.go index 27bd82888..8d45e1009 100644 --- a/internal/identify/identify.go +++ b/internal/identify/identify.go @@ -10,7 +10,7 @@ import ( "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/scene" "github.com/stashapp/stash/pkg/scraper" - "github.com/stashapp/stash/pkg/sliceutil/intslice" + "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/txn" "github.com/stashapp/stash/pkg/utils" ) @@ -226,7 +226,7 @@ func (t *SceneIdentifier) getSceneUpdater(ctx context.Context, s *models.Scene, return nil, fmt.Errorf("error converting tag ID %s: %w", *options.SkipSingleNamePerformerTag, err) } - tagIDs = intslice.IntAppendUnique(tagIDs, int(tagID)) + tagIDs = sliceutil.AppendUnique(tagIDs, int(tagID)) } if tagIDs != nil { ret.Partial.TagIDs = &models.UpdateIDs{ @@ -260,6 +260,9 @@ func (t *SceneIdentifier) modifyScene(ctx context.Context, txnManager txn.Manage var updater *scene.UpdateSet if err := txn.WithTxn(ctx, txnManager, func(ctx context.Context) error { // load scene relationships + if err := s.LoadURLs(ctx, t.SceneReaderUpdater); err != nil { + return err + } if err := s.LoadPerformerIDs(ctx, t.SceneReaderUpdater); err != nil { return err } @@ -320,7 +323,7 @@ func (t *SceneIdentifier) addTagToScene(ctx context.Context, txnManager txn.Mana } existing := s.TagIDs.List() - if intslice.IntInclude(existing, tagID) { + if sliceutil.Include(existing, tagID) { // skip if the scene was already tagged return nil } @@ -376,9 +379,27 @@ func getScenePartial(scene *models.Scene, scraped *scraper.ScrapedScene, fieldOp partial.Details = models.NewOptionalString(*scraped.Details) } } - if scraped.URL != nil && (scene.URL != *scraped.URL) { - if shouldSetSingleValueField(fieldOptions["url"], scene.URL != "") { - partial.URL = models.NewOptionalString(*scraped.URL) + if len(scraped.URLs) > 0 && shouldSetSingleValueField(fieldOptions["url"], false) { + // if overwrite, then set over the top + switch getFieldStrategy(fieldOptions["url"]) { + case FieldStrategyOverwrite: + // only overwrite if not equal + if len(sliceutil.Exclude(scene.URLs.List(), scraped.URLs)) != 0 { + partial.URLs = &models.UpdateStrings{ + Values: scraped.URLs, + Mode: models.RelationshipUpdateModeSet, + } + } + case FieldStrategyMerge: + // if merge, add if not already present + urls := sliceutil.AppendUniques(scene.URLs.List(), scraped.URLs) + + if len(urls) != len(scene.URLs.List()) { + partial.URLs = &models.UpdateStrings{ + Values: urls, + Mode: models.RelationshipUpdateModeSet, + } + } } } if scraped.Director != nil && (scene.Director != *scraped.Director) { @@ -399,7 +420,7 @@ func getScenePartial(scene *models.Scene, scraped *scraper.ScrapedScene, fieldOp return partial } -func shouldSetSingleValueField(strategy *FieldOptions, hasExistingValue bool) bool { +func getFieldStrategy(strategy *FieldOptions) FieldStrategy { // if unset then default to MERGE fs := FieldStrategyMerge @@ -407,6 +428,13 @@ func shouldSetSingleValueField(strategy *FieldOptions, hasExistingValue bool) bo fs = strategy.Strategy } + return fs +} + +func shouldSetSingleValueField(strategy *FieldOptions, hasExistingValue bool) bool { + // if unset then default to MERGE + fs := getFieldStrategy(strategy) + if fs == FieldStrategyIgnore { return false } diff --git a/internal/identify/identify_test.go b/internal/identify/identify_test.go index 0b59a5ad5..6c9f92cb2 100644 --- a/internal/identify/identify_test.go +++ b/internal/identify/identify_test.go @@ -11,6 +11,7 @@ import ( "github.com/stashapp/stash/pkg/models/mocks" "github.com/stashapp/stash/pkg/scraper" "github.com/stashapp/stash/pkg/sliceutil/intslice" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" ) @@ -108,8 +109,7 @@ func TestSceneIdentifier_Identify(t *testing.T) { } mockSceneReaderWriter := &mocks.SceneReaderWriter{} - mockTagFinderCreator := &mocks.TagReaderWriter{} - + mockSceneReaderWriter.On("GetURLs", mock.Anything, mock.Anything).Return(nil, nil) mockSceneReaderWriter.On("UpdatePartial", mock.Anything, mock.MatchedBy(func(id int) bool { return id == errUpdateID }), mock.Anything).Return(nil, errors.New("update error")) @@ -117,6 +117,7 @@ func TestSceneIdentifier_Identify(t *testing.T) { return id != errUpdateID }), mock.Anything).Return(nil, nil) + mockTagFinderCreator := &mocks.TagReaderWriter{} mockTagFinderCreator.On("Find", mock.Anything, skipMultipleTagID).Return(&models.Tag{ ID: skipMultipleTagID, Name: skipMultipleTagIDStr, @@ -236,6 +237,7 @@ func TestSceneIdentifier_modifyScene(t *testing.T) { "empty update", args{ &models.Scene{ + URLs: models.NewRelatedStrings([]string{}), PerformerIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), @@ -351,33 +353,44 @@ func Test_getScenePartial(t *testing.T) { Title: originalTitle, Date: &originalDateObj, Details: originalDetails, - URL: originalURL, + URLs: models.NewRelatedStrings([]string{originalURL}), } organisedScene := *originalScene organisedScene.Organized = true - emptyScene := &models.Scene{} + emptyScene := &models.Scene{ + URLs: models.NewRelatedStrings([]string{}), + } postPartial := models.ScenePartial{ Title: models.NewOptionalString(scrapedTitle), Date: models.NewOptionalDate(scrapedDateObj), Details: models.NewOptionalString(scrapedDetails), - URL: models.NewOptionalString(scrapedURL), + URLs: &models.UpdateStrings{ + Values: []string{scrapedURL}, + Mode: models.RelationshipUpdateModeSet, + }, + } + + postPartialMerge := postPartial + postPartialMerge.URLs = &models.UpdateStrings{ + Values: []string{scrapedURL}, + Mode: models.RelationshipUpdateModeSet, } scrapedScene := &scraper.ScrapedScene{ Title: &scrapedTitle, Date: &scrapedDate, Details: &scrapedDetails, - URL: &scrapedURL, + URLs: []string{scrapedURL}, } scrapedUnchangedScene := &scraper.ScrapedScene{ Title: &originalTitle, Date: &originalDate, Details: &originalDetails, - URL: &originalURL, + URLs: []string{originalURL}, } makeFieldOptions := func(input *FieldOptions) map[string]*FieldOptions { @@ -440,7 +453,12 @@ func Test_getScenePartial(t *testing.T) { mergeAll, false, }, - models.ScenePartial{}, + models.ScenePartial{ + URLs: &models.UpdateStrings{ + Values: []string{originalURL, scrapedURL}, + Mode: models.RelationshipUpdateModeSet, + }, + }, }, { "merge (empty values)", @@ -450,7 +468,7 @@ func Test_getScenePartial(t *testing.T) { mergeAll, false, }, - postPartial, + postPartialMerge, }, { "unchanged", @@ -487,9 +505,9 @@ func Test_getScenePartial(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := getScenePartial(tt.args.scene, tt.args.scraped, tt.args.fieldOptions, tt.args.setOrganized); !reflect.DeepEqual(got, tt.want) { - t.Errorf("getScenePartial() = %v, want %v", got, tt.want) - } + got := getScenePartial(tt.args.scene, tt.args.scraped, tt.args.fieldOptions, tt.args.setOrganized) + + assert.Equal(t, tt.want, got) }) } } diff --git a/internal/identify/scene.go b/internal/identify/scene.go index 86256cebb..7568d1b1f 100644 --- a/internal/identify/scene.go +++ b/internal/identify/scene.go @@ -24,6 +24,7 @@ type SceneReaderUpdater interface { models.PerformerIDLoader models.TagIDLoader models.StashIDLoader + models.URLLoader } type TagCreatorFinder interface { diff --git a/internal/manager/repository.go b/internal/manager/repository.go index 55fea1672..a8f0b9e7d 100644 --- a/internal/manager/repository.go +++ b/internal/manager/repository.go @@ -29,6 +29,7 @@ type GalleryReaderWriter interface { type SceneReaderWriter interface { models.SceneReaderWriter scene.CreatorUpdater + models.URLLoader GetManyFileIDs(ctx context.Context, ids []int) ([][]file.ID, error) } diff --git a/pkg/models/jsonschema/scene.go b/pkg/models/jsonschema/scene.go index fbfdad010..7ebae7a17 100644 --- a/pkg/models/jsonschema/scene.go +++ b/pkg/models/jsonschema/scene.go @@ -39,10 +39,12 @@ type SceneMovie struct { } type Scene struct { - Title string `json:"title,omitempty"` - Code string `json:"code,omitempty"` - Studio string `json:"studio,omitempty"` + Title string `json:"title,omitempty"` + Code string `json:"code,omitempty"` + Studio string `json:"studio,omitempty"` + // deprecated - for import only URL string `json:"url,omitempty"` + URLs []string `json:"urls,omitempty"` Date string `json:"date,omitempty"` Rating int `json:"rating,omitempty"` Organized bool `json:"organized,omitempty"` diff --git a/pkg/models/mocks/SceneReaderWriter.go b/pkg/models/mocks/SceneReaderWriter.go index b1e98d91f..8d7245ee9 100644 --- a/pkg/models/mocks/SceneReaderWriter.go +++ b/pkg/models/mocks/SceneReaderWriter.go @@ -624,6 +624,29 @@ func (_m *SceneReaderWriter) GetTagIDs(ctx context.Context, relatedID int) ([]in return r0, r1 } +// GetURLs provides a mock function with given fields: ctx, relatedID +func (_m *SceneReaderWriter) 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 +} + // HasCover provides a mock function with given fields: ctx, sceneID func (_m *SceneReaderWriter) HasCover(ctx context.Context, sceneID int) (bool, error) { ret := _m.Called(ctx, sceneID) diff --git a/pkg/models/model_scene.go b/pkg/models/model_scene.go index 79c865ed2..f19113f49 100644 --- a/pkg/models/model_scene.go +++ b/pkg/models/model_scene.go @@ -17,7 +17,6 @@ type Scene struct { Code string `json:"code"` Details string `json:"details"` Director string `json:"director"` - URL string `json:"url"` Date *Date `json:"date"` // Rating expressed in 1-100 scale Rating *int `json:"rating"` @@ -43,6 +42,7 @@ type Scene struct { PlayDuration float64 `json:"play_duration"` PlayCount int `json:"play_count"` + URLs RelatedStrings `json:"urls"` GalleryIDs RelatedIDs `json:"gallery_ids"` TagIDs RelatedIDs `json:"tag_ids"` PerformerIDs RelatedIDs `json:"performer_ids"` @@ -50,6 +50,12 @@ type Scene struct { StashIDs RelatedStashIDs `json:"stash_ids"` } +func (s *Scene) LoadURLs(ctx context.Context, l URLLoader) error { + return s.URLs.load(func() ([]string, error) { + return l.GetURLs(ctx, s.ID) + }) +} + func (s *Scene) LoadFiles(ctx context.Context, l VideoFileLoader) error { return s.Files.load(func() ([]*file.VideoFile, error) { return l.GetFiles(ctx, s.ID) @@ -110,6 +116,10 @@ func (s *Scene) LoadStashIDs(ctx context.Context, l StashIDLoader) error { } func (s *Scene) LoadRelationships(ctx context.Context, l SceneReader) error { + if err := s.LoadURLs(ctx, l); err != nil { + return err + } + if err := s.LoadGalleryIDs(ctx, l); err != nil { return err } @@ -144,7 +154,6 @@ type ScenePartial struct { Code OptionalString Details OptionalString Director OptionalString - URL OptionalString Date OptionalDate // Rating expressed in 1-100 scale Rating OptionalInt @@ -158,6 +167,7 @@ type ScenePartial struct { PlayCount OptionalInt LastPlayedAt OptionalTime + URLs *UpdateStrings GalleryIDs *UpdateIDs TagIDs *UpdateIDs PerformerIDs *UpdateIDs @@ -193,6 +203,7 @@ type SceneUpdateInput struct { Rating100 *int `json:"rating100"` OCounter *int `json:"o_counter"` Organized *bool `json:"organized"` + Urls []string `json:"urls"` StudioID *string `json:"studio_id"` GalleryIds []string `json:"gallery_ids"` PerformerIds []string `json:"performer_ids"` @@ -227,7 +238,7 @@ func (s ScenePartial) UpdateInput(id int) SceneUpdateInput { Code: s.Code.Ptr(), Details: s.Details.Ptr(), Director: s.Director.Ptr(), - URL: s.URL.Ptr(), + Urls: s.URLs.Strings(), Date: dateStr, Rating100: s.Rating.Ptr(), Organized: s.Organized.Ptr(), diff --git a/pkg/models/model_scene_test.go b/pkg/models/model_scene_test.go index 910991971..e3126acae 100644 --- a/pkg/models/model_scene_test.go +++ b/pkg/models/model_scene_test.go @@ -37,11 +37,14 @@ func TestScenePartial_UpdateInput(t *testing.T) { "full", id, ScenePartial{ - Title: NewOptionalString(title), - Code: NewOptionalString(code), - Details: NewOptionalString(details), - Director: NewOptionalString(director), - URL: NewOptionalString(url), + Title: NewOptionalString(title), + Code: NewOptionalString(code), + Details: NewOptionalString(details), + Director: NewOptionalString(director), + URLs: &UpdateStrings{ + Values: []string{url}, + Mode: RelationshipUpdateModeSet, + }, Date: NewOptionalDate(dateObj), Rating: NewOptionalInt(rating100), Organized: NewOptionalBool(organized), @@ -53,7 +56,7 @@ func TestScenePartial_UpdateInput(t *testing.T) { Code: &code, Details: &details, Director: &director, - URL: &url, + Urls: []string{url}, Date: &date, Rating: &ratingLegacy, Rating100: &rating100, diff --git a/pkg/models/relationships.go b/pkg/models/relationships.go index 3975bffc3..f59e7d92e 100644 --- a/pkg/models/relationships.go +++ b/pkg/models/relationships.go @@ -42,6 +42,10 @@ type AliasLoader interface { GetAliases(ctx context.Context, relatedID int) ([]string, error) } +type URLLoader interface { + GetURLs(ctx context.Context, relatedID int) ([]string, error) +} + // RelatedIDs represents a list of related IDs. // TODO - this can be made generic type RelatedIDs struct { diff --git a/pkg/models/scene.go b/pkg/models/scene.go index 92a28a206..8f8d2eaf4 100644 --- a/pkg/models/scene.go +++ b/pkg/models/scene.go @@ -159,6 +159,7 @@ type SceneReader interface { FindByGalleryID(ctx context.Context, performerID int) ([]*Scene, error) FindDuplicates(ctx context.Context, distance int, durationDiff float64) ([][]*Scene, error) + URLLoader GalleryIDLoader PerformerIDLoader TagIDLoader diff --git a/pkg/models/update.go b/pkg/models/update.go index ffa793bda..31d8bd21d 100644 --- a/pkg/models/update.go +++ b/pkg/models/update.go @@ -110,3 +110,11 @@ type UpdateStrings struct { Values []string `json:"values"` Mode RelationshipUpdateMode `json:"mode"` } + +func (u *UpdateStrings) Strings() []string { + if u == nil { + return nil + } + + return u.Values +} diff --git a/pkg/scene/export.go b/pkg/scene/export.go index 2696adb06..5fa3b8b2d 100644 --- a/pkg/scene/export.go +++ b/pkg/scene/export.go @@ -41,7 +41,7 @@ func ToBasicJSON(ctx context.Context, reader CoverGetter, scene *models.Scene) ( newSceneJSON := jsonschema.Scene{ Title: scene.Title, Code: scene.Code, - URL: scene.URL, + URLs: scene.URLs.List(), Details: scene.Details, Director: scene.Director, CreatedAt: json.JSONTime{Time: scene.CreatedAt}, @@ -86,53 +86,6 @@ func ToBasicJSON(ctx context.Context, reader CoverGetter, scene *models.Scene) ( return &newSceneJSON, nil } -// func getSceneFileJSON(scene *models.Scene) *jsonschema.SceneFile { -// ret := &jsonschema.SceneFile{} - -// TODO -// if scene.FileModTime != nil { -// ret.ModTime = json.JSONTime{Time: *scene.FileModTime} -// } - -// if scene.Size != nil { -// ret.Size = *scene.Size -// } - -// if scene.Duration != nil { -// ret.Duration = getDecimalString(*scene.Duration) -// } - -// if scene.VideoCodec != nil { -// ret.VideoCodec = *scene.VideoCodec -// } - -// if scene.AudioCodec != nil { -// ret.AudioCodec = *scene.AudioCodec -// } - -// if scene.Format != nil { -// ret.Format = *scene.Format -// } - -// if scene.Width != nil { -// ret.Width = *scene.Width -// } - -// if scene.Height != nil { -// ret.Height = *scene.Height -// } - -// if scene.Framerate != nil { -// ret.Framerate = getDecimalString(*scene.Framerate) -// } - -// if scene.Bitrate != nil { -// ret.Bitrate = int(*scene.Bitrate) -// } - -// return ret -// } - // GetStudioName returns the name of the provided scene's studio. It returns an // empty string if there is no studio assigned to the scene. func GetStudioName(ctx context.Context, reader studio.Finder, scene *models.Scene) (string, error) { diff --git a/pkg/scene/export_test.go b/pkg/scene/export_test.go index d02109d6e..224a30a2d 100644 --- a/pkg/scene/export_test.go +++ b/pkg/scene/export_test.go @@ -92,7 +92,7 @@ func createFullScene(id int) models.Scene { OCounter: ocounter, Rating: &rating, Organized: organized, - URL: url, + URLs: models.NewRelatedStrings([]string{url}), Files: models.NewRelatedVideoFiles([]*file.VideoFile{ { BaseFile: &file.BaseFile{ @@ -118,6 +118,7 @@ func createEmptyScene(id int) models.Scene { }, }, }), + URLs: models.NewRelatedStrings([]string{}), StashIDs: models.NewRelatedStashIDs([]models.StashID{}), CreatedAt: createTime, UpdatedAt: updateTime, @@ -133,7 +134,7 @@ func createFullJSONScene(image string) *jsonschema.Scene { OCounter: ocounter, Rating: rating, Organized: organized, - URL: url, + URLs: []string{url}, CreatedAt: json.JSONTime{ Time: createTime, }, @@ -149,6 +150,7 @@ func createFullJSONScene(image string) *jsonschema.Scene { func createEmptyJSONScene() *jsonschema.Scene { return &jsonschema.Scene{ + URLs: []string{}, Files: []string{path}, CreatedAt: json.JSONTime{ Time: createTime, diff --git a/pkg/scene/import.go b/pkg/scene/import.go index d90c8c4b9..1c00f8015 100644 --- a/pkg/scene/import.go +++ b/pkg/scene/import.go @@ -80,12 +80,10 @@ func (i *Importer) PreImport(ctx context.Context) error { func (i *Importer) sceneJSONToScene(sceneJSON jsonschema.Scene) models.Scene { newScene := models.Scene{ - // Path: i.Path, Title: sceneJSON.Title, Code: sceneJSON.Code, Details: sceneJSON.Details, Director: sceneJSON.Director, - URL: sceneJSON.URL, PerformerIDs: models.NewRelatedIDs([]int{}), TagIDs: models.NewRelatedIDs([]int{}), GalleryIDs: models.NewRelatedIDs([]int{}), @@ -93,6 +91,12 @@ func (i *Importer) sceneJSONToScene(sceneJSON jsonschema.Scene) models.Scene { StashIDs: models.NewRelatedStashIDs(sceneJSON.StashIDs), } + if len(sceneJSON.URLs) > 0 { + newScene.URLs = models.NewRelatedStrings(sceneJSON.URLs) + } else if sceneJSON.URL != "" { + newScene.URLs = models.NewRelatedStrings([]string{sceneJSON.URL}) + } + if sceneJSON.Date != "" { d := models.NewDate(sceneJSON.Date) newScene.Date = &d diff --git a/pkg/scraper/cache.go b/pkg/scraper/cache.go index 4c40c95c2..81607cd44 100644 --- a/pkg/scraper/cache.go +++ b/pkg/scraper/cache.go @@ -52,6 +52,11 @@ func isCDPPathWS(c GlobalConfig) bool { return strings.HasPrefix(c.GetScraperCDPPath(), "ws://") } +type SceneFinder interface { + scene.IDFinder + models.URLLoader +} + type PerformerFinder interface { match.PerformerAutoTagQueryer match.PerformerFinder @@ -73,7 +78,7 @@ type GalleryFinder interface { } type Repository struct { - SceneFinder scene.IDFinder + SceneFinder SceneFinder GalleryFinder GalleryFinder TagFinder TagFinder PerformerFinder PerformerFinder @@ -240,7 +245,19 @@ func (c Cache) ScrapeName(ctx context.Context, id, query string, ty ScrapeConten return nil, fmt.Errorf("%w: cannot use scraper %s to scrape by name", ErrNotSupported, id) } - return ns.viaName(ctx, c.client, query, ty) + content, err := ns.viaName(ctx, c.client, query, ty) + if err != nil { + return nil, fmt.Errorf("error while name scraping with scraper %s: %w", id, err) + } + + for i, cc := range content { + content[i], err = c.postScrape(ctx, cc) + if err != nil { + return nil, fmt.Errorf("error while post-scraping with scraper %s: %w", id, err) + } + } + + return content, nil } // ScrapeFragment uses the given fragment input to scrape @@ -361,7 +378,7 @@ func (c Cache) getScene(ctx context.Context, sceneID int) (*models.Scene, error) return fmt.Errorf("scene with id %d not found", sceneID) } - return nil + return ret.LoadURLs(ctx, c.repository.SceneFinder) }); err != nil { return nil, err } diff --git a/pkg/scraper/postprocessing.go b/pkg/scraper/postprocessing.go index cf8cac1eb..e2d404d7c 100644 --- a/pkg/scraper/postprocessing.go +++ b/pkg/scraper/postprocessing.go @@ -106,6 +106,14 @@ func (c Cache) postScrapeScenePerformer(ctx context.Context, p models.ScrapedPer } func (c Cache) postScrapeScene(ctx context.Context, scene ScrapedScene) (ScrapedContent, error) { + // set the URL/URLs field + if scene.URL == nil && len(scene.URLs) > 0 { + scene.URL = &scene.URLs[0] + } + if scene.URL != nil && len(scene.URLs) == 0 { + scene.URLs = []string{*scene.URL} + } + if err := txn.WithReadTxn(ctx, c.txnManager, func(ctx context.Context) error { pqb := c.repository.PerformerFinder mqb := c.repository.MovieFinder diff --git a/pkg/scraper/query_url.go b/pkg/scraper/query_url.go index 0ad4aa7e9..49cd08cf7 100644 --- a/pkg/scraper/query_url.go +++ b/pkg/scraper/query_url.go @@ -20,8 +20,8 @@ func queryURLParametersFromScene(scene *models.Scene) queryURLParameters { if scene.Title != "" { ret["title"] = scene.Title } - if scene.URL != "" { - ret["url"] = scene.URL + if len(scene.URLs.List()) > 0 { + ret["url"] = scene.URLs.List()[0] } return ret } @@ -37,7 +37,11 @@ func queryURLParametersFromScrapedScene(scene ScrapedSceneInput) queryURLParamet setField("title", scene.Title) setField("code", scene.Code) - setField("url", scene.URL) + if len(scene.URLs) > 0 { + setField("url", &scene.URLs[0]) + } else { + setField("url", scene.URL) + } setField("date", scene.Date) setField("details", scene.Details) setField("director", scene.Director) diff --git a/pkg/scraper/scene.go b/pkg/scraper/scene.go index 517f2a318..e5de74a23 100644 --- a/pkg/scraper/scene.go +++ b/pkg/scraper/scene.go @@ -5,12 +5,13 @@ import ( ) type ScrapedScene struct { - Title *string `json:"title"` - Code *string `json:"code"` - Details *string `json:"details"` - Director *string `json:"director"` - URL *string `json:"url"` - Date *string `json:"date"` + Title *string `json:"title"` + Code *string `json:"code"` + Details *string `json:"details"` + Director *string `json:"director"` + URL *string `json:"url"` + URLs []string `json:"urls"` + Date *string `json:"date"` // This should be a base64 encoded data URL Image *string `json:"image"` File *models.SceneFileType `json:"file"` @@ -26,11 +27,12 @@ type ScrapedScene struct { func (ScrapedScene) IsScrapedContent() {} type ScrapedSceneInput struct { - Title *string `json:"title"` - Code *string `json:"code"` - Details *string `json:"details"` - Director *string `json:"director"` - URL *string `json:"url"` - Date *string `json:"date"` - RemoteSiteID *string `json:"remote_site_id"` + Title *string `json:"title"` + Code *string `json:"code"` + Details *string `json:"details"` + Director *string `json:"director"` + URL *string `json:"url"` + URLs []string `json:"urls"` + Date *string `json:"date"` + RemoteSiteID *string `json:"remote_site_id"` } diff --git a/pkg/scraper/stash.go b/pkg/scraper/stash.go index 652a9de0a..f616789c4 100644 --- a/pkg/scraper/stash.go +++ b/pkg/scraper/stash.go @@ -328,7 +328,7 @@ func sceneToUpdateInput(scene *models.Scene) models.SceneUpdateInput { ID: strconv.Itoa(scene.ID), Title: &title, Details: &scene.Details, - URL: &scene.URL, + Urls: scene.URLs.List(), Date: dateToStringPtr(scene.Date), } } diff --git a/pkg/scraper/stashbox/stash_box.go b/pkg/scraper/stashbox/stash_box.go index 73cf5b030..54f113638 100644 --- a/pkg/scraper/stashbox/stash_box.go +++ b/pkg/scraper/stashbox/stash_box.go @@ -684,6 +684,7 @@ func getFingerprints(scene *graphql.SceneFragment) []*models.StashBoxFingerprint func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.SceneFragment) (*scraper.ScrapedScene, error) { stashID := s.ID + ss := &scraper.ScrapedScene{ Title: s.Title, Code: s.Code, @@ -698,6 +699,14 @@ func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.Scen // stash_id } + for _, u := range s.Urls { + ss.URLs = append(ss.URLs, u.URL) + } + + if len(ss.URLs) > 0 { + ss.URL = &ss.URLs[0] + } + if len(s.Images) > 0 { // TODO - #454 code sorts images by aspect ratio according to a wanted // orientation. I'm just grabbing the first for now @@ -823,8 +832,9 @@ func (c Client) SubmitSceneDraft(ctx context.Context, scene *models.Scene, endpo if scene.Director != "" { draft.Director = &scene.Director } - if scene.URL != "" && len(strings.TrimSpace(scene.URL)) > 0 { - url := strings.TrimSpace(scene.URL) + // TODO - draft does not accept multiple URLs. Use single URL for now. + if len(scene.URLs.List()) > 0 { + url := strings.TrimSpace(scene.URLs.List()[0]) draft.URL = &url } if scene.Date != nil { diff --git a/pkg/sliceutil/collections.go b/pkg/sliceutil/collections.go index 5a271268c..454038e17 100644 --- a/pkg/sliceutil/collections.go +++ b/pkg/sliceutil/collections.go @@ -2,6 +2,53 @@ package sliceutil import "reflect" +// Exclude removes all instances of any value in toExclude from the vs +// slice. It returns the new or unchanged slice. +func Exclude[T comparable](vs []T, toExclude []T) []T { + var ret []T + for _, v := range vs { + if !Include(toExclude, v) { + ret = append(ret, v) + } + } + + return ret +} + +func Index[T comparable](vs []T, t T) int { + for i, v := range vs { + if v == t { + return i + } + } + return -1 +} + +func Include[T comparable](vs []T, t T) bool { + return Index(vs, t) >= 0 +} + +// IntAppendUnique appends toAdd to the vs int slice if toAdd does not already +// exist in the slice. It returns the new or unchanged int slice. +func AppendUnique[T comparable](vs []T, toAdd T) []T { + if Include(vs, toAdd) { + return vs + } + + return append(vs, toAdd) +} + +// IntAppendUniques appends a slice of values to the vs slice. It only +// appends values that do not already exist in the slice. It returns the new or +// unchanged slice. +func AppendUniques[T comparable](vs []T, toAdd []T) []T { + for _, v := range toAdd { + vs = AppendUnique(vs, v) + } + + return vs +} + // SliceSame returns true if the two provided lists have the same elements, // regardless of order. Panics if either parameter is not a slice. func SliceSame(a, b interface{}) bool { diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index c16d1160d..a62b8d3c5 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -230,7 +230,6 @@ func (db *Anonymiser) anonymiseScenes(ctx context.Context) error { table.Col(idColumn), table.Col("title"), table.Col("details"), - table.Col("url"), table.Col("code"), table.Col("director"), ).Where(table.Col(idColumn).Gt(lastID)).Limit(1000) @@ -243,7 +242,6 @@ func (db *Anonymiser) anonymiseScenes(ctx context.Context) error { id int title sql.NullString details sql.NullString - url sql.NullString code sql.NullString director sql.NullString ) @@ -252,7 +250,6 @@ func (db *Anonymiser) anonymiseScenes(ctx context.Context) error { &id, &title, &details, - &url, &code, &director, ); err != nil { @@ -264,7 +261,6 @@ func (db *Anonymiser) anonymiseScenes(ctx context.Context) error { // if title set set new title db.obfuscateNullString(set, "title", title) db.obfuscateNullString(set, "details", details) - db.obfuscateNullString(set, "url", url) if len(set) > 0 { stmt := dialect.Update(table).Set(set).Where(table.Col(idColumn).Eq(id)) @@ -301,6 +297,10 @@ func (db *Anonymiser) anonymiseScenes(ctx context.Context) error { } } + if err := db.anonymiseURLs(ctx, goqu.T(scenesURLsTable), "scene_id"); err != nil { + return err + } + return nil } @@ -704,6 +704,68 @@ func (db *Anonymiser) anonymiseAliases(ctx context.Context, table exp.Identifier return nil } +func (db *Anonymiser) anonymiseURLs(ctx context.Context, table exp.IdentifierExpression, idColumn string) error { + lastID := 0 + lastURL := "" + total := 0 + const logEvery = 10000 + + for gotSome := true; gotSome; { + if err := txn.WithTxn(ctx, db, func(ctx context.Context) error { + query := dialect.From(table).Select( + table.Col(idColumn), + table.Col("url"), + ).Where(goqu.L("(" + idColumn + ", url)").Gt(goqu.L("(?, ?)", lastID, lastURL))).Limit(1000) + + gotSome = false + + const single = false + return queryFunc(ctx, query, single, func(rows *sqlx.Rows) error { + var ( + id int + url sql.NullString + ) + + if err := rows.Scan( + &id, + &url, + ); err != nil { + return err + } + + set := goqu.Record{} + db.obfuscateNullString(set, "url", url) + + if len(set) > 0 { + stmt := dialect.Update(table).Set(set).Where( + table.Col(idColumn).Eq(id), + table.Col("url").Eq(url), + ) + + if _, err := exec(ctx, stmt); err != nil { + return fmt.Errorf("anonymising %s: %w", table.GetTable(), err) + } + } + + lastID = id + lastURL = url.String + gotSome = true + total++ + + if total%logEvery == 0 { + logger.Infof("Anonymised %d %s URLs", total, table.GetTable()) + } + + return nil + }) + }); err != nil { + return err + } + } + + return nil +} + func (db *Anonymiser) anonymiseTags(ctx context.Context) error { logger.Infof("Anonymising tags") table := tagTableMgr.table diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 0da9d1313..db5d72c21 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -32,7 +32,7 @@ const ( dbConnTimeout = 30 ) -var appSchemaVersion uint = 46 +var appSchemaVersion uint = 47 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/47_scene_urls.up.sql b/pkg/sqlite/migrations/47_scene_urls.up.sql new file mode 100644 index 000000000..1334ffe2a --- /dev/null +++ b/pkg/sqlite/migrations/47_scene_urls.up.sql @@ -0,0 +1,94 @@ +PRAGMA foreign_keys=OFF; + +CREATE TABLE `scene_urls` ( + `scene_id` integer NOT NULL, + `position` integer NOT NULL, + `url` varchar(255) NOT NULL, + foreign key(`scene_id`) references `scenes`(`id`) on delete CASCADE, + PRIMARY KEY(`scene_id`, `position`, `url`) +); + +CREATE INDEX `scene_urls_url` on `scene_urls` (`url`); + +-- drop url +CREATE TABLE "scenes_new" ( + `id` integer not null primary key autoincrement, + `title` varchar(255), + `details` text, + `date` date, + `rating` tinyint, + `studio_id` integer, + `o_counter` tinyint not null default 0, + `organized` boolean not null default '0', + `created_at` datetime not null, + `updated_at` datetime not null, + `code` text, + `director` text, + `resume_time` float not null default 0, + `last_played_at` datetime default null, + `play_count` tinyint not null default 0, + `play_duration` float not null default 0, + `cover_blob` varchar(255) REFERENCES `blobs`(`checksum`), + foreign key(`studio_id`) references `studios`(`id`) on delete SET NULL +); + +INSERT INTO `scenes_new` + ( + `id`, + `title`, + `details`, + `date`, + `rating`, + `studio_id`, + `o_counter`, + `organized`, + `created_at`, + `updated_at`, + `code`, + `director`, + `resume_time`, + `last_played_at`, + `play_count`, + `play_duration`, + `cover_blob` + ) + SELECT + `id`, + `title`, + `details`, + `date`, + `rating`, + `studio_id`, + `o_counter`, + `organized`, + `created_at`, + `updated_at`, + `code`, + `director`, + `resume_time`, + `last_played_at`, + `play_count`, + `play_duration`, + `cover_blob` + FROM `scenes`; + +INSERT INTO `scene_urls` + ( + `scene_id`, + `position`, + `url` + ) + SELECT + `id`, + '0', + `url` + FROM `scenes` + WHERE `scenes`.`url` IS NOT NULL AND `scenes`.`url` != ''; + +DROP INDEX `index_scenes_on_studio_id`; +DROP TABLE `scenes`; +ALTER TABLE `scenes_new` rename to `scenes`; + +CREATE INDEX `index_scenes_on_studio_id` on `scenes` (`studio_id`); + +PRAGMA foreign_keys=ON; diff --git a/pkg/sqlite/scene.go b/pkg/sqlite/scene.go index 99250254b..ae8bca467 100644 --- a/pkg/sqlite/scene.go +++ b/pkg/sqlite/scene.go @@ -31,6 +31,8 @@ const ( scenesTagsTable = "scenes_tags" scenesGalleriesTable = "scenes_galleries" moviesScenesTable = "movies_scenes" + scenesURLsTable = "scene_urls" + sceneURLColumn = "url" sceneCoverBlobColumn = "cover_blob" ) @@ -76,7 +78,6 @@ type sceneRow struct { Code zero.String `db:"code"` Details zero.String `db:"details"` Director zero.String `db:"director"` - URL zero.String `db:"url"` Date NullDate `db:"date"` // expressed as 1-100 Rating null.Int `db:"rating"` @@ -100,7 +101,6 @@ func (r *sceneRow) fromScene(o models.Scene) { r.Code = zero.StringFrom(o.Code) r.Details = zero.StringFrom(o.Details) r.Director = zero.StringFrom(o.Director) - r.URL = zero.StringFrom(o.URL) r.Date = NullDateFromDatePtr(o.Date) r.Rating = intFromPtr(o.Rating) r.Organized = o.Organized @@ -130,7 +130,6 @@ func (r *sceneQueryRow) resolve() *models.Scene { Code: r.Code.String, Details: r.Details.String, Director: r.Director.String, - URL: r.URL.String, Date: r.Date.DatePtr(), Rating: nullIntPtr(r.Rating), Organized: r.Organized, @@ -166,7 +165,6 @@ func (r *sceneRowRecord) fromPartial(o models.ScenePartial) { r.setNullString("code", o.Code) r.setNullString("details", o.Details) r.setNullString("director", o.Director) - r.setNullString("url", o.URL) r.setNullDate("date", o.Date) r.setNullInt("rating", o.Rating) r.setBool("organized", o.Organized) @@ -268,6 +266,13 @@ func (qb *SceneStore) Create(ctx context.Context, newObject *models.Scene, fileI } } + if newObject.URLs.Loaded() { + const startPos = 0 + if err := scenesURLsTableMgr.insertJoins(ctx, id, startPos, newObject.URLs.List()); err != nil { + return err + } + } + if newObject.PerformerIDs.Loaded() { if err := scenesPerformersTableMgr.insertJoins(ctx, id, newObject.PerformerIDs.List()); err != nil { return err @@ -322,6 +327,11 @@ func (qb *SceneStore) UpdatePartial(ctx context.Context, id int, partial models. } } + if partial.URLs != nil { + if err := scenesURLsTableMgr.modifyJoins(ctx, id, partial.URLs.Values, partial.URLs.Mode); err != nil { + return nil, err + } + } if partial.PerformerIDs != nil { if err := scenesPerformersTableMgr.modifyJoins(ctx, id, partial.PerformerIDs.IDs, partial.PerformerIDs.Mode); err != nil { return nil, err @@ -364,6 +374,12 @@ func (qb *SceneStore) Update(ctx context.Context, updatedObject *models.Scene) e return err } + if updatedObject.URLs.Loaded() { + if err := scenesURLsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.URLs.List()); err != nil { + return err + } + } + if updatedObject.PerformerIDs.Loaded() { if err := scenesPerformersTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.PerformerIDs.List()); err != nil { return err @@ -974,7 +990,7 @@ func (qb *SceneStore) makeFilter(ctx context.Context, sceneFilter *models.SceneF query.handleCriterion(ctx, hasMarkersCriterionHandler(sceneFilter.HasMarkers)) query.handleCriterion(ctx, sceneIsMissingCriterionHandler(qb, sceneFilter.IsMissing)) - query.handleCriterion(ctx, stringCriterionHandler(sceneFilter.URL, "scenes.url")) + query.handleCriterion(ctx, sceneURLsCriterionHandler(sceneFilter.URL)) query.handleCriterion(ctx, criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if sceneFilter.StashID != nil { @@ -1308,6 +1324,18 @@ func sceneIsMissingCriterionHandler(qb *SceneStore, isMissing *string) criterion } } +func sceneURLsCriterionHandler(url *models.StringCriterionInput) criterionHandlerFunc { + h := stringListCriterionHandlerBuilder{ + joinTable: scenesURLsTable, + stringColumn: sceneURLColumn, + addJoinTable: func(f *filterBuilder) { + scenesURLsTableMgr.join(f, "", "scenes.id") + }, + } + + return h.handler(url) +} + func (qb *SceneStore) getMultiCriterionHandlerBuilder(foreignTable, joinTable, foreignFK string, addJoinsFunc func(f *filterBuilder)) multiCriterionHandlerBuilder { return multiCriterionHandlerBuilder{ primaryTable: sceneTable, @@ -1637,6 +1665,10 @@ func (qb *SceneStore) IncrementWatchCount(ctx context.Context, id int) (int, err return qb.getPlayCount(ctx, id) } +func (qb *SceneStore) GetURLs(ctx context.Context, sceneID int) ([]string, error) { + return scenesURLsTableMgr.get(ctx, sceneID) +} + func (qb *SceneStore) GetCover(ctx context.Context, sceneID int) ([]byte, error) { return qb.GetImage(ctx, sceneID, sceneCoverBlobColumn) } diff --git a/pkg/sqlite/scene_test.go b/pkg/sqlite/scene_test.go index 50691437d..11085a0c1 100644 --- a/pkg/sqlite/scene_test.go +++ b/pkg/sqlite/scene_test.go @@ -21,6 +21,12 @@ import ( ) func loadSceneRelationships(ctx context.Context, expected models.Scene, actual *models.Scene) error { + if expected.URLs.Loaded() { + if err := actual.LoadURLs(ctx, db.Scene); err != nil { + return err + } + } + if expected.GalleryIDs.Loaded() { if err := actual.LoadGalleryIDs(ctx, db.Scene); err != nil { return err @@ -108,7 +114,7 @@ func Test_sceneQueryBuilder_Create(t *testing.T) { Code: code, Details: details, Director: director, - URL: url, + URLs: models.NewRelatedStrings([]string{url}), Date: &date, Rating: &rating, Organized: true, @@ -153,7 +159,7 @@ func Test_sceneQueryBuilder_Create(t *testing.T) { Code: code, Details: details, Director: director, - URL: url, + URLs: models.NewRelatedStrings([]string{url}), Date: &date, Rating: &rating, Organized: true, @@ -346,7 +352,7 @@ func Test_sceneQueryBuilder_Update(t *testing.T) { Code: code, Details: details, Director: director, - URL: url, + URLs: models.NewRelatedStrings([]string{url}), Date: &date, Rating: &rating, Organized: true, @@ -513,7 +519,7 @@ func clearScenePartial() models.ScenePartial { Code: models.OptionalString{Set: true, Null: true}, Details: models.OptionalString{Set: true, Null: true}, Director: models.OptionalString{Set: true, Null: true}, - URL: models.OptionalString{Set: true, Null: true}, + URLs: &models.UpdateStrings{Mode: models.RelationshipUpdateModeSet}, Date: models.OptionalDate{Set: true, Null: true}, Rating: models.OptionalInt{Set: true, Null: true}, StudioID: models.OptionalInt{Set: true, Null: true}, @@ -560,11 +566,14 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { "full", sceneIDs[sceneIdxWithSpacedName], models.ScenePartial{ - Title: models.NewOptionalString(title), - Code: models.NewOptionalString(code), - Details: models.NewOptionalString(details), - Director: models.NewOptionalString(director), - URL: models.NewOptionalString(url), + Title: models.NewOptionalString(title), + Code: models.NewOptionalString(code), + Details: models.NewOptionalString(details), + Director: models.NewOptionalString(director), + URLs: &models.UpdateStrings{ + Values: []string{url}, + Mode: models.RelationshipUpdateModeSet, + }, Date: models.NewOptionalDate(date), Rating: models.NewOptionalInt(rating), Organized: models.NewOptionalBool(true), @@ -624,7 +633,7 @@ func Test_sceneQueryBuilder_UpdatePartial(t *testing.T) { Code: code, Details: details, Director: director, - URL: url, + URLs: models.NewRelatedStrings([]string{url}), Date: &date, Rating: &rating, Organized: true, @@ -2400,7 +2409,14 @@ func TestSceneQueryURL(t *testing.T) { verifyFn := func(s *models.Scene) { t.Helper() - verifyString(t, s.URL, urlCriterion) + + urls := s.URLs.List() + var url string + if len(urls) > 0 { + url = urls[0] + } + + verifyString(t, url, urlCriterion) } verifySceneQuery(t, filter, verifyFn) @@ -2576,6 +2592,12 @@ func verifySceneQuery(t *testing.T, filter models.SceneFilterType, verifyFn func scenes := queryScene(ctx, t, sqb, &filter, nil) + for _, scene := range scenes { + if err := scene.LoadRelationships(ctx, sqb); err != nil { + t.Errorf("Error loading scene relationships: %v", err) + } + } + // assume it should find at least one assert.Greater(t, len(scenes), 0) diff --git a/pkg/sqlite/setup_test.go b/pkg/sqlite/setup_test.go index e5b56efad..d869a35ba 100644 --- a/pkg/sqlite/setup_test.go +++ b/pkg/sqlite/setup_test.go @@ -1065,9 +1065,11 @@ func makeScene(i int) *models.Scene { rating := getRating(i) return &models.Scene{ - Title: title, - Details: details, - URL: getSceneEmptyString(i, urlField), + Title: title, + Details: details, + URLs: models.NewRelatedStrings([]string{ + getSceneEmptyString(i, urlField), + }), Rating: getIntPtr(rating), OCounter: getOCounter(i), Date: getObjectDate(i), diff --git a/pkg/sqlite/table.go b/pkg/sqlite/table.go index 1a33ee2bf..e3cedce37 100644 --- a/pkg/sqlite/table.go +++ b/pkg/sqlite/table.go @@ -14,6 +14,7 @@ import ( "github.com/stashapp/stash/pkg/file" "github.com/stashapp/stash/pkg/logger" "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/sliceutil" "github.com/stashapp/stash/pkg/sliceutil/intslice" "github.com/stashapp/stash/pkg/sliceutil/stringslice" ) @@ -472,6 +473,113 @@ func (t *stringTable) modifyJoins(ctx context.Context, id int, v []string, mode return nil } +type orderedValueTable[T comparable] struct { + table + valueColumn exp.IdentifierExpression +} + +func (t *orderedValueTable[T]) positionColumn() exp.IdentifierExpression { + const positionColumn = "position" + return t.table.table.Col(positionColumn) +} + +func (t *orderedValueTable[T]) get(ctx context.Context, id int) ([]T, error) { + q := dialect.Select(t.valueColumn).From(t.table.table).Where(t.idColumn.Eq(id)).Order(t.positionColumn().Asc()) + + const single = false + var ret []T + if err := queryFunc(ctx, q, single, func(rows *sqlx.Rows) error { + var v T + if err := rows.Scan(&v); err != nil { + return err + } + + ret = append(ret, v) + + return nil + }); err != nil { + return nil, fmt.Errorf("getting stash ids from %s: %w", t.table.table.GetTable(), err) + } + + return ret, nil +} + +func (t *orderedValueTable[T]) insertJoin(ctx context.Context, id int, position int, v T) (sql.Result, error) { + q := dialect.Insert(t.table.table).Cols(t.idColumn.GetCol(), t.positionColumn().GetCol(), t.valueColumn.GetCol()).Vals( + goqu.Vals{id, position, v}, + ) + ret, err := exec(ctx, q) + if err != nil { + return nil, fmt.Errorf("inserting into %s: %w", t.table.table.GetTable(), err) + } + + return ret, nil +} + +func (t *orderedValueTable[T]) insertJoins(ctx context.Context, id int, startPos int, v []T) error { + for i, fk := range v { + if _, err := t.insertJoin(ctx, id, i+startPos, fk); err != nil { + return err + } + } + + return nil +} + +func (t *orderedValueTable[T]) replaceJoins(ctx context.Context, id int, v []T) error { + if err := t.destroy(ctx, []int{id}); err != nil { + return err + } + + const startPos = 0 + return t.insertJoins(ctx, id, startPos, v) +} + +func (t *orderedValueTable[T]) addJoins(ctx context.Context, id int, v []T) error { + // get existing foreign keys + existing, err := t.get(ctx, id) + if err != nil { + return err + } + + // only add values that are not already present + filtered := sliceutil.Exclude(v, existing) + + if len(filtered) == 0 { + return nil + } + + startPos := len(existing) + return t.insertJoins(ctx, id, startPos, filtered) +} + +func (t *orderedValueTable[T]) destroyJoins(ctx context.Context, id int, v []T) error { + existing, err := t.get(ctx, id) + if err != nil { + return fmt.Errorf("getting existing %s: %w", t.table.table.GetTable(), err) + } + + newValue := sliceutil.Exclude(existing, v) + if len(newValue) == len(existing) { + return nil + } + + return t.replaceJoins(ctx, id, newValue) +} + +func (t *orderedValueTable[T]) modifyJoins(ctx context.Context, id int, v []T, mode models.RelationshipUpdateMode) error { + switch mode { + case models.RelationshipUpdateModeSet: + return t.replaceJoins(ctx, id, v) + case models.RelationshipUpdateModeAdd: + return t.addJoins(ctx, id, v) + case models.RelationshipUpdateModeRemove: + return t.destroyJoins(ctx, id, v) + } + + return nil +} + type scenesMoviesTable struct { table } diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index f6844b838..f5408b9a9 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -24,6 +24,7 @@ var ( scenesPerformersJoinTable = goqu.T(performersScenesTable) scenesStashIDsJoinTable = goqu.T("scene_stash_ids") scenesMoviesJoinTable = goqu.T(moviesScenesTable) + scenesURLsJoinTable = goqu.T(scenesURLsTable) performersAliasesJoinTable = goqu.T(performersAliasesTable) performersTagsJoinTable = goqu.T(performersTagsTable) @@ -160,6 +161,14 @@ var ( idColumn: scenesMoviesJoinTable.Col(sceneIDColumn), }, } + + scenesURLsTableMgr = &orderedValueTable[string]{ + table: table{ + table: scenesURLsJoinTable, + idColumn: scenesURLsJoinTable.Col(sceneIDColumn), + }, + valueColumn: scenesURLsJoinTable.Col(sceneURLColumn), + } ) var ( diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 4e11ef6eb..1517db79e 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -29,7 +29,7 @@ import { import { Icon } from "src/components/Shared/Icon"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { ImageInput } from "src/components/Shared/ImageInput"; -import { URLField } from "src/components/Shared/URLField"; +import { URLListInput } from "src/components/Shared/URLField"; import { useToast } from "src/hooks/Toast"; import ImageUtils from "src/utils/image"; import FormUtils from "src/utils/form"; @@ -106,7 +106,25 @@ export const SceneEditPanel: React.FC = ({ const schema = yup.object({ title: yup.string().ensure(), code: yup.string().ensure(), - url: yup.string().ensure(), + urls: yup + .array(yup.string().required()) + .defined() + .test({ + name: "unique", + test: (value) => { + const dupes = value + .map((e, i, a) => { + if (a.indexOf(e) !== i) { + return String(i - 1); + } else { + return null; + } + }) + .filter((e) => e !== null) as string[]; + if (dupes.length === 0) return true; + return new yup.ValidationError(dupes.join(" "), value, "urls"); + }, + }), date: yup .string() .ensure() @@ -143,7 +161,7 @@ export const SceneEditPanel: React.FC = ({ () => ({ title: scene.title ?? "", code: scene.code ?? "", - url: scene.url ?? "", + urls: scene.urls ?? [], date: scene.date ?? "", director: scene.director ?? "", rating100: scene.rating100 ?? null, @@ -333,7 +351,7 @@ export const SceneEditPanel: React.FC = ({ director: fragment.director, remote_site_id: fragment.remote_site_id, title: fragment.title, - url: fragment.url, + urls: fragment.urls, }; const result = await queryScrapeSceneQueryFragment(s, input); @@ -549,8 +567,8 @@ export const SceneEditPanel: React.FC = ({ formik.setFieldValue("date", updatedScene.date); } - if (updatedScene.url) { - formik.setFieldValue("url", updatedScene.url); + if (updatedScene.urls) { + formik.setFieldValue("urls", updatedScene.urls); } if (updatedScene.studio && updatedScene.studio.stored_id) { @@ -624,13 +642,13 @@ export const SceneEditPanel: React.FC = ({ } } - async function onScrapeSceneURL() { - if (!formik.values.url) { + async function onScrapeSceneURL(url: string) { + if (!url) { return; } setIsLoading(true); try { - const result = await queryScrapeSceneURL(formik.values.url); + const result = await queryScrapeSceneURL(url); if (!result.data || !result.data.scrapeSceneURL) { return; } @@ -683,6 +701,14 @@ export const SceneEditPanel: React.FC = ({ if (isLoading) return ; + const urlsErrors = Array.isArray(formik.errors.urls) + ? formik.errors.urls[0] + : formik.errors.urls; + const urlsErrorMsg = urlsErrors + ? intl.formatMessage({ id: "validation.urls_must_be_unique" }) + : undefined; + const urlsErrorIdx = urlsErrors?.split(" ").map((e) => parseInt(e)); + return (
= ({
{renderTextField("title", intl.formatMessage({ id: "title" }))} {renderTextField("code", intl.formatMessage({ id: "scene_code" }))} - + - + - formik.setFieldValue("urls", value)} + errors={urlsErrorMsg} + errorIdx={urlsErrorIdx} + onScrapeClick={(url) => onScrapeSceneURL(url)} urlScrapable={urlScrapable} - isInvalid={!!formik.getFieldMeta("url").error} /> diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx index b2edbfa93..a0d2f5202 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneFileInfoPanel.tsx @@ -16,7 +16,7 @@ import { useToast } from "src/hooks/Toast"; import NavUtils from "src/utils/navigation"; import TextUtils from "src/utils/text"; import { getStashboxBase } from "src/utils/stashbox"; -import { TextField, URLField } from "src/utils/field"; +import { TextField, URLField, URLsField } from "src/utils/field"; interface IFileInfoPanelProps { sceneID: string; @@ -316,12 +316,7 @@ export const SceneFileInfoPanel: React.FC = ( )} {renderFunscript()} {renderInteractiveSpeed()} - + {renderStashIDs()} = ({ const [code, setCode] = useState>( new ScrapeResult(scene.code, scraped.code) ); - const [url, setURL] = useState>( - new ScrapeResult(scene.url, scraped.url) + + const [urls, setURLs] = useState>( + new ScrapeResult( + scene.urls, + scraped.urls + ? uniq((scene.urls ?? []).concat(scraped.urls ?? [])) + : undefined + ) ); + const [date, setDate] = useState>( new ScrapeResult(scene.date, scraped.date) ); @@ -407,7 +416,7 @@ export const SceneScrapeDialog: React.FC = ({ [ title, code, - url, + urls, date, director, studio, @@ -581,7 +590,7 @@ export const SceneScrapeDialog: React.FC = ({ return { title: title.getNewValue(), code: code.getNewValue(), - url: url.getNewValue(), + urls: urls.getNewValue(), date: date.getNewValue(), director: director.getNewValue(), studio: newStudioValue @@ -627,10 +636,10 @@ export const SceneScrapeDialog: React.FC = ({ result={code} onChange={(value) => setCode(value)} /> - setURL(value)} + setURLs(value)} /> = ({ const [code, setCode] = useState>( new ScrapeResult(dest.code) ); - const [url, setURL] = useState>( - new ScrapeResult(dest.url) + const [url, setURL] = useState>( + new ScrapeResult(dest.urls) ); const [date, setDate] = useState>( new ScrapeResult(dest.date) @@ -164,7 +165,7 @@ const SceneMergeDetails: React.FC = ({ new ScrapeResult(dest.code, sources.find((s) => s.code)?.code, !dest.code) ); setURL( - new ScrapeResult(dest.url, sources.find((s) => s.url)?.url, !dest.url) + new ScrapeResult(dest.urls, sources.find((s) => s.urls)?.urls, !dest.urls) ); setDate( new ScrapeResult(dest.date, sources.find((s) => s.date)?.date, !dest.date) @@ -361,8 +362,8 @@ const SceneMergeDetails: React.FC = ({ result={code} onChange={(value) => setCode(value)} /> - setURL(value)} /> @@ -546,7 +547,7 @@ const SceneMergeDetails: React.FC = ({ id: dest.id, title: title.getNewValue(), code: code.getNewValue(), - url: url.getNewValue(), + urls: url.getNewValue(), date: date.getNewValue(), rating100: rating.getNewValue(), o_counter: oCounter.getNewValue(), diff --git a/ui/v2.5/src/components/Shared/ScrapeDialog.tsx b/ui/v2.5/src/components/Shared/ScrapeDialog.tsx index 0a5b1b9b2..0c4753f74 100644 --- a/ui/v2.5/src/components/Shared/ScrapeDialog.tsx +++ b/ui/v2.5/src/components/Shared/ScrapeDialog.tsx @@ -22,6 +22,7 @@ import { } from "@fortawesome/free-solid-svg-icons"; import { getCountryByISO } from "src/utils/country"; import { CountrySelect } from "./CountrySelect"; +import { StringListInput } from "./StringListInput"; export class ScrapeResult { public newValue?: T; @@ -102,6 +103,7 @@ interface IScrapedFieldProps { interface IScrapedRowProps extends IScrapedFieldProps { + className?: string; title: string; renderOriginalField: (result: ScrapeResult) => JSX.Element | undefined; renderNewField: (result: ScrapeResult) => JSX.Element | undefined; @@ -175,7 +177,7 @@ export const ScrapeDialogRow = ( } return ( - + {props.title} @@ -276,6 +278,71 @@ export const ScrapedInputGroupRow: React.FC = ( ); }; +interface IScrapedStringListProps { + isNew?: boolean; + placeholder?: string; + locked?: boolean; + result: ScrapeResult; + onChange?: (value: string[]) => void; +} + +const ScrapedStringList: React.FC = (props) => { + const value = props.isNew + ? props.result.newValue + : props.result.originalValue; + + return ( + { + if (props.isNew && props.onChange) { + props.onChange(v); + } + }} + placeholder={props.placeholder} + readOnly={!props.isNew || props.locked} + /> + ); +}; + +interface IScrapedStringListRowProps { + title: string; + placeholder?: string; + result: ScrapeResult; + locked?: boolean; + onChange: (value: ScrapeResult) => void; +} + +export const ScrapedStringListRow: React.FC = ( + props +) => { + return ( + ( + + )} + renderNewField={() => ( + + props.onChange(props.result.cloneWithValue(value)) + } + /> + )} + onChange={props.onChange} + /> + ); +}; + const ScrapedTextArea: React.FC = (props) => { return ( void; + placeholder?: string; + className?: string; + readOnly?: boolean; +} + +interface IListInputAppendProps { + value: string; +} + +export interface IStringListInputProps { value: string[]; setValue: (value: string[]) => void; + inputComponent?: ComponentType; + appendComponent?: ComponentType; placeholder?: string; className?: string; errors?: string; errorIdx?: number[]; + readOnly?: boolean; } +export const StringInput: React.FC = ({ + className, + placeholder, + value, + setValue, + readOnly = false, +}) => { + return ( + ) => + setValue(e.currentTarget.value) + } + placeholder={placeholder} + readOnly={readOnly} + /> + ); +}; + export const StringListInput: React.FC = (props) => { + const Input = props.inputComponent ?? StringInput; + const AppendComponent = props.appendComponent; const values = props.value.concat(""); function valueChanged(idx: number, value: string) { @@ -37,24 +74,24 @@ export const StringListInput: React.FC = (props) => { {values.map((v, i) => ( - ) => - valueChanged(i, e.currentTarget.value) - } + setValue={(value) => valueChanged(i, value)} placeholder={props.placeholder} + className={props.errorIdx?.includes(i) ? "is-invalid" : ""} + readOnly={props.readOnly} /> - + {AppendComponent && } + {!props.readOnly && ( + + )} ))} diff --git a/ui/v2.5/src/components/Shared/URLField.tsx b/ui/v2.5/src/components/Shared/URLField.tsx index 413f24fd9..9cea50e7c 100644 --- a/ui/v2.5/src/components/Shared/URLField.tsx +++ b/ui/v2.5/src/components/Shared/URLField.tsx @@ -4,6 +4,11 @@ import { Button, InputGroup, Form } from "react-bootstrap"; import { Icon } from "./Icon"; import { FormikHandlers } from "formik"; import { faFileDownload } from "@fortawesome/free-solid-svg-icons"; +import { + IStringListInputProps, + StringInput, + StringListInput, +} from "./StringListInput"; interface IProps { value: string; @@ -43,3 +48,33 @@ export const URLField: React.FC = (props: IProps) => { ); }; + +interface IURLListProps extends IStringListInputProps { + onScrapeClick(url: string): void; + urlScrapable(url: string): boolean; +} + +export const URLListInput: React.FC = ( + listProps: IURLListProps +) => { + const intl = useIntl(); + const { onScrapeClick, urlScrapable } = listProps; + return ( + ( + + )} + /> + ); +}; diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 4d166878f..fb7c51c2b 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -441,3 +441,7 @@ div.react-datepicker { right: 0; z-index: 4; } + +.string-list-row .input-group { + flex-wrap: nowrap; +} diff --git a/ui/v2.5/src/components/Tagger/context.tsx b/ui/v2.5/src/components/Tagger/context.tsx index 095c77a39..a497f0201 100644 --- a/ui/v2.5/src/components/Tagger/context.tsx +++ b/ui/v2.5/src/components/Tagger/context.tsx @@ -408,7 +408,7 @@ export const TaggerContext: React.FC = ({ children }) => { details: scene.details, remote_site_id: scene.remote_site_id, title: scene.title, - url: scene.url, + urls: scene.urls, }; const result = await queryScrapeSceneQueryFragment( diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index 155f4950f..db117c3e9 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -360,7 +360,7 @@ const StashSearchResult: React.FC = ({ ), studio_id: studioID, cover_image: resolveField("cover_image", undefined, imgData), - url: resolveField("url", stashScene.url, scene.url), + urls: resolveField("url", stashScene.urls, scene.urls), tag_ids: tagIDs, stash_ids: stashScene.stash_ids ?? [], code: resolveField("code", stashScene.code, scene.code), @@ -462,9 +462,11 @@ const StashSearchResult: React.FC = ({ ); } - const sceneTitleEl = scene.url ? ( + const url = scene.urls?.length ? scene.urls[0] : null; + + const sceneTitleEl = url ? ( = ({ }; const maybeRenderURL = () => { - if (scene.url) { + if (scene.urls) { return ( ); diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index 367b55e5c..98e30854b 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1270,10 +1270,12 @@ "type": "Type", "updated_at": "Updated At", "url": "URL", + "urls": "URLs", "validation": { "aliases_must_be_unique": "aliases must be unique", "date_invalid_form": "${path} must be in YYYY-MM-DD form", - "required": "${path} is a required field" + "required": "${path} is a required field", + "urls_must_be_unique": "URLs must be unique" }, "videos": "Videos", "video_codec": "Video Codec", diff --git a/ui/v2.5/src/utils/field.tsx b/ui/v2.5/src/utils/field.tsx index c947b8355..26ef6d86c 100644 --- a/ui/v2.5/src/utils/field.tsx +++ b/ui/v2.5/src/utils/field.tsx @@ -90,3 +90,50 @@ export const URLField: React.FC = ({ ); }; + +interface IURLsField { + id?: string; + name?: string; + abbr?: string | null; + urls?: string[] | null; + truncate?: boolean | null; + target?: string; + // use for internal links + trusted?: boolean; +} + +export const URLsField: React.FC = ({ + id, + name, + urls, + abbr, + truncate, + target, + trusted, +}) => { + const values = urls ?? []; + if (!values.length) { + return null; + } + + const message = ( + <>{id ? : name}: + ); + + const rel = !trusted ? "noopener noreferrer" : undefined; + + return ( + <> +
{abbr ? {message} : message}
+
+
+ {values.map((url, i) => ( + + {truncate ? : url} + + ))} +
+
+ + ); +};