Feature: Tag StashID support (#6255)

This commit is contained in:
Gykes 2025-11-12 19:24:09 -08:00 committed by GitHub
parent a08d2e258a
commit c99825a453
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 387 additions and 34 deletions

View file

@ -71,6 +71,8 @@ type ScrapedTag {
"Set if tag matched" "Set if tag matched"
stored_id: ID stored_id: ID
name: String! name: String!
"Remote site ID, if applicable"
remote_site_id: String
} }
type ScrapedScene { type ScrapedScene {

View file

@ -9,6 +9,7 @@ type Tag {
created_at: Time! created_at: Time!
updated_at: Time! updated_at: Time!
favorite: Boolean! favorite: Boolean!
stash_ids: [StashID!]!
image_path: String # Resolver image_path: String # Resolver
scene_count(depth: Int): Int! # Resolver scene_count(depth: Int): Int! # Resolver
scene_marker_count(depth: Int): Int! # Resolver scene_marker_count(depth: Int): Int! # Resolver
@ -35,6 +36,7 @@ input TagCreateInput {
favorite: Boolean favorite: Boolean
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
image: String image: String
stash_ids: [StashIDInput!]
parent_ids: [ID!] parent_ids: [ID!]
child_ids: [ID!] child_ids: [ID!]
@ -51,6 +53,7 @@ input TagUpdateInput {
favorite: Boolean favorite: Boolean
"This should be a URL or a base64 encoded data URL" "This should be a URL or a base64 encoded data URL"
image: String image: String
stash_ids: [StashIDInput!]
parent_ids: [ID!] parent_ids: [ID!]
child_ids: [ID!] child_ids: [ID!]

View file

@ -54,6 +54,16 @@ func (r *tagResolver) Aliases(ctx context.Context, obj *models.Tag) (ret []strin
return obj.Aliases.List(), nil 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) { 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 { if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = scene.CountByTagID(ctx, r.repository.Scene, obj.ID, depth) ret, err = scene.CountByTagID(ctx, r.repository.Scene, obj.ID, depth)

View file

@ -153,6 +153,14 @@ func (r *mutationResolver) makeSceneDraft(ctx context.Context, s *models.Scene,
return nil, err 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 draft.Cover = cover
return draft, nil return draft, nil

View file

@ -39,6 +39,14 @@ func (r *mutationResolver) TagCreate(ctx context.Context, input TagCreateInput)
newTag.Description = translator.string(input.Description) newTag.Description = translator.string(input.Description)
newTag.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) 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 var err error
newTag.ParentIDs, err = translator.relatedIds(input.ParentIds) 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") 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") updatedTag.ParentIDs, err = translator.updateIds(input.ParentIds, "parent_ids")
if err != nil { if err != nil {
return nil, fmt.Errorf("converting parent tag ids: %w", err) return nil, fmt.Errorf("converting parent tag ids: %w", err)

View file

@ -153,6 +153,8 @@ func (g sceneRelationships) tags(ctx context.Context) ([]int, error) {
tagIDs = originalTagIDs tagIDs = originalTagIDs
} }
endpoint := g.result.source.RemoteSite
for _, t := range scraped { for _, t := range scraped {
if t.StoredID != nil { if t.StoredID != nil {
// existing tag, just add it // existing tag, just add it
@ -163,10 +165,9 @@ func (g sceneRelationships) tags(ctx context.Context) ([]int, error) {
tagIDs = sliceutil.AppendUnique(tagIDs, int(tagID)) tagIDs = sliceutil.AppendUnique(tagIDs, int(tagID))
} else if createMissing { } else if createMissing {
newTag := models.NewTag() newTag := t.ToTag(endpoint, nil)
newTag.Name = t.Name
err := g.tagCreator.Create(ctx, &newTag) err := g.tagCreator.Create(ctx, newTag)
if err != nil { if err != nil {
return nil, fmt.Errorf("error creating tag: %w", err) return nil, fmt.Errorf("error creating tag: %w", err)
} }

View file

@ -45,7 +45,7 @@ func (r SceneRelationships) MatchRelationships(ctx context.Context, s *models.Sc
} }
for _, t := range s.Tags { for _, t := range s.Tags {
err := ScrapedTag(ctx, r.TagFinder, t) err := ScrapedTag(ctx, r.TagFinder, t, endpoint)
if err != nil { if err != nil {
return err return err
} }
@ -190,11 +190,29 @@ func ScrapedGroup(ctx context.Context, qb GroupNamesFinder, storedID *string, na
// ScrapedTag matches the provided tag with the tags // ScrapedTag matches the provided tag with the tags
// in the database and sets the ID field if one is found. // 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 { if s.StoredID != nil {
return 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) t, err := tag.ByName(ctx, qb, s.Name)
if err != nil { if err != nil {

View file

@ -6,20 +6,22 @@ import (
jsoniter "github.com/json-iterator/go" jsoniter "github.com/json-iterator/go"
"github.com/stashapp/stash/pkg/fsutil" "github.com/stashapp/stash/pkg/fsutil"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/models/json" "github.com/stashapp/stash/pkg/models/json"
) )
type Tag struct { type Tag struct {
Name string `json:"name,omitempty"` Name string `json:"name,omitempty"`
SortName string `json:"sort_name,omitempty"` SortName string `json:"sort_name,omitempty"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
Favorite bool `json:"favorite,omitempty"` Favorite bool `json:"favorite,omitempty"`
Aliases []string `json:"aliases,omitempty"` Aliases []string `json:"aliases,omitempty"`
Image string `json:"image,omitempty"` Image string `json:"image,omitempty"`
Parents []string `json:"parents,omitempty"` Parents []string `json:"parents,omitempty"`
IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"`
CreatedAt json.JSONTime `json:"created_at,omitempty"` StashIDs []models.StashID `json:"stash_ids,omitempty"`
UpdatedAt json.JSONTime `json:"updated_at,omitempty"` CreatedAt json.JSONTime `json:"created_at,omitempty"`
UpdatedAt json.JSONTime `json:"updated_at,omitempty"`
} }
func (s Tag) Filename() string { func (s Tag) Filename() string {

View file

@ -427,6 +427,29 @@ func (_m *TagReaderWriter) FindBySceneMarkerID(ctx context.Context, sceneMarkerI
return r0, r1 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 // FindByStudioID provides a mock function with given fields: ctx, studioID
func (_m *TagReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) { func (_m *TagReaderWriter) FindByStudioID(ctx context.Context, studioID int) ([]*models.Tag, error) {
ret := _m.Called(ctx, studioID) ret := _m.Called(ctx, studioID)
@ -565,6 +588,29 @@ func (_m *TagReaderWriter) GetParentIDs(ctx context.Context, relatedID int) ([]i
return r0, r1 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 // HasImage provides a mock function with given fields: ctx, tagID
func (_m *TagReaderWriter) HasImage(ctx context.Context, tagID int) (bool, error) { func (_m *TagReaderWriter) HasImage(ctx context.Context, tagID int) (bool, error) {
ret := _m.Called(ctx, tagID) ret := _m.Called(ctx, tagID)

View file

@ -447,12 +447,31 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool,
type ScrapedTag struct { type ScrapedTag struct {
// Set if tag matched // Set if tag matched
StoredID *string `json:"stored_id"` StoredID *string `json:"stored_id"`
Name string `json:"name"` Name string `json:"name"`
RemoteSiteID *string `json:"remote_site_id"`
} }
func (ScrapedTag) IsScrapedContent() {} 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 { func ScrapedTagSortFunction(a, b *ScrapedTag) int {
return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name)) return strings.Compare(strings.ToLower(a.Name), strings.ToLower(b.Name))
} }

View file

@ -15,9 +15,10 @@ type Tag struct {
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
Aliases RelatedStrings `json:"aliases"` Aliases RelatedStrings `json:"aliases"`
ParentIDs RelatedIDs `json:"parent_ids"` ParentIDs RelatedIDs `json:"parent_ids"`
ChildIDs RelatedIDs `json:"tag_ids"` ChildIDs RelatedIDs `json:"tag_ids"`
StashIDs RelatedStashIDs `json:"stash_ids"`
} }
func NewTag() Tag { 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 { type TagPartial struct {
Name OptionalString Name OptionalString
SortName OptionalString SortName OptionalString
@ -58,6 +65,7 @@ type TagPartial struct {
Aliases *UpdateStrings Aliases *UpdateStrings
ParentIDs *UpdateIDs ParentIDs *UpdateIDs
ChildIDs *UpdateIDs ChildIDs *UpdateIDs
StashIDs *UpdateStashIDs
} }
func NewTagPartial() TagPartial { func NewTagPartial() TagPartial {

View file

@ -25,6 +25,7 @@ type TagFinder interface {
FindByStudioID(ctx context.Context, studioID int) ([]*Tag, error) FindByStudioID(ctx context.Context, studioID int) ([]*Tag, error)
FindByName(ctx context.Context, name string, nocase bool) (*Tag, error) FindByName(ctx context.Context, name string, nocase bool) (*Tag, error)
FindByNames(ctx context.Context, names []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. // TagQueryer provides methods to query tags.
@ -87,6 +88,7 @@ type TagReader interface {
AliasLoader AliasLoader
TagRelationLoader TagRelationLoader
StashIDLoader
All(ctx context.Context) ([]*Tag, error) All(ctx context.Context) ([]*Tag, error)
GetImage(ctx context.Context, tagID int) ([]byte, error) GetImage(ctx context.Context, tagID int) ([]byte, error)

View file

@ -15,7 +15,8 @@ func postProcessTags(ctx context.Context, tqb models.TagQueryer, scrapedTags []*
ret = make([]*models.ScrapedTag, 0, len(scrapedTags)) ret = make([]*models.ScrapedTag, 0, len(scrapedTags))
for _, t := range 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 { if err != nil {
return nil, err return nil, err
} }

View file

@ -102,6 +102,7 @@ func (db *Anonymiser) deleteStashIDs() error {
func() error { return db.truncateTable("scene_stash_ids") }, func() error { return db.truncateTable("scene_stash_ids") },
func() error { return db.truncateTable("studio_stash_ids") }, func() error { return db.truncateTable("studio_stash_ids") },
func() error { return db.truncateTable("performer_stash_ids") }, func() error { return db.truncateTable("performer_stash_ids") },
func() error { return db.truncateTable("tag_stash_ids") },
}) })
} }

View file

@ -34,7 +34,7 @@ const (
cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE"
) )
var appSchemaVersion uint = 73 var appSchemaVersion uint = 74
//go:embed migrations/*.sql //go:embed migrations/*.sql
var migrationsBox embed.FS var migrationsBox embed.FS

View file

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

View file

@ -27,8 +27,9 @@ func testStashIDReaderWriter(ctx context.Context, t *testing.T, r stashIDReaderW
const stashIDStr = "stashID" const stashIDStr = "stashID"
const endpoint = "endpoint" const endpoint = "endpoint"
stashID := models.StashID{ stashID := models.StashID{
StashID: stashIDStr, StashID: stashIDStr,
Endpoint: endpoint, Endpoint: endpoint,
UpdatedAt: epochTime,
} }
// update stash ids and ensure was updated // update stash ids and ensure was updated

View file

@ -47,6 +47,7 @@ var (
tagsAliasesJoinTable = goqu.T(tagAliasesTable) tagsAliasesJoinTable = goqu.T(tagAliasesTable)
tagRelationsJoinTable = goqu.T(tagRelationsTable) tagRelationsJoinTable = goqu.T(tagRelationsTable)
tagsStashIDsJoinTable = goqu.T("tag_stash_ids")
) )
var ( var (
@ -375,6 +376,13 @@ var (
} }
tagsChildTagsTableMgr = *tagsParentTagsTableMgr.invert() tagsChildTagsTableMgr = *tagsParentTagsTableMgr.invert()
tagsStashIDsTableMgr = &stashIDTable{
table: table{
table: tagsStashIDsJoinTable,
idColumn: tagsStashIDsJoinTable.Col(tagIDColumn),
},
}
) )
var ( var (

View file

@ -101,7 +101,8 @@ func (r *tagRowRecord) fromPartial(o models.TagPartial) {
type tagRepositoryType struct { type tagRepositoryType struct {
repository repository
aliases stringRepository aliases stringRepository
stashIDs stashIDRepository
scenes joinRepository scenes joinRepository
images joinRepository images joinRepository
@ -121,6 +122,12 @@ var (
}, },
stringColumn: tagAliasColumn, stringColumn: tagAliasColumn,
}, },
stashIDs: stashIDRepository{
repository{
tableName: "tag_stash_ids",
idColumn: tagIDColumn,
},
},
scenes: joinRepository{ scenes: joinRepository{
repository: repository{ repository: repository{
tableName: scenesTagsTable, 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) updated, err := qb.find(ctx, id)
if err != nil { if err != nil {
return fmt.Errorf("finding after create: %w", err) return fmt.Errorf("finding after create: %w", err)
@ -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) 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 return nil
} }
@ -509,6 +534,24 @@ func (qb *TagStore) FindByNames(ctx context.Context, names []string, nocase bool
return ret, nil 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) { func (qb *TagStore) GetParentIDs(ctx context.Context, relatedID int) ([]int, error) {
return tagsParentTagsTableMgr.get(ctx, relatedID) 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) 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 { func (qb *TagStore) Merge(ctx context.Context, source []int, destination int) error {
if len(source) == 0 { if len(source) == 0 {
return nil return nil
@ -840,6 +891,19 @@ AND NOT EXISTS(SELECT 1 FROM `+table+` o WHERE o.`+idColumn+` = `+table+`.`+idCo
return err 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 { for _, id := range source {
err = qb.Destroy(ctx, id) err = qb.Destroy(ctx, id)
if err != nil { if err != nil {

View file

@ -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) { func TestTagMerge(t *testing.T) {
assert := assert.New(t) assert := assert.New(t)

View file

@ -205,7 +205,8 @@ func (c Client) sceneFragmentToScrapedScene(ctx context.Context, s *graphql.Scen
for _, t := range s.Tags { for _, t := range s.Tags {
st := &models.ScrapedTag{ st := &models.ScrapedTag{
Name: t.Name, Name: t.Name,
RemoteSiteID: &t.ID,
} }
ss.Tags = append(ss.Tags, st) ss.Tags = append(ss.Tags, st)
} }
@ -242,8 +243,9 @@ type SceneDraft struct {
Performers []*models.Performer Performers []*models.Performer
// StashIDs must be loaded // StashIDs must be loaded
Studio *models.Studio Studio *models.Studio
Tags []*models.Tag // StashIDs must be loaded
Cover []byte Tags []*models.Tag
Cover []byte
} }
func (c Client) SubmitSceneDraft(ctx context.Context, d SceneDraft) (*string, error) { 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 var tags []*graphql.DraftEntityInput
sceneTags := d.Tags sceneTags := d.Tags
for _, tag := range sceneTags { 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 draft.Tags = tags

View file

@ -16,6 +16,7 @@ type FinderAliasImageGetter interface {
GetAliases(ctx context.Context, studioID int) ([]string, error) GetAliases(ctx context.Context, studioID int) ([]string, error)
GetImage(ctx context.Context, tagID int) ([]byte, error) GetImage(ctx context.Context, tagID int) ([]byte, error)
FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error) FindByChildTagID(ctx context.Context, childID int) ([]*models.Tag, error)
models.StashIDLoader
} }
// ToJSON converts a Tag object into its JSON equivalent. // 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 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) image, err := reader.GetImage(ctx, tag.ID)
if err != nil { if err != nil {
logger.Errorf("Error getting tag image: %v", err) logger.Errorf("Error getting tag image: %v", err)

View file

@ -126,6 +126,13 @@ func TestToJSON(t *testing.T) {
db.Tag.On("GetAliases", testCtx, withParentsID).Return(nil, nil).Once() db.Tag.On("GetAliases", testCtx, withParentsID).Return(nil, nil).Once()
db.Tag.On("GetAliases", testCtx, errParentsID).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, tagID).Return(imageBytes, nil).Once()
db.Tag.On("GetImage", testCtx, noImageID).Return(nil, nil).Once() db.Tag.On("GetImage", testCtx, noImageID).Return(nil, nil).Once()
db.Tag.On("GetImage", testCtx, errImageID).Return(nil, imageErr).Once() db.Tag.On("GetImage", testCtx, errImageID).Return(nil, imageErr).Once()

View file

@ -42,6 +42,7 @@ func (i *Importer) PreImport(ctx context.Context) error {
Description: i.Input.Description, Description: i.Input.Description,
Favorite: i.Input.Favorite, Favorite: i.Input.Favorite,
IgnoreAutoTag: i.Input.IgnoreAutoTag, IgnoreAutoTag: i.Input.IgnoreAutoTag,
StashIDs: models.NewRelatedStashIDs(i.Input.StashIDs),
CreatedAt: i.Input.CreatedAt.GetTime(), CreatedAt: i.Input.CreatedAt.GetTime(),
UpdatedAt: i.Input.UpdatedAt.GetTime(), UpdatedAt: i.Input.UpdatedAt.GetTime(),
} }

View file

@ -158,6 +158,7 @@ fragment ScrapedSceneStudioData on ScrapedStudio {
fragment ScrapedSceneTagData on ScrapedTag { fragment ScrapedSceneTagData on ScrapedTag {
stored_id stored_id
name name
remote_site_id
} }
fragment ScrapedSceneData on ScrapedScene { fragment ScrapedSceneData on ScrapedScene {

View file

@ -6,6 +6,11 @@ fragment TagData on Tag {
aliases aliases
ignore_auto_tag ignore_auto_tag
favorite favorite
stash_ids {
endpoint
stash_id
updated_at
}
image_path image_path
scene_count scene_count
scene_count_all: scene_count(depth: -1) scene_count_all: scene_count(depth: -1)

View file

@ -4,7 +4,7 @@ import { ConfigurationContext } from "src/hooks/Config";
import { getStashboxBase } from "src/utils/stashbox"; import { getStashboxBase } from "src/utils/stashbox";
import { ExternalLink } from "./ExternalLink"; import { ExternalLink } from "./ExternalLink";
export type LinkType = "performers" | "scenes" | "studios"; export type LinkType = "performers" | "scenes" | "studios" | "tags";
export const StashIDPill: React.FC<{ export const StashIDPill: React.FC<{
stashID: Pick<StashId, "endpoint" | "stash_id">; stashID: Pick<StashId, "endpoint" | "stash_id">;

View file

@ -715,6 +715,18 @@ const StashSearchResult: React.FC<IStashSearchResultProps> = ({
async function onCreateTag(t: GQL.ScrapedTag) { async function onCreateTag(t: GQL.ScrapedTag) {
const toCreate: GQL.TagCreateInput = { name: t.name }; 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); const newTagID = await createNewTag(t, toCreate);
if (newTagID !== undefined) { if (newTagID !== undefined) {
setTagIDs([...tagIDs, newTagID]); setTagIDs([...tagIDs, newTagID]);

View file

@ -1,6 +1,7 @@
import React from "react"; import React from "react";
import { TagLink } from "src/components/Shared/TagLink"; import { TagLink } from "src/components/Shared/TagLink";
import { DetailItem } from "src/components/Shared/DetailItem"; import { DetailItem } from "src/components/Shared/DetailItem";
import { StashIDPill } from "src/components/Shared/StashID";
import * as GQL from "src/core/generated-graphql"; import * as GQL from "src/core/generated-graphql";
interface ITagDetails { interface ITagDetails {
@ -51,6 +52,22 @@ export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag, fullWidth }) => {
); );
} }
function renderStashIDs() {
if (!tag.stash_ids?.length) {
return;
}
return (
<ul className="pl-0">
{tag.stash_ids.map((stashID) => (
<li key={stashID.stash_id} className="row no-gutters">
<StashIDPill stashID={stashID} linkType="tags" />
</li>
))}
</ul>
);
}
return ( return (
<div className="detail-group"> <div className="detail-group">
<DetailItem <DetailItem
@ -68,6 +85,11 @@ export const TagDetailsPanel: React.FC<ITagDetails> = ({ tag, fullWidth }) => {
value={renderChildrenField()} value={renderChildrenField()}
fullWidth={fullWidth} fullWidth={fullWidth}
/> />
<DetailItem
id="stash_ids"
value={renderStashIDs()}
fullWidth={fullWidth}
/>
</div> </div>
); );
}; };

View file

@ -14,6 +14,7 @@ import { useToast } from "src/hooks/Toast";
import { handleUnsavedChanges } from "src/utils/navigation"; import { handleUnsavedChanges } from "src/utils/navigation";
import { formikUtils } from "src/utils/form"; import { formikUtils } from "src/utils/form";
import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup"; import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup";
import { getStashIDs } from "src/utils/stashIds";
import { Tag, TagSelect } from "../TagSelect"; import { Tag, TagSelect } from "../TagSelect";
interface ITagEditPanel { interface ITagEditPanel {
@ -52,6 +53,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
parent_ids: yup.array(yup.string().required()).defined(), parent_ids: yup.array(yup.string().required()).defined(),
child_ids: yup.array(yup.string().required()).defined(), child_ids: yup.array(yup.string().required()).defined(),
ignore_auto_tag: yup.boolean().defined(), ignore_auto_tag: yup.boolean().defined(),
stash_ids: yup.mixed<GQL.StashIdInput[]>().defined(),
image: yup.string().nullable().optional(), image: yup.string().nullable().optional(),
}); });
@ -63,6 +65,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
parent_ids: (tag?.parents ?? []).map((t) => t.id), parent_ids: (tag?.parents ?? []).map((t) => t.id),
child_ids: (tag?.children ?? []).map((t) => t.id), child_ids: (tag?.children ?? []).map((t) => t.id),
ignore_auto_tag: tag?.ignore_auto_tag ?? false, ignore_auto_tag: tag?.ignore_auto_tag ?? false,
stash_ids: getStashIDs(tag?.stash_ids),
}; };
type InputValues = yup.InferType<typeof schema>; type InputValues = yup.InferType<typeof schema>;
@ -140,10 +143,12 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
ImageUtils.onImageChange(event, onImageLoad); ImageUtils.onImageChange(event, onImageLoad);
} }
const { renderField, renderInputField, renderStringListField } = formikUtils( const {
intl, renderField,
formik renderInputField,
); renderStringListField,
renderStashIDsField,
} = formikUtils(intl, formik);
function renderParentTagsField() { function renderParentTagsField() {
const title = intl.formatMessage({ id: "parent_tags" }); const title = intl.formatMessage({ id: "parent_tags" });
@ -210,6 +215,7 @@ export const TagEditPanel: React.FC<ITagEditPanel> = ({
{renderInputField("description", "textarea")} {renderInputField("description", "textarea")}
{renderParentTagsField()} {renderParentTagsField()}
{renderSubTagsField()} {renderSubTagsField()}
{renderStashIDsField("stash_ids", "tags")}
<hr /> <hr />
{renderInputField("ignore_auto_tag", "checkbox")} {renderInputField("ignore_auto_tag", "checkbox")}
</Form> </Form>