diff --git a/graphql/schema/types/filters.graphql b/graphql/schema/types/filters.graphql index 7633457ce..c0b47f7cf 100644 --- a/graphql/schema/types/filters.graphql +++ b/graphql/schema/types/filters.graphql @@ -502,6 +502,8 @@ input StudioFilterType { child_count: IntCriterionInput "Filter by autotag ignore value" ignore_auto_tag: Boolean + "Filter by organized" + organized: Boolean "Filter by related scenes that meet this criteria" scenes_filter: SceneFilterType "Filter by related images that meet this criteria" diff --git a/graphql/schema/types/studio.graphql b/graphql/schema/types/studio.graphql index 3e991ce96..51a87bf4f 100644 --- a/graphql/schema/types/studio.graphql +++ b/graphql/schema/types/studio.graphql @@ -8,6 +8,7 @@ type Studio { aliases: [String!]! tags: [Tag!]! ignore_auto_tag: Boolean! + organized: Boolean! image_path: String # Resolver scene_count(depth: Int): Int! # Resolver @@ -46,6 +47,7 @@ input StudioCreateInput { aliases: [String!] tag_ids: [ID!] ignore_auto_tag: Boolean + organized: Boolean custom_fields: Map } @@ -67,6 +69,7 @@ input StudioUpdateInput { aliases: [String!] tag_ids: [ID!] ignore_auto_tag: Boolean + organized: Boolean custom_fields: CustomFieldsInput } @@ -82,6 +85,7 @@ input BulkStudioUpdateInput { details: String tag_ids: BulkUpdateIds ignore_auto_tag: Boolean + organized: Boolean } input StudioDestroyInput { diff --git a/internal/api/resolver_mutation_studio.go b/internal/api/resolver_mutation_studio.go index e3e1c6395..c7af918a1 100644 --- a/internal/api/resolver_mutation_studio.go +++ b/internal/api/resolver_mutation_studio.go @@ -38,6 +38,7 @@ func (r *mutationResolver) StudioCreate(ctx context.Context, input models.Studio newStudio.Favorite = translator.bool(input.Favorite) newStudio.Details = translator.string(input.Details) newStudio.IgnoreAutoTag = translator.bool(input.IgnoreAutoTag) + newStudio.Organized = translator.bool(input.Organized) newStudio.Aliases = models.NewRelatedStrings(stringslice.UniqueExcludeFold(stringslice.TrimSpace(input.Aliases), newStudio.Name)) newStudio.StashIDs = models.NewRelatedStashIDs(models.StashIDInputs(input.StashIds).ToStashIDs()) @@ -120,6 +121,7 @@ func (r *mutationResolver) StudioUpdate(ctx context.Context, input models.Studio updatedStudio.Rating = translator.optionalInt(input.Rating100, "rating100") updatedStudio.Favorite = translator.optionalBool(input.Favorite, "favorite") updatedStudio.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") + updatedStudio.Organized = translator.optionalBool(input.Organized, "organized") updatedStudio.Aliases = translator.updateStrings(input.Aliases, "aliases") updatedStudio.StashIDs = translator.updateStashIDs(input.StashIds, "stash_ids") @@ -261,6 +263,7 @@ func (r *mutationResolver) BulkStudioUpdate(ctx context.Context, input BulkStudi partial.Rating = translator.optionalInt(input.Rating100, "rating100") partial.Details = translator.optionalString(input.Details, "details") partial.IgnoreAutoTag = translator.optionalBool(input.IgnoreAutoTag, "ignore_auto_tag") + partial.Organized = translator.optionalBool(input.Organized, "organized") partial.TagIDs, err = translator.updateIdsBulk(input.TagIds, "tag_ids") if err != nil { diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index 37859ba61..4848b46ad 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -275,6 +275,12 @@ func (t *stashBoxBatchStudioTagTask) getName() string { } func (t *stashBoxBatchStudioTagTask) Start(ctx context.Context) { + // Skip organized studios + if t.studio != nil && t.studio.Organized { + logger.Infof("Skipping organized studio %s", t.studio.Name) + return + } + studio, err := t.findStashBoxStudio(ctx) if err != nil { logger.Errorf("Error fetching studio data from stash-box: %v", err) diff --git a/pkg/models/jsonschema/studio.go b/pkg/models/jsonschema/studio.go index 7684b4317..12a797c13 100644 --- a/pkg/models/jsonschema/studio.go +++ b/pkg/models/jsonschema/studio.go @@ -24,6 +24,7 @@ type Studio struct { StashIDs []models.StashID `json:"stash_ids,omitempty"` Tags []string `json:"tags,omitempty"` IgnoreAutoTag bool `json:"ignore_auto_tag,omitempty"` + Organized bool `json:"organized,omitempty"` CustomFields map[string]interface{} `json:"custom_fields,omitempty"` diff --git a/pkg/models/model_studio.go b/pkg/models/model_studio.go index ee6fae2d2..ec81aac0e 100644 --- a/pkg/models/model_studio.go +++ b/pkg/models/model_studio.go @@ -16,6 +16,7 @@ type Studio struct { Favorite bool `json:"favorite"` Details string `json:"details"` IgnoreAutoTag bool `json:"ignore_auto_tag"` + Organized bool `json:"organized"` Aliases RelatedStrings `json:"aliases"` URLs RelatedStrings `json:"urls"` @@ -62,6 +63,7 @@ type StudioPartial struct { CreatedAt OptionalTime UpdatedAt OptionalTime IgnoreAutoTag OptionalBool + Organized OptionalBool Aliases *UpdateStrings URLs *UpdateStrings diff --git a/pkg/models/studio.go b/pkg/models/studio.go index 5d1def1bc..7ad8719ac 100644 --- a/pkg/models/studio.go +++ b/pkg/models/studio.go @@ -38,6 +38,8 @@ type StudioFilterType struct { ChildCount *IntCriterionInput `json:"child_count"` // Filter by autotag ignore value IgnoreAutoTag *bool `json:"ignore_auto_tag"` + // Filter by organized + Organized *bool `json:"organized"` // Filter by related scenes that meet this criteria ScenesFilter *SceneFilterType `json:"scenes_filter"` // Filter by related images that meet this criteria @@ -69,6 +71,7 @@ type StudioCreateInput struct { Aliases []string `json:"aliases"` TagIds []string `json:"tag_ids"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` + Organized *bool `json:"organized"` CustomFields map[string]interface{} `json:"custom_fields"` } @@ -88,6 +91,7 @@ type StudioUpdateInput struct { Aliases []string `json:"aliases"` TagIds []string `json:"tag_ids"` IgnoreAutoTag *bool `json:"ignore_auto_tag"` + Organized *bool `json:"organized"` CustomFields CustomFieldsInput `json:"custom_fields"` } diff --git a/pkg/sqlite/database.go b/pkg/sqlite/database.go index 4a950b724..5b67e5602 100644 --- a/pkg/sqlite/database.go +++ b/pkg/sqlite/database.go @@ -34,7 +34,7 @@ const ( cacheSizeEnv = "STASH_SQLITE_CACHE_SIZE" ) -var appSchemaVersion uint = 79 +var appSchemaVersion uint = 80 //go:embed migrations/*.sql var migrationsBox embed.FS diff --git a/pkg/sqlite/migrations/80_studio_organized.up.sql b/pkg/sqlite/migrations/80_studio_organized.up.sql new file mode 100644 index 000000000..3aa9c4656 --- /dev/null +++ b/pkg/sqlite/migrations/80_studio_organized.up.sql @@ -0,0 +1 @@ +ALTER TABLE `studios` ADD COLUMN `organized` boolean not null default '0'; \ No newline at end of file diff --git a/pkg/sqlite/studio.go b/pkg/sqlite/studio.go index 949929c8d..a866a94ab 100644 --- a/pkg/sqlite/studio.go +++ b/pkg/sqlite/studio.go @@ -44,6 +44,7 @@ type studioRow struct { Favorite bool `db:"favorite"` Details zero.String `db:"details"` IgnoreAutoTag bool `db:"ignore_auto_tag"` + Organized bool `db:"organized"` // not used in resolutions or updates ImageBlob zero.String `db:"image_blob"` @@ -59,6 +60,7 @@ func (r *studioRow) fromStudio(o models.Studio) { r.Favorite = o.Favorite r.Details = zero.StringFrom(o.Details) r.IgnoreAutoTag = o.IgnoreAutoTag + r.Organized = o.Organized } func (r *studioRow) resolve() *models.Studio { @@ -72,6 +74,7 @@ func (r *studioRow) resolve() *models.Studio { Favorite: r.Favorite, Details: r.Details.String, IgnoreAutoTag: r.IgnoreAutoTag, + Organized: r.Organized, } return ret @@ -90,6 +93,7 @@ func (r *studioRowRecord) fromPartial(o models.StudioPartial) { r.setBool("favorite", o.Favorite) r.setNullString("details", o.Details) r.setBool("ignore_auto_tag", o.IgnoreAutoTag) + r.setBool("organized", o.Organized) } type studioRepositoryType struct { diff --git a/pkg/sqlite/studio_filter.go b/pkg/sqlite/studio_filter.go index 889bd4c74..cfe3c59b6 100644 --- a/pkg/sqlite/studio_filter.go +++ b/pkg/sqlite/studio_filter.go @@ -59,6 +59,7 @@ func (qb *studioFilterHandler) criterionHandler() criterionHandler { intCriterionHandler(studioFilter.Rating100, studioTable+".rating", nil), boolCriterionHandler(studioFilter.Favorite, studioTable+".favorite", nil), boolCriterionHandler(studioFilter.IgnoreAutoTag, studioTable+".ignore_auto_tag", nil), + boolCriterionHandler(studioFilter.Organized, studioTable+".organized", nil), criterionHandlerFunc(func(ctx context.Context, f *filterBuilder) { if studioFilter.StashID != nil { diff --git a/pkg/sqlite/studio_test.go b/pkg/sqlite/studio_test.go index 968f43413..eebc677c3 100644 --- a/pkg/sqlite/studio_test.go +++ b/pkg/sqlite/studio_test.go @@ -81,6 +81,7 @@ func Test_StudioStore_Create(t *testing.T) { rating = 3 aliases = []string{"alias1", "alias2"} ignoreAutoTag = true + organized = true favorite = true endpoint1 = "endpoint1" endpoint2 = "endpoint2" @@ -105,6 +106,7 @@ func Test_StudioStore_Create(t *testing.T) { Rating: &rating, Details: details, IgnoreAutoTag: ignoreAutoTag, + Organized: organized, TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithStudio], tagIDs[tagIdx1WithDupName]}), Aliases: models.NewRelatedStrings(aliases), StashIDs: models.NewRelatedStashIDs([]models.StashID{ @@ -206,6 +208,7 @@ func Test_StudioStore_Update(t *testing.T) { rating = 3 aliases = []string{"aliasX", "aliasY"} ignoreAutoTag = true + organized = true favorite = true endpoint1 = "endpoint1" endpoint2 = "endpoint2" @@ -231,6 +234,7 @@ func Test_StudioStore_Update(t *testing.T) { Rating: &rating, Details: details, IgnoreAutoTag: ignoreAutoTag, + Organized: organized, Aliases: models.NewRelatedStrings(aliases), TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithStudio]}), StashIDs: models.NewRelatedStashIDs([]models.StashID{ @@ -380,6 +384,7 @@ func Test_StudioStore_UpdatePartial(t *testing.T) { aliases = []string{"aliasX", "aliasY"} rating = 3 ignoreAutoTag = true + organized = true favorite = true endpoint1 = "endpoint1" endpoint2 = "endpoint2" @@ -413,6 +418,7 @@ func Test_StudioStore_UpdatePartial(t *testing.T) { Rating: models.NewOptionalInt(rating), Details: models.NewOptionalString(details), IgnoreAutoTag: models.NewOptionalBool(ignoreAutoTag), + Organized: models.NewOptionalBool(organized), TagIDs: &models.UpdateIDs{ IDs: []int{tagIDs[tagIdx1WithStudio], tagIDs[tagIdx1WithDupName]}, Mode: models.RelationshipUpdateModeSet, @@ -444,6 +450,7 @@ func Test_StudioStore_UpdatePartial(t *testing.T) { Rating: &rating, Details: details, IgnoreAutoTag: ignoreAutoTag, + Organized: organized, TagIDs: models.NewRelatedIDs([]int{tagIDs[tagIdx1WithDupName], tagIDs[tagIdx1WithStudio]}), StashIDs: models.NewRelatedStashIDs([]models.StashID{ { diff --git a/pkg/studio/export.go b/pkg/studio/export.go index c3a50668f..206791da6 100644 --- a/pkg/studio/export.go +++ b/pkg/studio/export.go @@ -27,6 +27,7 @@ func ToJSON(ctx context.Context, reader FinderImageStashIDGetter, studio *models Details: studio.Details, Favorite: studio.Favorite, IgnoreAutoTag: studio.IgnoreAutoTag, + Organized: studio.Organized, CreatedAt: json.JSONTime{Time: studio.CreatedAt}, UpdatedAt: json.JSONTime{Time: studio.UpdatedAt}, } diff --git a/pkg/studio/export_test.go b/pkg/studio/export_test.go index e41e6f36c..dce75ba9a 100644 --- a/pkg/studio/export_test.go +++ b/pkg/studio/export_test.go @@ -32,6 +32,7 @@ var ( details = "details" parentStudioName = "parentStudio" autoTagIgnored = true + studioOrganized = true emptyCustomFields = make(map[string]interface{}) customFields = map[string]interface{}{ "customField1": "customValue1", @@ -73,6 +74,7 @@ func createFullStudio(id int, parentID int) models.Studio { UpdatedAt: updateTime, Rating: &rating, IgnoreAutoTag: autoTagIgnored, + Organized: studioOrganized, Aliases: models.NewRelatedStrings(aliases), TagIDs: models.NewRelatedIDs([]int{}), StashIDs: models.NewRelatedStashIDs(stashIDs), @@ -115,6 +117,7 @@ func createFullJSONStudio(parentStudio, image string, aliases []string, customFi Aliases: aliases, StashIDs: stashIDs, IgnoreAutoTag: autoTagIgnored, + Organized: studioOrganized, CustomFields: customFields, } } diff --git a/pkg/studio/import.go b/pkg/studio/import.go index d9e52100c..264e2566a 100644 --- a/pkg/studio/import.go +++ b/pkg/studio/import.go @@ -233,6 +233,7 @@ func studioJSONtoStudio(studioJSON jsonschema.Studio) models.Studio { Details: studioJSON.Details, Favorite: studioJSON.Favorite, IgnoreAutoTag: studioJSON.IgnoreAutoTag, + Organized: studioJSON.Organized, CreatedAt: studioJSON.CreatedAt.GetTime(), UpdatedAt: studioJSON.UpdatedAt.GetTime(), diff --git a/pkg/studio/import_test.go b/pkg/studio/import_test.go index 4eb757293..c2bbd40f5 100644 --- a/pkg/studio/import_test.go +++ b/pkg/studio/import_test.go @@ -49,6 +49,7 @@ func TestImporterPreImport(t *testing.T) { Name: studioName, Image: invalidImage, IgnoreAutoTag: autoTagIgnored, + Organized: studioOrganized, }, } diff --git a/ui/v2.5/graphql/data/studio-slim.graphql b/ui/v2.5/graphql/data/studio-slim.graphql index c48f7d93e..4ca3c8b4d 100644 --- a/ui/v2.5/graphql/data/studio-slim.graphql +++ b/ui/v2.5/graphql/data/studio-slim.graphql @@ -17,5 +17,8 @@ fragment SlimStudioData on Studio { id name } + favorite + ignore_auto_tag + organized o_counter } diff --git a/ui/v2.5/graphql/data/studio.graphql b/ui/v2.5/graphql/data/studio.graphql index aabec7a9b..8347b4739 100644 --- a/ui/v2.5/graphql/data/studio.graphql +++ b/ui/v2.5/graphql/data/studio.graphql @@ -16,6 +16,7 @@ fragment StudioData on Studio { image_path } ignore_auto_tag + organized image_path scene_count scene_count_all: scene_count(depth: -1) diff --git a/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx b/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx index 293a8dfb3..1c34dfc36 100644 --- a/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx +++ b/ui/v2.5/src/components/Studios/EditStudiosDialog.tsx @@ -23,7 +23,13 @@ interface IListOperationProps { onClose: (applied: boolean) => void; } -const studioFields = ["favorite", "rating100", "details", "ignore_auto_tag"]; +const studioFields = [ + "favorite", + "rating100", + "details", + "ignore_auto_tag", + "organized", +]; export const EditStudiosDialog: React.FC = ( props: IListOperationProps @@ -236,6 +242,14 @@ export const EditStudiosDialog: React.FC = ( checked={updateInput.ignore_auto_tag ?? undefined} /> + + + setUpdateField({ organized: checked })} + checked={updateInput.organized ?? undefined} + /> + ); diff --git a/ui/v2.5/src/components/Studios/StudioCard.tsx b/ui/v2.5/src/components/Studios/StudioCard.tsx index 87c9b9528..839489182 100644 --- a/ui/v2.5/src/components/Studios/StudioCard.tsx +++ b/ui/v2.5/src/components/Studios/StudioCard.tsx @@ -7,13 +7,13 @@ import { PatchComponent } from "src/patch"; import { HoverPopover } from "../Shared/HoverPopover"; import { Icon } from "../Shared/Icon"; import { TagLink } from "../Shared/TagLink"; -import { Button, ButtonGroup } from "react-bootstrap"; +import { Button, ButtonGroup, OverlayTrigger, Tooltip } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import { PopoverCountButton } from "../Shared/PopoverCountButton"; import { RatingBanner } from "../Shared/RatingBanner"; import { FavoriteIcon } from "../Shared/FavoriteIcon"; import { useStudioUpdate } from "src/core/StashService"; -import { faTag } from "@fortawesome/free-solid-svg-icons"; +import { faTag, faBox } from "@fortawesome/free-solid-svg-icons"; import { OCounterButton } from "../Shared/CountButton"; interface IProps { @@ -185,6 +185,27 @@ export const StudioCard: React.FC = PatchComponent( return ; } + function maybeRenderOrganized() { + if (studio.organized) { + return ( + + + + } + placement="bottom" + > +
+ +
+
+ ); + } + } + function maybeRenderPopoverButtonGroup() { if ( studio.scene_count || @@ -193,7 +214,8 @@ export const StudioCard: React.FC = PatchComponent( studio.group_count || studio.performer_count || studio.o_counter || - studio.tags.length > 0 + studio.tags.length > 0 || + studio.organized ) { return ( <> @@ -206,6 +228,7 @@ export const StudioCard: React.FC = PatchComponent( {maybeRenderPerformersPopoverButton()} {maybeRenderTagPopoverButton()} {maybeRenderOCounter()} + {maybeRenderOrganized()} ); diff --git a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx index 2edc53fe1..0096851e2 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/Studio.tsx @@ -49,6 +49,7 @@ import { AliasList } from "src/components/Shared/DetailsPage/AliasList"; import { HeaderImage } from "src/components/Shared/DetailsPage/HeaderImage"; import { goBackOrReplace } from "src/utils/history"; import { OCounterButton } from "src/components/Shared/CountButton"; +import { OrganizedButton } from "src/components/Scenes/SceneDetails/OrganizedButton"; interface IProps { studio: GQL.StudioDataFragment; @@ -316,6 +317,28 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { } } + const [organizedLoading, setOrganizedLoading] = useState(false); + + async function onOrganizedClick() { + if (!studio.id) return; + + setOrganizedLoading(true); + try { + await updateStudio({ + variables: { + input: { + id: studio.id, + organized: !studio.organized, + }, + }, + }); + } catch (e) { + Toast.error(e); + } finally { + setOrganizedLoading(false); + } + } + // set up hotkeys useEffect(() => { Mousetrap.bind("e", () => toggleEditing()); @@ -467,6 +490,11 @@ const StudioPage: React.FC = ({ studio, tabKey }) => { favorite={studio.favorite} onToggleFavorite={(v) => setFavorite(v)} /> + diff --git a/ui/v2.5/src/docs/en/Manual/AutoTagging.md b/ui/v2.5/src/docs/en/Manual/AutoTagging.md index ad08027f6..c3ef00971 100644 --- a/ui/v2.5/src/docs/en/Manual/AutoTagging.md +++ b/ui/v2.5/src/docs/en/Manual/AutoTagging.md @@ -37,6 +37,8 @@ This task is part of the advanced settings mode. Scenes, images, and galleries that have the Organized flag added to them will not be modified by Auto tag. You can also use Organized flag status as a filter. +Studios also support the Organized flag, however it is purely informational. It serves as a front-end indicator for the user to mark that a studio's collection is complete and does not affect Auto tag behavior. The Ignore Auto tag flag should be used to exclude a studio from Auto tag. + ### Ignore Auto tag flag Performers or Tags that have Ignore Auto tag flag added to them will be skipped by the Auto tag task. diff --git a/ui/v2.5/src/models/list-filter/studios.ts b/ui/v2.5/src/models/list-filter/studios.ts index 42ac1b4dc..a38540a47 100644 --- a/ui/v2.5/src/models/list-filter/studios.ts +++ b/ui/v2.5/src/models/list-filter/studios.ts @@ -53,6 +53,7 @@ const criterionOptions = [ TagsCriterionOption, RatingCriterionOption, createBooleanCriterionOption("ignore_auto_tag"), + createBooleanCriterionOption("organized"), createMandatoryNumberCriterionOption("tag_count"), createMandatoryNumberCriterionOption("scene_count"), createMandatoryNumberCriterionOption("image_count"),