From 8c4b607454e0806a051c3cd6531e2a57c646b811 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 6 Nov 2025 16:01:24 +1100 Subject: [PATCH] Add bulk update markers interface (#6210) --- graphql/schema/schema.graphql | 1 + graphql/schema/types/scene-marker.graphql | 7 ++ internal/api/resolver_mutation_scene.go | 117 ++++++++++++++++++++++ pkg/models/model_scene_marker.go | 1 + pkg/sqlite/scene_marker.go | 16 ++- pkg/sqlite/tables.go | 12 +++ 6 files changed, 151 insertions(+), 3 deletions(-) diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index c50fd1864..2a9d067ae 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -328,6 +328,7 @@ type Mutation { sceneMarkerCreate(input: SceneMarkerCreateInput!): SceneMarker sceneMarkerUpdate(input: SceneMarkerUpdateInput!): SceneMarker + bulkSceneMarkerUpdate(input: BulkSceneMarkerUpdateInput!): [SceneMarker!] sceneMarkerDestroy(id: ID!): Boolean! sceneMarkersDestroy(ids: [ID!]!): Boolean! diff --git a/graphql/schema/types/scene-marker.graphql b/graphql/schema/types/scene-marker.graphql index 6d1441213..9312c5aa3 100644 --- a/graphql/schema/types/scene-marker.graphql +++ b/graphql/schema/types/scene-marker.graphql @@ -42,6 +42,13 @@ input SceneMarkerUpdateInput { tag_ids: [ID!] } +input BulkSceneMarkerUpdateInput { + ids: [ID!] + title: String + primary_tag_id: ID + tag_ids: BulkUpdateIds +} + type FindSceneMarkersResultType { count: Int! scene_markers: [SceneMarker!]! diff --git a/internal/api/resolver_mutation_scene.go b/internal/api/resolver_mutation_scene.go index b740955d0..b81ac0974 100644 --- a/internal/api/resolver_mutation_scene.go +++ b/internal/api/resolver_mutation_scene.go @@ -820,6 +820,123 @@ func (r *mutationResolver) SceneMarkerUpdate(ctx context.Context, input SceneMar return r.getSceneMarker(ctx, markerID) } +func (r *mutationResolver) BulkSceneMarkerUpdate(ctx context.Context, input BulkSceneMarkerUpdateInput) ([]*models.SceneMarker, error) { + ids, err := stringslice.StringSliceToIntSlice(input.Ids) + if err != nil { + return nil, fmt.Errorf("converting ids: %w", err) + } + + translator := changesetTranslator{ + inputMap: getUpdateInputMap(ctx), + } + + // Populate performer from the input + partial := models.NewSceneMarkerPartial() + + partial.Title = translator.optionalString(input.Title, "title") + + partial.PrimaryTagID, err = translator.optionalIntFromString(input.PrimaryTagID, "primary_tag_id") + if err != nil { + return nil, fmt.Errorf("converting primary tag id: %w", err) + } + + partial.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") + if err != nil { + return nil, fmt.Errorf("converting tag ids: %w", err) + } + + ret := []*models.SceneMarker{} + + // Start the transaction and save the performers + if err := r.withTxn(ctx, func(ctx context.Context) error { + qb := r.repository.SceneMarker + + for _, id := range ids { + l := partial + + if err := adjustMarkerPartialForTagExclusion(ctx, r.repository.SceneMarker, id, &l); err != nil { + return err + } + + updated, err := qb.UpdatePartial(ctx, id, l) + if err != nil { + return err + } + + ret = append(ret, updated) + } + + return nil + }); err != nil { + return nil, err + } + + // execute post hooks outside of txn + var newRet []*models.SceneMarker + for _, m := range ret { + r.hookExecutor.ExecutePostHooks(ctx, m.ID, hook.SceneMarkerUpdatePost, input, translator.getFields()) + + m, err = r.getSceneMarker(ctx, m.ID) + if err != nil { + return nil, err + } + + newRet = append(newRet, m) + } + + return newRet, nil +} + +// adjustMarkerPartialForTagExclusion adjusts the SceneMarkerPartial to exclude the primary tag from tag updates. +func adjustMarkerPartialForTagExclusion(ctx context.Context, r models.SceneMarkerReader, id int, partial *models.SceneMarkerPartial) error { + if partial.TagIDs == nil && !partial.PrimaryTagID.Set { + return nil + } + + // exclude primary tag from tag updates + var primaryTagID int + if partial.PrimaryTagID.Set { + primaryTagID = partial.PrimaryTagID.Value + } else { + existing, err := r.Find(ctx, id) + if err != nil { + return fmt.Errorf("finding existing primary tag id: %w", err) + } + + primaryTagID = existing.PrimaryTagID + } + + existingTagIDs, err := r.GetTagIDs(ctx, id) + if err != nil { + return fmt.Errorf("getting existing tag ids: %w", err) + } + + tagIDAttr := partial.TagIDs + + if tagIDAttr == nil { + tagIDAttr = &models.UpdateIDs{ + IDs: existingTagIDs, + Mode: models.RelationshipUpdateModeSet, + } + } + + newTagIDs := tagIDAttr.Apply(existingTagIDs) + // Remove primary tag from newTagIDs if present + newTagIDs = sliceutil.Exclude(newTagIDs, []int{primaryTagID}) + + if len(existingTagIDs) != len(newTagIDs) { + partial.TagIDs = &models.UpdateIDs{ + IDs: newTagIDs, + Mode: models.RelationshipUpdateModeSet, + } + } else { + // no change to tags required + partial.TagIDs = nil + } + + return nil +} + func (r *mutationResolver) SceneMarkerDestroy(ctx context.Context, id string) (bool, error) { return r.SceneMarkersDestroy(ctx, []string{id}) } diff --git a/pkg/models/model_scene_marker.go b/pkg/models/model_scene_marker.go index 778603315..8d723b391 100644 --- a/pkg/models/model_scene_marker.go +++ b/pkg/models/model_scene_marker.go @@ -30,6 +30,7 @@ type SceneMarkerPartial struct { Seconds OptionalFloat64 EndSeconds OptionalFloat64 PrimaryTagID OptionalInt + TagIDs *UpdateIDs SceneID OptionalInt CreatedAt OptionalTime UpdatedAt OptionalTime diff --git a/pkg/sqlite/scene_marker.go b/pkg/sqlite/scene_marker.go index ed98d0ef7..59a4137e1 100644 --- a/pkg/sqlite/scene_marker.go +++ b/pkg/sqlite/scene_marker.go @@ -15,7 +15,11 @@ import ( "github.com/stashapp/stash/pkg/models" ) -const sceneMarkerTable = "scene_markers" +const ( + sceneMarkerTable = "scene_markers" + sceneMarkersTagsTable = "scene_markers_tags" + sceneMarkerIDColumn = "scene_marker_id" +) const countSceneMarkersForTagQuery = ` SELECT scene_markers.id FROM scene_markers @@ -101,8 +105,8 @@ var ( }, tags: joinRepository{ repository: repository{ - tableName: "scene_markers_tags", - idColumn: "scene_marker_id", + tableName: sceneMarkersTagsTable, + idColumn: sceneMarkerIDColumn, }, fkColumn: tagIDColumn, }, @@ -157,6 +161,12 @@ func (qb *SceneMarkerStore) UpdatePartial(ctx context.Context, id int, partial m } } + if partial.TagIDs != nil { + if err := sceneMarkersTagsTableMgr.modifyJoins(ctx, id, partial.TagIDs.IDs, partial.TagIDs.Mode); err != nil { + return nil, fmt.Errorf("modifying scene marker tags: %w", err) + } + } + return qb.find(ctx, id) } diff --git a/pkg/sqlite/tables.go b/pkg/sqlite/tables.go index 35845d8f5..0188cfebc 100644 --- a/pkg/sqlite/tables.go +++ b/pkg/sqlite/tables.go @@ -28,6 +28,8 @@ var ( scenesGroupsJoinTable = goqu.T(groupsScenesTable) scenesURLsJoinTable = goqu.T(scenesURLsTable) + sceneMarkersTagsJoinTable = goqu.T(sceneMarkersTagsTable) + performersAliasesJoinTable = goqu.T(performersAliasesTable) performersURLsJoinTable = goqu.T(performerURLsTable) performersTagsJoinTable = goqu.T(performersTagsTable) @@ -160,6 +162,16 @@ var ( idColumn: goqu.T(sceneMarkerTable).Col(idColumn), } + sceneMarkersTagsTableMgr = &joinTable{ + table: table{ + table: sceneMarkersTagsJoinTable, + idColumn: sceneMarkersTagsJoinTable.Col(sceneMarkerIDColumn), + }, + fkColumn: sceneMarkersTagsJoinTable.Col(tagIDColumn), + foreignTable: tagTableMgr, + orderBy: tagTableSort, + } + scenesFilesTableMgr = &relatedFilesTable{ table: table{ table: scenesFilesJoinTable,