From 36624e930de08f2356bc863592e6bddfdd6afcae Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Fri, 8 May 2026 20:41:29 +0300 Subject: [PATCH 1/8] Add child_ids to studio update GraphQL schema input --- graphql/schema/types/studio.graphql | 2 + internal/api/resolver_mutation_studio.go | 66 +++++++++++++++++++ pkg/models/studio.go | 2 + .../Studios/StudioDetails/StudioEditPanel.tsx | 30 +++++++++ 4 files changed, 100 insertions(+) diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index 51a87bf4f..be11a1d84 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -36,6 +36,7 @@ input StudioCreateInput { url: String @deprecated(reason: "Use urls") urls: [String!] parent_id: ID + child_ids: [ID!] "This should be a URL or a base64 encoded data URL" image: String stash_ids: [StashIDInput!] @@ -58,6 +59,7 @@ input StudioUpdateInput { url: String @deprecated(reason: "Use urls") urls: [String!] parent_id: ID + child_ids: [ID!] "This should be a URL or a base64 encoded data URL" image: String stash_ids: [StashIDInput!] diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index c7af918a1..c809e3e0f 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -14,6 +14,50 @@ import ( ) // used to refetch studio after hooks run + +func setChildStudios(ctx context.Context, qb models.StudioReaderWriter, parentStudioID int, childStudioIDs []int) error { + currentChildren, err := qb.FindChildren(ctx, parentStudioID) + if err != nil { + return err + } + + newChildStudioIDs := make(map[int]struct{}, len(childStudioIDs)) + for _, childStudioID := range childStudioIDs { + if _, found := newChildStudioIDs[childStudioID]; found { + continue + } + newChildStudioIDs[childStudioID] = struct{}{} + + childPartial := models.NewStudioPartial() + childPartial.ID = childStudioID + childPartial.ParentID = models.NewOptionalInt(parentStudioID) + + if err := studio.ValidateModify(ctx, childPartial, qb); err != nil { + return err + } + + if _, err := qb.UpdatePartial(ctx, childPartial); err != nil { + return err + } + } + + for _, currentChild := range currentChildren { + if _, keep := newChildStudioIDs[currentChild.ID]; keep { + continue + } + + clearParentPartial := models.NewStudioPartial() + clearParentPartial.ID = currentChild.ID + clearParentPartial.ParentID = models.NewOptionalIntPtr(nil) + + if _, err := qb.UpdatePartial(ctx, clearParentPartial); err != nil { + return err + } + } + + return nil +} + func (r *mutationResolver) getStudio(ctx context.Context, id int) (ret *models.Studio, err error) { if err := r.withTxn(ctx, func(ctx context.Context) error { ret, err = r.repository.Studio.Find(ctx, id) @@ -62,6 +106,11 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio if err != nil { return nil, fmt.Errorf("converting tag ids: %w", err) } + + childStudioIDs, err := stringslice.StringSliceToIntSlice(input.ChildIds) + if err != nil { + return nil, fmt.Errorf("converting child ids: %w", err) + } newStudio.CustomFields = convertMapJSONNumbers(input.CustomFields) // Process the base 64 encoded image string @@ -93,6 +142,12 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio } } + if input.ChildIds != nil { + if err := setChildStudios(ctx, qb, newStudio.ID, childStudioIDs); err != nil { + return err + } + } + return nil }); err != nil { return nil, err @@ -135,6 +190,11 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio return nil, fmt.Errorf("converting tag ids: %w", err) } + childStudioIDs, err := stringslice.StringSliceToIntSlice(input.ChildIds) + if err != nil { + return nil, fmt.Errorf("converting child ids: %w", err) + } + if translator.hasField("urls") { // ensure url not included in the input if err := validateNoLegacyURLs(translator); err != nil { @@ -212,6 +272,12 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio } } + if translator.hasField("child_ids") { + if err := setChildStudios(ctx, qb, studioID, childStudioIDs); err != nil { + return err + } + } + return nil }); err != nil { return nil, err diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 7ad8719ac..06c4b2cfd 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -62,6 +62,7 @@ type StudioCreateInput struct { URL *string `json:"url"` // deprecated Urls []string `json:"urls"` ParentID *string `json:"parent_id"` + ChildIds []string `json:"child_ids"` // This should be a URL or a base64 encoded data URL Image *string `json:"image"` StashIds []StashIDInput `json:"stash_ids"` @@ -82,6 +83,7 @@ type StudioUpdateInput struct { URL *string `json:"url"` // deprecated Urls []string `json:"urls"` ParentID *string `json:"parent_id"` + ChildIds []string `json:"child_ids"` // This should be a URL or a base64 encoded data URL Image *string `json:"image"` StashIds []StashIDInput `json:"stash_ids"` diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index b1de160b1..837d21a87 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -57,12 +57,14 @@ export const StudioEditPanel: React.FC = ({ const [isLoading, setIsLoading] = useState(false); const [parentStudio, setParentStudio] = useState(null); + const [childStudios, setChildStudios] = useState([]); const schema = yup.object({ name: yup.string().required(), urls: yup.array(yup.string().required()).defined(), details: yup.string().ensure(), parent_id: yup.string().required().nullable(), + child_ids: yup.array(yup.string().required()).defined(), aliases: yupRequiredStringArray(intl).defined(), tag_ids: yup.array(yup.string().required()).defined(), ignore_auto_tag: yup.boolean().defined(), @@ -77,6 +79,7 @@ export const StudioEditPanel: React.FC = ({ urls: studio.urls ?? [], details: studio.details ?? "", parent_id: studio.parent_studio?.id ?? null, + child_ids: (studio.child_studios ?? []).map((child) => child.id), aliases: studio.aliases ?? [], tag_ids: (studio.tags ?? []).map((t) => t.id), ignore_auto_tag: studio.ignore_auto_tag ?? false, @@ -112,6 +115,14 @@ export const StudioEditPanel: React.FC = ({ formik.setFieldValue("parent_id", item ? item.id : null); } + function onSetChildStudios(items: Studio[]) { + setChildStudios(items); + formik.setFieldValue( + "child_ids", + items.map((item) => item.id) + ); + } + const encodingImage = ImageUtils.usePasteImage((imageData) => formik.setFieldValue("image", imageData) ); @@ -128,6 +139,10 @@ export const StudioEditPanel: React.FC = ({ ); }, [studio.parent_studio]); + useEffect(() => { + setChildStudios(studio.child_studios ?? []); + }, [studio.child_studios]); + useEffect(() => { setImage(formik.values.image); }, [formik.values.image, setImage]); @@ -205,6 +220,20 @@ export const StudioEditPanel: React.FC = ({ return renderField("parent_id", title, control); } + function renderSubStudiosField() { + const title = intl.formatMessage({ id: "subsidiary_studios" }); + const control = ( + + ); + + return renderField("child_ids", title, control); + } + function renderTagsField() { const title = intl.formatMessage({ id: "tags" }); return renderField("tag_ids", title, tagsControl()); @@ -246,6 +275,7 @@ export const StudioEditPanel: React.FC = ({ {renderStringListField("urls")} {renderInputField("details", "textarea")} {renderParentStudioField()} + {renderSubStudiosField()} {renderTagsField()} {renderStashIDsField( "stash_ids", From 431c9f601b3532d2dc5064d6275e38cbf8734d1e Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Fri, 8 May 2026 20:46:26 +0300 Subject: [PATCH 2/8] Fix child studio state typing in StudioEditPanel --- .../components/Studios/StudioDetails/StudioEditPanel.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index 837d21a87..63c7da8a9 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -140,7 +140,14 @@ export const StudioEditPanel: React.FC = ({ }, [studio.parent_studio]); useEffect(() => { - setChildStudios(studio.child_studios ?? []); + setChildStudios( + (studio.child_studios ?? []).map((childStudio) => ({ + id: childStudio.id, + name: childStudio.name, + aliases: [], + image_path: childStudio.image_path, + })) + ); }, [studio.child_studios]); useEffect(() => { From a5b56ad86909057a59f803145805974e7569a182 Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Fri, 8 May 2026 20:52:39 +0300 Subject: [PATCH 3/8] Format StudioEditPanel child studio excludeIds for prettier --- .../src/components/Studios/StudioDetails/StudioEditPanel.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index 63c7da8a9..00d1ebf25 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -234,7 +234,10 @@ export const StudioEditPanel: React.FC = ({ isMulti onSelect={onSetChildStudios} values={childStudios} - excludeIds={[...(studio?.id ? [studio.id] : []), ...(formik.values.parent_id ? [formik.values.parent_id] : [])]} + excludeIds={[ + ...(studio?.id ? [studio.id] : []), + ...(formik.values.parent_id ? [formik.values.parent_id] : []), + ]} /> ); From ff72d3264a9a0a9ab9fae2fe5da34810f0151a54 Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Fri, 8 May 2026 21:03:34 +0300 Subject: [PATCH 4/8] Fix atomic studio reparenting with child_ids updates --- internal/api/resolver_mutation_studio.go | 49 ++++++++++++++++-------- 1 file changed, 34 insertions(+), 15 deletions(-) diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index c809e3e0f..f78e866b1 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -15,12 +15,39 @@ import ( // used to refetch studio after hooks run -func setChildStudios(ctx context.Context, qb models.StudioReaderWriter, parentStudioID int, childStudioIDs []int) error { +func clearRemovedChildStudios(ctx context.Context, qb models.StudioReaderWriter, parentStudioID int, childStudioIDs []int) error { currentChildren, err := qb.FindChildren(ctx, parentStudioID) if err != nil { return err } + newChildStudioIDs := make(map[int]struct{}, len(childStudioIDs)) + for _, childStudioID := range childStudioIDs { + newChildStudioIDs[childStudioID] = struct{}{} + } + + for _, currentChild := range currentChildren { + if _, keep := newChildStudioIDs[currentChild.ID]; keep { + continue + } + + clearParentPartial := models.NewStudioPartial() + clearParentPartial.ID = currentChild.ID + clearParentPartial.ParentID = models.NewOptionalIntPtr(nil) + + if _, err := qb.UpdatePartial(ctx, clearParentPartial); err != nil { + return err + } + } + + return nil +} + +func setChildStudios(ctx context.Context, qb models.StudioReaderWriter, parentStudioID int, childStudioIDs []int) error { + if err := clearRemovedChildStudios(ctx, qb, parentStudioID, childStudioIDs); err != nil { + return err + } + newChildStudioIDs := make(map[int]struct{}, len(childStudioIDs)) for _, childStudioID := range childStudioIDs { if _, found := newChildStudioIDs[childStudioID]; found { @@ -41,20 +68,6 @@ func setChildStudios(ctx context.Context, qb models.StudioReaderWriter, parentSt } } - for _, currentChild := range currentChildren { - if _, keep := newChildStudioIDs[currentChild.ID]; keep { - continue - } - - clearParentPartial := models.NewStudioPartial() - clearParentPartial.ID = currentChild.ID - clearParentPartial.ParentID = models.NewOptionalIntPtr(nil) - - if _, err := qb.UpdatePartial(ctx, clearParentPartial); err != nil { - return err - } - } - return nil } @@ -257,6 +270,12 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio } } + if translator.hasField("child_ids") { + if err := clearRemovedChildStudios(ctx, qb, studioID, childStudioIDs); err != nil { + return err + } + } + if err := studio.ValidateModify(ctx, updatedStudio, qb); err != nil { return err } From 3e470a1b3274840e7b6ceec65d11ec33e96afd6e Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Fri, 8 May 2026 21:35:44 +0300 Subject: [PATCH 5/8] Retry artifact downloads in CI workflow --- .github/workflows/build.yml | 30 +++++++++++++++++++++++++++--- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index c427e4c06..fc4c848e7 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -97,7 +97,15 @@ jobs: go-version-file: 'go.mod' # Places generated Go files + UI build into the working tree so the build compiles - - name: Download generated artifacts + - name: Download generated artifacts (attempt 1) + uses: actions/download-artifact@v8 + id: download-generated-test-1 + continue-on-error: true + with: + name: generated + + - name: Download generated artifacts (attempt 2) + if: steps.download-generated-test-1.outcome == 'failure' uses: actions/download-artifact@v8 with: name: generated @@ -160,7 +168,15 @@ jobs: fetch-depth: 0 fetch-tags: true - - name: Download generated artifacts + - name: Download generated artifacts (attempt 1) + uses: actions/download-artifact@v8 + id: download-generated-build-1 + continue-on-error: true + with: + name: generated + + - name: Download generated artifacts (attempt 2) + if: steps.download-generated-build-1.outcome == 'failure' uses: actions/download-artifact@v8 with: name: generated @@ -208,7 +224,15 @@ jobs: fetch-tags: true # Downloads all artifacts (generated + 7 platform builds) into artifacts/ subdirectories - - name: Download all build artifacts + - name: Download all build artifacts (attempt 1) + uses: actions/download-artifact@v8 + id: download-all-release-1 + continue-on-error: true + with: + path: artifacts + + - name: Download all build artifacts (attempt 2) + if: steps.download-all-release-1.outcome == 'failure' uses: actions/download-artifact@v8 with: path: artifacts From 71107dc80e8ecbbe017acec4457fee843f33be7a Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Fri, 8 May 2026 21:36:33 +0300 Subject: [PATCH 6/8] Revert "Retry artifact downloads in CI workflow" This reverts commit bc4145903bf81eccd3b23b67af8b197a6fa62c70. --- .github/workflows/build.yml | 30 +++--------------------------- 1 file changed, 3 insertions(+), 27 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fc4c848e7..c427e4c06 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -97,15 +97,7 @@ jobs: go-version-file: 'go.mod' # Places generated Go files + UI build into the working tree so the build compiles - - name: Download generated artifacts (attempt 1) - uses: actions/download-artifact@v8 - id: download-generated-test-1 - continue-on-error: true - with: - name: generated - - - name: Download generated artifacts (attempt 2) - if: steps.download-generated-test-1.outcome == 'failure' + - name: Download generated artifacts uses: actions/download-artifact@v8 with: name: generated @@ -168,15 +160,7 @@ jobs: fetch-depth: 0 fetch-tags: true - - name: Download generated artifacts (attempt 1) - uses: actions/download-artifact@v8 - id: download-generated-build-1 - continue-on-error: true - with: - name: generated - - - name: Download generated artifacts (attempt 2) - if: steps.download-generated-build-1.outcome == 'failure' + - name: Download generated artifacts uses: actions/download-artifact@v8 with: name: generated @@ -224,15 +208,7 @@ jobs: fetch-tags: true # Downloads all artifacts (generated + 7 platform builds) into artifacts/ subdirectories - - name: Download all build artifacts (attempt 1) - uses: actions/download-artifact@v8 - id: download-all-release-1 - continue-on-error: true - with: - path: artifacts - - - name: Download all build artifacts (attempt 2) - if: steps.download-all-release-1.outcome == 'failure' + - name: Download all build artifacts uses: actions/download-artifact@v8 with: path: artifacts From abc652b491174fd5d0c1af4a32675c87e91109d0 Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Fri, 8 May 2026 21:36:57 +0300 Subject: [PATCH 7/8] Enforce parent/child mutual exclusion in StudioEditPanel --- .../src/components/Studios/StudioDetails/StudioEditPanel.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index 00d1ebf25..6de47b1f8 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -221,6 +221,10 @@ export const StudioEditPanel: React.FC = ({ onSetParentStudio(items.length > 0 ? items[0] : null) } values={parentStudio ? [parentStudio] : []} + excludeIds={[ + ...(studio?.id ? [studio.id] : []), + ...formik.values.child_ids, + ]} /> ); From 40e087f434d990f238e6f132864b67990bdb1c3b Mon Sep 17 00:00:00 2001 From: Slick Daddy <129640104+slick-daddy@users.noreply.github.com> Date: Fri, 8 May 2026 21:53:37 +0300 Subject: [PATCH 8/8] Bind child studio picker display to formik child_ids --- .../src/components/Studios/StudioDetails/StudioEditPanel.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index 6de47b1f8..020b66f38 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -237,7 +237,9 @@ export const StudioEditPanel: React.FC = ({ + formik.values.child_ids.includes(childStudio.id) + )} excludeIds={[ ...(studio?.id ? [studio.id] : []), ...(formik.values.parent_id ? [formik.values.parent_id] : []),