Skip to content

Commit

Permalink
Containing Group/Sub-Group relationships (#5105)
Browse files Browse the repository at this point in the history
* Add UI support for setting containing groups
* Show containing groups in group details panel
* Move tag hierarchical filter code into separate type
* Add depth to scene_count and add sub_group_count
* Add sub-groups tab to groups page
* Add containing groups to edit groups dialog
* Show containing group description in sub-group view
* Show group scene number in group scenes view
* Add ability to drag move grid cards
* Add sub group order option
* Add reorder sub-groups interface
* Separate page size selector component
* Add interfaces to add and remove sub-groups to a group
* Separate MultiSet components
* Allow setting description while setting containing groups
  • Loading branch information
WithoutPants authored Aug 30, 2024
1 parent 96fdd94 commit bcf0fda
Show file tree
Hide file tree
Showing 99 changed files with 5,384 additions and 931 deletions.
6 changes: 6 additions & 0 deletions graphql/schema/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -359,6 +359,12 @@ type Mutation {
groupsDestroy(ids: [ID!]!): Boolean!
bulkGroupUpdate(input: BulkGroupUpdateInput!): [Group!]

addGroupSubGroups(input: GroupSubGroupAddInput!): Boolean!
removeGroupSubGroups(input: GroupSubGroupRemoveInput!): Boolean!

"Reorder sub groups within a group. Returns true if successful."
reorderSubGroups(input: ReorderSubGroupsInput!): Boolean!

tagCreate(input: TagCreateInput!): Tag
tagUpdate(input: TagUpdateInput!): Tag
tagDestroy(input: TagDestroyInput!): Boolean!
Expand Down
11 changes: 10 additions & 1 deletion graphql/schema/types/filters.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ input SceneFilterType {
"Filter to only include scenes with this movie"
movies: MultiCriterionInput @deprecated(reason: "use groups instead")
"Filter to only include scenes with this group"
groups: MultiCriterionInput
groups: HierarchicalMultiCriterionInput
"Filter to only include scenes with this gallery"
galleries: MultiCriterionInput
"Filter to only include scenes with these tags"
Expand Down Expand Up @@ -390,6 +390,15 @@ input GroupFilterType {
"Filter by last update time"
updated_at: TimestampCriterionInput

"Filter by containing groups"
containing_groups: HierarchicalMultiCriterionInput
"Filter by sub groups"
sub_groups: HierarchicalMultiCriterionInput
"Filter by number of containing groups the group has"
containing_group_count: IntCriterionInput
"Filter by number of sub-groups the group has"
sub_group_count: IntCriterionInput

"Filter by related scenes that meet this criteria"
scenes_filter: SceneFilterType
"Filter by related studios that meet this criteria"
Expand Down
59 changes: 58 additions & 1 deletion graphql/schema/types/group.graphql
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
"GroupDescription represents a relationship to a group with a description of the relationship"
type GroupDescription {
group: Group!
description: String
}

type Group {
id: ID!
name: String!
Expand All @@ -15,12 +21,21 @@ type Group {
created_at: Time!
updated_at: Time!

containing_groups: [GroupDescription!]!
sub_groups: [GroupDescription!]!

front_image_path: String # Resolver
back_image_path: String # Resolver
scene_count: Int! # Resolver
scene_count(depth: Int): Int! # Resolver
sub_group_count(depth: Int): Int! # Resolver
scenes: [Scene!]!
}

input GroupDescriptionInput {
group_id: ID!
description: String
}

input GroupCreateInput {
name: String!
aliases: String
Expand All @@ -34,6 +49,10 @@ input GroupCreateInput {
synopsis: String
urls: [String!]
tag_ids: [ID!]

containing_groups: [GroupDescriptionInput!]
sub_groups: [GroupDescriptionInput!]

"This should be a URL or a base64 encoded data URL"
front_image: String
"This should be a URL or a base64 encoded data URL"
Expand All @@ -53,12 +72,21 @@ input GroupUpdateInput {
synopsis: String
urls: [String!]
tag_ids: [ID!]

containing_groups: [GroupDescriptionInput!]
sub_groups: [GroupDescriptionInput!]

"This should be a URL or a base64 encoded data URL"
front_image: String
"This should be a URL or a base64 encoded data URL"
back_image: String
}

input BulkUpdateGroupDescriptionsInput {
groups: [GroupDescriptionInput!]!
mode: BulkUpdateIdMode!
}

input BulkGroupUpdateInput {
clientMutationId: String
ids: [ID!]
Expand All @@ -68,13 +96,42 @@ input BulkGroupUpdateInput {
director: String
urls: BulkUpdateStrings
tag_ids: BulkUpdateIds

containing_groups: BulkUpdateGroupDescriptionsInput
sub_groups: BulkUpdateGroupDescriptionsInput
}

input GroupDestroyInput {
id: ID!
}

input ReorderSubGroupsInput {
"ID of the group to reorder sub groups for"
group_id: ID!
"""
IDs of the sub groups to reorder. These must be a subset of the current sub groups.
Sub groups will be inserted in this order at the insert_index
"""
sub_group_ids: [ID!]!
"The sub-group ID at which to insert the sub groups"
insert_at_id: ID!
"If true, the sub groups will be inserted after the insert_index, otherwise they will be inserted before"
insert_after: Boolean
}

type FindGroupsResultType {
count: Int!
groups: [Group!]!
}

input GroupSubGroupAddInput {
containing_group_id: ID!
sub_groups: [GroupDescriptionInput!]!
"The index at which to insert the sub groups. If not provided, the sub groups will be appended to the end"
insert_index: Int
}

input GroupSubGroupRemoveInput {
containing_group_id: ID!
sub_group_ids: [ID!]!
}
2 changes: 1 addition & 1 deletion graphql/schema/types/movie.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ type Movie {

front_image_path: String # Resolver
back_image_path: String # Resolver
scene_count: Int! # Resolver
scene_count(depth: Int): Int! # Resolver
scenes: [Scene!]!
}

Expand Down
2 changes: 1 addition & 1 deletion graphql/schema/types/performer.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ type Performer {
weight: Int
created_at: Time!
updated_at: Time!
groups: [Group!]! @deprecated(reason: "use groups instead")
groups: [Group!]!
movies: [Movie!]! @deprecated(reason: "use groups instead")
}

Expand Down
61 changes: 61 additions & 0 deletions internal/api/changeset_translator.go
Original file line number Diff line number Diff line change
Expand Up @@ -434,3 +434,64 @@ func (t changesetTranslator) updateGroupIDsBulk(value *BulkUpdateIds, field stri
Mode: value.Mode,
}, nil
}

func groupsDescriptionsFromGroupInput(input []*GroupDescriptionInput) ([]models.GroupIDDescription, error) {
ret := make([]models.GroupIDDescription, len(input))

for i, v := range input {
gID, err := strconv.Atoi(v.GroupID)
if err != nil {
return nil, fmt.Errorf("invalid group ID: %s", v.GroupID)
}

ret[i] = models.GroupIDDescription{
GroupID: gID,
}
if v.Description != nil {
ret[i].Description = *v.Description
}
}

return ret, nil
}

func (t changesetTranslator) groupIDDescriptions(value []*GroupDescriptionInput) (models.RelatedGroupDescriptions, error) {
groupsScenes, err := groupsDescriptionsFromGroupInput(value)
if err != nil {
return models.RelatedGroupDescriptions{}, err
}

return models.NewRelatedGroupDescriptions(groupsScenes), nil
}

func (t changesetTranslator) updateGroupIDDescriptions(value []*GroupDescriptionInput, field string) (*models.UpdateGroupDescriptions, error) {
if !t.hasField(field) {
return nil, nil
}

groupsScenes, err := groupsDescriptionsFromGroupInput(value)
if err != nil {
return nil, err
}

return &models.UpdateGroupDescriptions{
Groups: groupsScenes,
Mode: models.RelationshipUpdateModeSet,
}, nil
}

func (t changesetTranslator) updateGroupIDDescriptionsBulk(value *BulkUpdateGroupDescriptionsInput, field string) (*models.UpdateGroupDescriptions, error) {
if !t.hasField(field) || value == nil {
return nil, nil
}

groups, err := groupsDescriptionsFromGroupInput(value.Groups)
if err != nil {
return nil, err
}

return &models.UpdateGroupDescriptions{
Groups: groups,
Mode: value.Mode,
}, nil
}
1 change: 1 addition & 0 deletions internal/api/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ type Resolver struct {
sceneService manager.SceneService
imageService manager.ImageService
galleryService manager.GalleryService
groupService manager.GroupService

hookExecutor hookExecutor
}
Expand Down
68 changes: 66 additions & 2 deletions internal/api/resolver_model_movie.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import (

"github.com/stashapp/stash/internal/api/loaders"
"github.com/stashapp/stash/internal/api/urlbuilders"
"github.com/stashapp/stash/pkg/group"
"github.com/stashapp/stash/pkg/models"
"github.com/stashapp/stash/pkg/scene"
)

func (r *groupResolver) Date(ctx context.Context, obj *models.Group) (*string, error) {
Expand Down Expand Up @@ -71,6 +73,68 @@ func (r groupResolver) Tags(ctx context.Context, obj *models.Group) (ret []*mode
return ret, firstError(errs)
}

func (r groupResolver) relatedGroups(ctx context.Context, rgd models.RelatedGroupDescriptions) (ret []*GroupDescription, err error) {
// rgd must be loaded
gds := rgd.List()
ids := make([]int, len(gds))
for i, gd := range gds {
ids[i] = gd.GroupID
}

groups, errs := loaders.From(ctx).GroupByID.LoadAll(ids)

err = firstError(errs)
if err != nil {
return
}

ret = make([]*GroupDescription, len(groups))
for i, group := range groups {
ret[i] = &GroupDescription{Group: group}
d := gds[i].Description
if d != "" {
ret[i].Description = &d
}
}

return ret, firstError(errs)
}

func (r groupResolver) ContainingGroups(ctx context.Context, obj *models.Group) (ret []*GroupDescription, err error) {
if !obj.ContainingGroups.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadContainingGroupIDs(ctx, r.repository.Group)
}); err != nil {
return nil, err
}
}

return r.relatedGroups(ctx, obj.ContainingGroups)
}

func (r groupResolver) SubGroups(ctx context.Context, obj *models.Group) (ret []*GroupDescription, err error) {
if !obj.SubGroups.Loaded() {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
return obj.LoadSubGroupIDs(ctx, r.repository.Group)
}); err != nil {
return nil, err
}
}

return r.relatedGroups(ctx, obj.SubGroups)
}

func (r *groupResolver) SubGroupCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = group.CountByContainingGroupID(ctx, r.repository.Group, obj.ID, depth)
return err
}); err != nil {
return 0, err
}

return ret, nil
}

func (r *groupResolver) FrontImagePath(ctx context.Context, obj *models.Group) (*string, error) {
var hasImage bool
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
Expand Down Expand Up @@ -106,9 +170,9 @@ func (r *groupResolver) BackImagePath(ctx context.Context, obj *models.Group) (*
return &imagePath, nil
}

func (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group) (ret int, err error) {
func (r *groupResolver) SceneCount(ctx context.Context, obj *models.Group, depth *int) (ret int, err error) {
if err := r.withReadTxn(ctx, func(ctx context.Context) error {
ret, err = r.repository.Scene.CountByGroupID(ctx, obj.ID)
ret, err = scene.CountByGroupID(ctx, r.repository.Scene, obj.ID, depth)
return err
}); err != nil {
return 0, err
Expand Down
Loading

0 comments on commit bcf0fda

Please sign in to comment.