From c99825a453c240ed0d9c45e420f8db78e3604b91 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Wed, 12 Nov 2025 19:24:09 -0800 Subject: [PATCH] Feature: Tag StashID support (#6255) --- graphql/schema/types/scraper.graphql | 2 + graphql/schema/types/tag.graphql | 3 + internal/api/resolver_model_tag.go | 10 +++ internal/api/resolver_mutation_stash_box.go | 8 +++ internal/api/resolver_mutation_tag.go | 16 +++++ internal/identify/scene.go | 7 +- pkg/match/scraped.go | 22 ++++++- pkg/models/jsonschema/tag.go | 22 ++++--- pkg/models/mocks/TagReaderWriter.go | 46 +++++++++++++ pkg/models/model_scraped_item.go | 23 ++++++- pkg/models/model_tag.go | 14 +++- pkg/models/repository_tag.go | 2 + pkg/scraper/tag.go | 3 +- pkg/sqlite/anonymise.go | 1 + pkg/sqlite/database.go | 2 +- pkg/sqlite/migrations/74_tag_stash_ids.up.sql | 7 ++ pkg/sqlite/stash_id_test.go | 5 +- pkg/sqlite/tables.go | 8 +++ pkg/sqlite/tag.go | 66 ++++++++++++++++++- pkg/sqlite/tag_test.go | 60 +++++++++++++++++ pkg/stashbox/scene.go | 20 ++++-- pkg/tag/export.go | 10 +++ pkg/tag/export_test.go | 7 ++ pkg/tag/import.go | 1 + ui/v2.5/graphql/data/scrapers.graphql | 1 + ui/v2.5/graphql/data/tag.graphql | 5 ++ ui/v2.5/src/components/Shared/StashID.tsx | 2 +- .../Tagger/scenes/StashSearchResult.tsx | 12 ++++ .../Tags/TagDetails/TagDetailsPanel.tsx | 22 +++++++ .../Tags/TagDetails/TagEditPanel.tsx | 14 ++-- 30 files changed, 387 insertions(+), 34 deletions(-) create mode 100644 pkg/sqlite/migrations/74_tag_stash_ids.up.sql diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index 84b8b5c85..2c13872f3 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -71,6 +71,8 @@ type ScrapedTag { "Set if tag matched" stored_id: ID name: String! + "Remote site ID, if applicable" + remote_site_id: String } type ScrapedScene { diff --git a/graphql/schema/types/tag.graphql b/graphql/schema/types/tag.graphql index 504f23e3d..8424ab92a 100644 --- a/graphql/schema/types/tag.graphql +++ b/graphql/schema/types/tag.graphql @@ -9,6 +9,7 @@ type Tag { created_at: Time! updated_at: Time! favorite: Boolean! + stash_ids: [StashID!]! image_path: String # Resolver scene_count(depth: Int): Int! # Resolver scene_marker_count(depth: Int): Int! # Resolver @@ -35,6 +36,7 @@ input TagCreateInput { favorite: Boolean "This should be a URL or a base64 encoded data URL" image: String + stash_ids: [StashIDInput!] parent_ids: [ID!] child_ids: [ID!] @@ -51,6 +53,7 @@ input TagUpdateInput { favorite: Boolean "This should be a URL or a base64 encoded data URL" image: String + stash_ids: [StashIDInput!] parent_ids: [ID!] child_ids: [ID!] diff --git a/internal/api/resolver_model_tag.go b/internal/api/resolver_model_tag.go index 14237d2fe..deae41f21 100644 --- a/internal/api/resolver_model_tag.go +++ b/internal/api/resolver_model_tag.go @@ -54,6 +54,16 @@ func (r *tagResolver) Aliases(ctx context.Context, obj *models.Tag) (ret []strin return obj.Aliases.List(), nil } +func (r *tagResolver) StashIds(ctx context.Context, obj *models.Tag) ([]*models.StashID, error) { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + return obj.LoadStashIDs(ctx, r.repository.Tag) + }); err != nil { + return nil, err + } + + return stashIDsSliceToPtrSlice(obj.StashIDs.List()), nil +} + func (r *tagResolver) SceneCount(ctx context.Context, obj *models.Tag, depth *int) (ret int, err error) { if err := r.withReadTxn(ctx, func(ctx context.Context) error { ret, err = scene.CountByTagID(ctx, r.repository.Scene, obj.ID, depth) diff --git a/internal/api/resolver_mutation_stash_box.go b/internal/api/resolver_mutation_stash_box.go index bbfe8b854..4026667eb 100644 --- a/internal/api/resolver_mutation_stash_box.go +++ b/internal/api/resolver_mutation_stash_box.go @@ -153,6 +153,14 @@ func (r *mutationResolver) makeSceneDraft(ctx context.Context, s *models.Scene, return nil, err } + // Load StashIDs for tags + tqb := r.repository.Tag + for _, t := range draft.Tags { + if err := t.LoadStashIDs(ctx, tqb); err != nil { + return nil, err + } + } + draft.Cover = cover return draft, nil diff --git a/internal/api/resolver_mutation_tag.go b/internal/api/resolver_mutation_tag.go index 1e8b6066a..05d756acf 100644 --- a/internal/api/resolver_mutation_tag.go +++ b/internal/api/resolver_mutation_tag.go @@ -39,6 +39,14 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput) newTag.Description = translator.string(input.Description) newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) + var stashIDInputs models.StashIDInputs + for _, sid := range input.StashIds { + if sid != nil { + stashIDInputs = append(stashIDInputs, *sid) + } + } + newTag.StashIDs = models.NewRelatedStashIDs(stashIDInputs.ToStashIDs()) + var err error newTag.ParentIDs, err = translator.relatedIds(input.ParentIds) @@ -110,6 +118,14 @@ func (r *mutationResolver) TagUpdate(ctx context.Context, input TagUpdateInput) updatedTag.Aliases = translator.updateStrings(input.Aliases, "aliases") + var updateStashIDInputs models.StashIDInputs + for _, sid := range input.StashIds { + if sid != nil { + updateStashIDInputs = append(updateStashIDInputs, *sid) + } + } + updatedTag.StashIDs = translator.updateStashIDs(updateStashIDInputs, "stash_ids") + updatedTag.ParentIDs, err = translator.updateIds(input.ParentIds, "parent_ids") if err != nil { return nil, fmt.Errorf("converting parent tag ids: %w", err) diff --git a/internal/identify/scene.go b/internal/identify/scene.go index 847a140c5..789674693 100644 --- a/internal/identify/scene.go +++ b/internal/identify/scene.go @@ -153,6 +153,8 @@ func (g sceneRelationships) tags(ctx context.Context) ([]int, error) { tagIDs = originalTagIDs } + endpoint := g.result.source.RemoteSite + for _, t := range scraped { if t.StoredID != nil { // existing tag, just add it @@ -163,10 +165,9 @@ func (g sceneRelationships) tags(ctx context.Context) ([]int, error) { tagIDs = sliceutil.AppendUnique(tagIDs, int(tagID)) } else if createMissing { - newTag := models.NewTag() - newTag.Name = t.Name + newTag := t.ToTag(endpoint, nil) - err := g.tagCreator.Create(ctx, &newTag) + err := g.tagCreator.Create(ctx, newTag) if err != nil { return nil, fmt.Errorf("error creating tag: %w", err) } diff --git a/pkg/match/scraped.go b/pkg/match/scraped.go index b66f39a35..d3039f4c6 100644 --- a/pkg/match/scraped.go +++ b/pkg/match/scraped.go @@ -45,7 +45,7 @@ func (r SceneRelationships) MatchRelationships(ctx context.Context, s *models.Sc } for _, t := range s.Tags { - err := ScrapedTag(ctx, r.TagFinder, t) + err := ScrapedTag(ctx, r.TagFinder, t, endpoint) if err != nil { return err } @@ -190,11 +190,29 @@ func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, na // ScrapedTag matches the provided tag with the tags // in the database and sets the ID field if one is found. -func ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag) error { +func ScrapedTag(ctx context.Context, qb models.TagQueryer, s *models.ScrapedTag, stashBoxEndpoint string) error { if s.StoredID != nil { return nil } + // Check if a tag with the StashID already exists + if stashBoxEndpoint != "" && s.RemoteSiteID != nil { + if finder, ok := qb.(models.TagFinder); ok { + tags, err := finder.FindByStashID(ctx, models.StashID{ + StashID: *s.RemoteSiteID, + Endpoint: stashBoxEndpoint, + }) + if err != nil { + return err + } + if len(tags) > 0 { + id := strconv.Itoa(tags[0].ID) + s.StoredID = &id + return nil + } + } + } + t, err := tag.ByName(ctx, qb, s.Name) if err != nil { diff --git a/pkg/models/jsonschema/tag.go b/pkg/models/jsonschema/tag.go index ed2bc1c9c..faab1bfb2 100644 --- a/pkg/models/jsonschema/tag.go +++ b/pkg/models/jsonschema/tag.go @@ -6,20 +6,22 @@ import ( jsoniter "github.com/json-iterator/go" "github.com/stashapp/stash/pkg/fsutil" + "github.com/stashapp/stash/pkg/models" "github.com/stashapp/stash/pkg/models/json" ) type Tag struct { - Name string `json:"name,omitempty"` - SortName string `json:"sort_name,omitempty"` - Description string `json:"description,omitempty"` - Favorite bool `json:"favorite,omitempty"` - Aliases []string `json:"aliases,omitempty"` - Image string `json:"image,omitempty"` - Parents []string `json:"parents,omitempty"` - IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` - CreatedAt json.JSONTime `json:"created_at,omitempty"` - UpdatedAt json.JSONTime `json:"updated_at,omitempty"` + Name string `json:"name,omitempty"` + SortName string `json:"sort_name,omitempty"` + Description string `json:"description,omitempty"` + Favorite bool `json:"favorite,omitempty"` + Aliases []string `json:"aliases,omitempty"` + Image string `json:"image,omitempty"` + Parents []string `json:"parents,omitempty"` + IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` + StashIDs []models.StashID `json:"stash_ids,omitempty"` + CreatedAt json.JSONTime `json:"created_at,omitempty"` + UpdatedAt json.JSONTime `json:"updated_at,omitempty"` } func (s Tag) Filename() string { diff --git a/pkg/models/mocks/TagReaderWriter.go b/pkg/models/mocks/TagReaderWriter.go index a285b97bf..ac6b10584 100644 --- a/pkg/models/mocks/TagReaderWriter.go +++ b/pkg/models/mocks/TagReaderWriter.go @@ -427,6 +427,29 @@ func (_m *TagReaderWriter) FindBySceneMarkerID(ctx context.Context, sceneMarkerI return r0, r1 } +// FindByStashID provides a mock function with given fields: ctx, stashID +func (_m *TagReaderWriter) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Tag, error) { + ret := _m.Called(ctx, stashID) + + var r0 []*models.Tag + if rf, ok := ret.Get(0).(func(context.Context, models.StashID) []*models.Tag); ok { + r0 = rf(ctx, stashID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]*models.Tag) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, models.StashID) error); ok { + r1 = rf(ctx, stashID) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + // FindByStudioID provides a mock function with given fields: ctx, studioID func (_m *TagReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) { ret := _m.Called(ctx, studioID) @@ -565,6 +588,29 @@ func (_m *TagReaderWriter) GetParentIDs(ctx context.Context, relatedID int) ([]i return r0, r1 } +// GetStashIDs provides a mock function with given fields: ctx, relatedID +func (_m *TagReaderWriter) GetStashIDs(ctx context.Context, relatedID int) ([]models.StashID, error) { + ret := _m.Called(ctx, relatedID) + + var r0 []models.StashID + if rf, ok := ret.Get(0).(func(context.Context, int) []models.StashID); ok { + r0 = rf(ctx, relatedID) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]models.StashID) + } + } + + 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 +} + // HasImage provides a mock function with given fields: ctx, tagID func (_m *TagReaderWriter) HasImage(ctx context.Context, tagID int) (bool, error) { ret := _m.Called(ctx, tagID) diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index dc400ce4e..131f08be1 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -447,12 +447,31 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, type ScrapedTag struct { // Set if tag matched - StoredID *string `json:"stored_id"` - Name string `json:"name"` + StoredID *string `json:"stored_id"` + Name string `json:"name"` + RemoteSiteID *string `json:"remote_site_id"` } func (ScrapedTag) IsScrapedContent() {} +func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag { + currentTime := time.Now() + ret := NewTag() + ret.Name = t.Name + + if t.RemoteSiteID != nil && endpoint != "" { + ret.StashIDs = NewRelatedStashIDs([]StashID{ + { + Endpoint: endpoint, + StashID: *t.RemoteSiteID, + UpdatedAt: currentTime, + }, + }) + } + + return &ret +} + func ScrapedTagSortFunction(a, b *ScrapedTag) int { return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) } diff --git a/pkg/models/model_tag.go b/pkg/models/model_tag.go index 0d845750f..4cd038f7e 100644 --- a/pkg/models/model_tag.go +++ b/pkg/models/model_tag.go @@ -15,9 +15,10 @@ type Tag struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` - Aliases RelatedStrings `json:"aliases"` - ParentIDs RelatedIDs `json:"parent_ids"` - ChildIDs RelatedIDs `json:"tag_ids"` + Aliases RelatedStrings `json:"aliases"` + ParentIDs RelatedIDs `json:"parent_ids"` + ChildIDs RelatedIDs `json:"tag_ids"` + StashIDs RelatedStashIDs `json:"stash_ids"` } func NewTag() Tag { @@ -46,6 +47,12 @@ func (s *Tag) LoadChildIDs(ctx context.Context, l TagRelationLoader) error { }) } +func (s *Tag) LoadStashIDs(ctx context.Context, l StashIDLoader) error { + return s.StashIDs.load(func() ([]StashID, error) { + return l.GetStashIDs(ctx, s.ID) + }) +} + type TagPartial struct { Name OptionalString SortName OptionalString @@ -58,6 +65,7 @@ type TagPartial struct { Aliases *UpdateStrings ParentIDs *UpdateIDs ChildIDs *UpdateIDs + StashIDs *UpdateStashIDs } func NewTagPartial() TagPartial { diff --git a/pkg/models/repository_tag.go b/pkg/models/repository_tag.go index 2b073cae0..a7f828f0b 100644 --- a/pkg/models/repository_tag.go +++ b/pkg/models/repository_tag.go @@ -25,6 +25,7 @@ type TagFinder interface { FindByStudioID(ctx context.Context, studioID int) ([]*Tag, error) FindByName(ctx context.Context, name string, nocase bool) (*Tag, error) FindByNames(ctx context.Context, names []string, nocase bool) ([]*Tag, error) + FindByStashID(ctx context.Context, stashID StashID) ([]*Tag, error) } // TagQueryer provides methods to query tags. @@ -87,6 +88,7 @@ type TagReader interface { AliasLoader TagRelationLoader + StashIDLoader All(ctx context.Context) ([]*Tag, error) GetImage(ctx context.Context, tagID int) ([]byte, error) diff --git a/pkg/scraper/tag.go b/pkg/scraper/tag.go index c26aa855e..14f02e397 100644 --- a/pkg/scraper/tag.go +++ b/pkg/scraper/tag.go @@ -15,7 +15,8 @@ func postProcessTags(ctx context.Context, tqb models.TagQueryer, scrapedTags []* ret = make([]*models.ScrapedTag, 0, len(scrapedTags)) for _, t := range scrapedTags { - err := match.ScrapedTag(ctx, tqb, t) + // Pass empty string for endpoint since this is used by general scrapers, not just stash-box + err := match.ScrapedTag(ctx, tqb, t, "") if err != nil { return nil, err } diff --git a/pkg/sqlite/anonymise.go b/pkg/sqlite/anonymise.go index ba376d785..764f569c0 100644 --- a/pkg/sqlite/anonymise.go +++ b/pkg/sqlite/anonymise.go @@ -102,6 +102,7 @@ func (db *Anonymiser) deleteStashIDs() error { func() error { return db.truncateTable("scene_stash_ids") }, func() error { return db.truncateTable("studio_stash_ids") }, func() error { return db.truncateTable("performer_stash_ids") }, + func() error { return db.truncateTable("tag_stash_ids") }, }) } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index b846efaf4..29e39270d 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 73 +var appSchemaVersion uint = 74 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/74_tag_stash_ids.up.sql b/pkg/sqlite/migrations/74_tag_stash_ids.up.sql new file mode 100644 index 000000000..c281149c7 --- /dev/null +++ b/pkg/sqlite/migrations/74_tag_stash_ids.up.sql @@ -0,0 +1,7 @@ +CREATE TABLE `tag_stash_ids` ( + `tag_id` integer, + `endpoint` varchar(255), + `stash_id` varchar(36), + `updated_at` datetime not null default '1970-01-01T00:00:00Z', + foreign key(`tag_id`) references `tags`(`id`) on delete CASCADE +); \ No newline at end of file diff --git a/pkg/sqlite/stash_id_test.go b/pkg/sqlite/stash_id_test.go index 10949b475..a273c7960 100644 --- a/pkg/sqlite/stash_id_test.go +++ b/pkg/sqlite/stash_id_test.go @@ -27,8 +27,9 @@ func testStashIDReaderWriter(ctx context.Context, t *testing.T, r stashIDReaderW const stashIDStr = "stashID" const endpoint = "endpoint" stashID := models.StashID{ - StashID: stashIDStr, - Endpoint: endpoint, + StashID: stashIDStr, + Endpoint: endpoint, + UpdatedAt: epochTime, } // update stash ids and ensure was updated diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index b28dd777c..7cddf25cc 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -47,6 +47,7 @@ var ( tagsAliasesJoinTable = goqu.T(tagAliasesTable) tagRelationsJoinTable = goqu.T(tagRelationsTable) + tagsStashIDsJoinTable = goqu.T("tag_stash_ids") ) var ( @@ -375,6 +376,13 @@ var ( } tagsChildTagsTableMgr = *tagsParentTagsTableMgr.invert() + + tagsStashIDsTableMgr = &stashIDTable{ + table: table{ + table: tagsStashIDsJoinTable, + idColumn: tagsStashIDsJoinTable.Col(tagIDColumn), + }, + } ) var ( diff --git a/pkg/sqlite/tag.go b/pkg/sqlite/tag.go index 87ec01f5d..977ac0433 100644 --- a/pkg/sqlite/tag.go +++ b/pkg/sqlite/tag.go @@ -101,7 +101,8 @@ func (r *tagRowRecord) fromPartial(o models.TagPartial) { type tagRepositoryType struct { repository - aliases stringRepository + aliases stringRepository + stashIDs stashIDRepository scenes joinRepository images joinRepository @@ -121,6 +122,12 @@ var ( }, stringColumn: tagAliasColumn, }, + stashIDs: stashIDRepository{ + repository{ + tableName: "tag_stash_ids", + idColumn: tagIDColumn, + }, + }, scenes: joinRepository{ repository: repository{ tableName: scenesTagsTable, @@ -199,6 +206,12 @@ func (qb *TagStore) Create(ctx context.Context, newObject *models.Tag) error { } } + if newObject.StashIDs.Loaded() { + if err := tagsStashIDsTableMgr.insertJoins(ctx, id, newObject.StashIDs.List()); err != nil { + return err + } + } + updated, err := qb.find(ctx, id) if err != nil { return fmt.Errorf("finding after create: %w", err) @@ -242,6 +255,12 @@ func (qb *TagStore) UpdatePartial(ctx context.Context, id int, partial models.Ta } } + if partial.StashIDs != nil { + if err := tagsStashIDsTableMgr.modifyJoins(ctx, id, partial.StashIDs.StashIDs, partial.StashIDs.Mode); err != nil { + return nil, err + } + } + return qb.find(ctx, id) } @@ -271,6 +290,12 @@ func (qb *TagStore) Update(ctx context.Context, updatedObject *models.Tag) error } } + if updatedObject.StashIDs.Loaded() { + if err := tagsStashIDsTableMgr.replaceJoins(ctx, updatedObject.ID, updatedObject.StashIDs.List()); err != nil { + return err + } + } + return nil } @@ -509,6 +534,24 @@ func (qb *TagStore) FindByNames(ctx context.Context, names []string, nocase bool return ret, nil } +func (qb *TagStore) FindByStashID(ctx context.Context, stashID models.StashID) ([]*models.Tag, error) { + sq := dialect.From(tagsStashIDsJoinTable).Select(tagsStashIDsJoinTable.Col(tagIDColumn)).Where( + tagsStashIDsJoinTable.Col("stash_id").Eq(stashID.StashID), + tagsStashIDsJoinTable.Col("endpoint").Eq(stashID.Endpoint), + ) + + idsQuery := qb.selectDataset().Where( + qb.table().Col(idColumn).In(sq), + ) + + ret, err := qb.getMany(ctx, idsQuery) + if err != nil { + return nil, fmt.Errorf("getting tags for stash ID %s: %w", stashID.StashID, err) + } + + return ret, nil +} + func (qb *TagStore) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) { return tagsParentTagsTableMgr.get(ctx, relatedID) } @@ -779,6 +822,14 @@ func (qb *TagStore) UpdateAliases(ctx context.Context, tagID int, aliases []stri return tagRepository.aliases.replace(ctx, tagID, aliases) } +func (qb *TagStore) GetStashIDs(ctx context.Context, tagID int) ([]models.StashID, error) { + return tagsStashIDsTableMgr.get(ctx, tagID) +} + +func (qb *TagStore) UpdateStashIDs(ctx context.Context, tagID int, stashIDs []models.StashID) error { + return tagsStashIDsTableMgr.replaceJoins(ctx, tagID, stashIDs) +} + func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) error { if len(source) == 0 { return nil @@ -840,6 +891,19 @@ AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idCo return err } + // Merge StashIDs - move all source StashIDs to destination (ignoring duplicates) + _, err = dbWrapper.Exec(ctx, `UPDATE OR IGNORE `+"tag_stash_ids"+` +SET tag_id = ? +WHERE tag_id IN `+inBinding, args...) + if err != nil { + return err + } + + // Delete remaining source StashIDs that couldn't be moved (duplicates) + if _, err := dbWrapper.Exec(ctx, `DELETE FROM tag_stash_ids WHERE tag_id IN `+inBinding, srcArgs...); err != nil { + return err + } + for _, id := range source { err = qb.Destroy(ctx, id) if err != nil { diff --git a/pkg/sqlite/tag_test.go b/pkg/sqlite/tag_test.go index 770f39782..7d7d1bb09 100644 --- a/pkg/sqlite/tag_test.go +++ b/pkg/sqlite/tag_test.go @@ -900,6 +900,66 @@ func TestTagUpdateAlias(t *testing.T) { } } +func TestTagStashIDs(t *testing.T) { + if err := withTxn(func(ctx context.Context) error { + qb := db.Tag + + // create tag to test against + const name = "TestTagStashIDs" + tag := models.Tag{ + Name: name, + } + err := qb.Create(ctx, &tag) + if err != nil { + return fmt.Errorf("Error creating tag: %s", err.Error()) + } + + testStashIDReaderWriter(ctx, t, qb, tag.ID) + + return nil + }); err != nil { + t.Error(err.Error()) + } +} + +func TestTagFindByStashID(t *testing.T) { + withTxn(func(ctx context.Context) error { + qb := db.Tag + + // create tag to test against + const name = "TestTagFindByStashID" + const stashID = "stashid" + const endpoint = "endpoint" + tag := models.Tag{ + Name: name, + StashIDs: models.NewRelatedStashIDs([]models.StashID{{StashID: stashID, Endpoint: endpoint}}), + } + err := qb.Create(ctx, &tag) + if err != nil { + return fmt.Errorf("Error creating tag: %s", err.Error()) + } + + // find by stash ID + tags, err := qb.FindByStashID(ctx, models.StashID{StashID: stashID, Endpoint: endpoint}) + if err != nil { + return fmt.Errorf("Error finding by stash ID: %s", err.Error()) + } + + assert.Len(t, tags, 1) + assert.Equal(t, tag.ID, tags[0].ID) + + // find by non-existent stash ID + tags, err = qb.FindByStashID(ctx, models.StashID{StashID: "nonexistent", Endpoint: endpoint}) + if err != nil { + return fmt.Errorf("Error finding by stash ID: %s", err.Error()) + } + + assert.Len(t, tags, 0) + + return nil + }) +} + func TestTagMerge(t *testing.T) { assert := assert.New(t) diff --git a/pkg/stashbox/scene.go b/pkg/stashbox/scene.go index 33d427091..64c4defa2 100644 --- a/pkg/stashbox/scene.go +++ b/pkg/stashbox/scene.go @@ -205,7 +205,8 @@ func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.Scen for _, t := range s.Tags { st := &models.ScrapedTag{ - Name: t.Name, + Name: t.Name, + RemoteSiteID: &t.ID, } ss.Tags = append(ss.Tags, st) } @@ -242,8 +243,9 @@ type SceneDraft struct { Performers []*models.Performer // StashIDs must be loaded Studio *models.Studio - Tags []*models.Tag - Cover []byte + // StashIDs must be loaded + Tags []*models.Tag + Cover []byte } func (c Client) SubmitSceneDraft(ctx context.Context, d SceneDraft) (*string, error) { @@ -347,7 +349,17 @@ func newSceneDraftInput(d SceneDraft, endpoint string) graphql.SceneDraftInput { var tags []*graphql.DraftEntityInput sceneTags := d.Tags for _, tag := range sceneTags { - tags = append(tags, &graphql.DraftEntityInput{Name: tag.Name}) + tagDraft := graphql.DraftEntityInput{Name: tag.Name} + + stashIDs := tag.StashIDs.List() + for _, stashID := range stashIDs { + if stashID.Endpoint == endpoint { + tagDraft.ID = &stashID.StashID + break + } + } + + tags = append(tags, &tagDraft) } draft.Tags = tags diff --git a/pkg/tag/export.go b/pkg/tag/export.go index bd1573341..b07418667 100644 --- a/pkg/tag/export.go +++ b/pkg/tag/export.go @@ -16,6 +16,7 @@ type FinderAliasImageGetter interface { GetAliases(ctx context.Context, studioID int) ([]string, error) GetImage(ctx context.Context, tagID int) ([]byte, error) FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error) + models.StashIDLoader } // ToJSON converts a Tag object into its JSON equivalent. @@ -37,6 +38,15 @@ func ToJSON(ctx context.Context, reader FinderAliasImageGetter, tag *models.Tag) newTagJSON.Aliases = aliases + if err := tag.LoadStashIDs(ctx, reader); err != nil { + return nil, fmt.Errorf("loading tag stash ids: %w", err) + } + + stashIDs := tag.StashIDs.List() + if len(stashIDs) > 0 { + newTagJSON.StashIDs = stashIDs + } + image, err := reader.GetImage(ctx, tag.ID) if err != nil { logger.Errorf("Error getting tag image: %v", err) diff --git a/pkg/tag/export_test.go b/pkg/tag/export_test.go index 6c008c170..84e082f30 100644 --- a/pkg/tag/export_test.go +++ b/pkg/tag/export_test.go @@ -126,6 +126,13 @@ func TestToJSON(t *testing.T) { db.Tag.On("GetAliases", testCtx, withParentsID).Return(nil, nil).Once() db.Tag.On("GetAliases", testCtx, errParentsID).Return(nil, nil).Once() + db.Tag.On("GetStashIDs", testCtx, tagID).Return(nil, nil).Once() + db.Tag.On("GetStashIDs", testCtx, noImageID).Return(nil, nil).Once() + db.Tag.On("GetStashIDs", testCtx, errImageID).Return(nil, nil).Once() + // errAliasID test fails before GetStashIDs is called, so no mock needed + db.Tag.On("GetStashIDs", testCtx, withParentsID).Return(nil, nil).Once() + db.Tag.On("GetStashIDs", testCtx, errParentsID).Return(nil, nil).Once() + db.Tag.On("GetImage", testCtx, tagID).Return(imageBytes, nil).Once() db.Tag.On("GetImage", testCtx, noImageID).Return(nil, nil).Once() db.Tag.On("GetImage", testCtx, errImageID).Return(nil, imageErr).Once() diff --git a/pkg/tag/import.go b/pkg/tag/import.go index 21203afb0..53b741886 100644 --- a/pkg/tag/import.go +++ b/pkg/tag/import.go @@ -42,6 +42,7 @@ func (i *Importer) PreImport(ctx context.Context) error { Description: i.Input.Description, Favorite: i.Input.Favorite, IgnoreAutoTag: i.Input.IgnoreAutoTag, + StashIDs: models.NewRelatedStashIDs(i.Input.StashIDs), CreatedAt: i.Input.CreatedAt.GetTime(), UpdatedAt: i.Input.UpdatedAt.GetTime(), } diff --git a/ui/v2.5/graphql/data/scrapers.graphql b/ui/v2.5/graphql/data/scrapers.graphql index 25c1036d8..4a0f588a4 100644 --- a/ui/v2.5/graphql/data/scrapers.graphql +++ b/ui/v2.5/graphql/data/scrapers.graphql @@ -158,6 +158,7 @@ fragment ScrapedSceneStudioData on ScrapedStudio { fragment ScrapedSceneTagData on ScrapedTag { stored_id name + remote_site_id } fragment ScrapedSceneData on ScrapedScene { diff --git a/ui/v2.5/graphql/data/tag.graphql b/ui/v2.5/graphql/data/tag.graphql index 5eae173ea..2395f48bd 100644 --- a/ui/v2.5/graphql/data/tag.graphql +++ b/ui/v2.5/graphql/data/tag.graphql @@ -6,6 +6,11 @@ fragment TagData on Tag { aliases ignore_auto_tag favorite + stash_ids { + endpoint + stash_id + updated_at + } image_path scene_count scene_count_all: scene_count(depth: -1) diff --git a/ui/v2.5/src/components/Shared/StashID.tsx b/ui/v2.5/src/components/Shared/StashID.tsx index 00bddf58e..8e1fecef2 100644 --- a/ui/v2.5/src/components/Shared/StashID.tsx +++ b/ui/v2.5/src/components/Shared/StashID.tsx @@ -4,7 +4,7 @@ import { ConfigurationContext } from "src/hooks/Config"; import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "./ExternalLink"; -export type LinkType = "performers" | "scenes" | "studios"; +export type LinkType = "performers" | "scenes" | "studios" | "tags"; export const StashIDPill: React.FC<{ stashID: Pick; diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index f76369387..a3429be9a 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -715,6 +715,18 @@ const StashSearchResult: React.FC = ({ async function onCreateTag(t: GQL.ScrapedTag) { const toCreate: GQL.TagCreateInput = { name: t.name }; + + // If the tag has a remote_site_id and we have an endpoint, include the stash_id + const endpoint = currentSource?.sourceInput.stash_box_endpoint; + if (t.remote_site_id && endpoint) { + toCreate.stash_ids = [ + { + endpoint: endpoint, + stash_id: t.remote_site_id, + }, + ]; + } + const newTagID = await createNewTag(t, toCreate); if (newTagID !== undefined) { setTagIDs([...tagIDs, newTagID]); diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx index 9e368aa8b..92c92d072 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagDetailsPanel.tsx @@ -1,6 +1,7 @@ import React from "react"; import { TagLink } from "src/components/Shared/TagLink"; import { DetailItem } from "src/components/Shared/DetailItem"; +import { StashIDPill } from "src/components/Shared/StashID"; import * as GQL from "src/core/generated-graphql"; interface ITagDetails { @@ -51,6 +52,22 @@ export const TagDetailsPanel: React.FC = ({ tag, fullWidth }) => { ); } + function renderStashIDs() { + if (!tag.stash_ids?.length) { + return; + } + + return ( +
    + {tag.stash_ids.map((stashID) => ( +
  • + +
  • + ))} +
+ ); + } + return (
= ({ tag, fullWidth }) => { value={renderChildrenField()} fullWidth={fullWidth} /> +
); }; diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index da79b6c4e..41756953b 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -14,6 +14,7 @@ import { useToast } from "src/hooks/Toast"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup"; +import { getStashIDs } from "src/utils/stashIds"; import { Tag, TagSelect } from "../TagSelect"; interface ITagEditPanel { @@ -52,6 +53,7 @@ export const TagEditPanel: React.FC = ({ parent_ids: yup.array(yup.string().required()).defined(), child_ids: yup.array(yup.string().required()).defined(), ignore_auto_tag: yup.boolean().defined(), + stash_ids: yup.mixed().defined(), image: yup.string().nullable().optional(), }); @@ -63,6 +65,7 @@ export const TagEditPanel: React.FC = ({ parent_ids: (tag?.parents ?? []).map((t) => t.id), child_ids: (tag?.children ?? []).map((t) => t.id), ignore_auto_tag: tag?.ignore_auto_tag ?? false, + stash_ids: getStashIDs(tag?.stash_ids), }; type InputValues = yup.InferType; @@ -140,10 +143,12 @@ export const TagEditPanel: React.FC = ({ ImageUtils.onImageChange(event, onImageLoad); } - const { renderField, renderInputField, renderStringListField } = formikUtils( - intl, - formik - ); + const { + renderField, + renderInputField, + renderStringListField, + renderStashIDsField, + } = formikUtils(intl, formik); function renderParentTagsField() { const title = intl.formatMessage({ id: "parent_tags" }); @@ -210,6 +215,7 @@ export const TagEditPanel: React.FC = ({ {renderInputField("description", "textarea")} {renderParentTagsField()} {renderSubTagsField()} + {renderStashIDsField("stash_ids", "tags")}
{renderInputField("ignore_auto_tag", "checkbox")}