Feature Request: Add organized flag to studios (#6303)

This commit is contained in:
Gykes 2026-02-18 16:05:17 -06:00 committed by GitHub
parent 8bc4107e54
commit 3dc86239d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
23 changed files with 118 additions and 5 deletions

View file

@ -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"

View file

@ -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 {

View file

@ -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 {

View file

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

View file

@ -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"`

View file

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

View file

@ -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"`
}

View file

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

View file

@ -0,0 +1 @@
ALTER TABLE `studios` ADD COLUMN `organized` boolean not null default '0';

View file

@ -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 {

View file

@ -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 {

View file

@ -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{
{

View file

@ -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},
}

View file

@ -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,
}
}

View file

@ -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(),

View file

@ -49,6 +49,7 @@ func TestImporterPreImport(t *testing.T) {
Name: studioName,
Image: invalidImage,
IgnoreAutoTag: autoTagIgnored,
Organized: studioOrganized,
},
}

View file

@ -17,5 +17,8 @@ fragment SlimStudioData on Studio {
id
name
}
favorite
ignore_auto_tag
organized
o_counter
}

View file

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

View file

@ -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<IListOperationProps> = (
props: IListOperationProps
@ -236,6 +242,14 @@ export const EditStudiosDialog: React.FC<IListOperationProps> = (
checked={updateInput.ignore_auto_tag ?? undefined}
/>
</Form.Group>
<Form.Group controlId="organized">
<IndeterminateCheckbox
label={intl.formatMessage({ id: "organized" })}
setChecked={(checked) => setUpdateField({ organized: checked })}
checked={updateInput.organized ?? undefined}
/>
</Form.Group>
</Form>
</ModalComponent>
);

View file

@ -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<IProps> = PatchComponent(
return <OCounterButton value={studio.o_counter} />;
}
function maybeRenderOrganized() {
if (studio.organized) {
return (
<OverlayTrigger
overlay={
<Tooltip id="organized-tooltip">
<FormattedMessage id="organized" />
</Tooltip>
}
placement="bottom"
>
<div className="organized">
<Button className="minimal">
<Icon icon={faBox} />
</Button>
</div>
</OverlayTrigger>
);
}
}
function maybeRenderPopoverButtonGroup() {
if (
studio.scene_count ||
@ -193,7 +214,8 @@ export const StudioCard: React.FC<IProps> = 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<IProps> = PatchComponent(
{maybeRenderPerformersPopoverButton()}
{maybeRenderTagPopoverButton()}
{maybeRenderOCounter()}
{maybeRenderOrganized()}
</ButtonGroup>
</>
);

View file

@ -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<IProps> = ({ 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<IProps> = ({ studio, tabKey }) => {
favorite={studio.favorite}
onToggleFavorite={(v) => setFavorite(v)}
/>
<OrganizedButton
loading={organizedLoading}
organized={studio.organized}
onClick={onOrganizedClick}
/>
<ExternalLinkButtons urls={studio.urls} />
</span>
</DetailTitle>

View file

@ -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.

View file

@ -53,6 +53,7 @@ const criterionOptions = [
TagsCriterionOption,
RatingCriterionOption,
createBooleanCriterionOption("ignore_auto_tag"),
createBooleanCriterionOption("organized"),
createMandatoryNumberCriterionOption("tag_count"),
createMandatoryNumberCriterionOption("scene_count"),
createMandatoryNumberCriterionOption("image_count"),