From 8fe32fd778d4d58ad71928f533c5181cc6e99f7d Mon Sep 17 00:00:00 2001 From: techie2000 <38585780+techie2000@users.noreply.github.com> Date: Fri, 13 May 2022 20:03:20 +0100 Subject: [PATCH 01/76] Correct 'reload scrapes' path (#2583) --- ui/v2.5/src/docs/en/Scraping.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/docs/en/Scraping.md b/ui/v2.5/src/docs/en/Scraping.md index d95b0f285..69710fc04 100644 --- a/ui/v2.5/src/docs/en/Scraping.md +++ b/ui/v2.5/src/docs/en/Scraping.md @@ -39,7 +39,7 @@ Scrapers are added by placing yaml configuration files (format: `scrapername.yml > **⚠️ Note:** Some scrapers may require more than just the yaml file, consult the individual scraper documentation -After the yaml files are added, removed or edited while stash is running, they can be reloaded going to `Settings > Scrapers` and clicking `Reload Scrapers`. +After the yaml files are added, removed or edited while stash is running, they can be reloaded going to `Settings > Metadata Providers > Scrapers` and clicking `Reload Scrapers`. The stash community maintains a number of custom scraper configuration files that can be found [here](https://github.com/stashapp/CommunityScrapers). @@ -70,4 +70,4 @@ When used in combination with stash-box, the user can optionally submit scene fi ## Identify Task -This task iterates through your Scenes and attempts to identify the scene using a selection of scraping sources. This task can be found under `Settings -> Tasks -> "Identify..." (Button)`. For more information see the [Tasks > Identify](/help/Identify.md) page. \ No newline at end of file +This task iterates through your Scenes and attempts to identify the scene using a selection of scraping sources. This task can be found under `Settings -> Tasks -> "Identify..." (Button)`. For more information see the [Tasks > Identify](/help/Identify.md) page. From 714afd98b4e894567e9b67720378859c1ee0da27 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Sun, 20 Apr 2025 11:22:59 +0300 Subject: [PATCH 02/76] Update README to mention official forum [skip ci] --- README.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4debcfa91..dd33460e6 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,10 @@ Many community-maintained scrapers are available for download from [CommunityScr [StashDB](http://stashdb.org) is the canonical instance of our open source metadata API, [stash-box](https://github.com/stashapp/stash-box). +# Join Our Community + +We are excited to announce that we have a new home for support, feature requests, and discussions related to Stash and its associated projects. Join our community on the [Discourse forum](https://discourse.stashapp.cc) to connect with other users, share your ideas, and get help from fellow enthusiasts. + # Translation [![Translate](https://translate.stashapp.cc/widgets/stash/-/stash-desktop-client/svg-badge.svg)](https://translate.stashapp.cc/engage/stash/) 🇧🇷 🇨🇳 🇩🇰 🇳🇱 🇬🇧 🇪🇪 🇫🇮 🇫🇷 🇩🇪 🇮🇹 🇯🇵 🇰🇷 🇵🇱 🇷🇺 🇪🇸 🇸🇪 🇹🇼 🇹🇷 @@ -56,9 +60,10 @@ Stash is available in 25 languages (so far!) and it could be in your language to # Support (FAQ) -Check out our documentation on [Stash-Docs](https://docs.stashapp.cc) for information about the software, questions, guides, add-ons and more. +Check out our documentation on [Stash-Docs](https://docs.stashapp.cc) for information about the software documentation, guides. For more help you can: +* Join the community [Discourse forum](https://discourse.stashapp.cc) * Check the in-app documentation, in the top right corner of the app (it's also mirrored on [Stash-Docs](https://docs.stashapp.cc/in-app-manual)) * Join the [Matrix space](https://matrix.to/#/#stashapp:unredacted.org) * Join the [Discord server](https://discord.gg/2TsNFKt), where the community can offer support. From 41f061202526295ddaeeef5e4e5e1dd63f0a24ef Mon Sep 17 00:00:00 2001 From: Shadesbird Date: Wed, 3 Dec 2025 21:26:23 +0100 Subject: [PATCH 03/76] Update Identify.md - Add advanced settings hint (#6372) Did not find this feature by myself. Had to have a forum discussion to realise this feature exists and is hidden in the advanced settings. Added hint that this is an advanced setting. --- ui/v2.5/src/docs/en/Manual/Identify.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ui/v2.5/src/docs/en/Manual/Identify.md b/ui/v2.5/src/docs/en/Manual/Identify.md index cacd27923..c5c12d79d 100644 --- a/ui/v2.5/src/docs/en/Manual/Identify.md +++ b/ui/v2.5/src/docs/en/Manual/Identify.md @@ -1,5 +1,7 @@ # Identify +This task is part of the advanced settings mode. + This task iterates through your Scenes and attempts to identify the scene using a selection of scraping sources. This task accepts one or more scraper sources. Valid scraper sources for the Identify task are stash-box instances, and scene scrapers which support scraping via Scene Fragment. The order of the sources may be rearranged. From e02ef436a517603571860b99dfaee10d953928a6 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 Dec 2025 07:26:41 +1100 Subject: [PATCH 04/76] Fix batch tag update when studio/performer has no stash id (#6369) * Handle batch tagging where stash id not set Should search by name for these * Don't set empty stash ids --- internal/manager/task_stash_box_tag.go | 8 +++++++- pkg/models/model_scraped_item.go | 10 +++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/internal/manager/task_stash_box_tag.go b/internal/manager/task_stash_box_tag.go index d7d987a6d..37859ba61 100644 --- a/internal/manager/task_stash_box_tag.go +++ b/internal/manager/task_stash_box_tag.go @@ -88,7 +88,7 @@ func (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Contex performer = mergedPerformer } } - case t.performer != nil: + case t.performer != nil: // tagging or updating existing performer var remoteID string if err := r.WithReadTxn(ctx, func(ctx context.Context) error { qb := r.Performer @@ -123,6 +123,9 @@ func (t *stashBoxBatchPerformerTagTask) findStashBoxPerformer(ctx context.Contex performer = mergedPerformer } } + } else { + // find by performer name instead + performer, err = client.FindPerformerByName(ctx, t.performer.Name) } } @@ -328,6 +331,9 @@ func (t *stashBoxBatchStudioTagTask) findStashBoxStudio(ctx context.Context) (*m if remoteID != "" { studio, err = client.FindStudio(ctx, remoteID) + } else { + // find by studio name instead + studio, err = client.FindStudio(ctx, t.studio.Name) } } diff --git a/pkg/models/model_scraped_item.go b/pkg/models/model_scraped_item.go index 570f6034b..4254a9876 100644 --- a/pkg/models/model_scraped_item.go +++ b/pkg/models/model_scraped_item.go @@ -32,7 +32,7 @@ func (s *ScrapedStudio) ToStudio(endpoint string, excluded map[string]bool) *Stu ret := NewStudio() ret.Name = strings.TrimSpace(s.Name) - if s.RemoteSiteID != nil && endpoint != "" { + if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, @@ -141,7 +141,7 @@ func (s *ScrapedStudio) ToPartial(id string, endpoint string, excluded map[strin } } - if s.RemoteSiteID != nil && endpoint != "" { + if s.RemoteSiteID != nil && endpoint != "" && *s.RemoteSiteID != "" { ret.StashIDs = &UpdateStashIDs{ StashIDs: existingStashIDs, Mode: RelationshipUpdateModeSet, @@ -306,7 +306,7 @@ func (p *ScrapedPerformer) ToPerformer(endpoint string, excluded map[string]bool } } - if p.RemoteSiteID != nil && endpoint != "" { + if p.RemoteSiteID != nil && endpoint != "" && *p.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, @@ -435,7 +435,7 @@ func (p *ScrapedPerformer) ToPartial(endpoint string, excluded map[string]bool, } } - if p.RemoteSiteID != nil && endpoint != "" { + if p.RemoteSiteID != nil && endpoint != "" && *p.RemoteSiteID != "" { ret.StashIDs = &UpdateStashIDs{ StashIDs: existingStashIDs, Mode: RelationshipUpdateModeSet, @@ -464,7 +464,7 @@ func (t *ScrapedTag) ToTag(endpoint string, excluded map[string]bool) *Tag { ret := NewTag() ret.Name = t.Name - if t.RemoteSiteID != nil && endpoint != "" { + if t.RemoteSiteID != nil && endpoint != "" && *t.RemoteSiteID != "" { ret.StashIDs = NewRelatedStashIDs([]StashID{ { Endpoint: endpoint, From ee61fc879bc4edf1ed2e70d3f3767bd320b818f4 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 Dec 2025 07:27:47 +1100 Subject: [PATCH 05/76] Add nil check for scraped measurements (#6367) --- pkg/stashbox/performer.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/stashbox/performer.go b/pkg/stashbox/performer.go index 56d7b109e..38824eba1 100644 --- a/pkg/stashbox/performer.go +++ b/pkg/stashbox/performer.go @@ -125,8 +125,8 @@ func translateGender(gender *graphql.GenderEnum) *string { return nil } -func formatMeasurements(m graphql.MeasurementsFragment) *string { - if m.BandSize != nil && m.CupSize != nil && m.Hip != nil && m.Waist != nil { +func formatMeasurements(m *graphql.MeasurementsFragment) *string { + if m != nil && m.BandSize != nil && m.CupSize != nil && m.Hip != nil && m.Waist != nil { ret := fmt.Sprintf("%d%s-%d-%d", *m.BandSize, *m.CupSize, *m.Waist, *m.Hip) return &ret } @@ -209,7 +209,7 @@ func performerFragmentToScrapedPerformer(p graphql.PerformerFragment) *models.Sc Name: &p.Name, Disambiguation: p.Disambiguation, Country: p.Country, - Measurements: formatMeasurements(*p.Measurements), + Measurements: formatMeasurements(p.Measurements), CareerLength: formatCareerLength(p.CareerStartYear, p.CareerEndYear), Tattoos: formatBodyModifications(p.Tattoos), Piercings: formatBodyModifications(p.Piercings), From 0bc4faef2aab3dae5a97621cf527359191dd7261 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 Dec 2025 07:28:06 +1100 Subject: [PATCH 06/76] Add support for removing custom field keys (#6362) --- graphql/schema/types/metadata.graphql | 2 + internal/api/custom_fields.go | 12 ++++++ internal/api/resolver_mutation_performer.go | 9 ++-- pkg/models/custom_fields.go | 2 + pkg/sqlite/custom_fields.go | 46 ++++++++++++++++++--- pkg/sqlite/custom_fields_test.go | 38 ++++++++++++++++- 6 files changed, 98 insertions(+), 11 deletions(-) create mode 100644 internal/api/custom_fields.go diff --git a/graphql/schema/types/metadata.graphql b/graphql/schema/types/metadata.graphql index 923c25b4c..c01858f64 100644 --- a/graphql/schema/types/metadata.graphql +++ b/graphql/schema/types/metadata.graphql @@ -344,4 +344,6 @@ input CustomFieldsInput { full: Map "If populated, only the keys in this map will be updated" partial: Map + "Remove any keys in this list" + remove: [String!] } diff --git a/internal/api/custom_fields.go b/internal/api/custom_fields.go new file mode 100644 index 000000000..5eaa6f67a --- /dev/null +++ b/internal/api/custom_fields.go @@ -0,0 +1,12 @@ +package api + +import "github.com/stashapp/stash/pkg/models" + +func handleUpdateCustomFields(input models.CustomFieldsInput) models.CustomFieldsInput { + ret := input + // convert json.Numbers to int/float + ret.Full = convertMapJSONNumbers(ret.Full) + ret.Partial = convertMapJSONNumbers(ret.Partial) + + return ret +} diff --git a/internal/api/resolver_mutation_performer.go b/internal/api/resolver_mutation_performer.go index 15fb5056a..c54e3ca93 100644 --- a/internal/api/resolver_mutation_performer.go +++ b/internal/api/resolver_mutation_performer.go @@ -297,10 +297,7 @@ func (r *mutationResolver) PerformerUpdate(ctx context.Context, input models.Per return nil, fmt.Errorf("converting tag ids: %w", err) } - updatedPerformer.CustomFields = input.CustomFields - // convert json.Numbers to int/float - updatedPerformer.CustomFields.Full = convertMapJSONNumbers(updatedPerformer.CustomFields.Full) - updatedPerformer.CustomFields.Partial = convertMapJSONNumbers(updatedPerformer.CustomFields.Partial) + updatedPerformer.CustomFields = handleUpdateCustomFields(input.CustomFields) var imageData []byte imageIncluded := translator.hasField("image") @@ -417,6 +414,10 @@ func (r *mutationResolver) BulkPerformerUpdate(ctx context.Context, input BulkPe return nil, fmt.Errorf("converting tag ids: %w", err) } + if input.CustomFields != nil { + updatedPerformer.CustomFields = handleUpdateCustomFields(*input.CustomFields) + } + ret := []*models.Performer{} // Start the transaction and save the performers diff --git a/pkg/models/custom_fields.go b/pkg/models/custom_fields.go index 977c2fe89..5c3acd18b 100644 --- a/pkg/models/custom_fields.go +++ b/pkg/models/custom_fields.go @@ -9,6 +9,8 @@ type CustomFieldsInput struct { Full map[string]interface{} `json:"full"` // If populated, only the keys in this map will be updated Partial map[string]interface{} `json:"partial"` + // Remove any keys in this list + Remove []string `json:"remove"` } type CustomFieldsReader interface { diff --git a/pkg/sqlite/custom_fields.go b/pkg/sqlite/custom_fields.go index bac6ae5e1..63f85b250 100644 --- a/pkg/sqlite/custom_fields.go +++ b/pkg/sqlite/custom_fields.go @@ -41,18 +41,31 @@ func (s *customFieldsStore) SetCustomFields(ctx context.Context, id int, values case values.Partial != nil: partial = true valMap = values.Partial - default: - return nil } - if err := s.validateCustomFields(valMap); err != nil { + if valMap != nil { + if err := s.validateCustomFields(valMap, values.Remove); err != nil { + return err + } + + if err := s.setCustomFields(ctx, id, valMap, partial); err != nil { + return err + } + } + + if err := s.deleteCustomFields(ctx, id, values.Remove); err != nil { return err } - return s.setCustomFields(ctx, id, valMap, partial) + return nil } -func (s *customFieldsStore) validateCustomFields(values map[string]interface{}) error { +func (s *customFieldsStore) validateCustomFields(values map[string]interface{}, deleteKeys []string) error { + // if values is nil, nothing to validate + if values == nil { + return nil + } + // ensure that custom field names are valid // no leading or trailing whitespace, no empty strings for k := range values { @@ -61,6 +74,13 @@ func (s *customFieldsStore) validateCustomFields(values map[string]interface{}) } } + // ensure delete keys are not also in values + for _, k := range deleteKeys { + if _, ok := values[k]; ok { + return fmt.Errorf("custom field name %q cannot be in both values and delete keys", k) + } + } + return nil } @@ -130,6 +150,22 @@ func (s *customFieldsStore) setCustomFields(ctx context.Context, id int, values return nil } +func (s *customFieldsStore) deleteCustomFields(ctx context.Context, id int, keys []string) error { + if len(keys) == 0 { + return nil + } + + q := dialect.Delete(s.table). + Where(s.fk.Eq(id)). + Where(goqu.I("field").In(keys)) + + if _, err := exec(ctx, q); err != nil { + return fmt.Errorf("deleting custom fields: %w", err) + } + + return nil +} + func (s *customFieldsStore) GetCustomFields(ctx context.Context, id int) (map[string]interface{}, error) { q := dialect.Select("field", "value").From(s.table).Where(s.fk.Eq(id)) diff --git a/pkg/sqlite/custom_fields_test.go b/pkg/sqlite/custom_fields_test.go index ce5c77487..8ee154aec 100644 --- a/pkg/sqlite/custom_fields_test.go +++ b/pkg/sqlite/custom_fields_test.go @@ -64,6 +64,18 @@ func TestSetCustomFields(t *testing.T) { }), false, }, + { + "valid remove", + models.CustomFieldsInput{ + Remove: []string{"real"}, + }, + func() map[string]interface{} { + m := getPerformerCustomFields(performerIdx) + delete(m, "real") + return m + }(), + false, + }, { "leading space full", models.CustomFieldsInput{ @@ -144,16 +156,38 @@ func TestSetCustomFields(t *testing.T) { nil, true, }, + { + "invalid remove full", + models.CustomFieldsInput{ + Full: map[string]interface{}{ + "key": "value", + }, + Remove: []string{"key"}, + }, + nil, + true, + }, + { + "invalid remove partial", + models.CustomFieldsInput{ + Partial: map[string]interface{}{ + "real": float64(4.56), + }, + Remove: []string{"real"}, + }, + nil, + true, + }, } // use performer custom fields store store := db.Performer id := performerIDs[performerIdx] - assert := assert.New(t) - for _, tt := range tests { runWithRollbackTxn(t, tt.name, func(t *testing.T, ctx context.Context) { + assert := assert.New(t) + err := store.SetCustomFields(ctx, id, tt.input) if (err != nil) != tt.wantErr { t.Errorf("SetCustomFields() error = %v, wantErr %v", err, tt.wantErr) From 63e8830db47afb7656a167c389b49fff0fb61492 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 4 Dec 2025 07:28:30 +1100 Subject: [PATCH 07/76] Truncate custom field display to 5 lines (#6361) --- ui/v2.5/src/components/Shared/CustomFields.tsx | 3 ++- ui/v2.5/src/components/Shared/styles.scss | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/ui/v2.5/src/components/Shared/CustomFields.tsx b/ui/v2.5/src/components/Shared/CustomFields.tsx index a522961a8..e7355df66 100644 --- a/ui/v2.5/src/components/Shared/CustomFields.tsx +++ b/ui/v2.5/src/components/Shared/CustomFields.tsx @@ -8,6 +8,7 @@ import { Icon } from "./Icon"; import { faMinus, faPlus } from "@fortawesome/free-solid-svg-icons"; import cx from "classnames"; import { PatchComponent } from "src/patch"; +import { TruncatedText } from "./TruncatedText"; const maxFieldNameLength = 64; @@ -47,7 +48,7 @@ const CustomField: React.FC<{ field: string; value: unknown }> = ({ id={id} label={field} labelTitle={field} - value={valueStr} + value={{valueStr}} />} fullWidth={true} showEmpty /> diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index f7ad76e9d..55dff9d0f 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -712,6 +712,10 @@ button.btn.favorite-button { .custom-fields { width: 100%; + + .detail-item { + max-width: 100%; + } } .custom-fields .detail-item .detail-item-title { @@ -721,6 +725,14 @@ button.btn.favorite-button { white-space: nowrap; } +.custom-fields .detail-item .detail-item-value { + word-break: break-word; + + .TruncatedText { + white-space: pre-line; + } +} + .custom-fields-input > .collapse-button { font-weight: 700; } From 3d044896ad21c72a661a45e51616dfd9be7beb34 Mon Sep 17 00:00:00 2001 From: DogmaDragon <103123951+DogmaDragon@users.noreply.github.com> Date: Wed, 3 Dec 2025 22:48:36 +0200 Subject: [PATCH 08/76] Update Auto Tag/Identify documentation (#6371) * Update Auto Tag documentation * Update Identify documentation --- ui/v2.5/src/docs/en/Manual/AutoTagging.md | 52 ++++++++++++++++++----- ui/v2.5/src/docs/en/Manual/Identify.md | 39 +++++++++++------ 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/ui/v2.5/src/docs/en/Manual/AutoTagging.md b/ui/v2.5/src/docs/en/Manual/AutoTagging.md index c311bed7a..4b1cbb813 100644 --- a/ui/v2.5/src/docs/en/Manual/AutoTagging.md +++ b/ui/v2.5/src/docs/en/Manual/AutoTagging.md @@ -1,18 +1,48 @@ -# Auto Tagging +# Auto Tag -When media filepaths or filenames contain a Performer, Studio, or Tag name, it is assigned those Performers, Studios, and Tags. It will **only** tag based on Performer, Studio, and Tag names that exist in your database. +Auto Tag automatically assigns Performers, Studios, and Tags to your media based on their names found in file paths or filenames. This task works for scenes, images, and galleries. -When the Performer/Studio/Tag name has multiple words, the search will include paths/filenames where the Performer/Studio/Tag name is separated with `.`, `-`, `_`, and whitespace characters. +This task is part of the advanced settings mode. -For example, auto tagging for performer `Jane Doe` will match the following filenames: +## Rules -* `Jane.Doe.1.mp4` -* `Jane_Doe.2.mp4` -* `Jane-Doe.3.mp4` -* `Jane Doe.4.mp4` +> **Important:** Auto Tag only works for names that already exist in your Stash database. It does not create new Performers, Studios, or Tags. -Matching is case insensitive, and should only match exact wording within word boundaries. For example, the tag `Jane Doe` will not match `Maryjane-Doe` or `Jane-Doen`, but will match `Mary-Jane-Doe`, `Jane-Doe_n`, and `[OF]jane doe`. + - Multi-word names are matched when words appear in order and are separated by any of these characters: `.`, `-`, `_`, or whitespace. These separators are treated as word boundaries. + - Matching is case-insensitive but requires complete words within word boundaries. Partial words or misspelled words will not match. + - Auto Tag does not match performer aliases. Aliases will not be considered during matching. -Auto tagging for specific Performers, Studios, and Tags can be performed from the individual Performer/Studio/Tag page. +### Examples (performer "Jane Doe") -> **Note:** Performer autotagging does not currently match on performer aliases. \ No newline at end of file +**Matches:** + +| Example | Explanation | +|---|---| +| `Jane.Doe.1.mp4` | Dot as separator. | +| `Jane_Doe.2.mp4` | Underscore as separator. | +| `Jane-Doe.3.mp4` | Hyphen as separator. | +| `Jane Doe.4.mp4` | Whitespace as separator. | +| `Mary-Jane-Doe` | Extra characters around word boundaries are allowed. | +| `Jane-Doe_n` | Extra characters around word boundaries are allowed. | +| `[OF]jane doe` | Extra characters around word boundaries are allowed. | + +**Does not match:** + +| Example | Explanation | +|---|---| +| `Maryjane-Doe` | Combined words without separator. | +| `Jane-Doen` | Spelling mismatch. | + +### Organized flag + +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. + +### Ignore Auto Tag flag + +Performers or Tags that have Ignore Auto Tag flag added to them will be skipped by the Auto Tag task. + +## Running task + +- **Auto Tag:** You can run the Auto Tag task on your entire library from the Tasks page. +- **Selective Auto Tag:** You can run the Auto Tag task on specific directories from the Tasks page. +- **Individual pages:** You can run Auto Tag tasks for specific Performers, Studios, and Tags from their respective pages. diff --git a/ui/v2.5/src/docs/en/Manual/Identify.md b/ui/v2.5/src/docs/en/Manual/Identify.md index c5c12d79d..724a392a3 100644 --- a/ui/v2.5/src/docs/en/Manual/Identify.md +++ b/ui/v2.5/src/docs/en/Manual/Identify.md @@ -1,37 +1,50 @@ # Identify +The Identify task iterates through your Scenes and attempts to identify them using a selection of scraping sources. If a result is found in a source, the Scene is updated, and no further sources are checked for that scene. + This task is part of the advanced settings mode. -This task iterates through your Scenes and attempts to identify the scene using a selection of scraping sources. +## Rules -This task accepts one or more scraper sources. Valid scraper sources for the Identify task are stash-box instances, and scene scrapers which support scraping via Scene Fragment. The order of the sources may be rearranged. +- The task accepts one or more scraper sources, including stash-box instances and scene scrapers that support scraping via Scene Fragment. The order of the sources can be rearranged. +- The task iterates through the scraper sources in the provided order. +- If a result is found in a source, the Scene is updated, and further sources are not checked for that scene. -For each Scene, the Identify task iterates through the scraper sources, in the order provided, and tries to identify the scene using each source. If a result is found in a source, then the Scene is updated, and no further sources are checked for that scene. +### Organized flag + +Scenes that have the Organized flag added to them will not be modified by Identify. You can also use Organized flag status as a filter. ## Options -The following options can be set: +The following options can be configured: | Option | Description | |--------|-------------| -| Include male performers | If false, then male performers will not be created or set on scenes. | -| Set cover images | If false, then scene cover images will not be modified. | -| Set organised flag | If true, the organised flag is set to true when a scene is organised. | +| Include male performers | If false, male performers will not be created or set on scenes. | +| Set cover images | If false, scene cover images will not be modified. | +| Set organized flag | If true, the organized flag is set to true when a scene is organized. | | Skip matches that have more than one result | If this is not enabled and more than one result is returned, one will be randomly chosen to match | | Tag skipped matches with | If the above option is set and a scene is skipped, this will add the tag so that you can filter for it in the Scene Tagger view and choose the correct match by hand | | Skip single name performers with no disambiguation | If this is not enabled, performers that are often generic like Samantha or Olga will be matched | -| Tag skipped performers with | If the above options is set and a performer is skipped, this will add the tag so that you can filter for in it the Scene Tagger view and choose how you want to handle those performers | +| Tag skipped performers with | If the above option is set and a performer is skipped, this will add the tag so that you can filter for it in the Scene Tagger view and choose how you want to handle those performers | -Field specific options may be set as well. Each field may have a Strategy. The behaviour for each strategy value is as follows: +### Field specific options + +Each field may have a strategy. The behavior for each strategy is as follows: | Strategy | Description | |----------|-------------| -| Ignore | Not set. | -| Overwrite | Overwrite existing value. | +| Ignore | The field is not set. | +| Overwrite | Existing values are overwritten. | | Merge (*default*) | For multi-value fields, adds to existing values. For single-value fields, only sets if not already set. | -For Studio, Performers and Tags, an option is also available to Create Missing objects. This is enabled by default. When true, if a Studio/Performer/Tag is included during the identification process and does not exist in the system, then it will be created. +For Studio, Performers, and Tags, an option is available to **Create Missing Objects**. This is enabled by default. When true, if a Studio/Performer/Tag is included during the identification process and does not exist in the system, it will be created. -Default Options are applied to all sources unless overridden in specific source options. +## Running task + +- **Identify...:** Run the Identify task on your entire library from the Tasks page. +- **Selective Identify:** Configure and run the Identify task on specific directories from Tasks > Identify... page. At the top of the page click folder icon to select directories. + +## Logs The result of the identification process for each scene is output to the log. From 877491e62bb6efd405345dbdb9acf6a8a4473717 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Wed, 3 Dec 2025 16:09:49 -0600 Subject: [PATCH 09/76] Manually Search Stash ID - Edit Page - Scenes, Studios (#6340) --- internal/api/resolver_query_scraper.go | 2 +- pkg/stashbox/graphql/generated_client.go | 42 ++++ .../PerformerDetails/PerformerEditPanel.tsx | 22 +- .../Scenes/SceneDetails/SceneEditPanel.tsx | 39 +++- .../Shared/StashBoxIDSearchModal.tsx | 197 ++++++++++++++++-- .../Studios/StudioDetails/StudioEditPanel.tsx | 50 ++++- ui/v2.5/src/locales/en-GB.json | 1 + ui/v2.5/src/utils/stashIds.ts | 24 +++ 8 files changed, 339 insertions(+), 38 deletions(-) diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index 5875cd11e..4d77f227d 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -350,7 +350,7 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S return nil, nil } - return nil, errors.New("stash_box_index must be set") + return nil, errors.New("stash_box_endpoint must be set") } func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scraper.Source, input ScrapeSinglePerformerInput) ([]*models.ScrapedPerformer, error) { diff --git a/pkg/stashbox/graphql/generated_client.go b/pkg/stashbox/graphql/generated_client.go index 90553b14a..e2a18352e 100644 --- a/pkg/stashbox/graphql/generated_client.go +++ b/pkg/stashbox/graphql/generated_client.go @@ -17,6 +17,7 @@ type StashBoxGraphQLClient interface { FindPerformerByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindPerformerByID, error) FindSceneByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindSceneByID, error) FindStudio(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindStudio, error) + FindTag(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindTag, error) SubmitFingerprint(ctx context.Context, input FingerprintSubmission, interceptors ...clientv2.RequestInterceptor) (*SubmitFingerprint, error) Me(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*Me, error) SubmitSceneDraft(ctx context.Context, input SceneDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitSceneDraft, error) @@ -763,6 +764,17 @@ func (t *FindStudio) GetFindStudio() *StudioFragment { return t.FindStudio } +type FindTag struct { + FindTag *TagFragment "json:\"findTag,omitempty\" graphql:\"findTag\"" +} + +func (t *FindTag) GetFindTag() *TagFragment { + if t == nil { + t = &FindTag{} + } + return t.FindTag +} + type SubmitFingerprint struct { SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" } @@ -1695,6 +1707,35 @@ func (c *Client) FindStudio(ctx context.Context, id *string, name *string, inter return &res, nil } +const FindTagDocument = `query FindTag ($id: ID, $name: String) { + findTag(id: $id, name: $name) { + ... TagFragment + } +} +fragment TagFragment on Tag { + name + id +} +` + +func (c *Client) FindTag(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindTag, error) { + vars := map[string]any{ + "id": id, + "name": name, + } + + var res FindTag + if err := c.Client.Post(ctx, "FindTag", FindTagDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + const SubmitFingerprintDocument = `mutation SubmitFingerprint ($input: FingerprintSubmission!) { submitFingerprint(input: $input) } @@ -1796,6 +1837,7 @@ var DocumentOperationNames = map[string]string{ FindPerformerByIDDocument: "FindPerformerByID", FindSceneByIDDocument: "FindSceneByID", FindStudioDocument: "FindStudio", + FindTagDocument: "FindTag", SubmitFingerprintDocument: "SubmitFingerprint", MeDocument: "Me", SubmitSceneDraftDocument: "SubmitSceneDraft", diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 55bd20910..8d1352da0 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -15,7 +15,7 @@ import { ImageInput } from "src/components/Shared/ImageInput"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { CountrySelect } from "src/components/Shared/CountrySelect"; import ImageUtils from "src/utils/image"; -import { getStashIDs } from "src/utils/stashIds"; +import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { stashboxDisplayName } from "src/utils/stashbox"; import { useToast } from "src/hooks/Toast"; import { Prompt } from "react-router-dom"; @@ -574,23 +574,10 @@ export const PerformerEditPanel: React.FC = ({ function onStashIDSelected(item?: GQL.StashIdInput) { if (!item) return; - - // Check if StashID with this endpoint already exists - const existingIndex = formik.values.stash_ids.findIndex( - (s) => s.endpoint === item.endpoint + formik.setFieldValue( + "stash_ids", + addUpdateStashID(formik.values.stash_ids, item) ); - - let newStashIDs; - if (existingIndex >= 0) { - // Replace existing StashID - newStashIDs = [...formik.values.stash_ids]; - newStashIDs[existingIndex] = item; - } else { - // Add new StashID - newStashIDs = [...formik.values.stash_ids, item]; - } - - formik.setFieldValue("stash_ids", newStashIDs); } function renderButtons(classNames: string) { @@ -685,6 +672,7 @@ export const PerformerEditPanel: React.FC = ({ {maybeRenderScrapeDialog()} {isStashIDSearchOpen && ( s.endpoint diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index e56ea265b..11575ea7b 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -16,12 +16,12 @@ import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { ImageInput } from "src/components/Shared/ImageInput"; import { useToast } from "src/hooks/Toast"; import ImageUtils from "src/utils/image"; -import { getStashIDs } from "src/utils/stashIds"; +import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import { useConfigurationContext } from "src/hooks/Config"; import { IGroupEntry, SceneGroupTable } from "./SceneGroupTable"; -import { faSearch } from "@fortawesome/free-solid-svg-icons"; +import { faSearch, faPlus } from "@fortawesome/free-solid-svg-icons"; import { objectTitle } from "src/core/files"; import { galleryTitle } from "src/core/galleries"; import { lazyComponent } from "src/utils/lazyComponent"; @@ -41,6 +41,7 @@ import { Gallery, GallerySelect } from "src/components/Galleries/GallerySelect"; import { Group } from "src/components/Groups/GroupSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; +import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -77,6 +78,8 @@ export const SceneEditPanel: React.FC = ({ const [scraper, setScraper] = useState(); const [isScraperQueryModalOpen, setIsScraperQueryModalOpen] = useState(false); + const [isStashIDSearchOpen, setIsStashIDSearchOpen] = + useState(false); const [scrapedScene, setScrapedScene] = useState(); const [endpoint, setEndpoint] = useState(); @@ -547,6 +550,14 @@ export const SceneEditPanel: React.FC = ({ } } + function onStashIDSelected(item?: GQL.StashIdInput) { + if (!item) return; + formik.setFieldValue( + "stash_ids", + addUpdateStashID(formik.values.stash_ids, item) + ); + } + const image = useMemo(() => { if (encodingImage) { return ( @@ -696,6 +707,19 @@ export const SceneEditPanel: React.FC = ({ {renderScrapeQueryModal()} {maybeRenderScrapeDialog()} + {isStashIDSearchOpen && ( + s.endpoint + )} + onSelectItem={(item) => { + onStashIDSelected(item); + setIsStashIDSearchOpen(false); + }} + /> + )}
@@ -761,7 +785,16 @@ export const SceneEditPanel: React.FC = ({ "stash_ids", "scenes", "stash_ids", - fullWidthProps + fullWidthProps, + )} diff --git a/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx b/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx index 0b11a6d25..790e6aed9 100644 --- a/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx +++ b/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx @@ -11,11 +11,23 @@ import TextUtils from "src/utils/text"; import GenderIcon from "src/components/Performers/GenderIcon"; import { CountryFlag } from "src/components/Shared/CountryFlag"; import { Icon } from "src/components/Shared/Icon"; -import { stashBoxPerformerQuery } from "src/core/StashService"; +import { + stashBoxPerformerQuery, + stashBoxSceneQuery, + stashBoxStudioQuery, +} from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import { stringToGender } from "src/utils/gender"; +type SearchResultItem = + | GQL.ScrapedPerformerDataFragment + | GQL.ScrapedSceneDataFragment + | GQL.ScrapedStudioDataFragment; + +export type StashBoxEntityType = "performer" | "scene" | "studio"; + interface IProps { + entityType: StashBoxEntityType; stashBoxes: GQL.StashBox[]; excludedStashBoxEndpoints?: string[]; onSelectItem: (item?: GQL.StashIdInput) => void; @@ -132,8 +144,121 @@ export const PerformerSearchResult: React.FC = ({ ); }; +// Scene Result Component +interface ISceneResultProps { + scene: GQL.ScrapedSceneDataFragment; +} + +const SceneSearchResultDetails: React.FC = ({ scene }) => { + return ( +
+ + +
+

+ {scene.title} + {scene.code && ( + {` (${scene.code})`} + )} +

+
+ {scene.studio?.name && {scene.studio.name}} + {scene.date && ( + {` • ${scene.date}`} + )} +
+ {scene.performers && scene.performers.length > 0 && ( +
+ {scene.performers.map((p) => p.name).join(", ")} +
+ )} +
+
+ + + + + + +
+ ); +}; + +export const SceneSearchResult: React.FC = ({ scene }) => { + return ( +
+ +
+ ); +}; + +// Studio Result Component +interface IStudioResultProps { + studio: GQL.ScrapedStudioDataFragment; +} + +const StudioSearchResultDetails: React.FC = ({ + studio, +}) => { + return ( +
+ + +
+

+ {studio.name} +

+ {studio.parent?.name && ( +
+ {studio.parent.name} +
+ )} + {studio.urls && studio.urls.length > 0 && ( +
{studio.urls[0]}
+ )} +
+
+
+ ); +}; + +export const StudioSearchResult: React.FC = ({ + studio, +}) => { + return ( +
+ +
+ ); +}; + +// Helper to get entity type message id for i18n +function getEntityTypeMessageId(entityType: StashBoxEntityType): string { + switch (entityType) { + case "performer": + return "performer"; + case "scene": + return "scene"; + case "studio": + return "studio"; + } +} + +// Helper to get the "found" message id based on entity type +function getFoundMessageId(entityType: StashBoxEntityType): string { + switch (entityType) { + case "performer": + return "dialogs.performers_found"; + case "scene": + return "dialogs.scenes_found"; + case "studio": + return "dialogs.studios_found"; + } +} + // Main Modal Component export const StashBoxIDSearchModal: React.FC = ({ + entityType, stashBoxes, excludedStashBoxEndpoints = [], onSelectItem, @@ -146,9 +271,9 @@ export const StashBoxIDSearchModal: React.FC = ({ null ); const [query, setQuery] = useState(""); - const [results, setResults] = useState< - GQL.ScrapedPerformerDataFragment[] | undefined - >(undefined); + const [results, setResults] = useState( + undefined + ); const [loading, setLoading] = useState(false); useEffect(() => { @@ -168,17 +293,38 @@ export const StashBoxIDSearchModal: React.FC = ({ setResults([]); try { - const queryData = await stashBoxPerformerQuery( - query, - selectedStashBox.endpoint - ); - setResults(queryData.data?.scrapeSinglePerformer ?? []); + switch (entityType) { + case "performer": { + const queryData = await stashBoxPerformerQuery( + query, + selectedStashBox.endpoint + ); + setResults(queryData.data?.scrapeSinglePerformer ?? []); + break; + } + case "scene": { + const queryData = await stashBoxSceneQuery( + query, + selectedStashBox.endpoint + ); + setResults(queryData.data?.scrapeSingleScene ?? []); + break; + } + case "studio": { + const queryData = await stashBoxStudioQuery( + query, + selectedStashBox.endpoint + ); + setResults(queryData.data?.scrapeSingleStudio ?? []); + break; + } + } } catch (error) { Toast.error(error); } finally { setLoading(false); } - }, [query, selectedStashBox, Toast]); + }, [query, selectedStashBox, Toast, entityType]); function handleItemClick(item: IHasRemoteSiteID) { if (selectedStashBox && item.remote_site_id) { @@ -195,6 +341,25 @@ export const StashBoxIDSearchModal: React.FC = ({ onSelectItem(undefined); } + function renderResultItem(item: SearchResultItem) { + switch (entityType) { + case "performer": + return ( + + ); + case "scene": + return ( + + ); + case "studio": + return ( + + ); + } + } + function renderResults() { if (!results || results.length === 0) { return null; @@ -204,14 +369,14 @@ export const StashBoxIDSearchModal: React.FC = ({
    {results.map((item, i) => (
  • handleItemClick(item)}> - + {renderResultItem(item)}
  • ))}
@@ -219,13 +384,17 @@ export const StashBoxIDSearchModal: React.FC = ({ ); } + const entityTypeDisplayName = intl.formatMessage({ + id: getEntityTypeMessageId(entityType), + }); + return ( = ({ value={query} placeholder={intl.formatMessage( { id: "stashbox_search.placeholder_name_or_id" }, - { entityType: "Performer" } + { entityType: entityTypeDisplayName } )} className="text-input" ref={inputRef} diff --git a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx index 264afdc7c..c8cfd3a3e 100644 --- a/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx +++ b/ui/v2.5/src/components/Studios/StudioDetails/StudioEditPanel.tsx @@ -5,18 +5,22 @@ import * as yup from "yup"; import Mousetrap from "mousetrap"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; -import { Form } from "react-bootstrap"; +import { Button, Form } from "react-bootstrap"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; import ImageUtils from "src/utils/image"; -import { getStashIDs } from "src/utils/stashIds"; +import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; import isEqual from "lodash-es/isEqual"; import { useToast } from "src/hooks/Toast"; +import { useConfigurationContext } from "src/hooks/Config"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup"; import { Studio, StudioSelect } from "../StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; +import { Icon } from "src/components/Shared/Icon"; +import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; interface IStudioEditPanel { studio: Partial; @@ -37,9 +41,13 @@ export const StudioEditPanel: React.FC = ({ }) => { const intl = useIntl(); const Toast = useToast(); + const { configuration: stashConfig } = useConfigurationContext(); const isNew = studio.id === undefined; + // Editing state + const [isStashIDSearchOpen, setIsStashIDSearchOpen] = useState(false); + // Network state const [isLoading, setIsLoading] = useState(false); @@ -143,6 +151,14 @@ export const StudioEditPanel: React.FC = ({ ImageUtils.onImageChange(event, onImageLoad); } + function onStashIDSelected(item?: GQL.StashIdInput) { + if (!item) return; + formik.setFieldValue( + "stash_ids", + addUpdateStashID(formik.values.stash_ids, item) + ); + } + const { renderField, renderInputField, @@ -173,6 +189,20 @@ export const StudioEditPanel: React.FC = ({ return ( <> + {isStashIDSearchOpen && ( + s.endpoint + )} + onSelectItem={(item) => { + onStashIDSelected(item); + setIsStashIDSearchOpen(false); + }} + /> + )} + { @@ -191,7 +221,21 @@ export const StudioEditPanel: React.FC = ({ {renderInputField("details", "textarea")} {renderParentStudioField()} {renderTagsField()} - {renderStashIDsField("stash_ids", "studios")} + {renderStashIDsField( + "stash_ids", + "studios", + "stash_ids", + undefined, + + )}
{renderInputField("ignore_auto_tag", "checkbox")} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index e4c8b6a7c..c2b9b4247 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1015,6 +1015,7 @@ "video_previews_tooltip": "Video previews which play when hovering over a scene" }, "scenes_found": "{count} scenes found", + "studios_found": "{count} studios found", "scrape_entity_query": "{entity_type} Scrape Query", "scrape_entity_title": "{entity_type} Scrape Results", "scrape_results_existing": "Existing", diff --git a/ui/v2.5/src/utils/stashIds.ts b/ui/v2.5/src/utils/stashIds.ts index f44b182ab..92a4eaf1e 100644 --- a/ui/v2.5/src/utils/stashIds.ts +++ b/ui/v2.5/src/utils/stashIds.ts @@ -1,3 +1,5 @@ +import * as GQL from "src/core/generated-graphql"; + export const getStashIDs = ( ids?: { stash_id: string; endpoint: string; updated_at: string }[] ) => @@ -32,3 +34,25 @@ export const separateNamesAndStashIds = ( return { names, stashIds }; }; + +/** + * Utility to add or update a StashID in an array. + * If a StashID with the same endpoint exists, it will be replaced. + * Otherwise, the new StashID will be appended. + */ +export const addUpdateStashID = ( + existingStashIDs: GQL.StashIdInput[], + newItem: GQL.StashIdInput +): GQL.StashIdInput[] => { + const existingIndex = existingStashIDs.findIndex( + (s) => s.endpoint === newItem.endpoint + ); + + if (existingIndex >= 0) { + const newStashIDs = [...existingStashIDs]; + newStashIDs[existingIndex] = newItem; + return newStashIDs; + } + + return [...existingStashIDs, newItem]; +}; From 39fd8a65502fc63bc6807bbb05673422619f58aa Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Wed, 3 Dec 2025 18:20:29 -0600 Subject: [PATCH 10/76] Feature: Manual StashId Search - Tags (#6374) --- graphql/schema/schema.graphql | 6 + graphql/schema/types/scraper.graphql | 7 + graphql/stash-box/query.graphql | 15 ++ internal/api/resolver_query_scraper.go | 39 +++++ pkg/stashbox/graphql/generated_client.go | 62 ++++++++ pkg/stashbox/tag.go | 67 ++++++++ .../graphql/queries/scrapers/scrapers.graphql | 9 ++ .../Shared/StashBoxIDSearchModal.tsx | 43 +++++- .../Tags/TagDetails/TagEditPanel.tsx | 144 ++++++++++++------ ui/v2.5/src/core/StashService.ts | 17 +++ ui/v2.5/src/locales/en-GB.json | 1 + 11 files changed, 360 insertions(+), 50 deletions(-) create mode 100644 pkg/stashbox/tag.go diff --git a/graphql/schema/schema.graphql b/graphql/schema/schema.graphql index 2a9d067ae..8936b8a34 100644 --- a/graphql/schema/schema.graphql +++ b/graphql/schema/schema.graphql @@ -165,6 +165,12 @@ type Query { input: ScrapeSingleStudioInput! ): [ScrapedStudio!]! + "Scrape for a single tag" + scrapeSingleTag( + source: ScraperSourceInput! + input: ScrapeSingleTagInput! + ): [ScrapedTag!]! + "Scrape for a single performer" scrapeSinglePerformer( source: ScraperSourceInput! diff --git a/graphql/schema/types/scraper.graphql b/graphql/schema/types/scraper.graphql index cb193f47d..9c0e33fdf 100644 --- a/graphql/schema/types/scraper.graphql +++ b/graphql/schema/types/scraper.graphql @@ -198,6 +198,13 @@ input ScrapeSingleStudioInput { query: String } +input ScrapeSingleTagInput { + """ + Query can be either a name or a Stash ID + """ + query: String +} + input ScrapeSinglePerformerInput { "Instructs to query by string" query: String diff --git a/graphql/stash-box/query.graphql b/graphql/stash-box/query.graphql index 4fa023070..2367e85cf 100644 --- a/graphql/stash-box/query.graphql +++ b/graphql/stash-box/query.graphql @@ -170,6 +170,21 @@ query FindStudio($id: ID, $name: String) { } } +query FindTag($id: ID, $name: String) { + findTag(id: $id, name: $name) { + ...TagFragment + } +} + +query QueryTags($input: TagQueryInput!) { + queryTags(input: $input) { + count + tags { + ...TagFragment + } + } +} + mutation SubmitFingerprint($input: FingerprintSubmission!) { submitFingerprint(input: $input) } diff --git a/internal/api/resolver_query_scraper.go b/internal/api/resolver_query_scraper.go index 4d77f227d..86d449921 100644 --- a/internal/api/resolver_query_scraper.go +++ b/internal/api/resolver_query_scraper.go @@ -353,6 +353,45 @@ func (r *queryResolver) ScrapeSingleStudio(ctx context.Context, source scraper.S return nil, errors.New("stash_box_endpoint must be set") } +func (r *queryResolver) ScrapeSingleTag(ctx context.Context, source scraper.Source, input ScrapeSingleTagInput) ([]*models.ScrapedTag, error) { + if source.StashBoxIndex != nil || source.StashBoxEndpoint != nil { + b, err := resolveStashBox(source.StashBoxIndex, source.StashBoxEndpoint) + if err != nil { + return nil, err + } + + client := r.newStashBoxClient(*b) + + var ret []*models.ScrapedTag + out, err := client.QueryTag(ctx, *input.Query) + + if err != nil { + return nil, err + } else if out != nil { + ret = append(ret, out...) + } + + if len(ret) > 0 { + if err := r.withReadTxn(ctx, func(ctx context.Context) error { + for _, tag := range ret { + if err := match.ScrapedTag(ctx, r.repository.Tag, tag, b.Endpoint); err != nil { + return err + } + } + + return nil + }); err != nil { + return nil, err + } + return ret, nil + } + + return nil, nil + } + + return nil, errors.New("stash_box_endpoint must be set") +} + func (r *queryResolver) ScrapeSinglePerformer(ctx context.Context, source scraper.Source, input ScrapeSinglePerformerInput) ([]*models.ScrapedPerformer, error) { var ret []*models.ScrapedPerformer switch { diff --git a/pkg/stashbox/graphql/generated_client.go b/pkg/stashbox/graphql/generated_client.go index e2a18352e..640a1c893 100644 --- a/pkg/stashbox/graphql/generated_client.go +++ b/pkg/stashbox/graphql/generated_client.go @@ -18,6 +18,7 @@ type StashBoxGraphQLClient interface { FindSceneByID(ctx context.Context, id string, interceptors ...clientv2.RequestInterceptor) (*FindSceneByID, error) FindStudio(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindStudio, error) FindTag(ctx context.Context, id *string, name *string, interceptors ...clientv2.RequestInterceptor) (*FindTag, error) + QueryTags(ctx context.Context, input TagQueryInput, interceptors ...clientv2.RequestInterceptor) (*QueryTags, error) SubmitFingerprint(ctx context.Context, input FingerprintSubmission, interceptors ...clientv2.RequestInterceptor) (*SubmitFingerprint, error) Me(ctx context.Context, interceptors ...clientv2.RequestInterceptor) (*Me, error) SubmitSceneDraft(ctx context.Context, input SceneDraftInput, interceptors ...clientv2.RequestInterceptor) (*SubmitSceneDraft, error) @@ -643,6 +644,24 @@ func (t *FindStudio_FindStudio_StudioFragment_Parent) GetName() string { return t.Name } +type QueryTags_QueryTags struct { + Count int "json:\"count\" graphql:\"count\"" + Tags []*TagFragment "json:\"tags\" graphql:\"tags\"" +} + +func (t *QueryTags_QueryTags) GetCount() int { + if t == nil { + t = &QueryTags_QueryTags{} + } + return t.Count +} +func (t *QueryTags_QueryTags) GetTags() []*TagFragment { + if t == nil { + t = &QueryTags_QueryTags{} + } + return t.Tags +} + type Me_Me struct { Name string "json:\"name\" graphql:\"name\"" } @@ -775,6 +794,17 @@ func (t *FindTag) GetFindTag() *TagFragment { return t.FindTag } +type QueryTags struct { + QueryTags QueryTags_QueryTags "json:\"queryTags\" graphql:\"queryTags\"" +} + +func (t *QueryTags) GetQueryTags() *QueryTags_QueryTags { + if t == nil { + t = &QueryTags{} + } + return &t.QueryTags +} + type SubmitFingerprint struct { SubmitFingerprint bool "json:\"submitFingerprint\" graphql:\"submitFingerprint\"" } @@ -1736,6 +1766,37 @@ func (c *Client) FindTag(ctx context.Context, id *string, name *string, intercep return &res, nil } +const QueryTagsDocument = `query QueryTags ($input: TagQueryInput!) { + queryTags(input: $input) { + count + tags { + ... TagFragment + } + } +} +fragment TagFragment on Tag { + name + id +} +` + +func (c *Client) QueryTags(ctx context.Context, input TagQueryInput, interceptors ...clientv2.RequestInterceptor) (*QueryTags, error) { + vars := map[string]any{ + "input": input, + } + + var res QueryTags + if err := c.Client.Post(ctx, "QueryTags", QueryTagsDocument, &res, vars, interceptors...); err != nil { + if c.Client.ParseDataWhenErrors { + return &res, err + } + + return nil, err + } + + return &res, nil +} + const SubmitFingerprintDocument = `mutation SubmitFingerprint ($input: FingerprintSubmission!) { submitFingerprint(input: $input) } @@ -1838,6 +1899,7 @@ var DocumentOperationNames = map[string]string{ FindSceneByIDDocument: "FindSceneByID", FindStudioDocument: "FindStudio", FindTagDocument: "FindTag", + QueryTagsDocument: "QueryTags", SubmitFingerprintDocument: "SubmitFingerprint", MeDocument: "Me", SubmitSceneDraftDocument: "SubmitSceneDraft", diff --git a/pkg/stashbox/tag.go b/pkg/stashbox/tag.go new file mode 100644 index 000000000..df2ecbcc0 --- /dev/null +++ b/pkg/stashbox/tag.go @@ -0,0 +1,67 @@ +package stashbox + +import ( + "context" + + "github.com/google/uuid" + "github.com/stashapp/stash/pkg/models" + "github.com/stashapp/stash/pkg/stashbox/graphql" +) + +// QueryTag searches for tags by name or ID. +// If query is a valid UUID, it searches by ID (returns single result). +// Otherwise, it searches by name (returns multiple results). +func (c Client) QueryTag(ctx context.Context, query string) ([]*models.ScrapedTag, error) { + _, err := uuid.Parse(query) + if err == nil { + // Query is a UUID, use findTag for exact match + return c.findTagByID(ctx, query) + } + // Otherwise search by name + return c.queryTagsByName(ctx, query) +} + +func (c Client) findTagByID(ctx context.Context, id string) ([]*models.ScrapedTag, error) { + tag, err := c.client.FindTag(ctx, &id, nil) + if err != nil { + return nil, err + } + + if tag.FindTag == nil { + return nil, nil + } + + return []*models.ScrapedTag{{ + Name: tag.FindTag.Name, + RemoteSiteID: &tag.FindTag.ID, + }}, nil +} + +func (c Client) queryTagsByName(ctx context.Context, name string) ([]*models.ScrapedTag, error) { + input := graphql.TagQueryInput{ + Name: &name, + Page: 1, + PerPage: 25, + Direction: graphql.SortDirectionEnumAsc, + Sort: graphql.TagSortEnumName, + } + + result, err := c.client.QueryTags(ctx, input) + if err != nil { + return nil, err + } + + if result.QueryTags.Tags == nil { + return nil, nil + } + + var ret []*models.ScrapedTag + for _, t := range result.QueryTags.Tags { + ret = append(ret, &models.ScrapedTag{ + Name: t.Name, + RemoteSiteID: &t.ID, + }) + } + + return ret, nil +} diff --git a/ui/v2.5/graphql/queries/scrapers/scrapers.graphql b/ui/v2.5/graphql/queries/scrapers/scrapers.graphql index 8137fe054..4ddfbd91b 100644 --- a/ui/v2.5/graphql/queries/scrapers/scrapers.graphql +++ b/ui/v2.5/graphql/queries/scrapers/scrapers.graphql @@ -62,6 +62,15 @@ query ScrapeSingleStudio( } } +query ScrapeSingleTag( + $source: ScraperSourceInput! + $input: ScrapeSingleTagInput! +) { + scrapeSingleTag(source: $source, input: $input) { + ...ScrapedSceneTagData + } +} + query ScrapeSinglePerformer( $source: ScraperSourceInput! $input: ScrapeSinglePerformerInput! diff --git a/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx b/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx index 790e6aed9..4674db08a 100644 --- a/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx +++ b/ui/v2.5/src/components/Shared/StashBoxIDSearchModal.tsx @@ -15,6 +15,7 @@ import { stashBoxPerformerQuery, stashBoxSceneQuery, stashBoxStudioQuery, + stashBoxTagQuery, } from "src/core/StashService"; import { useToast } from "src/hooks/Toast"; import { stringToGender } from "src/utils/gender"; @@ -22,9 +23,10 @@ import { stringToGender } from "src/utils/gender"; type SearchResultItem = | GQL.ScrapedPerformerDataFragment | GQL.ScrapedSceneDataFragment - | GQL.ScrapedStudioDataFragment; + | GQL.ScrapedStudioDataFragment + | GQL.ScrapedSceneTagDataFragment; -export type StashBoxEntityType = "performer" | "scene" | "studio"; +export type StashBoxEntityType = "performer" | "scene" | "studio" | "tag"; interface IProps { entityType: StashBoxEntityType; @@ -232,6 +234,27 @@ export const StudioSearchResult: React.FC = ({ ); }; +// Tag Result Component +interface ITagResultProps { + tag: GQL.ScrapedSceneTagDataFragment; +} + +export const TagSearchResult: React.FC = ({ tag }) => { + return ( +
+
+ +
+

+ {tag.name} +

+
+
+
+
+ ); +}; + // Helper to get entity type message id for i18n function getEntityTypeMessageId(entityType: StashBoxEntityType): string { switch (entityType) { @@ -241,6 +264,8 @@ function getEntityTypeMessageId(entityType: StashBoxEntityType): string { return "scene"; case "studio": return "studio"; + case "tag": + return "tag"; } } @@ -253,6 +278,8 @@ function getFoundMessageId(entityType: StashBoxEntityType): string { return "dialogs.scenes_found"; case "studio": return "dialogs.studios_found"; + case "tag": + return "dialogs.tags_found"; } } @@ -318,6 +345,14 @@ export const StashBoxIDSearchModal: React.FC = ({ setResults(queryData.data?.scrapeSingleStudio ?? []); break; } + case "tag": { + const queryData = await stashBoxTagQuery( + query, + selectedStashBox.endpoint + ); + setResults(queryData.data?.scrapeSingleTag ?? []); + break; + } } } catch (error) { Toast.error(error); @@ -357,6 +392,10 @@ export const StashBoxIDSearchModal: React.FC = ({ return ( ); + case "tag": + return ( + + ); } } diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index 41756953b..35733394a 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -3,7 +3,8 @@ import { FormattedMessage, useIntl } from "react-intl"; import * as GQL from "src/core/generated-graphql"; import * as yup from "yup"; import { DetailsEditNavbar } from "src/components/Shared/DetailsEditNavbar"; -import { Form } from "react-bootstrap"; +import { Button, Form } from "react-bootstrap"; +import { faPlus } from "@fortawesome/free-solid-svg-icons"; import ImageUtils from "src/utils/image"; import { useFormik } from "formik"; import { Prompt } from "react-router-dom"; @@ -11,11 +12,14 @@ import Mousetrap from "mousetrap"; import { LoadingIndicator } from "src/components/Shared/LoadingIndicator"; import isEqual from "lodash-es/isEqual"; import { useToast } from "src/hooks/Toast"; +import { useConfigurationContext } from "src/hooks/Config"; import { handleUnsavedChanges } from "src/utils/navigation"; import { formikUtils } from "src/utils/form"; import { yupFormikValidate, yupUniqueAliases } from "src/utils/yup"; -import { getStashIDs } from "src/utils/stashIds"; +import { addUpdateStashID, getStashIDs } from "src/utils/stashIds"; import { Tag, TagSelect } from "../TagSelect"; +import { Icon } from "src/components/Shared/Icon"; +import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; interface ITagEditPanel { tag: Partial; @@ -36,9 +40,13 @@ export const TagEditPanel: React.FC = ({ }) => { const intl = useIntl(); const Toast = useToast(); + const { configuration: stashConfig } = useConfigurationContext(); const isNew = tag.id === undefined; + // Editing state + const [isStashIDSearchOpen, setIsStashIDSearchOpen] = useState(false); + // Network state const [isLoading, setIsLoading] = useState(false); @@ -143,6 +151,14 @@ export const TagEditPanel: React.FC = ({ ImageUtils.onImageChange(event, onImageLoad); } + function onStashIDSelected(item?: GQL.StashIdInput) { + if (!item) return; + formik.setFieldValue( + "stash_ids", + addUpdateStashID(formik.values.stash_ids, item) + ); + } + const { renderField, renderInputField, @@ -186,54 +202,86 @@ export const TagEditPanel: React.FC = ({ // TODO: CSS class return ( -
- {isNew && ( -

- -

+ <> + {isStashIDSearchOpen && ( + s.endpoint + )} + onSelectItem={(item) => { + onStashIDSelected(item); + setIsStashIDSearchOpen(false); + }} + /> )} - { - // Check if it's a redirect after tag creation - if (action === "PUSH" && location.pathname.startsWith("/tags/")) { - return true; +
+ {isNew && ( +

+ +

+ )} + + { + // Check if it's a redirect after tag creation + if (action === "PUSH" && location.pathname.startsWith("/tags/")) { + return true; + } + + return handleUnsavedChanges(intl, "tags", tag.id)(location); + }} + /> + +
+ {renderInputField("name")} + {renderInputField("sort_name", "text")} + {renderStringListField("aliases")} + {renderInputField("description", "textarea")} + {renderParentTagsField()} + {renderSubTagsField()} + {renderStashIDsField( + "stash_ids", + "tags", + "stash_ids", + undefined, + + )} +
+ {renderInputField("ignore_auto_tag", "checkbox")} +
+ + - -
- {renderInputField("name")} - {renderInputField("sort_name", "text")} - {renderStringListField("aliases")} - {renderInputField("description", "textarea")} - {renderParentTagsField()} - {renderSubTagsField()} - {renderStashIDsField("stash_ids", "tags")} -
- {renderInputField("ignore_auto_tag", "checkbox")} -
- - onImageLoad(null)} - onDelete={onDelete} - acceptSVG - /> -
+ onImageChange={onImageChange} + onImageChangeURL={onImageLoad} + onClearImage={() => onImageLoad(null)} + onDelete={onDelete} + acceptSVG + /> +
+ ); }; diff --git a/ui/v2.5/src/core/StashService.ts b/ui/v2.5/src/core/StashService.ts index e69d988bf..d43d87097 100644 --- a/ui/v2.5/src/core/StashService.ts +++ b/ui/v2.5/src/core/StashService.ts @@ -2329,6 +2329,23 @@ export const stashBoxSceneQuery = (query: string, stashBoxEndpoint: string) => } ); +export const stashBoxTagQuery = ( + query: string | null, + stashBoxEndpoint: string +) => + client.query({ + query: GQL.ScrapeSingleTagDocument, + variables: { + source: { + stash_box_endpoint: stashBoxEndpoint, + }, + input: { + query: query, + }, + }, + fetchPolicy: "network-only", + }); + export const mutateStashBoxBatchPerformerTag = ( input: GQL.StashBoxBatchTagInput ) => diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index c2b9b4247..54982b932 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -1016,6 +1016,7 @@ }, "scenes_found": "{count} scenes found", "studios_found": "{count} studios found", + "tags_found": "{count} tags found", "scrape_entity_query": "{entity_type} Scrape Query", "scrape_entity_title": "{entity_type} Scrape Results", "scrape_results_existing": "Existing", From d994df290055f863b4b45ff482e0ff9686142f85 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 5 Dec 2025 08:46:31 +1100 Subject: [PATCH 11/76] Don't convert config file location to absolute during setup (#6373) This was originally done for #3304. The ffmpeg code has been redone since and this is no longer necessary. It was also resulting in the scraper and plugin paths being absolute, despite all the others being relative to the provided config path. --- internal/manager/manager.go | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/internal/manager/manager.go b/internal/manager/manager.go index 2d47fd907..f4f3fa636 100644 --- a/internal/manager/manager.go +++ b/internal/manager/manager.go @@ -219,8 +219,11 @@ func (s *Manager) Setup(ctx context.Context, input SetupInput) error { // paths since they must not be relative. The config file property is // resolved to an absolute path when stash is run normally, so convert // relative paths to absolute paths during setup. - configFile, _ := filepath.Abs(input.ConfigLocation) - + // #6287 - this should no longer be necessary since the ffmpeg code + // converts to absolute paths. Converting the config location to + // absolute means that scraper and plugin paths default to absolute + // which we don't want. + configFile := input.ConfigLocation configDir := filepath.Dir(configFile) if exists, _ := fsutil.DirExists(configDir); !exists { From 88a149c085f32ab8b747aac30904477149b7eeac Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Fri, 5 Dec 2025 09:04:16 +1100 Subject: [PATCH 12/76] Correct sidebar styling on details pages (#6377) * Remove margin-bottom on xs to fix styling weirdness * Only set sidebar height when sidebar visible --- ui/v2.5/src/components/Shared/styles.scss | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/ui/v2.5/src/components/Shared/styles.scss b/ui/v2.5/src/components/Shared/styles.scss index 55dff9d0f..947dd22d7 100644 --- a/ui/v2.5/src/components/Shared/styles.scss +++ b/ui/v2.5/src/components/Shared/styles.scss @@ -828,7 +828,6 @@ button.btn.favorite-button { } @include media-breakpoint-down(xs) { .sidebar { - margin-bottom: $navbar-height; margin-top: 0; } } @@ -920,12 +919,16 @@ $sticky-header-height: calc(50px + 3.3rem); } .detail-body { + .sidebar-pane { + position: sticky; + top: calc($sticky-detail-header-height + $navbar-height); + } + .sidebar { // required for sticky to work align-self: flex-start; // take a further 15px padding to match the detail body - height: calc(100vh - $sticky-header-height - 15px); margin-top: -15px; max-height: calc(100vh - $sticky-header-height - 15px); overflow-y: auto; @@ -939,6 +942,10 @@ $sticky-header-height: calc(50px + 3.3rem); } } + .sidebar-pane:not(.hide-sidebar) .sidebar { + height: calc(100vh - $sticky-header-height - 15px); + } + .sidebar-pane.hide-sidebar .sidebar { left: -$sidebar-width; margin-left: calc(-15px - $sidebar-width); @@ -955,6 +962,10 @@ $sticky-header-height: calc(50px + 3.3rem); } } @include media-breakpoint-down(xs) { + .sidebar-pane { + top: 0; + } + .sidebar { // flex: 100% 0 0; height: calc(100vh - $navbar-height); From 061d21dede815f7835f6007419e9791533817fda Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:05:46 -0600 Subject: [PATCH 13/76] Feature Request: Sort All Urls Alphabetically (#6352) --- .../Galleries/GalleryDetails/GalleryEditPanel.tsx | 3 ++- .../Groups/GroupDetails/GroupEditPanel.tsx | 3 ++- .../Images/ImageDetails/ImageEditPanel.tsx | 3 ++- .../PerformerDetails/PerformerEditPanel.tsx | 3 ++- .../Scenes/SceneDetails/SceneEditPanel.tsx | 3 ++- .../src/components/Shared/ExternalLinksButton.tsx | 7 +++++-- .../Studios/StudioDetails/StudioDetailsPanel.tsx | 9 ++++++--- .../Studios/StudioDetails/StudioEditPanel.tsx | 3 ++- ui/v2.5/src/utils/url.ts | 14 ++++++++++++++ 9 files changed, 37 insertions(+), 11 deletions(-) create mode 100644 ui/v2.5/src/utils/url.ts diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index 5b9fa9da1..05385aaa4 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -31,6 +31,7 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; +import { sortURLs } from "src/utils/url"; interface IProps { gallery: Partial; @@ -81,7 +82,7 @@ export const GalleryEditPanel: React.FC = ({ const initialValues = { title: gallery?.title ?? "", code: gallery?.code ?? "", - urls: gallery?.urls ?? [], + urls: sortURLs(gallery?.urls ?? []), date: gallery?.date ?? "", photographer: gallery?.photographer ?? "", studio_id: gallery?.studio?.id ?? null, diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx index 0b94baf27..6f548027f 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx @@ -28,6 +28,7 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { Group } from "src/components/Groups/GroupSelect"; import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable"; +import { sortURLs } from "src/utils/url"; interface IGroupEditPanel { group: Partial; @@ -97,7 +98,7 @@ export const GroupEditPanel: React.FC = ({ return { group_id: m.group.id, description: m.description ?? "" }; }), director: group?.director ?? "", - urls: group?.urls ?? [], + urls: sortURLs(group?.urls ?? []), synopsis: group?.synopsis ?? "", }; diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx index f2771f542..83fc0343c 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx @@ -35,6 +35,7 @@ import { } from "src/components/Galleries/GallerySelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; +import { sortURLs } from "src/utils/url"; interface IProps { image: GQL.ImageDataFragment; @@ -91,7 +92,7 @@ export const ImageEditPanel: React.FC = ({ const initialValues = { title: image.title ?? "", code: image.code ?? "", - urls: image?.urls ?? [], + urls: sortURLs(image?.urls ?? []), date: image?.date ?? "", details: image.details ?? "", photographer: image.photographer ?? "", diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 8d1352da0..969af6633 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -48,6 +48,7 @@ import { yupUniqueStringList, } from "src/utils/yup"; import { useTagsEdit } from "src/hooks/tagsEdit"; +import { sortURLs } from "src/utils/url"; import { CustomFieldsInput } from "src/components/Shared/CustomFields"; import { cloneDeep } from "@apollo/client/utilities"; @@ -153,7 +154,7 @@ export const PerformerEditPanel: React.FC = ({ tattoos: performer.tattoos ?? "", piercings: performer.piercings ?? "", career_length: performer.career_length ?? "", - urls: performer.urls ?? [], + urls: sortURLs(performer.urls ?? []), details: performer.details ?? "", tag_ids: (performer.tags ?? []).map((t) => t.id), ignore_auto_tag: performer.ignore_auto_tag ?? false, diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 11575ea7b..87968c009 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -42,6 +42,7 @@ import { Group } from "src/components/Groups/GroupSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; +import { sortURLs } from "src/utils/url"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -138,7 +139,7 @@ export const SceneEditPanel: React.FC = ({ () => ({ title: scene.title ?? "", code: scene.code ?? "", - urls: scene.urls ?? [], + urls: sortURLs(scene.urls ?? []), date: scene.date ?? "", director: scene.director ?? "", gallery_ids: (scene.galleries ?? []).map((g) => g.id), diff --git a/ui/v2.5/src/components/Shared/ExternalLinksButton.tsx b/ui/v2.5/src/components/Shared/ExternalLinksButton.tsx index e35a1a992..e71419514 100644 --- a/ui/v2.5/src/components/Shared/ExternalLinksButton.tsx +++ b/ui/v2.5/src/components/Shared/ExternalLinksButton.tsx @@ -7,6 +7,7 @@ import { useMemo } from "react"; import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons"; import ReactDOM from "react-dom"; import { PatchComponent } from "src/patch"; +import { sortURLs } from "src/utils/url"; export const ExternalLinksButton: React.FC<{ icon?: IconDefinition; @@ -16,14 +17,16 @@ export const ExternalLinksButton: React.FC<{ }> = PatchComponent( "ExternalLinksButton", ({ urls, icon = faLink, className = "", openIfSingle = false }) => { - if (!urls.length) { + const sortedUrls = useMemo(() => sortURLs(urls), [urls]); + + if (!sortedUrls.length) { return null; } const Menu = () => ReactDOM.createPortal( - {urls.map((url) => ( + {sortedUrls.map((url) => ( = ({ ); } + const sortedURLs = useMemo(() => sortURLs(studio.urls ?? []), [studio.urls]); + function renderURLs() { - if (!studio.urls?.length) { + if (!sortedURLs.length) { return; } return (
); - async function onCreateTag(t: GQL.ScrapedTag) { - const toCreate: GQL.TagCreateInput = { name: t.name }; - - // If the tag has a remote_site_id and we have an endpoint, include the stash_id - const endpoint = currentSource?.sourceInput.stash_box_endpoint; - if (t.remote_site_id && endpoint) { - toCreate.stash_ids = [ - { - endpoint: endpoint, - stash_id: t.remote_site_id, - }, - ]; - } - - const newTagID = await createNewTag(t, toCreate); - if (newTagID !== undefined) { - setTagIDs([...tagIDs, newTagID]); - } - } - function maybeRenderTagsField() { if (!config.setTags) return; @@ -764,9 +786,24 @@ const StashSearchResult: React.FC = ({ }} > {t.name} - + ))}
diff --git a/ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx index 410ce2d19..c37df2258 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StudioResult.tsx @@ -3,16 +3,14 @@ import { Button, ButtonGroup } from "react-bootstrap"; import { FormattedMessage } from "react-intl"; import cx from "classnames"; -import { Icon } from "src/components/Shared/Icon"; -import { OperationButton } from "src/components/Shared/OperationButton"; import { StudioSelect, SelectObject } from "src/components/Shared/Select"; import * as GQL from "src/core/generated-graphql"; import { OptionalField } from "../IncludeButton"; -import { faSave } from "@fortawesome/free-solid-svg-icons"; import { getStashboxBase } from "src/utils/stashbox"; import { ExternalLink } from "src/components/Shared/ExternalLink"; import { Link } from "react-router-dom"; +import { LinkButton } from "../LinkButton"; const StudioLink: React.FC<{ studio: GQL.ScrapedStudio | GQL.SlimStudioDataFragment; @@ -117,21 +115,6 @@ const StudioResult: React.FC = ({ ); } - function maybeRenderLinkButton() { - if (endpoint && onLink) { - return ( - - - - ); - } - } - const selectedSource = !selectedID ? "skip" : "existing"; const safeBuildStudioScraperLink = (id: string | null | undefined) => { @@ -169,7 +152,9 @@ const StudioResult: React.FC = ({ })} isClearable={false} /> - {maybeRenderLinkButton()} + {endpoint && onLink && ( + + )} ); diff --git a/ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx b/ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx index 816e4e294..29339a9fc 100644 --- a/ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/sceneTaggerModals.tsx @@ -6,12 +6,17 @@ import PerformerModal from "../PerformerModal"; import { TaggerStateContext } from "../context"; import { useIntl } from "react-intl"; import { faTags } from "@fortawesome/free-solid-svg-icons"; +import { CreateLinkTagDialog } from "src/components/Shared/ScrapeDialog/CreateLinkTagDialog"; type PerformerModalCallback = (toCreate?: GQL.PerformerCreateInput) => void; type StudioModalCallback = ( toCreate?: GQL.StudioCreateInput, parentInput?: GQL.StudioCreateInput ) => void; +type TagModalCallback = (result: { + create?: GQL.TagCreateInput; + update?: GQL.TagUpdateInput; +}) => void; export interface ISceneTaggerModalsContextState { createPerformerModal: ( @@ -22,12 +27,14 @@ export interface ISceneTaggerModalsContextState { studio: GQL.ScrapedSceneStudioDataFragment, callback: StudioModalCallback ) => void; + createTagModal: (tag: GQL.ScrapedTag, callback: TagModalCallback) => void; } export const SceneTaggerModalsState = React.createContext({ createPerformerModal: () => {}, createStudioModal: () => {}, + createTagModal: () => {}, }); export const SceneTaggerModals: React.FC = ({ children }) => { @@ -47,6 +54,15 @@ export const SceneTaggerModals: React.FC = ({ children }) => { StudioModalCallback | undefined >(); + const [tagToCreate, setTagToCreate] = useState(); + const [tagCallback, setTagCallback] = useState< + | ((result: { + create?: GQL.TagCreateInput; + update?: GQL.TagUpdateInput; + }) => void) + | undefined + >(); + const intl = useIntl(); function handlePerformerSave(toCreate: GQL.PerformerCreateInput) { @@ -106,11 +122,28 @@ export const SceneTaggerModals: React.FC = ({ children }) => { setStudioCallback(() => callback); } + function handleTagSave(result: { + create?: GQL.TagCreateInput; + update?: GQL.TagUpdateInput; + }) { + if (tagCallback) { + tagCallback(result); + } + + setTagToCreate(undefined); + setTagCallback(undefined); + } + + function createTagModal(tag: GQL.ScrapedTag, callback: TagModalCallback) { + setTagToCreate(tag); + setTagCallback(() => callback); + } + const endpoint = currentSource?.sourceInput.stash_box_endpoint ?? undefined; return ( {performerToCreate && ( { endpoint={endpoint} /> )} + {tagToCreate && ( + + )} {children} ); diff --git a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx index 35733394a..e734de846 100644 --- a/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx +++ b/ui/v2.5/src/components/Tags/TagDetails/TagEditPanel.tsx @@ -153,9 +153,10 @@ export const TagEditPanel: React.FC = ({ function onStashIDSelected(item?: GQL.StashIdInput) { if (!item) return; + const allowMultiple = true; formik.setFieldValue( "stash_ids", - addUpdateStashID(formik.values.stash_ids, item) + addUpdateStashID(formik.values.stash_ids, item, allowMultiple) ); } @@ -203,13 +204,11 @@ export const TagEditPanel: React.FC = ({ // TODO: CSS class return ( <> + {/* allow many stash-ids from the same stash box */} {isStashIDSearchOpen && ( s.endpoint - )} onSelectItem={(item) => { onStashIDSelected(item); setIsStashIDSearchOpen(false); diff --git a/ui/v2.5/src/hooks/Toast.tsx b/ui/v2.5/src/hooks/Toast.tsx index 9be27e928..3590e0efb 100644 --- a/ui/v2.5/src/hooks/Toast.tsx +++ b/ui/v2.5/src/hooks/Toast.tsx @@ -150,3 +150,21 @@ export const useToast = () => { [addToast] ); }; + +export function toastOperation( + toast: ReturnType, + o: () => Promise, + successMessage: string +) { + async function operation() { + try { + await o(); + + toast.success(successMessage); + } catch (e) { + toast.error(e); + } + } + + return operation; +} diff --git a/ui/v2.5/src/locales/en-GB.json b/ui/v2.5/src/locales/en-GB.json index a78ca55ec..2911ccb6e 100644 --- a/ui/v2.5/src/locales/en-GB.json +++ b/ui/v2.5/src/locales/en-GB.json @@ -34,6 +34,7 @@ "create_chapters": "Create Chapter", "create_entity": "Create {entityType}", "create_marker": "Create Marker", + "create_new": "Create new", "create_parent_studio": "Create parent studio", "created_entity": "Created {entity_type}: {entity_name}", "customise": "Customise", @@ -225,7 +226,10 @@ "phash_matches": "{count} PHashes match", "unnamed": "Unnamed" }, + "verb_add_as_alias": "Add scraped name as alias", + "verb_link_existing": "Link to existing", "verb_match_fp": "Match Fingerprints", + "verb_match_tag": "Match Tag", "verb_matched": "Matched", "verb_scrape_all": "Scrape All", "verb_submit_fp": "Submit {fpCount, plural, one{# Fingerprint} other{# Fingerprints}}", diff --git a/ui/v2.5/src/utils/stashIds.ts b/ui/v2.5/src/utils/stashIds.ts index 92a4eaf1e..10e3835b8 100644 --- a/ui/v2.5/src/utils/stashIds.ts +++ b/ui/v2.5/src/utils/stashIds.ts @@ -42,17 +42,28 @@ export const separateNamesAndStashIds = ( */ export const addUpdateStashID = ( existingStashIDs: GQL.StashIdInput[], - newItem: GQL.StashIdInput + newItem: GQL.StashIdInput, + allowMultiple: boolean = false ): GQL.StashIdInput[] => { const existingIndex = existingStashIDs.findIndex( (s) => s.endpoint === newItem.endpoint ); - if (existingIndex >= 0) { + if (!allowMultiple && existingIndex >= 0) { const newStashIDs = [...existingStashIDs]; newStashIDs[existingIndex] = newItem; return newStashIDs; } + // ensure we don't add duplicates if allowMultiple is true + if ( + allowMultiple && + existingStashIDs.some( + (s) => s.endpoint === newItem.endpoint && s.stash_id === newItem.stash_id + ) + ) { + return existingStashIDs; + } + return [...existingStashIDs, newItem]; }; From fe41561dfec0f7944f3fa9fea73e2afad1785bbf Mon Sep 17 00:00:00 2001 From: CJ <72030708+cj12312021@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:01:38 -0600 Subject: [PATCH 20/76] add autostart button to videoplayer (#6368) --- .../components/ScenePlayer/ScenePlayer.tsx | 47 ++++++- .../ScenePlayer/autostart-button.ts | 126 ++++++++++++++++++ .../src/components/ScenePlayer/styles.scss | 60 ++++++++- 3 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 ui/v2.5/src/components/ScenePlayer/autostart-button.ts diff --git a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx index 5aeb56e96..31e3e79be 100644 --- a/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx +++ b/ui/v2.5/src/components/ScenePlayer/ScenePlayer.tsx @@ -16,6 +16,7 @@ import "./live"; import "./PlaylistButtons"; import "./source-selector"; import "./persist-volume"; +import "./autostart-button"; import MarkersPlugin, { type IMarker } from "./markers"; void MarkersPlugin; import "./vtt-thumbnails"; @@ -28,6 +29,7 @@ import cx from "classnames"; import { useSceneSaveActivity, useSceneIncrementPlayCount, + useConfigureInterface, } from "src/core/StashService"; import * as GQL from "src/core/generated-graphql"; @@ -249,6 +251,7 @@ export const ScenePlayer: React.FC = PatchComponent( const sceneId = useRef(); const [sceneSaveActivity] = useSceneSaveActivity(); const [sceneIncrementPlayCount] = useSceneIncrementPlayCount(); + const [updateInterfaceConfig] = useConfigureInterface(); const [time, setTime] = useState(0); const [ready, setReady] = useState(false); @@ -389,6 +392,9 @@ export const ScenePlayer: React.FC = PatchComponent( skipButtons: {}, trackActivity: {}, vrMenu: {}, + autostartButton: { + enabled: interfaceConfig?.autostartVideo ?? false, + }, abLoopPlugin: { start: 0, end: false, @@ -434,6 +440,9 @@ export const ScenePlayer: React.FC = PatchComponent( }; // empty deps - only init once // showAbLoopControls is necessary to re-init the player when the config changes + // Note: interfaceConfig?.autostartVideo is intentionally excluded to prevent + // player re-initialization when toggling autostart (which would interrupt playback) + // eslint-disable-next-line react-hooks/exhaustive-deps }, [uiConfig?.showAbLoopControls, uiConfig?.enableChromecast]); useEffect(() => { @@ -675,11 +684,6 @@ export const ScenePlayer: React.FC = PatchComponent( } } - auto.current = - autoplay || - (interfaceConfig?.autostartVideo ?? false) || - _initialTimestamp > 0; - const alwaysStartFromBeginning = uiConfig?.alwaysStartFromBeginning ?? false; const resumeTime = scene.resume_time ?? 0; @@ -698,6 +702,15 @@ export const ScenePlayer: React.FC = PatchComponent( player.load(); player.focus(); + // Check the autostart button plugin for user preference + const autostartButton = player.autostartButton(); + const buttonEnabled = autostartButton.getEnabled(); + auto.current = + autoplay || + buttonEnabled || + (interfaceConfig?.autostartVideo ?? false) || + _initialTimestamp > 0; + player.ready(() => { player.vttThumbnails().src(scene.paths.vtt ?? null); @@ -841,6 +854,30 @@ export const ScenePlayer: React.FC = PatchComponent( sceneSaveActivity, ]); + // Sync autostart button with config changes + useEffect(() => { + const player = getPlayer(); + if (!player) return; + + async function updateAutoStart(enabled: boolean) { + await updateInterfaceConfig({ + variables: { + input: { + autostartVideo: enabled, + }, + }, + }); + } + + const autostartButton = player.autostartButton(); + if (autostartButton) { + autostartButton.syncWithConfig( + interfaceConfig?.autostartVideo ?? false + ); + autostartButton.updateAutoStart = updateAutoStart; + } + }, [getPlayer, updateInterfaceConfig, interfaceConfig?.autostartVideo]); + useEffect(() => { const player = getPlayer(); if (!player) return; diff --git a/ui/v2.5/src/components/ScenePlayer/autostart-button.ts b/ui/v2.5/src/components/ScenePlayer/autostart-button.ts new file mode 100644 index 000000000..f5a35a63f --- /dev/null +++ b/ui/v2.5/src/components/ScenePlayer/autostart-button.ts @@ -0,0 +1,126 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import videojs, { VideoJsPlayer } from "video.js"; + +interface IAutostartButtonOptions { + enabled?: boolean; +} + +interface AutostartButtonOptions extends videojs.ComponentOptions { + autostartEnabled: boolean; +} + +class AutostartButton extends videojs.getComponent("Button") { + private autostartEnabled: boolean; + + constructor(player: VideoJsPlayer, options: AutostartButtonOptions) { + super(player, options); + this.autostartEnabled = options.autostartEnabled; + this.updateIcon(); + } + + buildCSSClass() { + return `vjs-autostart-button ${super.buildCSSClass()}`; + } + + private updateIcon() { + this.removeClass("vjs-icon-play-circle"); + this.removeClass("vjs-icon-cancel"); + + if (this.autostartEnabled) { + this.addClass("vjs-icon-play-circle"); + this.controlText(this.localize("Auto-start enabled (click to disable)")); + } else { + this.addClass("vjs-icon-cancel"); + this.controlText(this.localize("Auto-start disabled (click to enable)")); + } + } + + handleClick(event: Event) { + // Prevent the click from bubbling up and affecting the video player + event.stopPropagation(); + + this.autostartEnabled = !this.autostartEnabled; + this.updateIcon(); + this.trigger("autostartchanged", { enabled: this.autostartEnabled }); + } + + public setEnabled(enabled: boolean) { + this.autostartEnabled = enabled; + this.updateIcon(); + } +} + +class AutostartButtonPlugin extends videojs.getPlugin("plugin") { + private button: AutostartButton; + private autostartEnabled: boolean; + updateAutoStart: (enabled: boolean) => Promise = () => { + return Promise.resolve(); + }; + + constructor(player: VideoJsPlayer, options?: IAutostartButtonOptions) { + super(player, options); + + this.autostartEnabled = options?.enabled ?? false; + + this.button = new AutostartButton(player, { + autostartEnabled: this.autostartEnabled, + }); + + player.ready(() => { + this.ready(); + }); + } + + private ready() { + // Add button to control bar, before the fullscreen button + const { controlBar } = this.player; + const fullscreenToggle = controlBar.getChild("fullscreenToggle"); + if (fullscreenToggle) { + controlBar.addChild(this.button); + controlBar.el().insertBefore(this.button.el(), fullscreenToggle.el()); + } else { + controlBar.addChild(this.button); + } + + // Listen for changes + this.button.on("autostartchanged", (_, data: { enabled: boolean }) => { + this.autostartEnabled = data.enabled; + this.updateAutoStart(this.autostartEnabled); + }); + } + + public isEnabled(): boolean { + return this.autostartEnabled; + } + + public getEnabled(): boolean { + return this.autostartEnabled; + } + + public setEnabled(enabled: boolean) { + this.autostartEnabled = enabled; + this.button.setEnabled(enabled); + } + + public syncWithConfig(configEnabled: boolean) { + // Sync button state with external config changes + if (this.autostartEnabled !== configEnabled) { + this.setEnabled(configEnabled); + } + } +} + +// Register the plugin with video.js. +videojs.registerComponent("AutostartButton", AutostartButton); +videojs.registerPlugin("autostartButton", AutostartButtonPlugin); + +declare module "video.js" { + interface VideoJsPlayer { + autostartButton: () => AutostartButtonPlugin; + } + interface VideoJsPlayerPluginOptions { + autostartButton?: IAutostartButtonOptions; + } +} + +export default AutostartButtonPlugin; diff --git a/ui/v2.5/src/components/ScenePlayer/styles.scss b/ui/v2.5/src/components/ScenePlayer/styles.scss index 0e8041071..fc143a873 100644 --- a/ui/v2.5/src/components/ScenePlayer/styles.scss +++ b/ui/v2.5/src/components/ScenePlayer/styles.scss @@ -100,6 +100,57 @@ $sceneTabWidth: 450px; width: 1.6em; } + .vjs-autostart-button { + cursor: pointer; + + &.vjs-icon-play-circle::before { + align-items: center; + background-color: rgba(255, 255, 255, 0.9); + border-radius: 50%; + color: rgba(80, 80, 80, 0.9); + content: "\f101"; + font-size: 1em; + line-height: 1; + margin-left: 1rem; + padding: 0.3em; + position: relative; + z-index: 2; + } + + &.vjs-icon-cancel::before { + align-items: center; + background-color: rgba(80, 80, 80, 0.9); + border-radius: 50%; + color: #fff; + content: "\f103"; + font-size: 1em; + line-height: 1; + margin-right: 1rem; + padding: 0.3em; + position: relative; + z-index: 2; + } + + &.vjs-icon-play-circle::after, + &.vjs-icon-cancel::after { + background-color: rgb(255 255 255 / 70%); + border-radius: 8px; + content: ""; + height: 2.5rem; + left: 50%; + opacity: 0.7; + position: absolute; + top: 50%; + transform: translate(-50%, -50%) rotate(90deg); + width: 1rem; + z-index: 1; + } + + &:hover { + text-shadow: 0 0 1em rgba(255, 255, 255, 0.75); + } + } + .vjs-touch-overlay .vjs-play-control { z-index: 1; } @@ -344,9 +395,16 @@ $sceneTabWidth: 450px; } } } + @media (max-width: 576px) { + .vjs-control-bar { + .vjs-autostart-button { + display: none; + } + } + } // make controls a little more compact on smaller screens - @media (max-width: 576px) { + @media (max-width: 768px) { .vjs-control-bar { .vjs-control { width: 2.5em; From ba0102f2a651bf3e8cd55d35cdd7f07f126475c7 Mon Sep 17 00:00:00 2001 From: Gykes <24581046+Gykes@users.noreply.github.com> Date: Wed, 10 Dec 2025 15:07:16 -0600 Subject: [PATCH 21/76] use initialstate for scene performers in tagger (#6391) --- .../src/components/Tagger/scenes/StashSearchResult.tsx | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx index 01ce23643..f37754b37 100755 --- a/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx +++ b/ui/v2.5/src/components/Tagger/scenes/StashSearchResult.tsx @@ -291,9 +291,8 @@ const StashSearchResult: React.FC = ({ ); // map of original performer to id - const [performerIDs, setPerformerIDs] = useState<(string | undefined)[]>( - getInitialPerformers() - ); + const [performerIDs, setPerformerIDs, setInitialPerformerIDs] = + useInitialState<(string | undefined)[]>(getInitialPerformers()); const [studioID, setStudioID] = useState( getInitialStudio() @@ -304,8 +303,8 @@ const StashSearchResult: React.FC = ({ }, [getInitialTags, setInitialTagIDs]); useEffect(() => { - setPerformerIDs(getInitialPerformers()); - }, [getInitialPerformers]); + setInitialPerformerIDs(getInitialPerformers()); + }, [getInitialPerformers, setInitialPerformerIDs]); useEffect(() => { setStudioID(getInitialStudio()); From a4816b4cc95c72c9adcc4bd9613511d1213e0aea Mon Sep 17 00:00:00 2001 From: ghuds540 Date: Wed, 10 Dec 2025 16:22:29 -0500 Subject: [PATCH 22/76] Respect user preference for type-to-create in image/scene multi-select form (#6376) --- graphql/schema/types/config.graphql | 2 + internal/api/resolver_mutation_configure.go | 1 + internal/manager/config/config.go | 2 + internal/manager/config/ui.go | 1 + ui/v2.5/graphql/data/config.graphql | 1 + .../components/Galleries/GallerySelect.tsx | 45 +++++++++ .../SettingsInterfacePanel.tsx | 13 +++ ui/v2.5/src/components/Shared/MultiSet.tsx | 99 ++++++++++++++----- ui/v2.5/src/components/Shared/Select.tsx | 2 +- 9 files changed, 140 insertions(+), 26 deletions(-) diff --git a/graphql/schema/types/config.graphql b/graphql/schema/types/config.graphql index e82ea93e2..b6f52091b 100644 --- a/graphql/schema/types/config.graphql +++ b/graphql/schema/types/config.graphql @@ -319,6 +319,7 @@ input ConfigDisableDropdownCreateInput { tag: Boolean studio: Boolean movie: Boolean + gallery: Boolean } enum ImageLightboxDisplayMode { @@ -419,6 +420,7 @@ type ConfigDisableDropdownCreate { tag: Boolean! studio: Boolean! movie: Boolean! + gallery: Boolean! } type ConfigInterfaceResult { diff --git a/internal/api/resolver_mutation_configure.go b/internal/api/resolver_mutation_configure.go index b39cf373a..daed0b5b7 100644 --- a/internal/api/resolver_mutation_configure.go +++ b/internal/api/resolver_mutation_configure.go @@ -521,6 +521,7 @@ func (r *mutationResolver) ConfigureInterface(ctx context.Context, input ConfigI r.setConfigBool(config.DisableDropdownCreateStudio, ddc.Studio) r.setConfigBool(config.DisableDropdownCreateTag, ddc.Tag) r.setConfigBool(config.DisableDropdownCreateMovie, ddc.Movie) + r.setConfigBool(config.DisableDropdownCreateGallery, ddc.Gallery) } r.setConfigString(config.HandyKey, input.HandyKey) diff --git a/internal/manager/config/config.go b/internal/manager/config/config.go index 73b9de3ab..2cc3994f4 100644 --- a/internal/manager/config/config.go +++ b/internal/manager/config/config.go @@ -219,6 +219,7 @@ const ( DisableDropdownCreateStudio = "disable_dropdown_create.studio" DisableDropdownCreateTag = "disable_dropdown_create.tag" DisableDropdownCreateMovie = "disable_dropdown_create.movie" + DisableDropdownCreateGallery = "disable_dropdown_create.gallery" HandyKey = "handy_key" FunscriptOffset = "funscript_offset" @@ -1311,6 +1312,7 @@ func (i *Config) GetDisableDropdownCreate() *ConfigDisableDropdownCreate { Studio: i.getBool(DisableDropdownCreateStudio), Tag: i.getBool(DisableDropdownCreateTag), Movie: i.getBool(DisableDropdownCreateMovie), + Gallery: i.getBool(DisableDropdownCreateGallery), } } diff --git a/internal/manager/config/ui.go b/internal/manager/config/ui.go index b7033f193..de769304f 100644 --- a/internal/manager/config/ui.go +++ b/internal/manager/config/ui.go @@ -105,4 +105,5 @@ type ConfigDisableDropdownCreate struct { Tag bool `json:"tag"` Studio bool `json:"studio"` Movie bool `json:"movie"` + Gallery bool `json:"gallery"` } diff --git a/ui/v2.5/graphql/data/config.graphql b/ui/v2.5/graphql/data/config.graphql index 1c3e9dc1b..b65ba21cc 100644 --- a/ui/v2.5/graphql/data/config.graphql +++ b/ui/v2.5/graphql/data/config.graphql @@ -107,6 +107,7 @@ fragment ConfigInterfaceData on ConfigInterfaceResult { tag studio movie + gallery } handyKey funscriptOffset diff --git a/ui/v2.5/src/components/Galleries/GallerySelect.tsx b/ui/v2.5/src/components/Galleries/GallerySelect.tsx index c76266cf7..0e02b8cb3 100644 --- a/ui/v2.5/src/components/Galleries/GallerySelect.tsx +++ b/ui/v2.5/src/components/Galleries/GallerySelect.tsx @@ -11,6 +11,7 @@ import * as GQL from "src/core/generated-graphql"; import { queryFindGalleriesForSelect, queryFindGalleriesByIDForSelect, + useGalleryCreate, } from "src/core/StashService"; import { useConfigurationContext } from "src/hooks/Config"; import { useIntl } from "react-intl"; @@ -70,10 +71,14 @@ const gallerySelectSort = PatchFunction( const _GallerySelect: React.FC< IFilterProps & IFilterValueProps & ExtraGalleryProps > = (props) => { + const [createGallery] = useGalleryCreate(); + const { configuration } = useConfigurationContext(); const intl = useIntl(); const maxOptionsShown = configuration?.ui.maxOptionsShown ?? defaultMaxOptionsShown; + const defaultCreatable = + !configuration?.interface.disableDropdownCreate.gallery; const exclude = useMemo(() => props.excludeIds ?? [], [props.excludeIds]); @@ -203,6 +208,42 @@ const _GallerySelect: React.FC< return ; }; + const onCreate = async (name: string) => { + const result = await createGallery({ + variables: { input: { title: name } }, + }); + return { + value: result.data!.galleryCreate!.id, + item: result.data!.galleryCreate!, + message: "Created gallery", + }; + }; + + const getNamedObject = (id: string, name: string): Gallery => { + return { + id, + title: name, + files: [], + folder: null, + }; + }; + + const isValidNewOption = (inputValue: string, options: Gallery[]) => { + if (!inputValue) { + return false; + } + + if ( + options.some((o) => { + return galleryTitle(o).toLowerCase() === inputValue.toLowerCase(); + }) + ) { + return false; + } + + return true; + }; + return ( {...props} @@ -214,12 +255,16 @@ const _GallerySelect: React.FC< props.className )} loadOptions={loadGalleries} + getNamedObject={getNamedObject} + isValidNewOption={isValidNewOption} components={{ Option: GalleryOption, MultiValueLabel: GalleryMultiValueLabel, SingleValue: GalleryValueLabel, }} isMulti={props.isMulti ?? false} + creatable={props.creatable ?? defaultCreatable} + onCreate={onCreate} placeholder={ props.noSelectionString ?? intl.formatMessage( diff --git a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx index bbc334a96..7b3f936d3 100644 --- a/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx +++ b/ui/v2.5/src/components/Settings/SettingsInterfacePanel/SettingsInterfacePanel.tsx @@ -735,6 +735,19 @@ export const SettingsInterfacePanel: React.FC = PatchComponent( }) } /> + + saveInterface({ + disableDropdownCreate: { + ...iface.disableDropdownCreate, + gallery: v, + }, + }) + } + /> = (props) => { props.onUpdate(items.map((i) => i.id)); } - if (type === "galleries") { - return ( - - ); + switch (type) { + case "performers": + return ( + + ); + case "studios": + return ( + + ); + case "tags": + return ( + + ); + case "groups": + return ( + + ); + case "galleries": + return ( + + ); + default: + return ( + + ); } - - return ( - - ); }; function getModeText(intl: IntlShape, mode: GQL.BulkUpdateIdMode) { diff --git a/ui/v2.5/src/components/Shared/Select.tsx b/ui/v2.5/src/components/Shared/Select.tsx index 4eea52a38..ec0fb4ea9 100644 --- a/ui/v2.5/src/components/Shared/Select.tsx +++ b/ui/v2.5/src/components/Shared/Select.tsx @@ -385,7 +385,7 @@ export const FilterSelect: React.FC = (props) => { case "groups": return ; case "galleries": - return ; + return ; default: return ; } From 5f0d4e811d137d5ce27cf2825b06a641d722d7a5 Mon Sep 17 00:00:00 2001 From: WithoutPants <53250216+WithoutPants@users.noreply.github.com> Date: Thu, 11 Dec 2025 11:38:20 +1100 Subject: [PATCH 23/76] Revert "Feature Request: Sort All Urls Alphabetically (#6352)" (#6396) This reverts commit 061d21dede815f7835f6007419e9791533817fda. --- .../Galleries/GalleryDetails/GalleryEditPanel.tsx | 3 +-- .../Groups/GroupDetails/GroupEditPanel.tsx | 3 +-- .../Images/ImageDetails/ImageEditPanel.tsx | 3 +-- .../PerformerDetails/PerformerEditPanel.tsx | 3 +-- .../Scenes/SceneDetails/SceneEditPanel.tsx | 3 +-- .../src/components/Shared/ExternalLinksButton.tsx | 7 ++----- .../Studios/StudioDetails/StudioDetailsPanel.tsx | 9 +++------ .../Studios/StudioDetails/StudioEditPanel.tsx | 3 +-- ui/v2.5/src/utils/url.ts | 14 -------------- 9 files changed, 11 insertions(+), 37 deletions(-) delete mode 100644 ui/v2.5/src/utils/url.ts diff --git a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx index 05385aaa4..5b9fa9da1 100644 --- a/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx +++ b/ui/v2.5/src/components/Galleries/GalleryDetails/GalleryEditPanel.tsx @@ -31,7 +31,6 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { Scene, SceneSelect } from "src/components/Scenes/SceneSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; -import { sortURLs } from "src/utils/url"; interface IProps { gallery: Partial; @@ -82,7 +81,7 @@ export const GalleryEditPanel: React.FC = ({ const initialValues = { title: gallery?.title ?? "", code: gallery?.code ?? "", - urls: sortURLs(gallery?.urls ?? []), + urls: gallery?.urls ?? [], date: gallery?.date ?? "", photographer: gallery?.photographer ?? "", studio_id: gallery?.studio?.id ?? null, diff --git a/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx b/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx index 6f548027f..0b94baf27 100644 --- a/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx +++ b/ui/v2.5/src/components/Groups/GroupDetails/GroupEditPanel.tsx @@ -28,7 +28,6 @@ import { Studio, StudioSelect } from "src/components/Studios/StudioSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { Group } from "src/components/Groups/GroupSelect"; import { RelatedGroupTable, IRelatedGroupEntry } from "./RelatedGroupTable"; -import { sortURLs } from "src/utils/url"; interface IGroupEditPanel { group: Partial; @@ -98,7 +97,7 @@ export const GroupEditPanel: React.FC = ({ return { group_id: m.group.id, description: m.description ?? "" }; }), director: group?.director ?? "", - urls: sortURLs(group?.urls ?? []), + urls: group?.urls ?? [], synopsis: group?.synopsis ?? "", }; diff --git a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx index 83fc0343c..f2771f542 100644 --- a/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx +++ b/ui/v2.5/src/components/Images/ImageDetails/ImageEditPanel.tsx @@ -35,7 +35,6 @@ import { } from "src/components/Galleries/GallerySelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; -import { sortURLs } from "src/utils/url"; interface IProps { image: GQL.ImageDataFragment; @@ -92,7 +91,7 @@ export const ImageEditPanel: React.FC = ({ const initialValues = { title: image.title ?? "", code: image.code ?? "", - urls: sortURLs(image?.urls ?? []), + urls: image?.urls ?? [], date: image?.date ?? "", details: image.details ?? "", photographer: image.photographer ?? "", diff --git a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx index 969af6633..8d1352da0 100644 --- a/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx +++ b/ui/v2.5/src/components/Performers/PerformerDetails/PerformerEditPanel.tsx @@ -48,7 +48,6 @@ import { yupUniqueStringList, } from "src/utils/yup"; import { useTagsEdit } from "src/hooks/tagsEdit"; -import { sortURLs } from "src/utils/url"; import { CustomFieldsInput } from "src/components/Shared/CustomFields"; import { cloneDeep } from "@apollo/client/utilities"; @@ -154,7 +153,7 @@ export const PerformerEditPanel: React.FC = ({ tattoos: performer.tattoos ?? "", piercings: performer.piercings ?? "", career_length: performer.career_length ?? "", - urls: sortURLs(performer.urls ?? []), + urls: performer.urls ?? [], details: performer.details ?? "", tag_ids: (performer.tags ?? []).map((t) => t.id), ignore_auto_tag: performer.ignore_auto_tag ?? false, diff --git a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx index 87968c009..11575ea7b 100644 --- a/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx +++ b/ui/v2.5/src/components/Scenes/SceneDetails/SceneEditPanel.tsx @@ -42,7 +42,6 @@ import { Group } from "src/components/Groups/GroupSelect"; import { useTagsEdit } from "src/hooks/tagsEdit"; import { ScraperMenu } from "src/components/Shared/ScraperMenu"; import StashBoxIDSearchModal from "src/components/Shared/StashBoxIDSearchModal"; -import { sortURLs } from "src/utils/url"; const SceneScrapeDialog = lazyComponent(() => import("./SceneScrapeDialog")); const SceneQueryModal = lazyComponent(() => import("./SceneQueryModal")); @@ -139,7 +138,7 @@ export const SceneEditPanel: React.FC = ({ () => ({ title: scene.title ?? "", code: scene.code ?? "", - urls: sortURLs(scene.urls ?? []), + urls: scene.urls ?? [], date: scene.date ?? "", director: scene.director ?? "", gallery_ids: (scene.galleries ?? []).map((g) => g.id), diff --git a/ui/v2.5/src/components/Shared/ExternalLinksButton.tsx b/ui/v2.5/src/components/Shared/ExternalLinksButton.tsx index e71419514..e35a1a992 100644 --- a/ui/v2.5/src/components/Shared/ExternalLinksButton.tsx +++ b/ui/v2.5/src/components/Shared/ExternalLinksButton.tsx @@ -7,7 +7,6 @@ import { useMemo } from "react"; import { faInstagram, faTwitter } from "@fortawesome/free-brands-svg-icons"; import ReactDOM from "react-dom"; import { PatchComponent } from "src/patch"; -import { sortURLs } from "src/utils/url"; export const ExternalLinksButton: React.FC<{ icon?: IconDefinition; @@ -17,16 +16,14 @@ export const ExternalLinksButton: React.FC<{ }> = PatchComponent( "ExternalLinksButton", ({ urls, icon = faLink, className = "", openIfSingle = false }) => { - const sortedUrls = useMemo(() => sortURLs(urls), [urls]); - - if (!sortedUrls.length) { + if (!urls.length) { return null; } const Menu = () => ReactDOM.createPortal( - {sortedUrls.map((url) => ( + {urls.map((url) => ( = ({ ); } - const sortedURLs = useMemo(() => sortURLs(studio.urls ?? []), [studio.urls]); - function renderURLs() { - if (!sortedURLs.length) { + if (!studio.urls?.length) { return; } return (